From e52fe010ae8a3a721d3b766b3e41a249a7ee6947 Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Wed, 23 Sep 2026 19:21:51 +0200 Subject: [PATCH 1/2] feat(eduide): redirect ended sessions to the landing page instead of 404 A session's HTTPRoute is owner-referenced to its Session, so Kubernetes garbage-collects it the moment the session ends. The path then matches nothing and Envoy answers a bare 404: the student's tab is stuck, and reloading only produces a browser error with no way back. Add a catch-all route on the instance hostnames that redirects anything no live session claims to the landing page's /session-ended. Kept as a separate route rather than a rule on httproute-instances.yaml: that route is an input the operator READS - it copies its parentRefs and hostnames onto each per-session route - and leaving it rule-free keeps that one-way relationship intact. Live sessions are unaffected. Gateway API ranks matches across routes on a listener by hostname, then by characters in the matching path, so a session's // prefix and its Exact rule both outrank /. 302 only, enforced by a template fail. Eager sessions reuse instance paths (/-/), so a cached 301 would permanently break an instance in that browser for every student who lands on it afterwards - the opposite of the argument the cluster chart's redirect template makes for its own case. Also corrects the claim, in three places, that the operator patches rules into httproute-instances.yaml and that it ships `rules: []`. It reads that route and never writes it, and the field is omitted rather than empty. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 13 ++-- AGENTS.md | 5 +- CHANGELOG.md | 2 + charts/eduide/Chart.yaml | 2 +- charts/eduide/README.md | 9 ++- .../eduide/templates/httproute-instances.yaml | 18 ++++-- .../templates/httproute-session-ended.yaml | 59 +++++++++++++++++++ charts/eduide/values.yaml | 23 ++++++++ 8 files changed, 114 insertions(+), 17 deletions(-) create mode 100644 charts/eduide/templates/httproute-session-ended.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca27c9a..1bc8fd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,12 +94,13 @@ jobs: # Gateway API and theia.cloud are not in the default schema set. # # HTTPRoute is skipped deliberately. templates/httproute-instances.yaml - # ships `rules: []` on purpose - the operator patches rules in at - # runtime to publish each new session. The CRDs-catalog schema declares - # minItems: 1 on spec.rules and so rejects it, but the upstream Gateway - # API CRD does not: gateway-api v1.2.1 sets only maxItems: 16 on that - # field. The catalog is stricter than what the cluster enforces, so this - # is a false positive rather than a defect in the chart. + # OMITS spec.rules entirely: it exists to carry the gateway attachment + # and hostnames that the operator copies onto the per-session route it + # creates for each session. The CRDs-catalog schema declares minItems: 1 + # on spec.rules and so rejects a route without it, but the upstream + # Gateway API CRD does not: gateway-api v1.2.1 sets only maxItems: 16 on + # that field. The catalog is stricter than what the cluster enforces, so + # this is a false positive rather than a defect in the chart. # Revisit when the gateway templates are restructured; validating against # schemas converted from the real CRDs would let us drop the skip. SCHEMAS=( diff --git a/AGENTS.md b/AGENTS.md index 0e8b1e1..58aa517 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,8 +87,9 @@ watching `main`. Full procedure in the README, and as a skill in 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. + `httproute-instances.yaml` deliberately omits the field. The operator does not + patch that route - it reads its `parentRefs` and `hostnames` and copies them + onto a new, Session-owned route per session. ## Renovate diff --git a/CHANGELOG.md b/CHANGELOG.md index f4f6640..89b77ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [1.2.0] - estimated between 2025-11 and 2026-05 +- [eduide] Redirect requests for ended sessions to the landing page instead of a bare 404 [#137](https://github.com/EduIDE/EduIDE-Helm/pull/137) + ## [1.1.2] - 2025-09-26 - [theia-cloud] Use ingress path type ImplementationSpecific instead of Prefix [#99](https://github.com/eclipse-theia/theia-cloud-helm/pull/99) diff --git a/charts/eduide/Chart.yaml b/charts/eduide/Chart.yaml index 558d97a..6789ddf 100644 --- a/charts/eduide/Chart.yaml +++ b/charts/eduide/Chart.yaml @@ -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.1.5 +version: 2.2.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. diff --git a/charts/eduide/README.md b/charts/eduide/README.md index 08c86ce..d8fe6d5 100644 --- a/charts/eduide/README.md +++ b/charts/eduide/README.md @@ -1,6 +1,6 @@ # eduide -![Version: 2.1.5](https://img.shields.io/badge/Version-2.1.5-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) +![Version: 2.2.0](https://img.shields.io/badge/Version-2.2.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. @@ -37,7 +37,7 @@ environment. Requires eduide-cluster to be installed on the cluster first. | demoApplication.pullSecret | string | `""` | the image pull secret. Leave empty if registry is public | | demoApplication.timeout | string | `"30"` | Limit in minutes | | 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":false,"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 | object | `{"className":"envoy","create":false,"enabled":true,"httpEnabled":false,"httpPort":80,"httpsPort":443,"instancesRouteName":"theia-cloud-demo-ws-route","instancesWildcardSecretNames":{},"name":"theia-cloud-gateway","parentRefs":[],"routes":{"enabled":true,"sessionEndedRedirect":{"enabled":true,"includeWildcardInstances":true,"path":"/session-ended","statusCode":302}},"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 | `false` | Create a Gateway in this namespace. Leave this false. The supported model is one shared Gateway per cluster, installed by the eduide-cluster chart, which every environment attaches to through `gateway.parentRefs` below. That is what every real installation uses and the only path that is tested. Setting this true renders a Gateway whose HTTPS listeners reference the Secrets `ws-cert-secret`, `service-cert-secret` and `landing-page-cert-secret` - which NEITHER CHART CREATES. You would have to create all three yourself. The listeners sit at Programmed=False until you do, and nothing in `helm status` explains why. | | gateway.enabled | bool | `true` | Master switch for Gateway API resources. | @@ -48,6 +48,11 @@ environment. Requires eduide-cluster to be installed on the cluster first. | 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: eduide-system | | gateway.routes.enabled | bool | `true` | Whether to render HTTPRoute resources. | +| gateway.routes.sessionEndedRedirect | object | (see details below) | Where a browser lands when it asks for a session that no longer exists. Session routes are owned by their Session and vanish with it, so a timed-out tab that reloads otherwise gets a bare Envoy 404. This renders a catch-all route on the instance hostnames that redirects those requests to the landing page, which explains what happened and offers to resume. Live sessions are unaffected: Gateway API ranks matches by path length, and a session's path always beats `/`. | +| gateway.routes.sessionEndedRedirect.enabled | bool | `true` | Whether to render the catch-all redirect. Needs `landingPage.enabled`, since there is nowhere to send people otherwise. | +| gateway.routes.sessionEndedRedirect.includeWildcardInstances | bool | `true` | Also catch the webview hostnames. A dead session's webviews usually fail before its main frame does, so this is where students land first. | +| gateway.routes.sessionEndedRedirect.path | string | `"/session-ended"` | Path on the landing page that explains the session ended. | +| gateway.routes.sessionEndedRedirect.statusCode | int | `302` | Redirect status code. Only 302 is accepted: eager sessions reuse instance paths, so a cached 301 would permanently break an instance in the user's browser. | | 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) | | hosts | object | (see details below) | You may adjust the hostname below. | diff --git a/charts/eduide/templates/httproute-instances.yaml b/charts/eduide/templates/httproute-instances.yaml index f89744d..d9ba570 100644 --- a/charts/eduide/templates/httproute-instances.yaml +++ b/charts/eduide/templates/httproute-instances.yaml @@ -1,17 +1,23 @@ {{- if and .Values.gateway.enabled .Values.gateway.routes.enabled }} {{/* - `rules` is deliberately ABSENT, not empty. The operator patches one rule per - live session into this route at runtime, so the chart must not state a value - for the field at all. + `rules` is deliberately ABSENT, not empty. + + This route is a TEMPLATE the operator reads, not one it writes. For each + session it copies this route's `parentRefs` and `hostnames` onto a brand-new + HTTPRoute of its own (`IngressManager.buildSessionRoute` in EduIDE-Cloud), + owner-referenced to the Session so Kubernetes garbage-collects it when the + session ends. Nothing ever patches rules into this object. `rules: []` was wrong twice over. Gateway API v1.5 added `minItems: 1`, so the API server rejects the create outright - v1.3 accepted it, which is why this - only surfaced on a newer cluster. And because Helm would then carry `rules: []` - as desired state, every `helm upgrade` would patch the live route back to zero - rules and cut routing to every running session until the operator caught up. + only surfaced on a newer cluster. And Helm would carry `rules: []` as desired + state, so every `helm upgrade` would patch the live route back to zero rules. Omitting the field avoids both: nothing to reject, and no patch generated for a field the chart does not own. + + Requests that match no live session are caught by `httproute-session-ended.yaml`, + which redirects them to the landing page instead of letting Envoy answer 404. */}} apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute diff --git a/charts/eduide/templates/httproute-session-ended.yaml b/charts/eduide/templates/httproute-session-ended.yaml new file mode 100644 index 0000000..61575bc --- /dev/null +++ b/charts/eduide/templates/httproute-session-ended.yaml @@ -0,0 +1,59 @@ +{{- if and .Values.gateway.enabled .Values.gateway.routes.enabled .Values.gateway.routes.sessionEndedRedirect.enabled .Values.landingPage.enabled }} +{{- $redirect := .Values.gateway.routes.sessionEndedRedirect }} +{{- $code := default 302 $redirect.statusCode }} +{{/* + Catches requests to session paths that no longer exist. + + The operator gives every session its own HTTPRoute, owner-referenced to the + Session, so Kubernetes garbage-collects the route the moment the session is + deleted. Without this route the path then matches nothing and the student gets + a bare Envoy 404 on reload, with no explanation and no way back. + + This is a SEPARATE route rather than a rule on `httproute-instances.yaml`, + deliberately: that route is an input the operator reads (it copies its + parentRefs and hostnames onto every session route). Leaving it rule-free keeps + that one-way relationship intact. + + WHY THIS DOES NOT SWALLOW LIVE SESSIONS. Gateway API resolves matches across + all routes on a listener by hostname specificity first, then by the number of + characters in the matching path. This route and the session routes carry the + same hostnames, so path length decides: a session's `//` prefix, and its + `Exact` rule, both outrank `/`. The catch-all only ever sees requests no live + session claims. + + 301 IS FORBIDDEN HERE, despite the cluster chart's redirect template arguing + for it. Eager sessions reuse stable paths (`/-/`), so a + cached permanent redirect would break that instance in that browser for good - + for every student who lands on it afterwards. +*/}} +{{- if ne (int $code) 302 }} +{{- fail (printf "gateway.routes.sessionEndedRedirect.statusCode is %v; only 302 is allowed. Eager sessions reuse instance paths, so a cached 301 would permanently break an instance in the user's browser." $code) }} +{{- end }} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: session-ended-route + namespace: {{ .Release.Namespace }} +spec: + parentRefs: +{{ include "theia-cloud.gateway.parentRefs" . | nindent 2 }} + hostnames: + - {{ include "theia-cloud.host.instance" . | quote }} + {{- if $redirect.includeWildcardInstances }} + {{- include "theia-cloud.host.wildcardInstances" . | trim | nindent 2 }} + {{- end }} + rules: + - matches: + - path: + type: PathPrefix + value: / + filters: + - type: RequestRedirect + requestRedirect: + scheme: https + hostname: {{ include "theia-cloud.host.landing" . | quote }} + statusCode: {{ int $code }} + path: + type: ReplaceFullPath + replaceFullPath: {{ $redirect.path | quote }} +{{- end }} diff --git a/charts/eduide/values.yaml b/charts/eduide/values.yaml index 85e2f65..e7b4469 100644 --- a/charts/eduide/values.yaml +++ b/charts/eduide/values.yaml @@ -426,6 +426,29 @@ gateway: routes: # -- Whether to render HTTPRoute resources. enabled: true + # -- Where a browser lands when it asks for a session that no longer exists. + # + # Session routes are owned by their Session and vanish with it, so a timed-out + # tab that reloads otherwise gets a bare Envoy 404. This renders a catch-all + # route on the instance hostnames that redirects those requests to the landing + # page, which explains what happened and offers to resume. + # + # Live sessions are unaffected: Gateway API ranks matches by path length, and + # a session's path always beats `/`. + # @default -- (see details below) + sessionEndedRedirect: + # -- Whether to render the catch-all redirect. Needs `landingPage.enabled`, + # since there is nowhere to send people otherwise. + enabled: true + # -- Path on the landing page that explains the session ended. + path: /session-ended + # -- Redirect status code. Only 302 is accepted: eager sessions reuse + # instance paths, so a cached 301 would permanently break an instance in + # the user's browser. + statusCode: 302 + # -- Also catch the webview hostnames. A dead session's webviews usually + # fail before its main frame does, so this is where students land first. + includeWildcardInstances: true # -- GatewayClassName to use (Envoy Gateway default is typically "envoy") className: "envoy" # -- Name of the Gateway resource From f45116523972eadbef7aaf633edda50a6d16bb72 Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Wed, 23 Sep 2026 19:42:45 +0200 Subject: [PATCH 2/2] fix(eduide): put the redirect on the instances route, not a second one Review caught that the separate route could never have worked. Gateway API DEFAULTS an omitted spec.rules to a single PathPrefix "/" rule - there is a `default:` on that field in the HTTPRoute CRD, in both v1.2.1 and v1.3.0. So the instances route has always had a catch-all rule, just an empty one with no backendRefs, which is why an ended session answers 5xx rather than a plain 404. A second route carrying its own "/" rule therefore ties with it on path length, and the tie goes to the older creation timestamp - the instances route, on every upgrade. The redirect would simply never have been reached. Putting the rule on the instances route replaces the defaulted one instead of losing to it. Session routes are untouched: their // prefix and Exact rules are longer than "/" and outrank it, and the operator copies only parentRefs and hostnames from this template, never rules. includeWildcardInstances goes away with the separate route - the instances route already carries the webview hostnames, so the redirect covers them by construction. Co-Authored-By: Claude Opus 5 --- charts/eduide/README.md | 7 +-- .../eduide/templates/httproute-instances.yaml | 59 ++++++++++++++----- .../templates/httproute-session-ended.yaml | 59 ------------------- charts/eduide/values.yaml | 18 +++--- 4 files changed, 58 insertions(+), 85 deletions(-) delete mode 100644 charts/eduide/templates/httproute-session-ended.yaml diff --git a/charts/eduide/README.md b/charts/eduide/README.md index d8fe6d5..ff6dd0a 100644 --- a/charts/eduide/README.md +++ b/charts/eduide/README.md @@ -37,7 +37,7 @@ environment. Requires eduide-cluster to be installed on the cluster first. | demoApplication.pullSecret | string | `""` | the image pull secret. Leave empty if registry is public | | demoApplication.timeout | string | `"30"` | Limit in minutes | | 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":false,"enabled":true,"httpEnabled":false,"httpPort":80,"httpsPort":443,"instancesRouteName":"theia-cloud-demo-ws-route","instancesWildcardSecretNames":{},"name":"theia-cloud-gateway","parentRefs":[],"routes":{"enabled":true,"sessionEndedRedirect":{"enabled":true,"includeWildcardInstances":true,"path":"/session-ended","statusCode":302}},"serviceRouteRequestTimeout":"60s","tls":true}` | Gateway API configuration (Envoy Gateway by default) | +| gateway | object | `{"className":"envoy","create":false,"enabled":true,"httpEnabled":false,"httpPort":80,"httpsPort":443,"instancesRouteName":"theia-cloud-demo-ws-route","instancesWildcardSecretNames":{},"name":"theia-cloud-gateway","parentRefs":[],"routes":{"enabled":true,"sessionEndedRedirect":{"enabled":true,"path":"/session-ended","statusCode":302}},"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 | `false` | Create a Gateway in this namespace. Leave this false. The supported model is one shared Gateway per cluster, installed by the eduide-cluster chart, which every environment attaches to through `gateway.parentRefs` below. That is what every real installation uses and the only path that is tested. Setting this true renders a Gateway whose HTTPS listeners reference the Secrets `ws-cert-secret`, `service-cert-secret` and `landing-page-cert-secret` - which NEITHER CHART CREATES. You would have to create all three yourself. The listeners sit at Programmed=False until you do, and nothing in `helm status` explains why. | | gateway.enabled | bool | `true` | Master switch for Gateway API resources. | @@ -48,9 +48,8 @@ environment. Requires eduide-cluster to be installed on the cluster first. | 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: eduide-system | | gateway.routes.enabled | bool | `true` | Whether to render HTTPRoute resources. | -| gateway.routes.sessionEndedRedirect | object | (see details below) | Where a browser lands when it asks for a session that no longer exists. Session routes are owned by their Session and vanish with it, so a timed-out tab that reloads otherwise gets a bare Envoy 404. This renders a catch-all route on the instance hostnames that redirects those requests to the landing page, which explains what happened and offers to resume. Live sessions are unaffected: Gateway API ranks matches by path length, and a session's path always beats `/`. | -| gateway.routes.sessionEndedRedirect.enabled | bool | `true` | Whether to render the catch-all redirect. Needs `landingPage.enabled`, since there is nowhere to send people otherwise. | -| gateway.routes.sessionEndedRedirect.includeWildcardInstances | bool | `true` | Also catch the webview hostnames. A dead session's webviews usually fail before its main frame does, so this is where students land first. | +| gateway.routes.sessionEndedRedirect | object | (see details below) | Where a browser lands when it asks for a session that no longer exists. Session routes are owned by their Session and vanish with it, so a timed-out tab that reloads matches nothing. This turns the instances route's catch-all rule into a redirect to the landing page, which explains what happened and offers to resume, instead of the empty rule Gateway API defaults it to. It applies to every hostname on that route, webviews included - which is where students usually land first, since a dead session's webviews fail before its main frame does. Live sessions are unaffected: Gateway API ranks matches by path length, and a session's path always beats `/`. | +| gateway.routes.sessionEndedRedirect.enabled | bool | `true` | Whether to emit the redirect. Needs `landingPage.enabled`, since there is nowhere to send people otherwise. With this off, the instances route falls back to the defaulted empty catch-all rule. | | gateway.routes.sessionEndedRedirect.path | string | `"/session-ended"` | Path on the landing page that explains the session ended. | | gateway.routes.sessionEndedRedirect.statusCode | int | `302` | Redirect status code. Only 302 is accepted: eager sessions reuse instance paths, so a cached 301 would permanently break an instance in the user's browser. | | gateway.serviceRouteRequestTimeout | string | `"60s"` | HTTPRoute request timeout for service-route (Envoy default can be 15s) | diff --git a/charts/eduide/templates/httproute-instances.yaml b/charts/eduide/templates/httproute-instances.yaml index d9ba570..299fbd1 100644 --- a/charts/eduide/templates/httproute-instances.yaml +++ b/charts/eduide/templates/httproute-instances.yaml @@ -1,24 +1,39 @@ {{- if and .Values.gateway.enabled .Values.gateway.routes.enabled }} +{{- $redirect := .Values.gateway.routes.sessionEndedRedirect }} +{{- $emitRedirect := and $redirect.enabled .Values.landingPage.enabled }} +{{- $code := default 302 $redirect.statusCode }} {{/* - `rules` is deliberately ABSENT, not empty. + The app-level route. This is a TEMPLATE the operator reads, not one it writes: + for each session it copies this route's `parentRefs` and `hostnames` onto a + brand-new HTTPRoute of its own (`IngressManager.buildSessionRoute` in + EduIDE-Cloud), owner-referenced to the Session so Kubernetes garbage-collects + it when the session ends. Nothing ever patches rules into this object. - This route is a TEMPLATE the operator reads, not one it writes. For each - session it copies this route's `parentRefs` and `hostnames` onto a brand-new - HTTPRoute of its own (`IngressManager.buildSessionRoute` in EduIDE-Cloud), - owner-referenced to the Session so Kubernetes garbage-collects it when the - session ends. Nothing ever patches rules into this object. + THE ONE RULE HERE IS THE CATCH-ALL, and it has to live on this route rather + than on a route of its own. - `rules: []` was wrong twice over. Gateway API v1.5 added `minItems: 1`, so the - API server rejects the create outright - v1.3 accepted it, which is why this - only surfaced on a newer cluster. And Helm would carry `rules: []` as desired - state, so every `helm upgrade` would patch the live route back to zero rules. + Gateway API DEFAULTS an omitted `spec.rules` to a single + `PathPrefix: /` rule with no backendRefs - see the `default:` on + `spec.rules` in the HTTPRoute CRD. So this route always has a `/` rule + whether we write one or not, and an empty one merely answers 5xx. - Omitting the field avoids both: nothing to reject, and no patch generated for - a field the chart does not own. + A second route carrying its own `/` rule therefore cannot win: matches are + ranked by hostname, then by characters in the path, so the two `/` rules tie, + and the tie is broken by the older creation timestamp - which on any upgrade + is this route. Putting the redirect here replaces the defaulted rule instead + of losing to it. - Requests that match no live session are caught by `httproute-session-ended.yaml`, - which redirects them to the landing page instead of letting Envoy answer 404. + Session routes are unaffected: their `//` prefix and `Exact` rules are + longer than `/` and outrank it, and the operator copies only parentRefs and + hostnames from here - never rules. + + `rules: []` would be wrong twice over. Gateway API v1.5 added `minItems: 1`, so + the API server rejects it outright, and Helm would carry it as desired state + and patch the live route back to zero rules on every upgrade. */}} +{{- if and $emitRedirect (ne (int $code) 302) }} +{{- fail (printf "gateway.routes.sessionEndedRedirect.statusCode is %v; only 302 is allowed. Eager sessions reuse instance paths, so a cached 301 would permanently break an instance in the user's browser." $code) }} +{{- end }} apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: @@ -30,4 +45,20 @@ spec: hostnames: - {{ include "theia-cloud.host.instance" . | quote }} {{- include "theia-cloud.host.wildcardInstances" . | trim | nindent 2 }} + {{- if $emitRedirect }} + rules: + - matches: + - path: + type: PathPrefix + value: / + filters: + - type: RequestRedirect + requestRedirect: + scheme: https + hostname: {{ include "theia-cloud.host.landing" . | quote }} + statusCode: {{ int $code }} + path: + type: ReplaceFullPath + replaceFullPath: {{ $redirect.path | quote }} + {{- end }} {{- end }} diff --git a/charts/eduide/templates/httproute-session-ended.yaml b/charts/eduide/templates/httproute-session-ended.yaml deleted file mode 100644 index 61575bc..0000000 --- a/charts/eduide/templates/httproute-session-ended.yaml +++ /dev/null @@ -1,59 +0,0 @@ -{{- if and .Values.gateway.enabled .Values.gateway.routes.enabled .Values.gateway.routes.sessionEndedRedirect.enabled .Values.landingPage.enabled }} -{{- $redirect := .Values.gateway.routes.sessionEndedRedirect }} -{{- $code := default 302 $redirect.statusCode }} -{{/* - Catches requests to session paths that no longer exist. - - The operator gives every session its own HTTPRoute, owner-referenced to the - Session, so Kubernetes garbage-collects the route the moment the session is - deleted. Without this route the path then matches nothing and the student gets - a bare Envoy 404 on reload, with no explanation and no way back. - - This is a SEPARATE route rather than a rule on `httproute-instances.yaml`, - deliberately: that route is an input the operator reads (it copies its - parentRefs and hostnames onto every session route). Leaving it rule-free keeps - that one-way relationship intact. - - WHY THIS DOES NOT SWALLOW LIVE SESSIONS. Gateway API resolves matches across - all routes on a listener by hostname specificity first, then by the number of - characters in the matching path. This route and the session routes carry the - same hostnames, so path length decides: a session's `//` prefix, and its - `Exact` rule, both outrank `/`. The catch-all only ever sees requests no live - session claims. - - 301 IS FORBIDDEN HERE, despite the cluster chart's redirect template arguing - for it. Eager sessions reuse stable paths (`/-/`), so a - cached permanent redirect would break that instance in that browser for good - - for every student who lands on it afterwards. -*/}} -{{- if ne (int $code) 302 }} -{{- fail (printf "gateway.routes.sessionEndedRedirect.statusCode is %v; only 302 is allowed. Eager sessions reuse instance paths, so a cached 301 would permanently break an instance in the user's browser." $code) }} -{{- end }} -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - name: session-ended-route - namespace: {{ .Release.Namespace }} -spec: - parentRefs: -{{ include "theia-cloud.gateway.parentRefs" . | nindent 2 }} - hostnames: - - {{ include "theia-cloud.host.instance" . | quote }} - {{- if $redirect.includeWildcardInstances }} - {{- include "theia-cloud.host.wildcardInstances" . | trim | nindent 2 }} - {{- end }} - rules: - - matches: - - path: - type: PathPrefix - value: / - filters: - - type: RequestRedirect - requestRedirect: - scheme: https - hostname: {{ include "theia-cloud.host.landing" . | quote }} - statusCode: {{ int $code }} - path: - type: ReplaceFullPath - replaceFullPath: {{ $redirect.path | quote }} -{{- end }} diff --git a/charts/eduide/values.yaml b/charts/eduide/values.yaml index e7b4469..cf23d89 100644 --- a/charts/eduide/values.yaml +++ b/charts/eduide/values.yaml @@ -429,16 +429,21 @@ gateway: # -- Where a browser lands when it asks for a session that no longer exists. # # Session routes are owned by their Session and vanish with it, so a timed-out - # tab that reloads otherwise gets a bare Envoy 404. This renders a catch-all - # route on the instance hostnames that redirects those requests to the landing - # page, which explains what happened and offers to resume. + # tab that reloads matches nothing. This turns the instances route's catch-all + # rule into a redirect to the landing page, which explains what happened and + # offers to resume, instead of the empty rule Gateway API defaults it to. + # + # It applies to every hostname on that route, webviews included - which is + # where students usually land first, since a dead session's webviews fail + # before its main frame does. # # Live sessions are unaffected: Gateway API ranks matches by path length, and # a session's path always beats `/`. # @default -- (see details below) sessionEndedRedirect: - # -- Whether to render the catch-all redirect. Needs `landingPage.enabled`, - # since there is nowhere to send people otherwise. + # -- Whether to emit the redirect. Needs `landingPage.enabled`, since there + # is nowhere to send people otherwise. With this off, the instances route + # falls back to the defaulted empty catch-all rule. enabled: true # -- Path on the landing page that explains the session ended. path: /session-ended @@ -446,9 +451,6 @@ gateway: # instance paths, so a cached 301 would permanently break an instance in # the user's browser. statusCode: 302 - # -- Also catch the webview hostnames. A dead session's webviews usually - # fail before its main frame does, so this is where students land first. - includeWildcardInstances: true # -- GatewayClassName to use (Envoy Gateway default is typically "envoy") className: "envoy" # -- Name of the Gateway resource