From a1ec0449e7a70ba955204c87ace6ed08629f12e0 Mon Sep 17 00:00:00 2001 From: Nathan Eudeline Date: Fri, 13 Mar 2026 09:48:46 +0100 Subject: [PATCH 1/2] Add HTTPRoute support for exposed services --- docs/region-configuration.md | 28 +++++ .../api/services/impl/ServiceUrlResolver.java | 100 +++++++++++++++++- onyxia-api/src/main/resources/regions.json | 4 + .../main/resources/schemas/ide/httproute.json | 68 ++++++++++++ .../services/impl/ServiceUrlResolverTest.java | 45 +++++++- .../k8s-httproute-header-match.yaml | 21 ++++ .../k8s-httproute-no-hostnames.yaml | 16 +++ .../kubernetes-manifest/k8s-httproute.yaml | 19 ++++ .../fr/insee/onyxia/model/region/Region.java | 74 +++++++++++++ 9 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 onyxia-api/src/main/resources/schemas/ide/httproute.json create mode 100644 onyxia-api/src/test/resources/kubernetes-manifest/k8s-httproute-header-match.yaml create mode 100644 onyxia-api/src/test/resources/kubernetes-manifest/k8s-httproute-no-hostnames.yaml create mode 100644 onyxia-api/src/test/resources/kubernetes-manifest/k8s-httproute.yaml diff --git a/docs/region-configuration.md b/docs/region-configuration.md index 8343e969..27683801 100644 --- a/docs/region-configuration.md +++ b/docs/region-configuration.md @@ -125,6 +125,7 @@ Note : If you want Onyxia to create the ResourceQuota but not override it at eac | `domain` | | When users request to expose their service, only the subdomain of this object will be created. | | `ingress` | true | Whether or not Kubernetes Ingress is enabled | | `route` | false | Whether or not OpenShift Route is enabled | +| `httpRoute` | | See [HTTPRoute](#httproute) | | `istio` | | See [Istio](#istio) | | `ingressClassName` | '' | Ingress Class Name: useful if you want to use a specific ingress controller instead of a default one | | `ingressPort` | | Optional : define this if your ingress controller does not listen to 80/443 port. If set, the UI will append this port number to the "open service" button link. | @@ -141,6 +142,33 @@ Note : If you want Onyxia to create the ResourceQuota but not override it at eac | `gateways` | [] | List of istio gateways to be used. Should contain at least one element. E.g. `["istio-system/my-gateway"]` | +#### httproute + +| Key | Default | Description | +|--------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------| +| `enabled` | false | Whether or not Kubernetes Gateway API HTTPRoute support is enabled | +| `parentRefs` | [] | List of parent references to the Gateway configured by the cluster administrator. Must contain at least one element when enabled. | + +Each `parentRefs` entry must define the Gateway `name` and may also define `namespace`, `sectionName`, or `port`. For example: + +```json +{ + "enabled": true, + "parentRefs": [ + { + "name": "shared-gateway", + "namespace": "gateway-system", + "sectionName": "web" + } + ] +} +``` + +If `namespace` points to a different namespace than the service namespace, the target Gateway listener must allow routes from that namespace. + +When using HTTPRoute, service charts should also set explicit `spec.hostnames` if you want Onyxia to resolve public service URLs for the "Open" action. + + ## Data properties diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/ServiceUrlResolver.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/ServiceUrlResolver.java index 605d3d53..ffce74ad 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/ServiceUrlResolver.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/ServiceUrlResolver.java @@ -3,6 +3,9 @@ import fr.insee.onyxia.model.region.Region; import io.fabric8.kubernetes.api.model.GenericKubernetesResource; import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.gatewayapi.v1.HTTPRoute; +import io.fabric8.kubernetes.api.model.gatewayapi.v1.HTTPRouteMatch; +import io.fabric8.kubernetes.api.model.gatewayapi.v1.HTTPRouteRule; import io.fabric8.kubernetes.api.model.networking.v1.Ingress; import io.fabric8.kubernetes.client.KubernetesClient; import java.io.ByteArrayInputStream; @@ -10,6 +13,7 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.stream.Stream; import org.slf4j.Logger; @@ -24,7 +28,9 @@ private ServiceUrlResolver() {} static List getServiceUrls(Region region, String manifest, KubernetesClient client) { Region.Expose expose = region.getServices().getExpose(); boolean isIstioEnabled = expose.getIstio() != null && expose.getIstio().isEnabled(); - boolean isServiceExposed = expose.getIngress() || expose.getRoute() || isIstioEnabled; + boolean isHttpRouteEnabled = expose.getHttpRoute().isEnabled(); + boolean isServiceExposed = + expose.getIngress() || expose.getRoute() || isIstioEnabled || isHttpRouteEnabled; if (!isServiceExposed) { return List.of(); } @@ -98,10 +104,102 @@ static List getServiceUrls(Region region, String manifest, KubernetesCli } } + if (isHttpRouteEnabled) { + List httpRoutes = getResourceOfType(hasMetadata, HTTPRoute.class).toList(); + + for (HTTPRoute httpRoute : httpRoutes) { + try { + urls.addAll(getHttpRouteUrls(httpRoute)); + } catch (Exception e) { + LOGGER.warn( + "Could not read urls from HTTPRoute {}", + httpRoute.getFullResourceName()); + } + } + } + // Ensure every URL start with http-prefix return urls.stream().map(url -> url.startsWith("http") ? url : "https://" + url).toList(); } + private static List getHttpRouteUrls(HTTPRoute httpRoute) { + List hostnames = + httpRoute.getSpec() == null || httpRoute.getSpec().getHostnames() == null + ? List.of() + : httpRoute.getSpec().getHostnames(); + + if (hostnames.isEmpty()) { + LOGGER.warn( + "Could not determine urls from HTTPRoute {} because spec.hostnames is empty", + httpRoute.getFullResourceName()); + return List.of(); + } + + List paths = getHttpRoutePaths(httpRoute); + + return hostnames.stream() + .flatMap(hostname -> paths.stream().map(path -> hostname + normalizePath(path))) + .toList(); + } + + private static List getHttpRoutePaths(HTTPRoute httpRoute) { + if (httpRoute.getSpec() == null + || httpRoute.getSpec().getRules() == null + || httpRoute.getSpec().getRules().isEmpty()) { + return List.of("/"); + } + + LinkedHashSet paths = new LinkedHashSet<>(); + boolean hasImplicitRootMatch = false; + + for (HTTPRouteRule rule : httpRoute.getSpec().getRules()) { + if (rule.getMatches() == null || rule.getMatches().isEmpty()) { + hasImplicitRootMatch = true; + continue; + } + + for (HTTPRouteMatch match : rule.getMatches()) { + if ((match.getHeaders() != null && !match.getHeaders().isEmpty()) + || (match.getQueryParams() != null && !match.getQueryParams().isEmpty()) + || match.getMethod() != null) { + continue; + } + + if (match.getPath() == null) { + continue; + } + + String pathType = match.getPath().getType(); + + if (pathType != null && !pathType.equals("PathPrefix") && !pathType.equals("Exact")) { + continue; + } + + String pathValue = match.getPath().getValue(); + + if (pathValue == null || pathValue.isBlank()) { + continue; + } + + paths.add(normalizePath(pathValue)); + } + } + + if (!paths.isEmpty()) { + return List.copyOf(paths); + } + + return hasImplicitRootMatch ? List.of("/") : List.of(); + } + + private static String normalizePath(String path) { + if (path == null || path.isBlank()) { + return "/"; + } + + return path.startsWith("/") ? path : "/" + path; + } + private static Stream getResourceOfType( List resourcesStream, Class type) { return resourcesStream.stream().filter(type::isInstance).map(type::cast); diff --git a/onyxia-api/src/main/resources/regions.json b/onyxia-api/src/main/resources/regions.json index 89107074..db73e64d 100644 --- a/onyxia-api/src/main/resources/regions.json +++ b/onyxia-api/src/main/resources/regions.json @@ -51,6 +51,10 @@ "domain": "fakedomain.kub.example.com", "ingress": true, "route": false, + "httpRoute": { + "enabled": false, + "parentRefs": [] + }, "istio": { "enabled": false, "gateways": [] diff --git a/onyxia-api/src/main/resources/schemas/ide/httproute.json b/onyxia-api/src/main/resources/schemas/ide/httproute.json new file mode 100644 index 00000000..f82a46cb --- /dev/null +++ b/onyxia-api/src/main/resources/schemas/ide/httproute.json @@ -0,0 +1,68 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HTTPRoute", + "description": "Gateway API HTTPRoute parameters", + "type": "object", + "properties": { + "enabled": { + "description": "Enable HTTPRoute", + "type": "boolean", + "default": false, + "x-onyxia": { + "hidden": true, + "overwriteDefaultWith": "k8s.httpRoute.enabled" + } + }, + "hostname": { + "type": "string", + "form": true, + "title": "Hostname", + "description": "Convenience hostname that service charts can map to HTTPRoute spec.hostnames", + "x-onyxia": { + "hidden": true, + "overwriteDefaultWith": "{{project.id}}-{{k8s.randomSubdomain}}-0.{{k8s.domain}}" + } + }, + "userHostname": { + "type": "string", + "form": true, + "title": "Hostname", + "description": "Convenience hostname for user-facing routes that service charts can map to HTTPRoute spec.hostnames", + "x-onyxia": { + "hidden": true, + "overwriteDefaultWith": "{{project.id}}-{{k8s.randomSubdomain}}-user.{{k8s.domain}}" + } + }, + "parentRefs": { + "description": "Gateway parent references", + "type": "array", + "default": [], + "x-onyxia": { + "hidden": true, + "overwriteDefaultWith": "k8s.httpRoute.parentRefs" + }, + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Gateway name", + "description": "Name of the Gateway parent reference. Required when HTTPRoute is enabled." + }, + "namespace": { + "type": "string", + "title": "Gateway namespace" + }, + "sectionName": { + "type": "string", + "title": "Listener name" + }, + "port": { + "type": "integer", + "title": "Listener port" + } + } + } + } + } +} diff --git a/onyxia-api/src/test/java/fr/insee/onyxia/api/services/impl/ServiceUrlResolverTest.java b/onyxia-api/src/test/java/fr/insee/onyxia/api/services/impl/ServiceUrlResolverTest.java index b4820336..a97d71c2 100644 --- a/onyxia-api/src/test/java/fr/insee/onyxia/api/services/impl/ServiceUrlResolverTest.java +++ b/onyxia-api/src/test/java/fr/insee/onyxia/api/services/impl/ServiceUrlResolverTest.java @@ -16,6 +16,11 @@ class ServiceUrlResolverTest { private final String ISTIO_VIRTUAL_SERVICE_MANIFEST_PATH = "kubernetes-manifest/istio-virtualservice.yaml"; private final String INGRESS_MANIFEST_PATH = "kubernetes-manifest/k8s-ingress.yaml"; + private final String HTTP_ROUTE_MANIFEST_PATH = "kubernetes-manifest/k8s-httproute.yaml"; + private final String HTTP_ROUTE_NO_HOSTNAMES_MANIFEST_PATH = + "kubernetes-manifest/k8s-httproute-no-hostnames.yaml"; + private final String HTTP_ROUTE_HEADER_MATCH_MANIFEST_PATH = + "kubernetes-manifest/k8s-httproute-header-match.yaml"; private final String OPENSHIFT_ROUTE_MANIFEST_PATH = "kubernetes-manifest/openshift-route.yaml"; private final String YAML_LINE_BREAK = "\n---\n"; @@ -27,6 +32,8 @@ void urls_should_be_empty() { + YAML_LINE_BREAK + getClassPathResource(INGRESS_MANIFEST_PATH) + YAML_LINE_BREAK + + getClassPathResource(HTTP_ROUTE_MANIFEST_PATH) + + YAML_LINE_BREAK + getClassPathResource(OPENSHIFT_ROUTE_MANIFEST_PATH); List urls = @@ -39,12 +46,15 @@ void urls_should_be_present_for_all_ingress_types() { Region region = getRegionNoExposed(); region.getServices().getExpose().setIngress(true); region.getServices().getExpose().setRoute(true); + region.getServices().getExpose().getHttpRoute().setEnabled(true); region.getServices().getExpose().getIstio().setEnabled(true); var allManifest = getClassPathResource(ISTIO_VIRTUAL_SERVICE_MANIFEST_PATH) + YAML_LINE_BREAK + getClassPathResource(INGRESS_MANIFEST_PATH) + YAML_LINE_BREAK + + getClassPathResource(HTTP_ROUTE_MANIFEST_PATH) + + YAML_LINE_BREAK + getClassPathResource(OPENSHIFT_ROUTE_MANIFEST_PATH); List urls = @@ -53,8 +63,9 @@ void urls_should_be_present_for_all_ingress_types() { List.of( "https://jupyter-python-3574-0.example.com/", "https://hello-openshift.example.com", - "https://jupyter-python-3574-0.example.com"); - assertEquals(expected, urls); + "https://jupyter-python-3574-0.example.com", + "https://jupyter-python-3574-0.example.com/lab"); + assertEquals(expected.stream().sorted().toList(), urls.stream().sorted().toList()); } @Test @@ -87,6 +98,36 @@ void openshift_route_should_be_included_in_urls() { assertEquals(List.of("https://hello-openshift.example.com"), urls); } + @Test + void gateway_api_httproute_should_be_included_in_urls() { + Region region = getRegionNoExposed(); + region.getServices().getExpose().getHttpRoute().setEnabled(true); + var manifest = getClassPathResource(HTTP_ROUTE_MANIFEST_PATH); + + List urls = ServiceUrlResolver.getServiceUrls(region, manifest, kubernetesClient); + assertEquals(List.of("https://jupyter-python-3574-0.example.com/lab"), urls); + } + + @Test + void gateway_api_httproute_without_hostnames_should_not_be_included_in_urls() { + Region region = getRegionNoExposed(); + region.getServices().getExpose().getHttpRoute().setEnabled(true); + var manifest = getClassPathResource(HTTP_ROUTE_NO_HOSTNAMES_MANIFEST_PATH); + + List urls = ServiceUrlResolver.getServiceUrls(region, manifest, kubernetesClient); + assertEquals(List.of(), urls); + } + + @Test + void gateway_api_httproute_with_header_match_should_not_be_included_in_urls() { + Region region = getRegionNoExposed(); + region.getServices().getExpose().getHttpRoute().setEnabled(true); + var manifest = getClassPathResource(HTTP_ROUTE_HEADER_MATCH_MANIFEST_PATH); + + List urls = ServiceUrlResolver.getServiceUrls(region, manifest, kubernetesClient); + assertEquals(List.of(), urls); + } + private static Region getRegionNoExposed() { Region.Expose expose = new Region.Expose(); expose.setIngress(false); diff --git a/onyxia-api/src/test/resources/kubernetes-manifest/k8s-httproute-header-match.yaml b/onyxia-api/src/test/resources/kubernetes-manifest/k8s-httproute-header-match.yaml new file mode 100644 index 00000000..888bea56 --- /dev/null +++ b/onyxia-api/src/test/resources/kubernetes-manifest/k8s-httproute-header-match.yaml @@ -0,0 +1,21 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: jupyter-python-3574-ui +spec: + parentRefs: + - name: shared-gateway + hostnames: + - jupyter-python-3574-0.example.com + rules: + - matches: + - path: + type: PathPrefix + value: /lab + headers: + - type: Exact + name: x-onyxia + value: allowed + backendRefs: + - name: jupyter-python-3574 + port: 8888 diff --git a/onyxia-api/src/test/resources/kubernetes-manifest/k8s-httproute-no-hostnames.yaml b/onyxia-api/src/test/resources/kubernetes-manifest/k8s-httproute-no-hostnames.yaml new file mode 100644 index 00000000..7d45c670 --- /dev/null +++ b/onyxia-api/src/test/resources/kubernetes-manifest/k8s-httproute-no-hostnames.yaml @@ -0,0 +1,16 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: jupyter-python-3574-ui +spec: + parentRefs: + - name: shared-gateway + namespace: gateway-system + rules: + - matches: + - path: + type: PathPrefix + value: /lab + backendRefs: + - name: jupyter-python-3574 + port: 8888 diff --git a/onyxia-api/src/test/resources/kubernetes-manifest/k8s-httproute.yaml b/onyxia-api/src/test/resources/kubernetes-manifest/k8s-httproute.yaml new file mode 100644 index 00000000..e7707220 --- /dev/null +++ b/onyxia-api/src/test/resources/kubernetes-manifest/k8s-httproute.yaml @@ -0,0 +1,19 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: jupyter-python-3574-ui +spec: + parentRefs: + - name: shared-gateway + namespace: gateway-system + sectionName: web + hostnames: + - jupyter-python-3574-0.example.com + rules: + - matches: + - path: + type: PathPrefix + value: /lab + backendRefs: + - name: jupyter-python-3574 + port: 8888 diff --git a/onyxia-model/src/main/java/fr/insee/onyxia/model/region/Region.java b/onyxia-model/src/main/java/fr/insee/onyxia/model/region/Region.java index 8fb6f6ed..b731c22a 100644 --- a/onyxia-model/src/main/java/fr/insee/onyxia/model/region/Region.java +++ b/onyxia-model/src/main/java/fr/insee/onyxia/model/region/Region.java @@ -649,6 +649,8 @@ public static class Expose { private boolean route = false; + private HttpRoute httpRoute = new HttpRoute(); + private IstioIngress istio; private CertManager certManager; @@ -708,6 +710,14 @@ public void setRoute(boolean route) { this.route = route; } + public HttpRoute getHttpRoute() { + return httpRoute; + } + + public void setHttpRoute(HttpRoute httpRoute) { + this.httpRoute = httpRoute == null ? new HttpRoute() : httpRoute; + } + public IstioIngress getIstio() { return istio; } @@ -747,6 +757,70 @@ public void setGateways(String[] gateways) { } } + public static class HttpRoute { + private boolean enabled = false; + + private List parentRefs = new ArrayList<>(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public List getParentRefs() { + return parentRefs; + } + + public void setParentRefs(List parentRefs) { + this.parentRefs = parentRefs == null ? new ArrayList<>() : parentRefs; + } + } + + public static class HttpRouteParentRef { + private String name; + + private String namespace; + + private String sectionName; + + private Integer port; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getNamespace() { + return namespace; + } + + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + public String getSectionName() { + return sectionName; + } + + public void setSectionName(String sectionName) { + this.sectionName = sectionName; + } + + public Integer getPort() { + return port; + } + + public void setPort(Integer port) { + this.port = port; + } + } + public static class Server { @JsonProperty("URL") From 8a052fe48046f95ad8bac5d79e9c47571af8b5dc Mon Sep 17 00:00:00 2001 From: Nathan Eudeline Date: Fri, 13 Mar 2026 10:05:40 +0100 Subject: [PATCH 2/2] Simplify HTTPRoute path extraction --- .../api/services/impl/ServiceUrlResolver.java | 57 ++++++++++--------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/ServiceUrlResolver.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/ServiceUrlResolver.java index ffce74ad..57dee0f5 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/ServiceUrlResolver.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/ServiceUrlResolver.java @@ -110,7 +110,7 @@ static List getServiceUrls(Region region, String manifest, KubernetesCli for (HTTPRoute httpRoute : httpRoutes) { try { urls.addAll(getHttpRouteUrls(httpRoute)); - } catch (Exception e) { + } catch (Exception _) { LOGGER.warn( "Could not read urls from HTTPRoute {}", httpRoute.getFullResourceName()); @@ -153,43 +153,46 @@ private static List getHttpRoutePaths(HTTPRoute httpRoute) { boolean hasImplicitRootMatch = false; for (HTTPRouteRule rule : httpRoute.getSpec().getRules()) { - if (rule.getMatches() == null || rule.getMatches().isEmpty()) { + List matches = rule.getMatches(); + + if (matches == null || matches.isEmpty()) { hasImplicitRootMatch = true; - continue; + } else { + matches.stream() + .map(ServiceUrlResolver::getHttpRoutePath) + .flatMap(java.util.Optional::stream) + .forEach(paths::add); } + } - for (HTTPRouteMatch match : rule.getMatches()) { - if ((match.getHeaders() != null && !match.getHeaders().isEmpty()) - || (match.getQueryParams() != null && !match.getQueryParams().isEmpty()) - || match.getMethod() != null) { - continue; - } - - if (match.getPath() == null) { - continue; - } - - String pathType = match.getPath().getType(); + if (!paths.isEmpty()) { + return List.copyOf(paths); + } - if (pathType != null && !pathType.equals("PathPrefix") && !pathType.equals("Exact")) { - continue; - } + return hasImplicitRootMatch ? List.of("/") : List.of(); + } - String pathValue = match.getPath().getValue(); + private static java.util.Optional getHttpRoutePath(HTTPRouteMatch match) { + if ((match.getHeaders() != null && !match.getHeaders().isEmpty()) + || (match.getQueryParams() != null && !match.getQueryParams().isEmpty()) + || match.getMethod() != null + || match.getPath() == null) { + return java.util.Optional.empty(); + } - if (pathValue == null || pathValue.isBlank()) { - continue; - } + String pathType = match.getPath().getType(); - paths.add(normalizePath(pathValue)); - } + if (pathType != null && !pathType.equals("PathPrefix") && !pathType.equals("Exact")) { + return java.util.Optional.empty(); } - if (!paths.isEmpty()) { - return List.copyOf(paths); + String pathValue = match.getPath().getValue(); + + if (pathValue == null || pathValue.isBlank()) { + return java.util.Optional.empty(); } - return hasImplicitRootMatch ? List.of("/") : List.of(); + return java.util.Optional.of(normalizePath(pathValue)); } private static String normalizePath(String path) {