Skip to content
Open
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
28 changes: 28 additions & 0 deletions docs/region-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
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;
import java.io.IOException;
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;
Expand All @@ -24,7 +28,9 @@ private ServiceUrlResolver() {}
static List<String> 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();
}
Expand Down Expand Up @@ -98,10 +104,105 @@ static List<String> getServiceUrls(Region region, String manifest, KubernetesCli
}
}

if (isHttpRouteEnabled) {
List<HTTPRoute> httpRoutes = getResourceOfType(hasMetadata, HTTPRoute.class).toList();

for (HTTPRoute httpRoute : httpRoutes) {
try {
urls.addAll(getHttpRouteUrls(httpRoute));
} catch (Exception _) {
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<String> getHttpRouteUrls(HTTPRoute httpRoute) {
List<String> 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<String> paths = getHttpRoutePaths(httpRoute);

return hostnames.stream()
.flatMap(hostname -> paths.stream().map(path -> hostname + normalizePath(path)))
.toList();
}

private static List<String> getHttpRoutePaths(HTTPRoute httpRoute) {
if (httpRoute.getSpec() == null
|| httpRoute.getSpec().getRules() == null
|| httpRoute.getSpec().getRules().isEmpty()) {
return List.of("/");
}

LinkedHashSet<String> paths = new LinkedHashSet<>();
boolean hasImplicitRootMatch = false;

for (HTTPRouteRule rule : httpRoute.getSpec().getRules()) {
List<HTTPRouteMatch> matches = rule.getMatches();

if (matches == null || matches.isEmpty()) {
hasImplicitRootMatch = true;
} else {
matches.stream()
.map(ServiceUrlResolver::getHttpRoutePath)
.flatMap(java.util.Optional::stream)
.forEach(paths::add);
}
}

if (!paths.isEmpty()) {
return List.copyOf(paths);
}

return hasImplicitRootMatch ? List.of("/") : List.of();
}

private static java.util.Optional<String> 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();
}

String pathType = match.getPath().getType();

if (pathType != null && !pathType.equals("PathPrefix") && !pathType.equals("Exact")) {
return java.util.Optional.empty();
}

String pathValue = match.getPath().getValue();

if (pathValue == null || pathValue.isBlank()) {
return java.util.Optional.empty();
}

return java.util.Optional.of(normalizePath(pathValue));
}

private static String normalizePath(String path) {
if (path == null || path.isBlank()) {
return "/";
}

return path.startsWith("/") ? path : "/" + path;
}

private static <T extends HasMetadata> Stream<T> getResourceOfType(
List<HasMetadata> resourcesStream, Class<T> type) {
return resourcesStream.stream().filter(type::isInstance).map(type::cast);
Expand Down
4 changes: 4 additions & 0 deletions onyxia-api/src/main/resources/regions.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
"domain": "fakedomain.kub.example.com",
"ingress": true,
"route": false,
"httpRoute": {
"enabled": false,
"parentRefs": []
},
"istio": {
"enabled": false,
"gateways": []
Expand Down
68 changes: 68 additions & 0 deletions onyxia-api/src/main/resources/schemas/ide/httproute.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
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";

Check warning on line 19 in onyxia-api/src/test/java/fr/insee/onyxia/api/services/impl/ServiceUrlResolverTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this field "HTTP_ROUTE_MANIFEST_PATH" to match the regular expression '^[a-z][a-zA-Z0-9]*$'.

See more on https://sonarcloud.io/project/issues?id=InseeFrLab_onyxia-api&issues=AZzmZ9HPtPOaBsa69JSQ&open=AZzmZ9HPtPOaBsa69JSQ&pullRequest=664
private final String HTTP_ROUTE_NO_HOSTNAMES_MANIFEST_PATH =

Check warning on line 20 in onyxia-api/src/test/java/fr/insee/onyxia/api/services/impl/ServiceUrlResolverTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this field "HTTP_ROUTE_NO_HOSTNAMES_MANIFEST_PATH" to match the regular expression '^[a-z][a-zA-Z0-9]*$'.

See more on https://sonarcloud.io/project/issues?id=InseeFrLab_onyxia-api&issues=AZzmZ9HPtPOaBsa69JSR&open=AZzmZ9HPtPOaBsa69JSR&pullRequest=664
"kubernetes-manifest/k8s-httproute-no-hostnames.yaml";
private final String HTTP_ROUTE_HEADER_MATCH_MANIFEST_PATH =

Check warning on line 22 in onyxia-api/src/test/java/fr/insee/onyxia/api/services/impl/ServiceUrlResolverTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this field "HTTP_ROUTE_HEADER_MATCH_MANIFEST_PATH" to match the regular expression '^[a-z][a-zA-Z0-9]*$'.

See more on https://sonarcloud.io/project/issues?id=InseeFrLab_onyxia-api&issues=AZzmZ9HPtPOaBsa69JSS&open=AZzmZ9HPtPOaBsa69JSS&pullRequest=664
"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";

Expand All @@ -27,6 +32,8 @@
+ YAML_LINE_BREAK
+ getClassPathResource(INGRESS_MANIFEST_PATH)
+ YAML_LINE_BREAK
+ getClassPathResource(HTTP_ROUTE_MANIFEST_PATH)
+ YAML_LINE_BREAK
+ getClassPathResource(OPENSHIFT_ROUTE_MANIFEST_PATH);

List<String> urls =
Expand All @@ -39,12 +46,15 @@
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<String> urls =
Expand All @@ -53,8 +63,9 @@
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
Expand Down Expand Up @@ -87,6 +98,36 @@
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<String> 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<String> 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<String> urls = ServiceUrlResolver.getServiceUrls(region, manifest, kubernetesClient);
assertEquals(List.of(), urls);
}

private static Region getRegionNoExposed() {
Region.Expose expose = new Region.Expose();
expose.setIngress(false);
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading