-
Notifications
You must be signed in to change notification settings - Fork 27
[WIP] [Operator] Add Route #2418
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bf1d392
fe4307a
5ea4edb
a55f779
740c4db
ecd09a6
f4e8e36
d404554
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,15 +2,16 @@ | |
|
|
||
| import jakarta.enterprise.context.ApplicationScoped; | ||
| import jakarta.inject.Inject; | ||
|
|
||
| import com.github.streamshub.console.ReconciliationException; | ||
| import com.github.streamshub.console.api.v1alpha1.Console; | ||
|
|
||
| import io.fabric8.kubernetes.api.model.networking.v1.Ingress; | ||
| import io.fabric8.openshift.api.model.Route; | ||
| import io.javaoperatorsdk.operator.api.config.informer.Informer; | ||
| import io.javaoperatorsdk.operator.api.reconciler.Context; | ||
| import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; | ||
| import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; | ||
| import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; | ||
| import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; | ||
|
|
||
| @ApplicationScoped | ||
| @KubernetesDependent(informer = @Informer(labelSelector = ConsoleResource.MANAGEMENT_SELECTOR)) | ||
|
|
@@ -33,6 +34,13 @@ public String resourceName() { | |
| @Override | ||
| protected Ingress desired(Console primary, Context<Console> context) { | ||
| String host = primary.getSpec().getHostname(); | ||
|
|
||
| if (host == null || host.isBlank()) { | ||
| throw new ReconciliationException( | ||
| "spec.hostname is required when running on plain Kubernetes vanila clusters. " + | ||
| "Please set a hostname in your Console resource."); | ||
|
Comment on lines
+39
to
+41
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should probably go in Also, the |
||
| } | ||
|
|
||
| setAttribute(context, INGRESS_URL_KEY, "https://" + host); | ||
|
|
||
| return load(context, "console.ingress.yaml", Ingress.class) | ||
|
|
@@ -43,7 +51,9 @@ protected Ingress desired(Console primary, Context<Console> context) { | |
| .withLabels(commonLabels("console")) | ||
| .endMetadata() | ||
| .editSpec() | ||
| .withIngressClassName(getIngressClassName(context)) | ||
| // Plain Kubernetes (non-OCP) clusters don't need a class name; the | ||
| // default ingress controller picks it up automatically. | ||
| .withIngressClassName(null) | ||
| .editDefaultBackend() | ||
| .editService() | ||
| .withName(service.instanceName(primary)) | ||
|
|
@@ -66,10 +76,15 @@ protected Ingress desired(Console primary, Context<Console> context) { | |
| } | ||
|
|
||
| /** | ||
| * The class name is not required for functionality on OCP. However, monitoring | ||
| * will issue an alert if it is not present. | ||
| * Only create the plain Ingress on clusters that do NOT support OpenShift | ||
| * Routes. On OpenShift / MicroShift, {@link ConsoleRoute} is used instead. | ||
| * <p> | ||
| * Note: not a CDI bean — conditions are instantiated by the operator SDK. | ||
| */ | ||
| private String getIngressClassName(Context<Console> context) { | ||
| return context.getClient().supports(Route.class) ? "openshift-default" : null; | ||
| public static class Precondition implements Condition<Ingress, Console> { | ||
| @Override | ||
| public boolean isMet(DependentResource<Ingress, Console> dependentResource, Console primary, Context<Console> context) { | ||
| return !context.getClient().supports(Route.class); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| package com.github.streamshub.console.dependents; | ||
|
|
||
| import jakarta.enterprise.context.ApplicationScoped; | ||
| import jakarta.inject.Inject; | ||
| import com.github.streamshub.console.api.v1alpha1.Console; | ||
| import io.fabric8.openshift.api.model.Route; | ||
| import io.fabric8.openshift.api.model.RouteBuilder; | ||
| import io.javaoperatorsdk.operator.api.config.informer.Informer; | ||
| import io.javaoperatorsdk.operator.api.reconciler.Context; | ||
| import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; | ||
| import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; | ||
| import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; | ||
| import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| @ApplicationScoped | ||
| @KubernetesDependent(informer = @Informer(labelSelector = ConsoleResource.MANAGEMENT_SELECTOR)) | ||
| public class ConsoleRoute extends CRUDKubernetesDependentResource<Route, Console> | ||
| implements ConsoleResource<Route> { | ||
|
|
||
| public static final String NAME = "console-route"; | ||
|
|
||
| @Inject | ||
| ConsoleService service; | ||
|
|
||
| public ConsoleRoute() { | ||
| super(Route.class); | ||
| } | ||
|
|
||
| @Override | ||
| public String resourceName() { | ||
| return NAME; | ||
| } | ||
|
|
||
| @Override | ||
| public Optional<Route> getSecondaryResource(Console primary, Context<Console> context) { | ||
| return ConsoleResource.super.getSecondaryResource(primary, context); | ||
| } | ||
|
|
||
| @Override | ||
| protected Route desired(Console primary, Context<Console> context) { | ||
| // hostname is optional on openshift — when null, the router auto-assigns one from the route | ||
| // name, namespace, and cluster domain (issue #1471) | ||
| String host = primary.getSpec().getHostname(); | ||
jankalinic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| String serviceName = service.instanceName(primary); | ||
|
|
||
| // Only set INGRESS_URL_KEY now, if we already know the hostname | ||
| // when missing, RouteReadyCondition sets it once the router has set | ||
| // route.status.ingress[0].host so that ConsoleDeployment gets the right NEXTAUTH_URL | ||
| if (host != null) { | ||
| setAttribute(context, INGRESS_URL_KEY, "https://" + host); | ||
| } | ||
|
|
||
| return new RouteBuilder() | ||
| .withNewMetadata() | ||
| .withName(instanceName(primary)) | ||
| .withNamespace(primary.getMetadata().getNamespace()) | ||
| .withLabels(commonLabels("console")) | ||
| .endMetadata() | ||
| .withNewSpec() | ||
| // null is valid — OpenShift assigns the host automatically | ||
| .withHost(host) | ||
| .withNewTo() | ||
| .withKind("Service") | ||
| .withName(serviceName) | ||
| .withWeight(100) | ||
| .endTo() | ||
| .withNewTls() | ||
| .withTermination("edge") | ||
| .withInsecureEdgeTerminationPolicy("Redirect") | ||
| .endTls() | ||
| .endSpec() | ||
| .build(); | ||
| } | ||
|
|
||
| /** | ||
| * Used as BOTH {@code reconcilePrecondition} AND {@code activationCondition} | ||
| * in the workflow so that: | ||
| * <ul> | ||
| * <li>No Route is reconciled on plain Kubernetes clusters.</li> | ||
| * <li>No informer/watch for {@link Route} is registered on clusters that | ||
| * lack the Route API, avoiding API-discovery errors on startup.</li> | ||
| * </ul> | ||
| * Note: not a CDI bean — conditions are instantiated by the operator SDK. | ||
| */ | ||
| public static class Precondition implements Condition<Route, Console> { | ||
jankalinic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| @Override | ||
| public boolean isMet(DependentResource<Route, Console> dependentResource, Console primary, Context<Console> context) { | ||
| return context.getClient().supports(Route.class); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| package com.github.streamshub.console.dependents.conditions; | ||
|
|
||
| import com.github.streamshub.console.api.v1alpha1.Console; | ||
| import com.github.streamshub.console.dependents.ConsoleIngress; | ||
| import com.github.streamshub.console.dependents.ConsoleResource; | ||
| import com.github.streamshub.console.dependents.ConsoleRoute; | ||
| import io.fabric8.kubernetes.api.model.HasMetadata; | ||
| import io.fabric8.kubernetes.api.model.apps.Deployment; | ||
| import io.fabric8.kubernetes.api.model.networking.v1.Ingress; | ||
| import io.fabric8.kubernetes.api.model.networking.v1.IngressLoadBalancerStatus; | ||
| import io.fabric8.kubernetes.api.model.networking.v1.IngressStatus; | ||
| import io.fabric8.openshift.api.model.Route; | ||
| import io.fabric8.openshift.api.model.RouteIngress; | ||
| import io.fabric8.openshift.api.model.RouteStatus; | ||
| import io.javaoperatorsdk.operator.api.reconciler.Context; | ||
| import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; | ||
| import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ManagedWorkflowAndDependentResourceContext; | ||
| import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; | ||
| import org.jboss.logging.Logger; | ||
|
|
||
| import java.util.Collection; | ||
| import java.util.Objects; | ||
| import java.util.Optional; | ||
|
|
||
| public class IngressOrRouteReadyCondition implements Condition<Deployment, Console> { | ||
|
|
||
| private static final Logger LOGGER = Logger.getLogger(IngressOrRouteReadyCondition.class); | ||
|
|
||
| @Override | ||
| public boolean isMet(DependentResource<Deployment, Console> dependentResource, Console primary, Context<Console> context) { | ||
| if (context.getClient().supports(Route.class)) { | ||
| return getSecondaryResource(primary, context, ConsoleRoute.NAME, Route.class) | ||
| .map(route -> isRouteReady(route, context)) | ||
| .orElse(false); | ||
| } else { | ||
| return getSecondaryResource(primary, context, ConsoleIngress.NAME, Ingress.class) | ||
| .map(this::isIngressReady) | ||
| .orElse(false); | ||
| } | ||
| } | ||
|
|
||
| private <R extends HasMetadata> Optional<R> getSecondaryResource(Console primary, Context<Console> context, String resourceName, Class<R> type) { | ||
| String instanceName = primary.getMetadata().getName() + "-" + resourceName; | ||
| return context.getSecondaryResourcesAsStream(type) | ||
| .filter(r -> Objects.equals(instanceName, r.getMetadata().getName())) | ||
| .findFirst(); | ||
| } | ||
|
|
||
| // Route | ||
| private boolean isRouteReady(Route route, Context<Console> context) { | ||
| String routeName = route.getMetadata().getName(); | ||
|
|
||
| Optional<RouteIngress> admittedIngress = Optional.ofNullable(route.getStatus()) | ||
| .map(RouteStatus::getIngress) | ||
| .filter(list -> !list.isEmpty()) | ||
| .flatMap(ingresses -> ingresses.stream() | ||
| .filter(this::isRouteAdmitted) | ||
| .findFirst()); | ||
|
|
||
| boolean ready = admittedIngress.isPresent(); | ||
|
|
||
| if (ready) { | ||
| // When the user did not specify hostname in the Console spec, openshift auto-assigns it | ||
| // Set INGRESS_URL_KEY so ConsoleDeployment can use it for NEXTAUTH_URL | ||
| admittedIngress | ||
| .map(RouteIngress::getHost) | ||
| .filter(host -> !host.isBlank()) | ||
| .ifPresent(host -> { | ||
| ManagedWorkflowAndDependentResourceContext ctx = context.managedWorkflowAndDependentResourceContext(); | ||
| if (ctx.get(ConsoleResource.INGRESS_URL_KEY, String.class).isEmpty()) { | ||
| LOGGER.debugf("Route %s: auto-assigned host %s", routeName, host); | ||
| ctx.put(ConsoleResource.INGRESS_URL_KEY, "https://" + host); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| LOGGER.debugf("Route %s ready: %s", routeName, ready); | ||
| return ready; | ||
| } | ||
|
|
||
| private boolean isRouteAdmitted(RouteIngress ingress) { | ||
| return Optional.ofNullable(ingress.getConditions()) | ||
| .map(conditions -> conditions.stream() | ||
| .anyMatch(condition -> | ||
| "Admitted".equals(condition.getType()) && | ||
| "True".equals(condition.getStatus()) | ||
| ) | ||
| ) | ||
| .orElse(false); | ||
| } | ||
|
|
||
| // Ingress | ||
| private boolean isIngressReady(Ingress ingress) { | ||
| Boolean ready = Optional.ofNullable(ingress.getStatus()) | ||
| .map(IngressStatus::getLoadBalancer) | ||
| .map(IngressLoadBalancerStatus::getIngress) | ||
| .map(Collection::isEmpty) | ||
| .map(Boolean.FALSE::equals) | ||
| .orElse(false); | ||
|
|
||
| LOGGER.debugf("Ingress %s ready: %s", ingress.getMetadata().getName(), ready); | ||
| return ready; | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.