Skip to content
Draft
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
5 changes: 5 additions & 0 deletions operator/bin/modify-bundle-metadata.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -32,10 +21,9 @@
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.support.RootCause;

import io.javaoperatorsdk.operator.AggregatedOperatorException;
import io.javaoperatorsdk.operator.api.reconciler.Cleaner;
import io.javaoperatorsdk.operator.api.reconciler.Context;
Expand All @@ -56,6 +44,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,
Expand Down Expand Up @@ -140,17 +140,26 @@
@Dependent(
name = ConsoleIngress.NAME,
type = ConsoleIngress.class,
reconcilePrecondition = ConsoleIngress.Precondition.class,
activationCondition = ConsoleIngress.Precondition.class,
dependsOn = {
ConsoleService.NAME
},
readyPostcondition = IngressReadyCondition.class),
}),
@Dependent(
name = ConsoleRoute.NAME,
type = ConsoleRoute.class,
reconcilePrecondition = ConsoleRoute.Precondition.class,
activationCondition = ConsoleRoute.Precondition.class,
dependsOn = {
ConsoleService.NAME
}),
@Dependent(
name = ConsoleDeployment.NAME,
type = ConsoleDeployment.class,
reconcilePrecondition = IngressOrRouteReadyCondition.class,
dependsOn = {
ConsoleClusterRoleBinding.NAME,
ConsoleSecret.NAME,
ConsoleIngress.NAME
ConsoleSecret.NAME
},
readyPostcondition = DeploymentReadyCondition.class),
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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.");
}

setAttribute(context, INGRESS_URL_KEY, "https://" + host);

return load(context, "console.ingress.yaml", Ingress.class)
Expand All @@ -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))
Expand All @@ -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();
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> {
@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;
}
}

This file was deleted.

Loading
Loading