From 7ac7ea57b95cfb492da2c631502bacbabf6f27a5 Mon Sep 17 00:00:00 2001 From: jkalinic Date: Mon, 16 Mar 2026 17:56:39 +0100 Subject: [PATCH 1/7] [WIP] operator route Signed-off-by: jkalinic --- .../streamshub/console/ConsoleReconciler.java | 41 +++++---- .../console/dependents/ConsoleIngress.java | 21 +++-- .../console/dependents/ConsoleRoute.java | 90 +++++++++++++++++++ .../conditions/RouteReadyCondition.java | 50 +++++++++++ 4 files changed, 181 insertions(+), 21 deletions(-) create mode 100644 operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java create mode 100644 operator/src/main/java/com/github/streamshub/console/dependents/conditions/RouteReadyCondition.java diff --git a/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java b/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java index d24b93b96..535184935 100644 --- a/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java +++ b/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java @@ -1,17 +1,5 @@ package com.github.streamshub.console; -import java.time.Duration; -import java.time.Instant; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.TimeUnit; -import java.util.function.Predicate; -import java.util.stream.Collectors; - import com.github.streamshub.console.api.v1alpha1.Console; import com.github.streamshub.console.api.v1alpha1.status.Condition; import com.github.streamshub.console.api.v1alpha1.status.ConditionBuilder; @@ -22,6 +10,7 @@ import com.github.streamshub.console.dependents.ConsoleIngress; import com.github.streamshub.console.dependents.ConsoleMonitoringClusterRoleBinding; import com.github.streamshub.console.dependents.ConsoleResource; +import com.github.streamshub.console.dependents.ConsoleRoute; import com.github.streamshub.console.dependents.ConsoleSecret; import com.github.streamshub.console.dependents.ConsoleService; import com.github.streamshub.console.dependents.ConsoleServiceAccount; @@ -34,8 +23,8 @@ import com.github.streamshub.console.dependents.conditions.DeploymentReadyCondition; import com.github.streamshub.console.dependents.conditions.IngressReadyCondition; import com.github.streamshub.console.dependents.conditions.PrometheusPrecondition; +import com.github.streamshub.console.dependents.conditions.RouteReadyCondition; import com.github.streamshub.console.support.RootCause; - import io.javaoperatorsdk.operator.AggregatedOperatorException; import io.javaoperatorsdk.operator.api.reconciler.Cleaner; import io.javaoperatorsdk.operator.api.reconciler.Context; @@ -56,6 +45,18 @@ import io.quarkiverse.operatorsdk.annotations.CSVMetadata.Maintainer; import io.quarkiverse.operatorsdk.annotations.CSVMetadata.Provider; +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import java.util.stream.Collectors; + @ControllerConfiguration( maxReconciliationInterval = @MaxReconciliationInterval( interval = 60, @@ -140,17 +141,27 @@ @Dependent( name = ConsoleIngress.NAME, type = ConsoleIngress.class, + reconcilePrecondition = ConsoleIngress.Precondition.class, dependsOn = { ConsoleService.NAME }, readyPostcondition = IngressReadyCondition.class), + @Dependent( + name = ConsoleRoute.NAME, + type = ConsoleRoute.class, + reconcilePrecondition = ConsoleRoute.Precondition.class, + dependsOn = { + ConsoleService.NAME + }, + readyPostcondition = RouteReadyCondition.class), @Dependent( name = ConsoleDeployment.NAME, type = ConsoleDeployment.class, dependsOn = { ConsoleClusterRoleBinding.NAME, ConsoleSecret.NAME, - ConsoleIngress.NAME + ConsoleIngress.NAME, + ConsoleRoute.NAME }, readyPostcondition = DeploymentReadyCondition.class), }) @@ -349,4 +360,4 @@ private String getMessage(Throwable rootCause) { return message; } -} +} \ No newline at end of file diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleIngress.java b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleIngress.java index c6795255e..4ac00c830 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleIngress.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleIngress.java @@ -9,8 +9,10 @@ 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)) @@ -43,7 +45,9 @@ protected Ingress desired(Console primary, Context 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 +70,15 @@ protected Ingress desired(Console primary, Context context) { } /** - * The class name is not required for functionality on OCP. However, monitoring - * will issue an alert if it is not present. + * Reconcile precondition: only create the plain Ingress on clusters that do + * NOT support OpenShift Routes. On OpenShift / MicroShift the Route-based + * dependent ({@code ConsoleRoute}) is used instead. */ - private String getIngressClassName(Context context) { - return context.getClient().supports(Route.class) ? "openshift-default" : null; + @ApplicationScoped + public static class Precondition implements Condition { + @Override + public boolean isMet(DependentResource dependentResource, Console primary, Context context) { + return !context.getClient().supports(Route.class); + } } -} +} \ No newline at end of file diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java new file mode 100644 index 000000000..7d2cc1d9a --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java @@ -0,0 +1,90 @@ +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.fabric8.openshift.api.model.RouteTargetReferenceBuilder; +import io.fabric8.openshift.api.model.TLSConfigBuilder; +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 + implements ConsoleResource { + + public static final String NAME = "console-route"; + + @Inject + ConsoleService service; + + public ConsoleRoute() { + super(Route.class); + } + + @Override + public String resourceName() { + return NAME; + } + + @Override + public Optional getSecondaryResource(Console primary, Context context) { + return ConsoleResource.super.getSecondaryResource(primary, context); + } + + @Override + protected Route desired(Console primary, Context context) { + String host = primary.getSpec().getHostname(); + String serviceName = service.instanceName(primary); + + // Store the URL so ConsoleDeployment can use it for NEXTAUTH_URL + setAttribute(context, INGRESS_URL_KEY, "https://" + host); + + return new RouteBuilder() + .withNewMetadata() + .withName(instanceName(primary)) + .withNamespace(primary.getMetadata().getNamespace()) + .withLabels(commonLabels("console")) + .endMetadata() + .withNewSpec() + .withHost(host) + .withTo(new RouteTargetReferenceBuilder() + .withKind("Service") + .withName(serviceName) + .withWeight(100) + .build()) + .withNewPort() + .withNewTargetPort("https") + .endPort() + .withTls(new TLSConfigBuilder() + .withTermination("edge") + .withInsecureEdgeTerminationPolicy("Redirect") + .build()) + .endSpec() + .build(); + } + + /** + * Reconcile precondition: only create the Route when the cluster supports + * OpenShift Route resources (i.e. OpenShift / MicroShift). + */ + @ApplicationScoped + public static class Precondition implements Condition { + @Override + public boolean isMet(DependentResource dependentResource, + Console primary, + Context context) { + return context.getClient().supports(Route.class); + } + } +} \ No newline at end of file diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/conditions/RouteReadyCondition.java b/operator/src/main/java/com/github/streamshub/console/dependents/conditions/RouteReadyCondition.java new file mode 100644 index 000000000..91e6bf99b --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/dependents/conditions/RouteReadyCondition.java @@ -0,0 +1,50 @@ +package com.github.streamshub.console.dependents.conditions; + +import com.github.streamshub.console.api.v1alpha1.Console; +import io.fabric8.openshift.api.model.Route; +import io.fabric8.openshift.api.model.RouteIngress; +import io.fabric8.openshift.api.model.RouteIngressCondition; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; +import org.jboss.logging.Logger; + +import java.util.Optional; + +public class RouteReadyCondition implements Condition { + + private static final Logger LOGGER = Logger.getLogger(RouteReadyCondition.class); + + @Override + public boolean isMet(DependentResource dependentResource, + Console primary, + Context context) { + return dependentResource.getSecondaryResource(primary, context) + .map(this::isReady) + .orElse(false); + } + + private boolean isReady(Route route) { + // A Route is ready when at least one ingress entry carries an + // Admitted=True condition — this is how the OpenShift router signals + // that it has picked up the route (works on both full OCP and MicroShift). + boolean ready = Optional.ofNullable(route.getStatus()) + .map(s -> s.getIngress()) + .filter(list -> !list.isEmpty()) + .map(ingresses -> ingresses.stream().anyMatch(this::isAdmitted)) + .orElse(false); + + LOGGER.debugf("Route %s ready: %s", route.getMetadata().getName(), ready); + return ready; + } + + private boolean isAdmitted(RouteIngress ingress) { + return Optional.ofNullable(ingress.getConditions()) + .map(conditions -> conditions.stream().anyMatch(this::admittedTrue)) + .orElse(false); + } + + private boolean admittedTrue(RouteIngressCondition condition) { + return "Admitted".equals(condition.getType()) && "True".equals(condition.getStatus()); + } +} \ No newline at end of file From 624845b2256b380d2ca8aa809629c7c2f1729e59 Mon Sep 17 00:00:00 2001 From: jkalinic Date: Mon, 16 Mar 2026 21:03:07 +0100 Subject: [PATCH 2/7] [WIP] operator route - fix Signed-off-by: jkalinic --- .../streamshub/console/ConsoleReconciler.java | 2 + .../console/dependents/ConsoleIngress.java | 11 +++--- .../console/dependents/ConsoleRoute.java | 27 +++++++++---- .../conditions/RouteReadyCondition.java | 38 ++++++++++++++----- 4 files changed, 56 insertions(+), 22 deletions(-) diff --git a/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java b/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java index 535184935..144c0c764 100644 --- a/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java +++ b/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java @@ -142,6 +142,7 @@ name = ConsoleIngress.NAME, type = ConsoleIngress.class, reconcilePrecondition = ConsoleIngress.Precondition.class, + activationCondition = ConsoleIngress.Precondition.class, dependsOn = { ConsoleService.NAME }, @@ -150,6 +151,7 @@ name = ConsoleRoute.NAME, type = ConsoleRoute.class, reconcilePrecondition = ConsoleRoute.Precondition.class, + activationCondition = ConsoleRoute.Precondition.class, dependsOn = { ConsoleService.NAME }, diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleIngress.java b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleIngress.java index 4ac00c830..a05d99358 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleIngress.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleIngress.java @@ -16,7 +16,8 @@ @ApplicationScoped @KubernetesDependent(informer = @Informer(labelSelector = ConsoleResource.MANAGEMENT_SELECTOR)) -public class ConsoleIngress extends CRUDKubernetesDependentResource implements ConsoleResource { +public class ConsoleIngress extends CRUDKubernetesDependentResource + implements ConsoleResource { public static final String NAME = "console-ingress"; @@ -70,11 +71,11 @@ protected Ingress desired(Console primary, Context context) { } /** - * Reconcile precondition: only create the plain Ingress on clusters that do - * NOT support OpenShift Routes. On OpenShift / MicroShift the Route-based - * dependent ({@code ConsoleRoute}) is used instead. + * Only create the plain Ingress on clusters that do NOT support OpenShift + * Routes. On OpenShift / MicroShift, {@link ConsoleRoute} is used instead. + *

+ * Note: not a CDI bean — conditions are instantiated by the operator SDK. */ - @ApplicationScoped public static class Precondition implements Condition { @Override public boolean isMet(DependentResource dependentResource, Console primary, Context context) { diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java index 7d2cc1d9a..51c1a2574 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java @@ -2,9 +2,7 @@ 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.fabric8.openshift.api.model.RouteTargetReferenceBuilder; @@ -44,11 +42,17 @@ public Optional getSecondaryResource(Console primary, Context co @Override protected Route desired(Console primary, Context 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(); String serviceName = service.instanceName(primary); - // Store the URL so ConsoleDeployment can use it for NEXTAUTH_URL - setAttribute(context, INGRESS_URL_KEY, "https://" + host); + // 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() @@ -57,6 +61,7 @@ protected Route desired(Console primary, Context context) { .withLabels(commonLabels("console")) .endMetadata() .withNewSpec() + // null is valid — OpenShift assigns the host automatically .withHost(host) .withTo(new RouteTargetReferenceBuilder() .withKind("Service") @@ -64,7 +69,8 @@ protected Route desired(Console primary, Context context) { .withWeight(100) .build()) .withNewPort() - .withNewTargetPort("https") + // 80 is the HTTP port on the console-ui service + .withNewTargetPort(80) .endPort() .withTls(new TLSConfigBuilder() .withTermination("edge") @@ -75,10 +81,15 @@ protected Route desired(Console primary, Context context) { } /** - * Reconcile precondition: only create the Route when the cluster supports - * OpenShift Route resources (i.e. OpenShift / MicroShift). + * Used as BOTH {@code reconcilePrecondition} AND {@code activationCondition} + * in the workflow so that: + *

    + *
  • No Route is reconciled on plain Kubernetes clusters.
  • + *
  • No informer/watch for {@link Route} is registered on clusters that + * lack the Route API, avoiding API-discovery errors on startup.
  • + *
+ * Note: not a CDI bean — conditions are instantiated by the operator SDK. */ - @ApplicationScoped public static class Precondition implements Condition { @Override public boolean isMet(DependentResource dependentResource, diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/conditions/RouteReadyCondition.java b/operator/src/main/java/com/github/streamshub/console/dependents/conditions/RouteReadyCondition.java index 91e6bf99b..cffc4a58c 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/conditions/RouteReadyCondition.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/conditions/RouteReadyCondition.java @@ -1,6 +1,7 @@ package com.github.streamshub.console.dependents.conditions; import com.github.streamshub.console.api.v1alpha1.Console; +import com.github.streamshub.console.dependents.ConsoleResource; import io.fabric8.openshift.api.model.Route; import io.fabric8.openshift.api.model.RouteIngress; import io.fabric8.openshift.api.model.RouteIngressCondition; @@ -20,21 +21,40 @@ public boolean isMet(DependentResource dependentResource, Console primary, Context context) { return dependentResource.getSecondaryResource(primary, context) - .map(this::isReady) + .map(route -> isReady(route, context)) .orElse(false); } - private boolean isReady(Route route) { - // A Route is ready when at least one ingress entry carries an - // Admitted=True condition — this is how the OpenShift router signals - // that it has picked up the route (works on both full OCP and MicroShift). - boolean ready = Optional.ofNullable(route.getStatus()) + private boolean isReady(Route route, Context context) { + String routeName = route.getMetadata().getName(); + + Optional admittedIngress = Optional.ofNullable(route.getStatus()) .map(s -> s.getIngress()) .filter(list -> !list.isEmpty()) - .map(ingresses -> ingresses.stream().anyMatch(this::isAdmitted)) - .orElse(false); + .flatMap(ingresses -> ingresses.stream() + .filter(this::isAdmitted) + .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(h -> h != null && !h.isBlank()) + .ifPresent(host -> { + Optional existing = context.managedWorkflowAndDependentResourceContext() + .get(ConsoleResource.INGRESS_URL_KEY, String.class); + if (existing.isEmpty()) { + LOGGER.debugf("Route %s: auto-assigned host %s", routeName, host); + context.managedWorkflowAndDependentResourceContext() + .put(ConsoleResource.INGRESS_URL_KEY, "https://" + host); + } + }); + } - LOGGER.debugf("Route %s ready: %s", route.getMetadata().getName(), ready); + LOGGER.debugf("Route %s ready: %s", routeName, ready); return ready; } From 847c00157dcb35cb79c167bd7ac3278e0d40e54f Mon Sep 17 00:00:00 2001 From: jkalinic Date: Mon, 16 Mar 2026 21:50:18 +0100 Subject: [PATCH 3/7] [WIP] operator route - fix2 Signed-off-by: jkalinic --- .../streamshub/console/ConsoleReconciler.java | 14 +-- .../console/dependents/ConsoleRoute.java | 52 +++++---- .../IngressOrRouteReadyCondition.java | 101 ++++++++++++++++++ .../conditions/IngressReadyCondition.java | 37 ------- .../conditions/RouteReadyCondition.java | 70 ------------ 5 files changed, 130 insertions(+), 144 deletions(-) create mode 100644 operator/src/main/java/com/github/streamshub/console/dependents/conditions/IngressOrRouteReadyCondition.java delete mode 100644 operator/src/main/java/com/github/streamshub/console/dependents/conditions/IngressReadyCondition.java delete mode 100644 operator/src/main/java/com/github/streamshub/console/dependents/conditions/RouteReadyCondition.java diff --git a/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java b/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java index 144c0c764..e42e0bb55 100644 --- a/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java +++ b/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java @@ -21,9 +21,8 @@ import com.github.streamshub.console.dependents.PrometheusService; import com.github.streamshub.console.dependents.PrometheusServiceAccount; import com.github.streamshub.console.dependents.conditions.DeploymentReadyCondition; -import com.github.streamshub.console.dependents.conditions.IngressReadyCondition; +import com.github.streamshub.console.dependents.conditions.IngressOrRouteReadyCondition; import com.github.streamshub.console.dependents.conditions.PrometheusPrecondition; -import com.github.streamshub.console.dependents.conditions.RouteReadyCondition; import com.github.streamshub.console.support.RootCause; import io.javaoperatorsdk.operator.AggregatedOperatorException; import io.javaoperatorsdk.operator.api.reconciler.Cleaner; @@ -145,8 +144,7 @@ activationCondition = ConsoleIngress.Precondition.class, dependsOn = { ConsoleService.NAME - }, - readyPostcondition = IngressReadyCondition.class), + }), @Dependent( name = ConsoleRoute.NAME, type = ConsoleRoute.class, @@ -154,16 +152,14 @@ activationCondition = ConsoleRoute.Precondition.class, dependsOn = { ConsoleService.NAME - }, - readyPostcondition = RouteReadyCondition.class), + }), @Dependent( name = ConsoleDeployment.NAME, type = ConsoleDeployment.class, + reconcilePrecondition = IngressOrRouteReadyCondition.class, dependsOn = { ConsoleClusterRoleBinding.NAME, - ConsoleSecret.NAME, - ConsoleIngress.NAME, - ConsoleRoute.NAME + ConsoleSecret.NAME }, readyPostcondition = DeploymentReadyCondition.class), }) diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java index 51c1a2574..29c0a193b 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java @@ -5,8 +5,6 @@ import com.github.streamshub.console.api.v1alpha1.Console; import io.fabric8.openshift.api.model.Route; import io.fabric8.openshift.api.model.RouteBuilder; -import io.fabric8.openshift.api.model.RouteTargetReferenceBuilder; -import io.fabric8.openshift.api.model.TLSConfigBuilder; import io.javaoperatorsdk.operator.api.config.informer.Informer; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; @@ -55,29 +53,29 @@ protected Route desired(Console primary, Context context) { } 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) - .withTo(new RouteTargetReferenceBuilder() - .withKind("Service") - .withName(serviceName) - .withWeight(100) - .build()) - .withNewPort() - // 80 is the HTTP port on the console-ui service - .withNewTargetPort(80) - .endPort() - .withTls(new TLSConfigBuilder() - .withTermination("edge") - .withInsecureEdgeTerminationPolicy("Redirect") - .build()) - .endSpec() - .build(); + .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() + .withNewPort() + // 80 is the HTTP port on the console-ui service + .withNewTargetPort(80) + .endPort() + .withNewTls() + .withTermination("edge") + .withInsecureEdgeTerminationPolicy("Redirect") + .endTls() + .endSpec() + .build(); } /** @@ -92,9 +90,7 @@ protected Route desired(Console primary, Context context) { */ public static class Precondition implements Condition { @Override - public boolean isMet(DependentResource dependentResource, - Console primary, - Context context) { + public boolean isMet(DependentResource dependentResource, Console primary, Context context) { return context.getClient().supports(Route.class); } } diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/conditions/IngressOrRouteReadyCondition.java b/operator/src/main/java/com/github/streamshub/console/dependents/conditions/IngressOrRouteReadyCondition.java new file mode 100644 index 000000000..0fd8ddafb --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/dependents/conditions/IngressOrRouteReadyCondition.java @@ -0,0 +1,101 @@ +package com.github.streamshub.console.dependents.conditions; + +import jakarta.inject.Inject; +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.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.processing.dependent.workflow.Condition; +import org.jboss.logging.Logger; + +import java.util.Collection; +import java.util.Optional; + +public class IngressOrRouteReadyCondition implements Condition { + + private static final Logger LOGGER = Logger.getLogger(IngressOrRouteReadyCondition.class); + + @Inject + ConsoleIngress consoleIngress; + + @Inject + ConsoleRoute consoleRoute; + + @Override + public boolean isMet(DependentResource dependentResource, Console primary, Context context) { + if (context.getClient().supports(Route.class)) { + return consoleRoute.getSecondaryResource(primary, context) + .map(route -> isRouteReady(route, context)) + .orElse(false); + } else { + return consoleIngress.getSecondaryResource(primary, context) + .map(this::isIngressReady) + .orElse(false); + } + } + + private boolean isRouteReady(Route route, Context context) { + String routeName = route.getMetadata().getName(); + + Optional 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 -> { + Optional existing = context.managedWorkflowAndDependentResourceContext() + .get(ConsoleResource.INGRESS_URL_KEY, String.class); + if (existing.isEmpty()) { + LOGGER.debugf("Route %s: auto-assigned host %s", routeName, host); + context.managedWorkflowAndDependentResourceContext() + .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); + } + + 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; + } +} \ No newline at end of file diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/conditions/IngressReadyCondition.java b/operator/src/main/java/com/github/streamshub/console/dependents/conditions/IngressReadyCondition.java deleted file mode 100644 index b7941fcca..000000000 --- a/operator/src/main/java/com/github/streamshub/console/dependents/conditions/IngressReadyCondition.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.github.streamshub.console.dependents.conditions; - -import java.util.Collection; -import java.util.Optional; - -import org.jboss.logging.Logger; - -import com.github.streamshub.console.api.v1alpha1.Console; - -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.javaoperatorsdk.operator.api.reconciler.Context; -import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; -import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; - -public class IngressReadyCondition implements Condition { - - private static final Logger LOGGER = Logger.getLogger(IngressReadyCondition.class); - - @Override - public boolean isMet(DependentResource dependentResource, Console primary, Context context) { - return dependentResource.getSecondaryResource(primary, context).map(this::isReady).orElse(false); - } - - private boolean isReady(Ingress ingress) { - var 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; - } -} diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/conditions/RouteReadyCondition.java b/operator/src/main/java/com/github/streamshub/console/dependents/conditions/RouteReadyCondition.java deleted file mode 100644 index cffc4a58c..000000000 --- a/operator/src/main/java/com/github/streamshub/console/dependents/conditions/RouteReadyCondition.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.github.streamshub.console.dependents.conditions; - -import com.github.streamshub.console.api.v1alpha1.Console; -import com.github.streamshub.console.dependents.ConsoleResource; -import io.fabric8.openshift.api.model.Route; -import io.fabric8.openshift.api.model.RouteIngress; -import io.fabric8.openshift.api.model.RouteIngressCondition; -import io.javaoperatorsdk.operator.api.reconciler.Context; -import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; -import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; -import org.jboss.logging.Logger; - -import java.util.Optional; - -public class RouteReadyCondition implements Condition { - - private static final Logger LOGGER = Logger.getLogger(RouteReadyCondition.class); - - @Override - public boolean isMet(DependentResource dependentResource, - Console primary, - Context context) { - return dependentResource.getSecondaryResource(primary, context) - .map(route -> isReady(route, context)) - .orElse(false); - } - - private boolean isReady(Route route, Context context) { - String routeName = route.getMetadata().getName(); - - Optional admittedIngress = Optional.ofNullable(route.getStatus()) - .map(s -> s.getIngress()) - .filter(list -> !list.isEmpty()) - .flatMap(ingresses -> ingresses.stream() - .filter(this::isAdmitted) - .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(h -> h != null && !h.isBlank()) - .ifPresent(host -> { - Optional existing = context.managedWorkflowAndDependentResourceContext() - .get(ConsoleResource.INGRESS_URL_KEY, String.class); - if (existing.isEmpty()) { - LOGGER.debugf("Route %s: auto-assigned host %s", routeName, host); - context.managedWorkflowAndDependentResourceContext() - .put(ConsoleResource.INGRESS_URL_KEY, "https://" + host); - } - }); - } - - LOGGER.debugf("Route %s ready: %s", routeName, ready); - return ready; - } - - private boolean isAdmitted(RouteIngress ingress) { - return Optional.ofNullable(ingress.getConditions()) - .map(conditions -> conditions.stream().anyMatch(this::admittedTrue)) - .orElse(false); - } - - private boolean admittedTrue(RouteIngressCondition condition) { - return "Admitted".equals(condition.getType()) && "True".equals(condition.getStatus()); - } -} \ No newline at end of file From 60e099f1629c331a7629776f04af23b0dd894860 Mon Sep 17 00:00:00 2001 From: jkalinic Date: Mon, 16 Mar 2026 21:54:46 +0100 Subject: [PATCH 4/7] [WIP] operator route - fix2 Signed-off-by: jkalinic --- .../java/com/github/streamshub/console/ConsoleReconciler.java | 2 +- .../github/streamshub/console/dependents/ConsoleIngress.java | 2 +- .../com/github/streamshub/console/dependents/ConsoleRoute.java | 2 +- .../dependents/conditions/IngressOrRouteReadyCondition.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java b/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java index e42e0bb55..0903184eb 100644 --- a/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java +++ b/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java @@ -358,4 +358,4 @@ private String getMessage(Throwable rootCause) { return message; } -} \ No newline at end of file +} diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleIngress.java b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleIngress.java index a05d99358..ee3a5b5d5 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleIngress.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleIngress.java @@ -82,4 +82,4 @@ public boolean isMet(DependentResource dependentResource, Cons return !context.getClient().supports(Route.class); } } -} \ No newline at end of file +} diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java index 29c0a193b..37dd26870 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java @@ -94,4 +94,4 @@ public boolean isMet(DependentResource dependentResource, Consol return context.getClient().supports(Route.class); } } -} \ No newline at end of file +} diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/conditions/IngressOrRouteReadyCondition.java b/operator/src/main/java/com/github/streamshub/console/dependents/conditions/IngressOrRouteReadyCondition.java index 0fd8ddafb..d5cb6f9cc 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/conditions/IngressOrRouteReadyCondition.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/conditions/IngressOrRouteReadyCondition.java @@ -98,4 +98,4 @@ private boolean isIngressReady(Ingress ingress) { LOGGER.debugf("Ingress %s ready: %s", ingress.getMetadata().getName(), ready); return ready; } -} \ No newline at end of file +} From 9475f7632f884ceac69f758ffe29cbcd7b33b6a3 Mon Sep 17 00:00:00 2001 From: jkalinic Date: Tue, 17 Mar 2026 01:19:14 +0100 Subject: [PATCH 5/7] [WIP] operator route-working dev Signed-off-by: jkalinic --- .../console/dependents/ConsoleRoute.java | 4 --- .../IngressOrRouteReadyCondition.java | 33 ++++++++++--------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java index 37dd26870..09d31be18 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleRoute.java @@ -66,10 +66,6 @@ protected Route desired(Console primary, Context context) { .withName(serviceName) .withWeight(100) .endTo() - .withNewPort() - // 80 is the HTTP port on the console-ui service - .withNewTargetPort(80) - .endPort() .withNewTls() .withTermination("edge") .withInsecureEdgeTerminationPolicy("Redirect") diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/conditions/IngressOrRouteReadyCondition.java b/operator/src/main/java/com/github/streamshub/console/dependents/conditions/IngressOrRouteReadyCondition.java index d5cb6f9cc..292308558 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/conditions/IngressOrRouteReadyCondition.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/conditions/IngressOrRouteReadyCondition.java @@ -1,10 +1,10 @@ package com.github.streamshub.console.dependents.conditions; -import jakarta.inject.Inject; 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; @@ -14,35 +14,39 @@ 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 { private static final Logger LOGGER = Logger.getLogger(IngressOrRouteReadyCondition.class); - @Inject - ConsoleIngress consoleIngress; - - @Inject - ConsoleRoute consoleRoute; - @Override public boolean isMet(DependentResource dependentResource, Console primary, Context context) { if (context.getClient().supports(Route.class)) { - return consoleRoute.getSecondaryResource(primary, context) + return getSecondaryResource(primary, context, ConsoleRoute.NAME, Route.class) .map(route -> isRouteReady(route, context)) .orElse(false); } else { - return consoleIngress.getSecondaryResource(primary, context) + return getSecondaryResource(primary, context, ConsoleIngress.NAME, Ingress.class) .map(this::isIngressReady) .orElse(false); } } + private Optional getSecondaryResource(Console primary, Context context, String resourceName, Class 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 context) { String routeName = route.getMetadata().getName(); @@ -62,12 +66,10 @@ private boolean isRouteReady(Route route, Context context) { .map(RouteIngress::getHost) .filter(host -> !host.isBlank()) .ifPresent(host -> { - Optional existing = context.managedWorkflowAndDependentResourceContext() - .get(ConsoleResource.INGRESS_URL_KEY, String.class); - if (existing.isEmpty()) { + ManagedWorkflowAndDependentResourceContext ctx = context.managedWorkflowAndDependentResourceContext(); + if (ctx.get(ConsoleResource.INGRESS_URL_KEY, String.class).isEmpty()) { LOGGER.debugf("Route %s: auto-assigned host %s", routeName, host); - context.managedWorkflowAndDependentResourceContext() - .put(ConsoleResource.INGRESS_URL_KEY, "https://" + host); + ctx.put(ConsoleResource.INGRESS_URL_KEY, "https://" + host); } }); } @@ -87,6 +89,7 @@ private boolean isRouteAdmitted(RouteIngress ingress) { .orElse(false); } + // Ingress private boolean isIngressReady(Ingress ingress) { Boolean ready = Optional.ofNullable(ingress.getStatus()) .map(IngressStatus::getLoadBalancer) @@ -98,4 +101,4 @@ private boolean isIngressReady(Ingress ingress) { LOGGER.debugf("Ingress %s ready: %s", ingress.getMetadata().getName(), ready); return ready; } -} +} \ No newline at end of file From 3324ae590436498a28f38f5adc0b854753cd2299 Mon Sep 17 00:00:00 2001 From: jkalinic Date: Thu, 19 Mar 2026 16:46:45 +0100 Subject: [PATCH 6/7] add rbac for routes and custom hostname Signed-off-by: jkalinic --- operator/src/main/kubernetes/kubernetes.yml | 22 +++++++++++++++++++ .../dependents/console.clusterrole.yaml | 12 ++++++++++ 2 files changed, 34 insertions(+) diff --git a/operator/src/main/kubernetes/kubernetes.yml b/operator/src/main/kubernetes/kubernetes.yml index 3544f0d2e..6c9117cb9 100644 --- a/operator/src/main/kubernetes/kubernetes.yml +++ b/operator/src/main/kubernetes/kubernetes.yml @@ -170,6 +170,28 @@ rules: verbs: - get + # Used by operator to manage Route resources for Console instances on OpenShift + - apiGroups: + - route.openshift.io + resources: + - routes + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + # Allow operator to create routes with custom hostnames + - apiGroups: + - route.openshift.io + resources: + - routes/custom-host + verbs: + - create + - update + # Granted to Prometheus instances (when using embedded instance) - apiGroups: [ '' ] resources: diff --git a/operator/src/main/resources/com/github/streamshub/console/dependents/console.clusterrole.yaml b/operator/src/main/resources/com/github/streamshub/console/dependents/console.clusterrole.yaml index 6e67113a9..39d98ed62 100644 --- a/operator/src/main/resources/com/github/streamshub/console/dependents/console.clusterrole.yaml +++ b/operator/src/main/resources/com/github/streamshub/console/dependents/console.clusterrole.yaml @@ -3,6 +3,18 @@ apiVersion: rbac.authorization.k8s.io/v1 metadata: name: console-server rules: + - verbs: + - get + - watch + - list + - create + - update + - patch + - delete + apiGroups: + - route.openshift.io + resources: + - routes - verbs: - get - watch From 767879d9dceb58a9827c59b1c243644e8b700eb1 Mon Sep 17 00:00:00 2001 From: jkalinic Date: Thu, 19 Mar 2026 22:48:22 +0100 Subject: [PATCH 7/7] throw error for no hostname on ingress and remove nativeAPI routes.openshift Signed-off-by: jkalinic --- operator/bin/modify-bundle-metadata.sh | 5 +++++ .../console/dependents/ConsoleIngress.java | 13 +++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/operator/bin/modify-bundle-metadata.sh b/operator/bin/modify-bundle-metadata.sh index 1f1a95037..a793fd915 100755 --- a/operator/bin/modify-bundle-metadata.sh +++ b/operator/bin/modify-bundle-metadata.sh @@ -149,4 +149,9 @@ fi ${YQ} -i '.spec.icon = [{ "base64data": "'$(base64 -w0 ${SCRIPT_PATH}/../src/main/olm/icon.png)'", "mediatype": "image/png" }]' "${CSV_FILE_PATH}" +# Remove route.openshift.io from nativeAPIs - it's an optional OpenShift-only API +# and must not be a hard OLM install requirement on plain Kubernetes +echo "[DEBUG] Removing route.openshift.io from nativeAPIs" +${YQ} eval -o yaml -i 'del(.spec.nativeAPIs[] | select(.group == "route.openshift.io"))' "${CSV_FILE_PATH}" + operator-sdk bundle validate "${BUNDLE_PATH}" --select-optional name=operatorhub diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleIngress.java b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleIngress.java index ee3a5b5d5..348f1d59c 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleIngress.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleIngress.java @@ -2,9 +2,8 @@ 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; @@ -16,8 +15,7 @@ @ApplicationScoped @KubernetesDependent(informer = @Informer(labelSelector = ConsoleResource.MANAGEMENT_SELECTOR)) -public class ConsoleIngress extends CRUDKubernetesDependentResource - implements ConsoleResource { +public class ConsoleIngress extends CRUDKubernetesDependentResource implements ConsoleResource { public static final String NAME = "console-ingress"; @@ -36,6 +34,13 @@ public String resourceName() { @Override protected Ingress desired(Console primary, Context 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."); + } + setAttribute(context, INGRESS_URL_KEY, "https://" + host); return load(context, "console.ingress.yaml", Ingress.class)