Routing design¶
One routing mode applies to the whole platform, set by the Helm value
routing.mode. A routing backend in core/src/ecmwf/sites/core/kubernetes/
routing/ hides the difference; the operator uses it to create,
update, and delete the route of a site. The Hub only maintains the
read-only state and the master-Ingress snippet through the data access object (DAO).
Installation and configuration procedures are in
sys-admin: routing and networking.
Modes¶
ingress (default) |
gateway |
|
|---|---|---|
| Platform resource | NGINX mergeable Ingress pair: sites-ingress-master + one minion per site |
One Gateway (optionally created by the chart, routing.gateway.create) + one HTTPRoute for the Hub and one per site |
| Controller features (session affinity, timeouts, buffering) | NGINX annotations and snippets | A policy provider; the shipped provider none adds nothing, so controller defaults apply |
| Security headers, read-only enforcement | Ingress location snippets | Hub Flask middleware (10) |
| TLS | routing.tls.secretName on the master Ingress (minion Ingresses carry no TLS block) |
Listener certificate on the Gateway; with an existing Gateway the chart does not use routing.tls |
| Requirements | NGINX mergeable-ingress behaviour enabled on the controller | Gateway API standard-channel Custom Resource Definitions (CRDs), a running controller, a listener accepting routes from the release namespace, networkPolicy.gatewayNamespace set when NetworkPolicies are on |
flowchart LR
subgraph ingress["ingress mode"]
I1["client"] --> M["master Ingress (host, TLS, platform snippets)"] --> MN["minion Ingress per site (/space/site)"] --> SVC["site Service"]
M --> H["Hub: master defaultBackend + minion path /"] --> HS["hub Service"]
end
subgraph gateway["gateway mode"]
G1["client"] --> GW["Gateway (listener + certificate)"] --> HR["HTTPRoute Hub"] --> HS2["hub Service"]
GW --> SR["HTTPRoute per site"] --> SS["site Service"]
end
The site path layout is identical in both modes (01-overview). Each site gets exactly one route object. Public sub-domain routing is not implemented: sites are reachable by path under the platform host only.
routing.host and routing.scheme (chart values) drive route hosts and the
gateway redirect scheme. Site routes use routing.host; in ingress mode a
site route without routing.host falls back to the host of the master
Ingress — specifically the first rule of that Ingress
(ingress_utils.extract_ingress_host, result cached with the
cache.timeout_cold time to live (TTL)) — while
gateway mode fails when no host is available
(core/.../routing/endpoint.py; it never reads an Ingress). The fallback
applies only to a legacy or hand-written configuration that omits
routing.host: chart rendering requires the value in every mode (below).
The chart rejects an unknown routing.mode at render time, rejects
sitesConfig.routing ("use the top-level routing values"), and rejects the
moved keys ingress.* (top level), routing.hstsEnabled,
sitesConfig.kubernetes.ingress_class_name, and
sitesConfig.kubernetes.ingress_extra_annotations. The Python schema
applies the same rules to the rendered configuration, so a hand-edited
ConfigMap cannot carry them either (_helpers.tpl,
core/.../config/schema/models.py).
Routing values (chart)¶
| Key | Default | Purpose |
|---|---|---|
routing.mode |
ingress |
ingress or gateway; one mode platform-wide |
routing.host |
sites.example.org in values |
Public host of all sites, and of the Hub when routing.hubHost is empty; the chart requires a non-empty value in every mode, and the Python schema additionally requires it in gateway mode |
routing.hubHost |
"" |
Separate public host of the Hub; must differ from routing.host. The chart then renders sites-ingress-hub (ingress mode) or the sites-hub-host HTTPRoute (gateway mode), and the Hub answers 308 to this host for every request on routing.host |
routing.tls.hubSecretName |
"" |
Certificate Secret of routing.hubHost; empty means routing.tls.secretName |
routing.gateway.hubListenerName |
"" |
sectionName of the sites-hub-host route; a chart-created Gateway gets the listeners hub-https (and hub-http when httpRedirect is true) |
routing.scheme |
https |
Scheme used in redirect URLs; http or https |
routing.hsts |
true |
Adds the Strict-Transport-Security header (minion snippet in ingress mode, Hub middleware in gateway mode) |
routing.tls.enabled / .secretName |
true / sites-tls |
TLS block on the master Ingress and on a chart-created Gateway; the chart only references the Secret |
routing.ingress.className |
nginx |
Class of every site Ingress and of the platform Ingresses |
routing.ingress.hubAnnotations |
{} |
Extra annotations on the master Ingress |
routing.ingress.routeAnnotations |
{} |
Extra annotations on every site Ingress |
routing.gateway.name |
sites-gateway |
Gateway that all HTTPRoutes attach to; required in gateway mode |
routing.gateway.namespace |
"" |
Gateway namespace; must be empty or the release namespace when the chart creates the Gateway |
routing.gateway.listenerName |
"" |
sectionName for all HTTPRoutes; with a chart-created Gateway an empty value means https (or http without TLS); empty with an existing Gateway means all listeners |
routing.gateway.create / .className / .annotations / .listeners |
false / "" / {} / [] |
Chart-managed Gateway; className is required when create is true |
routing.gateway.httpRedirect |
true |
HTTP→HTTPS listener + redirect route on a chart-created Gateway (see below) |
routing.gateway.routeAnnotations |
{} |
Extra annotations on all HTTPRoutes (Hub and sites) |
routing.gateway.policyProvider / .policies |
none / [] |
Policy provider and the features it must supply (validated against the provider) |
routing.gateway.policyRbacRules |
[] |
Extra operator Role rules for policy kinds; the chart does not validate them |
Route shapes in ingress mode¶
Three kinds of Ingress objects exist. All carry
ingressClassName: routing.ingress.className.
sites-ingress-master (chart): the mergeable master for
routing.host. It carries defaultBackend: sites-hub:8080, so the Hub
answers every path no minion claims. Its fixed annotations set
nginx.org/redirect-to-https, a 600 s connect/read/send proxy timeout,
64k buffers, unlimited client body size, buffering off, and a
location-snippet with proxy_cache off and request buffering off. The TLS
block renders only with routing.tls.enabled, and references
routing.tls.secretName — the chart never creates the Secret; a
certificate controller or the operator must provide it.
routing.ingress.hubAnnotations merges on top.
sites-ingress Hub minion (chart): the mergeable minion with the
single path / → sites-hub:8080. Its nginx.org/location-snippets
carry the platform behaviour that document 10 attributes
to the "minion snippet": the five security headers, Content Security Policy (CSP) from
trusted_domains, HTTP Strict Transport Security (HSTS) when routing.hsts, Cache-Control: no-cache,
and X-Sites-Proxy. The same snippet denies /hub/metrics/ (deny all)
and rewrites /<space>/<site> without a trailing slash to the path with
a trailing slash (redirect) — the browser-friendly site entry.
Site minions (operator-built, one per site, named after the site id):
mergeable minion, single path /<space>/<site>, host from
routing.host or the master Ingress. Annotations merge in this order, so
later layers win: site metadata annotations → generated annotations
(mergeable-ingress-type: minion, websocket-services: <site id>, and
lb-method: hash $remote_addr consistent when site.replicas > 1) →
application annotations → routing.ingress.routeAnnotations. A disabled
site gets the snippet return 302 /hub/error/page/410/; appended to its
location-snippets (ingress_utils.archive_conf_snippet).
Route shapes in gateway mode¶
Gateway. The chart creates routing.gateway.name only when
routing.gateway.create is true. Listener selection, in order:
routing.gateway.listeners(a raw listener list) replaces everything.- With
routing.tls.enabled: an HTTPS listener on port 443 namedhttps, hostnamerouting.host,certificateRefstorouting.tls.secretName(reference only), plus a port-80httplistener whenrouting.gateway.httpRedirectis true. - Otherwise: one HTTP listener on port 80 named
http.
Every chart-managed listener sets allowedRoutes.namespaces.from: Same.
With create: false the chart renders no Gateway and cannot check these
conditions — the existing Gateway must accept routes from the release
namespace.
HTTP→HTTPS redirect. The chart renders sites-http-redirect (an
HTTPRoute on the http listener with a RequestRedirect filter, status
301) only when all of: create is true, routing.tls.enabled,
httpRedirect, and no custom listeners. Setting listeners silently
drops the redirect route, and an existing Gateway gets no redirect at all —
there, redirecting is the platform operator's job. Ingress mode does not
need either: the master Ingress always carries
nginx.org/redirect-to-https.
Hub HTTPRoute (sites-hub): hostnames: [routing.host], parentRef
from the chart helper, routing.gateway.routeAnnotations, and two rules:
/hub/metrics (Prefix) redirects to /hub/error/page/403/ (status 302 —
monitoring reads the Service directly), and / (Prefix) → sites-hub:8080.
Site HTTPRoutes (operator-built through
SiteExporter.to_http_route, named after the site id): one parentRefs
entry and hostnames: [routing.host]; annotations combine the policy
provider's annotations with routing.gateway.routeAnnotations; updates
carry over the previous resourceVersion and ownerReferences. The
parentRef includes namespace only when the Gateway lives in another
namespace and sectionName only when routing.gateway.listenerName is
set; the chart helper and the Python get_parent_ref build the same
reference.
Rule conversion translates the application's Ingress paths
(core/.../gateway_utils.py):
| Ingress path | HTTPRoute result |
|---|---|
pathType: Prefix (the site path /space/site) |
PathPrefix match → backendRefs to the site Service |
Prefix path ending with / |
adds an Exact match without the slash → 302 redirect to the slashed path |
pathType: Exact |
Exact match, no redirect rule |
| Any other path type | UnsupportedRouteError — the export fails |
| Backend without a numeric Service port | UnsupportedRouteError |
| Disabled site (any path type) | the match gets a RequestRedirect filter, status 302, to /hub/error/page/410/ |
Site Service (operator-built through SiteExporter.to_service, named
after the site id): both modes route to it. It selects the site pod, uses
ClientIP session affinity, and sets publishNotReadyAddresses: true. The
pod stays in the Service endpoints when it is not Ready. The site-custom
readiness probe (custom applications) only
reports whether the custom application accepts connections. It does not
remove the pod from routing, so the static site, the Site API, and the other
site containers stay reachable while the custom application does not accept
connections. During pod start-up, a request can reach the pod before its
containers listen; the gateway then answers 502, not 503. An existing
Service gets the field at the next Site update or forced upgrade.
Route ownership¶
Every route object created for a site carries the sites.ecmwf.int/id
label, and its name is the site id in both modes. Two rules follow from
this:
- The operator owns site routes. The routing backend reads the object
by the site id and only changes routes whose
sites.ecmwf.int/idlabel equals the site id; a same-named object with any other label raisesRouteOwnershipError(core/.../routing/backend.py). A site update (update_routesinoperator/.../site_update.py) creates or replaces the route of the active mode — and deletes the active-mode route when the site has no public path — then deletes any route of the other mode that carries the site's label. The other-mode deletion is skipped for a missing or foreign route, and a deletion failure retries the whole update after 30 s. The create path (create_routes) does not delete other-mode routes; cleanup therefore depends on an update, which is exactly what the staged mode switch exploits. New routes and policy objects are adopted (kopf.adopt) so they are removed with the Site. - The Helm release owns platform routes. The Hub route, the Gateway
(when chart-created), and the HTTP→HTTPS redirect route
(
routing.gateway.httpRedirect, default true) are Helm-managed.
Gateway policy resources (from a policy provider) are synced with the site
route: the operator creates missing objects, replaces owned ones, lists by
the label selector sites.ecmwf.int/id=<site id> and deletes stale ones,
and deletes all policy objects of the provider when the route is
deleted. A policy object that exists with a foreign label raises
RouteOwnershipError (core/.../routing/gateway.py). The chart can grant
the operator role-based access control (RBAC) for extra policy resource kinds through
routing.gateway.policyRbacRules; the operator Role already allows
httproutes get/create/update/delete in both modes, so a mode switch
never needs an RBAC change. The Hub has no HTTPRoute RBAC at all — it only
touches the master Ingress and the platform-state ConfigMap.
Policy providers¶
Standard Gateway API resources do not cover features such as session
affinity. A policy provider converts the requested features into
controller-specific resources (core/.../routing/policies.py):
- The only shipped provider is
none(StandardPolicyProvider): no annotations, no policy objects. routing.gateway.policieslists required features (currentlysession_affinity); the configuration schema rejects a feature the selected provider does not support.- When
site.replicas > 1and no policy supplies session affinity, the export logs a warning (in ingress mode thelb-methodannotation supplies it automatically). - New providers register at import time through
register_policy_provider; an unknown provider name is a configuration error.
Mode switch design¶
A mode switch is deliberately staged, because the Helm upgrade changes only the platform routes. Sites keep serving through the old mode until the operator touches each one, and the operator touches a site only on an update. The procedure exploits this:
- Upgrade to a version that supports both modes, keeping the current mode.
The Hub copies the legacy read-only state from the master Ingress into
the
sites-platform-stateConfigMap. - Open the maintenance window.
helm upgradewith the newrouting.mode.- If the new entry point has a different external address, move the public DNS record to it (integration or manual). When both modes share one address, the record does not change. The platform does not manage DNS records.
- Verify the Hub.
- Force an upgrade of all sites: "Force Upgrade All Sites" or
POST /hub/api/v2/sites/upgrade/?force=true(09). Each site update installs the new-mode route and removes the old-mode route. - Verify all sites.
Rollback: run helm upgrade with the previous mode (or helm rollback).
Sites you did not upgrade become reachable again through their retained
old-mode route; upgraded sites need one more forced upgrade. Never edit
sites-platform-state manually.
The routing backend rejects ambiguous ownership: taking over a route
labelled for a different site raises an error, and the operator retries
after 60 s rather than deleting the other route
(LIFE-07). The
same 60 s deferral also protects a new site whose name already has a
foreign route (site_handler.__has_route before the create flow).
Three gotchas for step 3 and step 6:
- Gateway mode needs a non-empty
routing.hostbefore any site update: the operator fails the update with akopf.TemporaryError("Public host name required") when it builds the site files ConfigMap. - The chart renders gateway mode only when the cluster serves the
gateway.networking.k8s.io/v1 HTTPRouteCRD (render-time check; nothing checks later removal). - Routing values live in the
sites-configConfigMap, whose hash is an annotation on the Hub and operator pod templates. A routing change rolls those pods; that is intentional and needs no extra restart.
Read-only state¶
Read-only mode is a routing-adjacent platform switch stored under the
read-only key of the sites-platform-state ConfigMap. Helm does not
manage that ConfigMap, so the state survives upgrades and mode switches
(core/.../kubernetes/platform_state.py; concurrent writes retry up to
five times on a 409 conflict, and the API contract is in
12-hub-api).
State resolution (SitesDAO.is_system_read_only): the ConfigMap value
wins; without one, ingress mode falls back to the presence of the legacy
snippet on the master Ingress, and gateway mode reports false. The toggle
writes the ConfigMap and re-syncs the snippet; the Hub startup runs the
same sync in a background thread, because a helm upgrade can recreate the
master Ingress without the snippet, and imports the legacy state once.
The NGINX snippet is the ingress-mode enforcement point. Its exact logic:
methods outside GET|OPTIONS|HEAD return 405 /hub/error/page/405/,
except the exact URI /hub/api/v2/read-only/ and any path under
/hub/list/ — the same allow-list the gateway-mode Hub middleware applies
(the middleware behaviour, including the 5-second per-replica state cache
and its immediate clear after a toggle through that Hub, is owned by
12-hub-api).
Site-level read-only enforcement does not exist; the switch is for the Hub
API surface.
Behaviour requirements¶
| ID | Requirement | Implemented in | Verified by |
|---|---|---|---|
| RTG-01 | Exactly one routing mode is active platform-wide; sitesConfig.routing is rejected, routing settings come only from chart values. |
_helpers.tpl, core/.../config/schema/models.py |
core/tests/test_helm_configuration.py (both render-failure paths), utilities/tests/helm-routing-and-secrets.test.sh |
| RTG-02 | A site has at most one active route object; updating a site deletes the route of the other mode only when the site owns it, and a route with no public path is deleted. | core/.../routing/backend.py, operator/.../site_update.py |
operator/tests/test_site_update.py (test_deletes_routes_of_other_modes, test_ignores_missing_or_foreign_routes_of_other_modes, test_deletes_route_without_public_path) |
| RTG-03 | A route owned by another site is never stolen; the operator defers with a 60 s retry, in the update path and before the create path. | operator/.../site_update.py, site_handler.py |
operator/tests/test_site_update.py (test_foreign_route_raises_temporary_error; the delay value is not asserted; the create-path deferral is not tested) |
| RTG-04 | In gateway mode, controller-specific features come only from a policy provider validated against declared features; the shipped provider is none. |
core/.../routing/policies.py, models.py |
core/tests/test_routing.py (TestPolicyProviders, TestRoutingSchema.test_policies_supported_by_provider) |
| RTG-05 | The chart never creates TLS Secrets; it references routing.tls.secretName (Ingress mode or chart-created Gateway). |
helm/.../values.yaml, ingress/gateway templates |
utilities/tests/helm-routing-and-secrets.test.sh (asserts references; the absence of a rendered TLS Secret is not asserted) |
| RTG-06 | Disabled sites respond with a 302 redirect to /hub/error/page/410/ in both modes. |
ingress_utils.py, gateway_utils.py |
core/tests/test_routing.py::TestGatewayExport::test_rules_for_disabled_site, core/tests/test_site_exporter_ingress.py (asserts the snippet is applied for a disabled site; the snippet text is not asserted) |
| RTG-07 | Gateway mode requires routing.host and routing.gateway.name; the chart also requires the HTTPRoute CRD, the gateway namespace rule, and networkPolicy.gatewayNamespace when policies are on. |
models.py (validate_gateway_mode), _helpers.tpl (sites.routing.validate) |
core/tests/test_routing.py::TestRoutingSchema, core/tests/test_helm_configuration.py, utilities/tests/helm-routing-and-secrets.test.sh |
| RTG-08 | Policy objects are labelled with the site id, created, replaced, garbage-collected by that label, and deleted with the route; a foreign policy object raises RouteOwnershipError. |
core/.../routing/gateway.py |
core/tests/test_routing.py::TestGatewayRoutingBackend (test_sync_policies_creates_updates_and_removes, test_delete_removes_policies) |
| RTG-09 | Site HTTPRoute rules use only PathPrefix/Exact matches with numeric Service ports; any other path or port raises UnsupportedRouteError; a slashed Prefix path gains an exact-match 302 to the slashed path. |
core/.../gateway_utils.py |
core/tests/test_routing.py::TestGatewayExport (test_rules_for_enabled_site, test_exact_path_has_no_redirect, test_unsupported_paths) |
| RTG-10 | The platform-state ConfigMap is the authoritative read-only store; ingress mode falls back to the legacy master-Ingress snippet; the toggle and the Hub startup sync add or remove the snippet in ingress mode only, and the startup import never overwrites an existing state. | core/.../kubernetes/sites_dao.py, hub/.../routing_rules.py |
core/tests/test_routing.py::TestReadOnlyState, hub/tests/test_routing_rules.py |
| RTG-11 | HTTP→HTTPS: the master Ingress always carries redirect-to-https; the chart adds the 301 redirect HTTPRoute only for a chart-created Gateway with TLS, httpRedirect, and no custom listeners. |
ingress-master.yaml, gateway.yaml |
— (render conditions are not asserted) |
| RTG-12 | Public /hub/metrics requests are blocked in both modes: deny all in the Hub minion snippet, and a 302 redirect to /hub/error/page/403/ in the Hub HTTPRoute. |
ingress-minion.yaml, httproute-hub.yaml |
— (render conditions are not asserted) |
| RTG-13 | The site Service publishes not-ready addresses, on create and on update, so pod readiness never removes a site pod from routing. | core/.../model/site_exporter.py (to_service) |
core/tests/test_site_exporter_service.py |