Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ jobs:
- name: App definitions, landing page and preloading agree
run: ./scripts/test-app-consistency.sh

- name: Maintenance page and rolling updates
run: ./scripts/test-maintenance-page.sh

- name: Chart version must be bumped when a chart changes
if: github.event_name == 'pull_request'
run: |
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [eduide 2.4.0] - 2026-09-28

- [eduide] Serve a static maintenance page instead of Envoy's raw `no healthy upstream` when the landing page is down (`maintenancePage.enabled`, default on)
- [eduide] Add a readiness probe to the REST service, and a `preStop` drain and `maxUnavailable: 0` to the landing page and REST service, so deploys keep a ready pod
- [eduide] Add `landingPage.replicas` and `service.replicas`, with a PodDisruptionBudget (`podDisruptionBudget.enabled`) and node spreading when above 1

## [1.2.0] - estimated between 2025-11 and 2026-05

## [1.1.2] - 2025-09-26
Expand Down
2 changes: 1 addition & 1 deletion charts/eduide/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ 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: 2.3.0
version: 2.4.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.
Expand Down
8 changes: 7 additions & 1 deletion charts/eduide/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# eduide

![Version: 2.3.0](https://img.shields.io/badge/Version-2.3.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.3.0](https://img.shields.io/badge/AppVersion-1.3.0-informational?style=flat-square)
![Version: 2.4.0](https://img.shields.io/badge/Version-2.4.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.3.0](https://img.shields.io/badge/AppVersion-1.3.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.
Expand Down Expand Up @@ -94,8 +94,11 @@ environment. Requires eduide-cluster to be installed on the cluster first.
| landingPage.privacy.dataProtectionOfficer.email | string | `""` | Where the data protection officer is reached, e.g. "dpo@example.edu". |
| landingPage.privacy.dataProtectionOfficer.name | string | `""` | Optional name. Most institutions publish only the address. |
| landingPage.privacy.scientificUse | bool | `false` | State that anonymised usage data may also be used for scientific research. Leave off unless this installation has a legal basis for it: this is a processing purpose, not a cosmetic string. |
| landingPage.replicas | int | `1` | Number of landing page pods. More than one keeps the landing page up when a node goes away, and turns on its PodDisruptionBudget. |
| landingPage.sentry | object | (see details below) | Values related to Sentry on the landing page. |
| landingPage.sentry.enable | bool | `false` | Set SENTRY_ENABLE=true in the landing page deployment. Off by default: the DSN is compiled into the published images and points at TUM's Sentry, so enabling this outside TUM sends your hostnames and namespace names there. |
| maintenancePage | object | (see details below) | Static page shown instead of Envoy's raw error when the landing page is down |
| maintenancePage.enabled | bool | `true` | Answer 502/503/504 on the landing route with files/maintenance.html ("EduIDE is currently unavailable") instead of Envoy's plain-text error. Covers the landing page only - the REST service and sessions keep their own errors. Needs Envoy Gateway (BackendTrafficPolicy responseOverride). |
| monitor | object | (see details below) | Values to influence the monitor initialization on the operator |
| monitor.activityTracker | object | (see details below) | Values to influence the activityTracker module |
| monitor.activityTracker.enable | bool | `true` | Should the activityTracker module be enabled |
Expand Down Expand Up @@ -132,6 +135,8 @@ environment. Requires eduide-cluster to be installed on the cluster first.
| operator.storageClassName | string | `"default"` | The name of the storage class for persistent volume claims for workspaces. This storage class must be present on the cluster. Most cloud providers offer a default storage class without additional configuration. |
| 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"` | |
| podDisruptionBudget | object | (see details below) | PodDisruptionBudgets for the landing page and REST service |
| podDisruptionBudget.enabled | bool | `true` | Render a PodDisruptionBudget (minAvailable: 1) for every Deployment above that runs more than one replica. Turn this off on single-node clusters: there a drain can never reschedule the evicted replica, so it waits on the last one forever. |
| 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. |
Expand All @@ -149,6 +154,7 @@ environment. Requires eduide-cluster to be installed on the cluster first.
| service.imagePullSecret | string | `nil` | Optional: the image pull secret |
| service.port | int | `8081` | service port (default: 8081) |
| service.protocol | string | `"https"` | protocol of the REST-API |
| service.replicas | int | `1` | Number of REST service pods. The service keeps no state of its own, so more than one is safe; it also turns on its PodDisruptionBudget. |
| service.sentry | object | (see details below) | Values related to Sentry on the service. |
| service.sentry.enable | bool | `false` | Set SENTRY_ENABLE=true in the service deployment. Off by default: the DSN is compiled into the published images and points at TUM's Sentry, so enabling this outside TUM sends your hostnames and namespace names there. |
| servicerole.name | string | `"service-api-access"` | |
Expand Down
37 changes: 37 additions & 0 deletions charts/eduide/files/maintenance.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="30">
<meta name="robots" content="noindex">
<title>EduIDE - currently unavailable</title>
<style>
:root { color-scheme: light dark; --bg: #f5f5f5; --fg: #1a1a1a; --muted: #5c5c5c; }
@media (prefers-color-scheme: dark) {
:root { --bg: #121212; --fg: #f0f0f0; --muted: #a8a8a8; }
}
body { min-height: 100vh; margin: 0; }
body {
display: flex; align-items: center; justify-content: center;
padding: 0 16px; box-sizing: border-box;
background: var(--bg); color: var(--fg);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
text-align: center;
}
main { max-width: 32rem; }
.logo { width: 8rem; height: auto; margin-bottom: 2rem; }
h1 { font-size: 1.6rem; margin: 0 0 1rem; }
p { line-height: 1.5; margin: 0 0 0.75rem; }
.de { color: var(--muted); font-size: 0.9rem; }
</style>
</head>
<body>
<main>
<svg class="logo" role="img" aria-label="EduIDE" version="1.1" xmlns="http://www.w3.org/2000/svg" x="0" y="0" preserveAspectRatio="xMinYMin meet" viewBox="0, 0, 1150, 540.6"> <g id="Layer_1" fill="#0d82ff"> <path d="M880.199,2.8 C1028.1,2.8 1147.9,122.6 1147.9,270.5 C1147.9,418.3 1028.1,538.2 880.2,538.2 L290.1,538.2 C269,538.2 251.9,521.1 251.9,500 C251.9,478.9 269,461.8 290.1,461.8 L427.6,461.8 C448.6,461.8 465.7,444.7 465.7,423.6 C465.7,402.5 448.6,385.4 427.6,385.4 L396.999,385.4 C375.9,385.4 358.8,368.3 358.8,347.2 C358.8,326.1 375.9,309 397,309 L488.703,309 C509.918,308.941 526.373,291.65 526.9,270.8 C526.9,249.7 509.8,232.6 488.7,232.6 L167.8,232.6 C146.7,232.6 129.6,215.5 129.6,194.4 C129.6,173.3 146.7,156.2 167.8,156.2 L404.604,156.2 C425.818,156.141 442.273,138.85 442.8,118 C442.8,96.9 425.7,79.8 404.6,79.8 L351.2,79.8 C330.1,79.8 313,62.7 313,41.6 C313,20.5 330.1,2.4 351.2,2.4 L880.199,2.8 z M837.4,92 L837.4,92 C755.2,92 688.7,158.6 688.7,240.7 L688.7,300.2 C688.7,382.4 755.2,448.9 837.4,448.9 C919.5,448.9 986.1,382.4 986.1,300.2 L986.1,240.7 C986.1,158.6 919.5,92 837.4,92 L837.4,92 z M888.2,232.6 C908,232.6 924.1,248.7 924.1,268.5 L924.1,273.1 C924.1,292.9 908,309 888.2,309 L776.6,309 C756.8,309 740.7,292.9 740.7,273.1 L740.7,268.5 C740.7,248.7 756.8,232.6 776.6,232.6 L888.2,232.6 z" /> <path d="M170.1,461.8 C190,461.8 206,477.8 206,497.7 L206,502.3 C206,522.1 190,538.2 170.1,538.2 L38,538.2 C18.2,538.2 2.1,522.1 2.1,502.3 L2.1,497.7 C2.1,477.8 18.2,461.8 38,461.8 L170.1,461.8 z" /> <path d="M231.3,3.4 C251.1,3.4 267.1,19.5 267.1,39.3 L267.1,44 C267.1,63.8 251.1,79.8 231.3,79.8 L83.8,79.8 C64,79.8 47.9,63.8 47.9,44 L47.9,39.3 C47.9,19.5 64,3.4 83.8,3.4 L231.3,3.4 z" /> <path d="M277.1,309 C296.9,309 313,325.1 313,344.9 L313,349.5 C313,369.3 296.9,385.4 277.1,385.4 L196.1,385.4 C176.3,385.4 160.2,369.3 160.2,349.5 L160.2,344.9 C160.2,325.1 176.3,309 196.1,309 L277.1,309 z" /> </g> </svg>
<h1>EduIDE is currently unavailable</h1>
<p>We're updating EduIDE and will be back in a few minutes. This page reloads automatically.</p>
<p class="de" lang="de">EduIDE wird gerade aktualisiert und ist in wenigen Minuten wieder da.</p>
</main>
</body>
</html>
26 changes: 26 additions & 0 deletions charts/eduide/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,29 @@ disagree with what the installation offers.
{{- end -}}
{{- $out | toJson -}}
{{- end -}}

{{/*
Keep serving for a few seconds after the pod is marked for deletion. Envoy only
learns that the endpoint is gone after the Pod is deleted, so without this it
keeps sending requests to a process that is already shutting down.
*/}}
{{- define "eduide.preStopDrain" -}}
preStop:
exec:
command: ["sleep", "10"]
{{- end }}

{{/*
Prefer putting the replicas of a Deployment on different nodes, so one node
going away does not take all of them. Soft, so a single-node cluster still
schedules every replica.
*/}}
{{- define "eduide.spreadAcrossNodes" -}}
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: {{ . }}
{{- end }}
11 changes: 10 additions & 1 deletion charts/eduide/templates/landing-page.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ metadata:
app: landing-page
namespace: {{ .Release.Namespace }}
spec:
replicas: 1
replicas: {{ .Values.landingPage.replicas }}
{{- /* Start the new pod before stopping the old one, so a deploy never leaves the route without a ready pod. */}}
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: landing-page
Expand All @@ -20,6 +26,7 @@ spec:
checksum/config: {{ include (print $.Template.BasePath "/landing-page-config-map.yaml") . | sha256sum }}
spec:
automountServiceAccountToken: false
{{- include "eduide.spreadAcrossNodes" "landing-page" | nindent 6 }}
containers:
- name: landing-page-container
image: {{ tpl (.Values.landingPage.image | toString) . }}
Expand All @@ -37,6 +44,8 @@ spec:
port: 80
initialDelaySeconds: 5
periodSeconds: 5
lifecycle:
{{- include "eduide.preStopDrain" . | nindent 12 }}
volumeMounts:
- name: landing-page-config
mountPath: /usr/share/nginx/html/config.js
Expand Down
44 changes: 44 additions & 0 deletions charts/eduide/templates/maintenance-page.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{{- /*
The page Envoy serves instead of its raw "no healthy upstream" when the landing
page has no ready pod - during a deploy, or on purpose when the maintenance
workflow in EduIDE-deployment scales it to 0. Envoy serves it itself, so it
must not load anything from the landing page. The status code stays 5xx.
*/}}
{{- if and .Values.maintenancePage.enabled .Values.landingPage.enabled .Values.gateway.enabled .Values.gateway.routes.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: maintenance-page
namespace: {{ .Release.Namespace }}
data:
response.body: |
{{ .Files.Get "files/maintenance.html" | indent 4 }}
---
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
name: landing-maintenance-page
namespace: {{ .Release.Namespace }}
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: landing-route
responseOverride:
- match:
statusCodes:
- type: Value
value: 502
- type: Value
value: 503
- type: Value
value: 504
response:
contentType: text/html; charset=utf-8
body:
type: ValueRef
valueRef:
group: ""
kind: ConfigMap
name: maintenance-page
{{- end }}
30 changes: 30 additions & 0 deletions charts/eduide/templates/pod-disruption-budgets.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{{- /*
Only with more than one replica: a PDB over a single pod blocks every drain.
Single-node clusters must turn these off, because a drain there can never
reschedule the evicted replica and so waits on the second one forever.
*/}}
{{- if and .Values.podDisruptionBudget.enabled .Values.landingPage.enabled (gt (int .Values.landingPage.replicas) 1) }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: landing-page-pdb
namespace: {{ .Release.Namespace }}
spec:
minAvailable: 1
selector:
matchLabels:
app: landing-page
{{- end }}
{{- if and .Values.podDisruptionBudget.enabled (gt (int .Values.service.replicas) 1) }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: service-pdb
namespace: {{ .Release.Namespace }}
spec:
minAvailable: 1
selector:
matchLabels:
app: service
{{- end }}
21 changes: 20 additions & 1 deletion charts/eduide/templates/service.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ metadata:
app: service
namespace: {{ .Release.Namespace }}
spec:
replicas: 1
replicas: {{ .Values.service.replicas }}
{{- /* Start the new pod before stopping the old one, so a deploy never leaves the route without a ready pod. */}}
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: service
Expand All @@ -21,6 +27,7 @@ spec:
# we need to be able to create and read sessions
automountServiceAccountToken: true
serviceAccountName: service-api-service-account
{{- include "eduide.spreadAcrossNodes" "service" | nindent 6 }}
containers:
- name: service-container
image: {{ tpl (.Values.service.image | toString) . }}
Expand All @@ -29,6 +36,18 @@ spec:
- name: http
containerPort: {{ tpl (.Values.service.port | toString) . }}
protocol: TCP
{{- /*
The service has no health endpoint. An open port means Quarkus is up,
which is what matters: without this the pod counted as ready the moment
the container started and the old one was stopped too early.
*/}}
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: 5
periodSeconds: 5
lifecycle:
{{- include "eduide.preStopDrain" . | nindent 10 }}
envFrom:
- configMapRef:
name: service-config
Expand Down
22 changes: 22 additions & 0 deletions charts/eduide/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ hosts:
landingPage:
# -- Whether the landing page shall be enabled
enabled: true
# -- Number of landing page pods. More than one keeps the landing page up
# when a node goes away, and turns on its PodDisruptionBudget.
replicas: 1
# -- the landing page image to use. Templated, so the tag follows
# versions.landingPage unless the whole string is overridden.
image: '{{ .Values.imageRegistry }}/eduide-landing-page:{{ .Values.versions.landingPage }}'
Expand Down Expand Up @@ -447,8 +450,27 @@ service:
enable: false
# -- service port (default: 8081)
port: 8081
# -- Number of REST service pods. The service keeps no state of its own, so
# more than one is safe; it also turns on its PodDisruptionBudget.
replicas: 1
# -- protocol of the REST-API
protocol: https
# -- Static page shown instead of Envoy's raw error when the landing page is down
# @default -- (see details below)
maintenancePage:
# -- Answer 502/503/504 on the landing route with files/maintenance.html
# ("EduIDE is currently unavailable") instead of Envoy's plain-text error.
# Covers the landing page only - the REST service and sessions keep their
# own errors. Needs Envoy Gateway (BackendTrafficPolicy responseOverride).
enabled: true
# -- PodDisruptionBudgets for the landing page and REST service
# @default -- (see details below)
podDisruptionBudget:
# -- Render a PodDisruptionBudget (minAvailable: 1) for every Deployment
# above that runs more than one replica. Turn this off on single-node
# clusters: there a drain can never reschedule the evicted replica, so it
# waits on the last one forever.
enabled: true
# -- Gateway API configuration (Envoy Gateway by default)
gateway:
# -- Master switch for Gateway API resources.
Expand Down
33 changes: 33 additions & 0 deletions docs/charts.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,39 @@ 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.

## Maintenance page and rolling updates

While the landing page has no ready pod, Envoy would answer with a bare
`no healthy upstream`. The `eduide` chart ships a `BackendTrafficPolicy` on
`landing-route` that replaces every 502/503/504 there with
`charts/eduide/files/maintenance.html` ("EduIDE is currently unavailable").
Envoy serves the page from the `maintenance-page` ConfigMap itself, so it works
with the landing page down and loads nothing from anywhere else. The status
code stays 5xx. It needs Envoy Gateway; turn it off with
`maintenancePage.enabled: false` elsewhere.

Keep `%` out of the page. Envoy reads the body as a format string, rejects
the override because of it and still reports the policy as Accepted, so
visitors get an empty 503. `scripts/test-maintenance-page.sh` checks for it.

It covers the landing page only. The REST service and sessions keep their own
errors, and nothing helps while Envoy itself is down.

To show the page on purpose, scale `landing-page-deployment` to 0 - the
`maintenance.yml` workflow in EduIDE-deployment does exactly that. The next
deploy scales it back.

So that deploys rarely show it:

- the landing page and REST service start the new pod before stopping the old
one (`maxUnavailable: 0`), and the REST service is only ready once its port
is open
- both sleep 10s in `preStop`, so Envoy stops sending traffic before the
process exits
- `landingPage.replicas` / `service.replicas` above 1 add a
PodDisruptionBudget each (`podDisruptionBudget.enabled`, off on single-node
clusters, where it would block every drain) and prefer different nodes

## Checking a change

```bash
Expand Down
Loading
Loading