Enhance the Keycloak Operator with Network Policies (#34788)

Closes #34659

Signed-off-by: Pedro Ruivo <pruivo@redhat.com>
This commit is contained in:
Pedro Ruivo
2024-12-04 08:50:28 +00:00
committed by GitHub
parent dc5535f8c0
commit e8841b6ae3
20 changed files with 886 additions and 101 deletions
+2 -2
View File
@@ -73,7 +73,7 @@ jobs:
kubernetes version: ${{ env.KUBERNETES_VERSION }}
github token: ${{ secrets.GITHUB_TOKEN }}
driver: docker
start args: --addons=ingress --memory=${{ env.MINIKUBE_MEMORY }}
start args: --addons=ingress --memory=${{ env.MINIKUBE_MEMORY }} --cni cilium --cpus=max
- name: Download keycloak distribution
id: download-keycloak-dist
@@ -117,7 +117,7 @@ jobs:
kubernetes version: ${{ env.KUBERNETES_VERSION }}
github token: ${{ secrets.GITHUB_TOKEN }}
driver: docker
start args: --addons=ingress --memory=${{ env.MINIKUBE_MEMORY }}
start args: --addons=ingress --memory=${{ env.MINIKUBE_MEMORY }} --cni cilium --cpus=max
- name: Download keycloak distribution
id: download-keycloak-dist
@@ -316,4 +316,27 @@ If a master realm has already been created for you cluster, then the spec.boostr
For more information on how to bootstrap a temporary admin user or service account and recover lost admin access, refer to the <@links.server id="bootstrap-admin-recovery"/> guide.
=== Network Policies (Experimental)
NetworkPolicies allow you to specify rules for traffic flow within your cluster, and also between Pods and the outside world.
Your cluster must use a network plugin that supports NetworkPolicy enforcement.
The operator can automatically create a NetworkPolicy to deny access to the clustering port of your {project_name} Pods.
The HTTP(S) endpoint is open to traffic from any namespace and the outside world.
To enable the NetworkPolicy, set `spec.networkPolicy.enabled` in your Keycloak CR, as shown in the example below.
.Keycloak CR with Network Policies enabled
[source,yaml]
----
apiVersion: k8s.keycloak.org/v2alpha1
kind: Keycloak
metadata:
name: example-kc
spec:
networkPolicy:
enabled: true
----
Check the https://kubernetes.io/docs/concepts/services-networking/network-policies/[Kubernetes Network Policies documentation] for more information about NetworkPolicies.
</@tmpl.guide>
+8
View File
@@ -43,6 +43,14 @@ kc.operator.keycloak.image-pull-policy
### Quick start on Minikube
Start minikube with `ingress` addon and `cilium` Container Network Interface (CNI).
Vanilla minikube does not support Network Policies, and Cilium implements the CNI and supports Network Policies.
Another CNI implementation may work too.
```bash
minikube start --addons ingress --cni cilium
```
Enable the Minikube Docker daemon:
```bash
@@ -59,6 +59,9 @@ public final class Constants {
public static final Integer KEYCLOAK_DISCOVERY_SERVICE_PORT = 7800;
public static final String KEYCLOAK_DISCOVERY_TCP_PORT_NAME = "tcp";
public static final String KEYCLOAK_DISCOVERY_SERVICE_SUFFIX = "-discovery";
public static final Integer KEYCLOAK_JGROUPS_DATA_PORT = 7800;
public static final Integer KEYCLOAK_JGROUPS_FD_PORT = 57800;
public static final String KEYCLOAK_JGROUPS_PROTOCOL = "TCP";
public static final Integer KEYCLOAK_MANAGEMENT_PORT = 9000;
public static final String KEYCLOAK_MANAGEMENT_PORT_NAME = "management";
@@ -74,4 +77,6 @@ public final class Constants {
public static final String KEYCLOAK_HTTP_RELATIVE_PATH_KEY = "http-relative-path";
public static final String KEYCLOAK_HTTP_MANAGEMENT_RELATIVE_PATH_KEY = "http-management-relative-path";
public static final String KEYCLOAK_NETWORK_POLICY_SUFFIX = "-network-policy";
}
@@ -63,7 +63,8 @@ import jakarta.inject.Inject;
@Dependent(type = KeycloakAdminSecretDependentResource.class, reconcilePrecondition = KeycloakAdminSecretDependentResource.EnabledCondition.class),
@Dependent(type = KeycloakIngressDependentResource.class, reconcilePrecondition = KeycloakIngressDependentResource.EnabledCondition.class),
@Dependent(type = KeycloakServiceDependentResource.class, useEventSourceWithName = "serviceSource"),
@Dependent(type = KeycloakDiscoveryServiceDependentResource.class, useEventSourceWithName = "serviceSource")
@Dependent(type = KeycloakDiscoveryServiceDependentResource.class, useEventSourceWithName = "serviceSource"),
@Dependent(type = KeycloakNetworkPolicyDependentResource.class, reconcilePrecondition = KeycloakNetworkPolicyDependentResource.EnabledCondition.class)
})
public class KeycloakController implements Reconciler<Keycloak>, EventSourceInitializer<Keycloak>, ErrorStatusHandler<Keycloak> {
@@ -77,7 +78,7 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
@Inject
KeycloakDistConfigurator distConfigurator;
volatile KeycloakDeploymentDependentResource deploymentDependentResource;
@Override
@@ -95,10 +96,10 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
Map<String, EventSource> sources = new HashMap<>();
sources.put("serviceSource", servicesEvent);
this.deploymentDependentResource = new KeycloakDeploymentDependentResource(config, watchedResources, distConfigurator);
sources.putAll(EventSourceInitializer.nameEventSourcesFromDependentResource(context, this.deploymentDependentResource));
return sources;
}
@@ -132,7 +133,7 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
if (modifiedSpec) {
return UpdateControl.updateResource(kc);
}
// after the spec has possibly been updated, reconcile the StatefulSet
this.deploymentDependentResource.reconcile(kc, context);
@@ -299,10 +299,7 @@ public class KeycloakDeploymentDependentResource extends CRUDKubernetesDependent
// probes
var protocol = isTlsConfigured(keycloakCR) ? "HTTPS" : "HTTP";
var port = Optional.ofNullable(keycloakCR.getSpec())
.map(KeycloakSpec::getHttpManagementSpec)
.map(HttpManagementSpec::getPort)
.orElse(Constants.KEYCLOAK_MANAGEMENT_PORT);
var port = HttpManagementSpec.managementPort(keycloakCR);
var relativePath = readConfigurationValue(Constants.KEYCLOAK_HTTP_MANAGEMENT_RELATIVE_PATH_KEY, keycloakCR, context)
.or(() -> readConfigurationValue(Constants.KEYCLOAK_HTTP_RELATIVE_PATH_KEY, keycloakCR, context))
.map(path -> !path.endsWith("/") ? path + "/" : path)
@@ -0,0 +1,152 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.operator.controllers;
import java.util.Optional;
import io.fabric8.kubernetes.api.model.IntOrString;
import io.fabric8.kubernetes.api.model.networking.v1.NetworkPolicy;
import io.fabric8.kubernetes.api.model.networking.v1.NetworkPolicyBuilder;
import io.fabric8.kubernetes.api.model.networking.v1.NetworkPolicyFluent;
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 org.jboss.logging.Logger;
import org.keycloak.operator.Constants;
import org.keycloak.operator.Utils;
import org.keycloak.operator.crds.v2alpha1.CRDUtils;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.HttpManagementSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.HttpSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.NetworkPolicySpec;
import static org.keycloak.operator.Constants.KEYCLOAK_JGROUPS_DATA_PORT;
import static org.keycloak.operator.Constants.KEYCLOAK_JGROUPS_FD_PORT;
import static org.keycloak.operator.Constants.KEYCLOAK_JGROUPS_PROTOCOL;
import static org.keycloak.operator.Constants.KEYCLOAK_SERVICE_PROTOCOL;
@KubernetesDependent(labelSelector = Constants.DEFAULT_LABELS_AS_STRING)
public class KeycloakNetworkPolicyDependentResource extends CRUDKubernetesDependentResource<NetworkPolicy, Keycloak> {
private static final Logger LOG = Logger.getLogger(KeycloakNetworkPolicyDependentResource.class.getName());
public KeycloakNetworkPolicyDependentResource() {
super(NetworkPolicy.class);
}
public static class EnabledCondition implements Condition<NetworkPolicy, Keycloak> {
@Override
public boolean isMet(DependentResource<NetworkPolicy, Keycloak> dependentResource, Keycloak primary,
Context<Keycloak> context) {
return NetworkPolicySpec.isNetworkPolicyEnabled(primary);
}
}
@Override
protected NetworkPolicy desired(Keycloak primary, Context<Keycloak> context) {
var builder = new NetworkPolicyBuilder();
addMetadata(builder, primary);
var specBuilder = builder.withNewSpec()
.withPolicyTypes("Ingress");
addPodSelector(specBuilder, primary);
addApplicationPorts(specBuilder, primary);
if (CRDUtils.isJGroupEnabled(primary)) {
addJGroupsPorts(specBuilder, primary);
}
// see org.keycloak.quarkus.runtime.configuration.mappers.ManagementPropertyMappers.isManagementEnabled()
if (CRDUtils.isManagementEndpointEnabled(primary)) {
addManagementPorts(specBuilder, primary);
}
var np = specBuilder.endSpec().build();
LOG.debugf("Create a Network Policy => %s", np);
return np;
}
private static void addPodSelector(NetworkPolicyFluent<NetworkPolicyBuilder>.SpecNested<NetworkPolicyBuilder> builder, Keycloak keycloak) {
builder.withNewPodSelector()
.withMatchLabels(Utils.allInstanceLabels(keycloak))
.endPodSelector();
}
private static void addApplicationPorts(NetworkPolicyFluent<NetworkPolicyBuilder>.SpecNested<NetworkPolicyBuilder> builder, Keycloak keycloak) {
var tlsEnabled = CRDUtils.isTlsConfigured(keycloak);
var httpEnabled = Optional.ofNullable(keycloak.getSpec())
.map(KeycloakSpec::getHttpSpec)
.map(HttpSpec::getHttpEnabled)
.orElse(false);
if (!tlsEnabled || httpEnabled) {
var httpIngressBuilder = builder.addNewIngress();
httpIngressBuilder.addNewPort()
.withPort(new IntOrString(HttpSpec.httpPort(keycloak)))
.withProtocol(KEYCLOAK_SERVICE_PROTOCOL)
.endPort();
httpIngressBuilder.endIngress();
}
if (tlsEnabled) {
var httpsIngressBuilder = builder.addNewIngress();
httpsIngressBuilder.addNewPort()
.withPort(new IntOrString(HttpSpec.httpsPort(keycloak)))
.withProtocol(KEYCLOAK_SERVICE_PROTOCOL)
.endPort();
httpsIngressBuilder.endIngress();
}
}
private static void addManagementPorts(NetworkPolicyFluent<NetworkPolicyBuilder>.SpecNested<NetworkPolicyBuilder> builder, Keycloak keycloak) {
var ingressBuilder = builder.addNewIngress();
ingressBuilder.addNewPort()
.withPort(new IntOrString(HttpManagementSpec.managementPort(keycloak)))
.withProtocol(KEYCLOAK_SERVICE_PROTOCOL)
.endPort();
ingressBuilder.endIngress();
}
private static void addJGroupsPorts(NetworkPolicyFluent<NetworkPolicyBuilder>.SpecNested<NetworkPolicyBuilder> builder, Keycloak keycloak) {
var ingressBuilder = builder.addNewIngress();
ingressBuilder.addNewPort()
.withPort(new IntOrString(KEYCLOAK_JGROUPS_DATA_PORT))
.withProtocol(KEYCLOAK_JGROUPS_PROTOCOL)
.endPort();
ingressBuilder.addNewPort()
.withPort(new IntOrString(KEYCLOAK_JGROUPS_FD_PORT))
.withProtocol(KEYCLOAK_JGROUPS_PROTOCOL)
.endPort();
ingressBuilder.addNewFrom()
.withNewPodSelector()
.addToMatchLabels(Utils.allInstanceLabels(keycloak))
.endPodSelector()
.endFrom();
ingressBuilder.endIngress();
}
private static void addMetadata(NetworkPolicyBuilder builder, Keycloak keycloak) {
builder.withNewMetadata()
.withName(NetworkPolicySpec.networkPolicyName(keycloak))
.withNamespace(keycloak.getMetadata().getNamespace())
.addToLabels(Utils.allInstanceLabels(keycloak))
.endMetadata();
}
}
@@ -16,6 +16,8 @@
*/
package org.keycloak.operator.controllers;
import java.util.Optional;
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.api.model.Service;
import io.fabric8.kubernetes.api.model.ServiceBuilder;
@@ -25,15 +27,11 @@ import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.ResourceDiscriminator;
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource;
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
import org.keycloak.operator.Constants;
import org.keycloak.operator.Utils;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.HttpSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.HttpManagementSpec;
import java.util.Optional;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.HttpSpec;
import static org.keycloak.operator.crds.v2alpha1.CRDUtils.isTlsConfigured;
@@ -59,26 +57,21 @@ public class KeycloakServiceDependentResource extends CRUDKubernetesDependentRes
boolean httpEnabled = httpSpec.map(HttpSpec::getHttpEnabled).orElse(false);
if (!tlsConfigured || httpEnabled) {
builder.addNewPort()
.withPort(getServicePort(false, keycloak))
.withPort(HttpSpec.httpPort(keycloak))
.withName(Constants.KEYCLOAK_HTTP_PORT_NAME)
.withProtocol(Constants.KEYCLOAK_SERVICE_PROTOCOL)
.endPort();
}
if (tlsConfigured) {
builder.addNewPort()
.withPort(getServicePort(true, keycloak))
.withPort(HttpSpec.httpsPort(keycloak))
.withName(Constants.KEYCLOAK_HTTPS_PORT_NAME)
.withProtocol(Constants.KEYCLOAK_SERVICE_PROTOCOL)
.endPort();
}
var managementPort = Optional.ofNullable(keycloak.getSpec())
.map(KeycloakSpec::getHttpManagementSpec)
.map(HttpManagementSpec::getPort)
.orElse(Constants.KEYCLOAK_MANAGEMENT_PORT);
builder.addNewPort()
.withPort(managementPort)
.withPort(HttpManagementSpec.managementPort(keycloak))
.withName(Constants.KEYCLOAK_MANAGEMENT_PORT_NAME)
.withProtocol(Constants.KEYCLOAK_SERVICE_PROTOCOL)
.endPort();
@@ -102,12 +95,4 @@ public class KeycloakServiceDependentResource extends CRUDKubernetesDependentRes
public static String getServiceName(HasMetadata keycloak) {
return keycloak.getMetadata().getName() + Constants.KEYCLOAK_SERVICE_SUFFIX;
}
public static int getServicePort(boolean tls, Keycloak keycloak) {
Optional<HttpSpec> httpSpec = Optional.ofNullable(keycloak.getSpec().getHttpSpec());
if (tls) {
return httpSpec.map(HttpSpec::getHttpsPort).orElse(Constants.KEYCLOAK_HTTPS_PORT);
}
return httpSpec.map(HttpSpec::getHttpPort).orElse(Constants.KEYCLOAK_HTTP_PORT);
}
}
@@ -17,18 +17,68 @@
package org.keycloak.operator.crds.v2alpha1;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.HttpSpec;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
import org.keycloak.operator.Constants;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.FeatureSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.HttpSpec;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public final class CRDUtils {
private static final String METRICS_ENABLED = "metrics-enabled";
private static final String HEALTH_ENABLED = "health-enabled";
private static final String LEGACY_MANAGEMENT_ENABLED = "legacy-observability-interface";
public static boolean isTlsConfigured(Keycloak keycloakCR) {
var tlsSecret = Optional.ofNullable(keycloakCR.getSpec().getHttpSpec()).map(HttpSpec::getTlsSecret);
var tlsSecret = keycloakSpecOf(keycloakCR).map(KeycloakSpec::getHttpSpec).map(HttpSpec::getTlsSecret);
return tlsSecret.isPresent() && !tlsSecret.get().trim().isEmpty();
}
public static boolean isJGroupEnabled(Keycloak keycloak) {
// If multi-site or clusterless are present, JGroups is not enabled.
return CRDUtils.keycloakSpecOf(keycloak)
.map(KeycloakSpec::getFeatureSpec)
.map(FeatureSpec::getEnabledFeatures)
.filter(features -> features.contains("multi-site") || features.contains("clusterless"))
.isEmpty();
}
public static boolean isManagementEndpointEnabled(Keycloak keycloak) {
Map<String, String> options = new HashMap<>();
// add default options
Constants.DEFAULT_DIST_CONFIG_LIST
.forEach(valueOrSecret -> options.put(valueOrSecret.getName(), valueOrSecret.getValue()));
// overwrite the configured ones
keycloakSpecOf(keycloak)
.map(KeycloakSpec::getAdditionalOptions)
.stream()
.flatMap(Collection::stream)
.forEach(valueOrSecret -> options.put(valueOrSecret.getName(), valueOrSecret.getValue()));
// Legacy management enabled
if (Boolean.parseBoolean(options.get(LEGACY_MANAGEMENT_ENABLED))) {
return false;
}
// Only metrics and health use the management endpoint.
return Stream.of(METRICS_ENABLED, HEALTH_ENABLED)
.map(options::get)
.filter(Objects::nonNull)
.anyMatch(Boolean::parseBoolean);
}
public static Optional<KeycloakSpec> keycloakSpecOf(Keycloak keycloak) {
return Optional.ofNullable(keycloak)
.map(Keycloak::getSpec);
}
}
@@ -28,6 +28,7 @@ import org.keycloak.operator.crds.v2alpha1.deployment.spec.HostnameSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.HttpManagementSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.HttpSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.IngressSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.NetworkPolicySpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.ProxySpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.SchedulingSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.TransactionsSpec;
@@ -120,6 +121,10 @@ public class KeycloakSpec {
@JsonPropertyDescription("In this section you can configure Keycloak's bootstrap admin - will be used only for inital cluster creation.")
private BootstrapAdminSpec bootstrapAdminSpec;
@JsonProperty("networkPolicy")
@JsonPropertyDescription("Controls the ingress traffic flow into Keycloak pods.")
private NetworkPolicySpec networkPolicySpec;
public HttpSpec getHttpSpec() {
return httpSpec;
}
@@ -269,7 +274,7 @@ public class KeycloakSpec {
public void setSchedulingSpec(SchedulingSpec schedulingSpec) {
this.schedulingSpec = schedulingSpec;
}
public BootstrapAdminSpec getBootstrapAdminSpec() {
return bootstrapAdminSpec;
}
@@ -277,4 +282,12 @@ public class KeycloakSpec {
public void setBootstrapAdminSpec(BootstrapAdminSpec bootstrapAdminSpec) {
this.bootstrapAdminSpec = bootstrapAdminSpec;
}
}
public NetworkPolicySpec getNetworkPolicySpec() {
return networkPolicySpec;
}
public void setNetworkPolicySpec(NetworkPolicySpec networkPolicySpec) {
this.networkPolicySpec = networkPolicySpec;
}
}
@@ -20,6 +20,9 @@ import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import io.sundr.builder.annotations.Buildable;
import org.keycloak.operator.Constants;
import org.keycloak.operator.crds.v2alpha1.CRDUtils;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakSpec;
@JsonInclude(JsonInclude.Include.NON_NULL)
@Buildable(editableEnabled = false, builderPackage = "io.fabric8.kubernetes.api.builder")
@@ -35,4 +38,11 @@ public class HttpManagementSpec {
public void setPort(Integer port) {
this.port = port;
}
public static int managementPort(Keycloak keycloak) {
return CRDUtils.keycloakSpecOf(keycloak)
.map(KeycloakSpec::getHttpManagementSpec)
.map(HttpManagementSpec::getPort)
.orElse(Constants.KEYCLOAK_MANAGEMENT_PORT);
}
}
@@ -17,10 +17,15 @@
package org.keycloak.operator.crds.v2alpha1.deployment.spec;
import java.util.Optional;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import io.sundr.builder.annotations.Buildable;
import org.keycloak.operator.Constants;
import org.keycloak.operator.crds.v2alpha1.CRDUtils;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakSpec;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
@@ -71,4 +76,23 @@ public class HttpSpec {
public void setHttpsPort(Integer httpsPort) {
this.httpsPort = httpsPort;
}
public static int httpPort(Keycloak keycloak) {
return httpSpec(keycloak)
.map(HttpSpec::getHttpPort)
.orElse(Constants.KEYCLOAK_HTTP_PORT);
}
public static int httpsPort(Keycloak keycloak) {
return httpSpec(keycloak)
.map(HttpSpec::getHttpsPort)
.orElse(Constants.KEYCLOAK_HTTPS_PORT);
}
private static Optional<HttpSpec> httpSpec(Keycloak keycloak) {
return CRDUtils.keycloakSpecOf(keycloak)
.map(KeycloakSpec::getHttpSpec);
}
}
@@ -0,0 +1,55 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.operator.crds.v2alpha1.deployment.spec;
import java.util.Optional;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import io.sundr.builder.annotations.Buildable;
import org.keycloak.operator.Constants;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
@JsonInclude(JsonInclude.Include.NON_NULL)
@Buildable(editableEnabled = false, builderPackage = "io.fabric8.kubernetes.api.builder")
public class NetworkPolicySpec {
@JsonProperty("enabled")
@JsonPropertyDescription("Enables or disable the ingress traffic control.")
private boolean networkPolicyEnabled = false;
public boolean isNetworkPolicyEnabled() {
return networkPolicyEnabled;
}
public void setNetworkPolicyEnabled(boolean networkPolicyEnabled) {
this.networkPolicyEnabled = networkPolicyEnabled;
}
public static boolean isNetworkPolicyEnabled(Keycloak keycloak) {
return Optional.ofNullable(keycloak.getSpec().getNetworkPolicySpec())
.map(NetworkPolicySpec::isNetworkPolicyEnabled)
.orElse(false);
}
public static String networkPolicyName(Keycloak keycloak) {
return keycloak.getMetadata().getName() + Constants.KEYCLOAK_NETWORK_POLICY_SUFFIX;
}
}
@@ -308,7 +308,7 @@ public class BaseOperatorTest implements QuarkusTestAfterEachCallback {
var rootsDeleted = CompletableFuture.allOf(roots.stream()
.map(c -> k8sclient.resources(c).informOnCondition(List::isEmpty)).toArray(CompletableFuture[]::new));
roots.stream().forEach(c -> k8sclient.resources(c).withGracePeriod(0).delete());
roots.forEach(c -> k8sclient.resources(c).withGracePeriod(10).delete());
try {
rootsDeleted.get(1, TimeUnit.MINUTES);
} catch (Exception e) {
@@ -316,7 +316,7 @@ public class BaseOperatorTest implements QuarkusTestAfterEachCallback {
throw new RuntimeException(e);
}
dependents.stream().map(c -> k8sclient.resources(c).withLabels(Constants.DEFAULT_LABELS))
.forEach(r -> r.withGracePeriod(0).delete());
.forEach(r -> r.withGracePeriod(10).delete());
// enforce that the dependents are gone
Awaitility.await().during(5, TimeUnit.SECONDS).until(() -> {
if (dependents.stream().anyMatch(
@@ -518,4 +518,8 @@ public class BaseOperatorTest implements QuarkusTestAfterEachCallback {
return keycloak;
}
protected static String namespaceOf(Keycloak keycloak) {
return keycloak.getMetadata().getNamespace();
}
}
@@ -25,8 +25,6 @@ import io.fabric8.kubernetes.api.model.ResourceRequirements;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.api.model.SecretBuilder;
import io.fabric8.kubernetes.api.model.SecretKeySelectorBuilder;
import io.fabric8.kubernetes.api.model.Service;
import io.fabric8.kubernetes.api.model.ServicePort;
import io.fabric8.kubernetes.api.model.apps.StatefulSet;
import io.fabric8.kubernetes.api.model.apps.StatefulSetBuilder;
import io.fabric8.kubernetes.api.model.apps.StatefulSetSpecBuilder;
@@ -294,8 +292,8 @@ public class KeycloakDeploymentTest extends BaseOperatorTest {
kc.getSpec().getHttpSpec().setHttpEnabled(true);
deployKeycloak(k8sclient, kc, true);
assertKeycloakAccessibleViaService(kc, false, Constants.KEYCLOAK_HTTP_PORT);
assertManagementInterfaceAccessibleViaService(kc, false);
CRAssert.assertKeycloakAccessibleViaService(k8sclient, kc, false, Constants.KEYCLOAK_HTTP_PORT);
CRAssert.assertManagementInterfaceAccessibleViaService(k8sclient, kc, false);
}
@Test
@@ -304,10 +302,10 @@ public class KeycloakDeploymentTest extends BaseOperatorTest {
kc.getSpec().getHttpSpec().setHttpEnabled(true);
deployKeycloak(k8sclient, kc, true);
assertKeycloakAccessibleViaService(kc, false, Constants.KEYCLOAK_HTTP_PORT);
CRAssert.assertKeycloakAccessibleViaService(k8sclient, kc, false, Constants.KEYCLOAK_HTTP_PORT);
// if TLS is enabled, management interface should use https
assertManagementInterfaceAccessibleViaService(kc, true);
CRAssert.assertManagementInterfaceAccessibleViaService(k8sclient, kc, true);
}
@Test
@@ -366,7 +364,7 @@ public class KeycloakDeploymentTest extends BaseOperatorTest {
deployKeycloak(k8sclient, kc, true);
assertKeycloakAccessibleViaService(kc, true, httpsPort);
CRAssert.assertKeycloakAccessibleViaService(k8sclient, kc, true, httpsPort);
}
@Test
@@ -386,7 +384,7 @@ public class KeycloakDeploymentTest extends BaseOperatorTest {
deployKeycloak(k8sclient, kc, true);
assertKeycloakAccessibleViaService(kc, false, httpPort);
CRAssert.assertKeycloakAccessibleViaService(k8sclient, kc, false, httpPort);
}
@Test
@@ -780,43 +778,4 @@ public class KeycloakDeploymentTest extends BaseOperatorTest {
LocalObjectReference localObjRefAsSecretTmp = new LocalObjectReferenceBuilder().withName(imagePullSecret.getMetadata().getName()).build();
keycloakCR.getSpec().setImagePullSecrets(Collections.singletonList(localObjRefAsSecretTmp));
}
private void assertKeycloakAccessibleViaService(Keycloak kc, boolean https, int port) {
Awaitility.await()
.ignoreExceptions()
.untilAsserted(() -> {
String protocol = https ? "https" : "http";
String serviceName = KeycloakServiceDependentResource.getServiceName(kc);
assertThat(k8sclient.resources(Service.class).withName(serviceName).require().getSpec().getPorts()
.stream().map(ServicePort::getName).anyMatch(protocol::equals)).isTrue();
String url = protocol + "://" + serviceName + "." + namespace + ":" + port + "/admin/master/console/";
Log.info("Checking url: " + url);
var curlOutput = K8sUtils.inClusterCurl(k8sclient, namespace, url);
Log.info("Curl Output: " + curlOutput);
assertEquals("200", curlOutput);
});
}
private void assertManagementInterfaceAccessibleViaService(Keycloak kc, boolean https) {
Awaitility.await()
.ignoreExceptions()
.untilAsserted(() -> {
String serviceName = KeycloakServiceDependentResource.getServiceName(kc);
assertThat(k8sclient.resources(Service.class).withName(serviceName).require().getSpec().getPorts()
.stream().map(ServicePort::getName).anyMatch(Constants.KEYCLOAK_MANAGEMENT_PORT_NAME::equals)).isTrue();
String protocol = https ? "https" : "http";
String url = protocol + "://" + serviceName + "." + namespace + ":" + Constants.KEYCLOAK_MANAGEMENT_PORT;
Log.info("Checking url: " + url);
var curlOutput = K8sUtils.inClusterCurl(k8sclient, namespace, url);
Log.info("Curl Output: " + curlOutput);
assertEquals("200", curlOutput);
});
}
}
@@ -0,0 +1,337 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.operator.testsuite.integration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import io.fabric8.kubernetes.api.model.NamespaceBuilder;
import io.fabric8.kubernetes.api.model.networking.v1.NetworkPolicy;
import io.fabric8.kubernetes.api.model.networking.v1.NetworkPolicyIngressRule;
import io.fabric8.kubernetes.api.model.networking.v1.NetworkPolicyPort;
import io.quarkus.logging.Log;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.keycloak.operator.Constants;
import org.keycloak.operator.Utils;
import org.keycloak.operator.controllers.KeycloakController;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.ValueOrSecret;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.HostnameSpecBuilder;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.HttpManagementSpecBuilder;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.IngressSpecBuilder;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.NetworkPolicySpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.NetworkPolicySpecBuilder;
import org.keycloak.operator.testsuite.utils.CRAssert;
import org.keycloak.operator.testsuite.utils.K8sUtils;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@QuarkusTest
public class KeycloakNetworkPolicyTest extends BaseOperatorTest {
private static NetworkPolicy networkPolicy(Keycloak keycloak) {
return k8sclient.network().networkPolicies()
.inNamespace(namespaceOf(keycloak))
.withName(NetworkPolicySpec.networkPolicyName(keycloak))
.get();
}
@Test
public void testDefaults() {
var kc = create();
K8sUtils.deployKeycloak(k8sclient, kc, true);
CRAssert.awaitClusterSize(k8sclient, kc, 2);
assertNull(networkPolicy(kc), "Expects no network policies deployed");
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
public void testHttpOnly(boolean randomPort) {
var kc = create();
enableNetworkPolicy(kc);
disableHttps(kc);
var httpPort = enableHttp(kc, randomPort);
var mngtPort = configureManagement(kc, randomPort);
K8sUtils.deployKeycloak(k8sclient, kc, true);
CRAssert.awaitClusterSize(k8sclient, kc, 2);
assertIngressRules(kc, httpPort, -1, mngtPort);
CRAssert.assertKeycloakAccessibleViaService(k8sclient, kc, false, httpPort);
CRAssert.assertManagementInterfaceAccessibleViaService(k8sclient, kc, false, mngtPort);
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
public void testHttpsOnly(boolean randomPort) {
var kc = create();
enableNetworkPolicy(kc);
var httpsPort = configureHttps(kc, randomPort);
var mngtPort = configureManagement(kc, randomPort);
K8sUtils.deployKeycloak(k8sclient, kc, true);
CRAssert.awaitClusterSize(k8sclient, kc, 2);
assertIngressRules(kc, -1, httpsPort, mngtPort);
CRAssert.assertKeycloakAccessibleViaService(k8sclient, kc, true, httpsPort);
CRAssert.assertManagementInterfaceAccessibleViaService(k8sclient, kc, true, mngtPort);
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
public void testHttpAndHttps(boolean randomPort) {
var kc = create();
enableNetworkPolicy(kc);
var httpPort = enableHttp(kc, randomPort);
var httpsPort = configureHttps(kc, randomPort);
var mngtPort = configureManagement(kc, randomPort);
K8sUtils.deployKeycloak(k8sclient, kc, true);
CRAssert.awaitClusterSize(k8sclient, kc, 2);
assertIngressRules(kc, httpPort, httpsPort, mngtPort);
CRAssert.assertKeycloakAccessibleViaService(k8sclient, kc, false, httpPort);
CRAssert.assertKeycloakAccessibleViaService(k8sclient, kc, true, httpsPort);
CRAssert.assertManagementInterfaceAccessibleViaService(k8sclient, kc, true, mngtPort);
}
@ParameterizedTest()
@ValueSource(booleans = {true, false})
public void testManagementDisabled(boolean legacyOption) {
var kc = create();
disableProbes(kc);
enableNetworkPolicy(kc);
disableManagement(kc, legacyOption);
K8sUtils.deployKeycloak(k8sclient, kc, true);
CRAssert.awaitClusterSize(k8sclient, kc, 2);
assertIngressRules(kc, -1, Constants.KEYCLOAK_HTTPS_PORT, -1);
CRAssert.assertKeycloakAccessibleViaService(k8sclient, kc, true, Constants.KEYCLOAK_HTTPS_PORT);
}
@Test
public void testJGroupsConnectivity() {
var kc = create();
enableNetworkPolicy(kc);
K8sUtils.deployKeycloak(k8sclient, kc, true);
CRAssert.awaitClusterSize(k8sclient, kc, 2);
assertIngressRules(kc, -1, Constants.KEYCLOAK_HTTPS_PORT, Constants.KEYCLOAK_MANAGEMENT_PORT);
var namespace = namespaceOf(kc);
var podIp = k8sclient.pods().inNamespace(namespace).list().getItems().get(0).getStatus().getPodIP();
// pod in the same namespace, labels match: able to connect.
CRAssert.assertJGroupsConnection(k8sclient, podIp, namespace, Utils.allInstanceLabels(kc), true);
// pod in the same namespace, labels do not match: fail to connect.
CRAssert.assertJGroupsConnection(k8sclient, podIp, namespace, Map.of(), false);
var otherNamespace = getNewRandomNamespaceName();
try {
k8sclient.resource(new NamespaceBuilder().withNewMetadata().withName(otherNamespace).endMetadata().build()).create();
// pod in a different namespace: fail to connect
CRAssert.assertJGroupsConnection(k8sclient, podIp, otherNamespace, Utils.allInstanceLabels(kc), false);
CRAssert.assertJGroupsConnection(k8sclient, podIp, otherNamespace, Map.of(), false);
} finally {
k8sclient.namespaces().withName(otherNamespace).delete();
}
}
@Test
public void testUpdate() {
var kc = create();
enableNetworkPolicy(kc);
K8sUtils.deployKeycloak(k8sclient, kc, true);
CRAssert.awaitClusterSize(k8sclient, kc, 2);
assertIngressRules(kc, -1, Constants.KEYCLOAK_HTTPS_PORT, Constants.KEYCLOAK_MANAGEMENT_PORT);
// disable should remove the network policy
kc.getSpec().getNetworkPolicySpec().setNetworkPolicyEnabled(false);
K8sUtils.deployKeycloak(k8sclient, kc, true);
CRAssert.awaitClusterSize(k8sclient, kc, 2);
assertNull(networkPolicy(kc), "Expects no network policies deployed");
// disable should remove the network policy
kc.getSpec().getNetworkPolicySpec().setNetworkPolicyEnabled(true);
K8sUtils.deployKeycloak(k8sclient, kc, true);
CRAssert.awaitClusterSize(k8sclient, kc, 2);
assertIngressRules(kc, -1, Constants.KEYCLOAK_HTTPS_PORT, Constants.KEYCLOAK_MANAGEMENT_PORT);
}
private static void assertPodSelectorAndPolicy(Keycloak keycloak, NetworkPolicy networkPolicy) {
assertNotNull(networkPolicy, "Expects a network policy");
assertEquals(Utils.allInstanceLabels(keycloak), networkPolicy.getSpec().getPodSelector().getMatchLabels(), "Expects same pod match labels");
assertTrue(networkPolicy.getSpec().getPolicyTypes().contains("Ingress"), "Expect ingress polity type present");
}
private static void assertManagementRulePresent(NetworkPolicy networkPolicy, int mgmtPort) {
var rule = findIngressRuleWithPort(networkPolicy, mgmtPort);
assertTrue(rule.isPresent(), "Management Ingress Rule is missing");
assertTrue(rule.get().getFrom().isEmpty());
var ports = portAndProtocol(rule.get());
assertEquals(Map.of(mgmtPort, Constants.KEYCLOAK_SERVICE_PROTOCOL), ports);
}
private static void assertApplicationRulePresent(NetworkPolicy networkPolicy, int applicationPort) {
var rule = findIngressRuleWithPort(networkPolicy, applicationPort);
assertTrue(rule.isPresent(), "Application Ingress Rule is missing");
assertTrue(rule.get().getFrom().isEmpty());
var ports = portAndProtocol(rule.get());
assertEquals(Map.of(applicationPort, Constants.KEYCLOAK_SERVICE_PROTOCOL), ports);
}
private static void assertJGroupsRulePresent(Keycloak keycloak, NetworkPolicy networkPolicy) {
var rule = findIngressRuleWithPort(networkPolicy, Constants.KEYCLOAK_JGROUPS_DATA_PORT);
assertTrue(rule.isPresent(), "JGroups Ingress Rule is missing");
var from = rule.get().getFrom();
assertEquals(1, from.size(), "Incorrect 'from' list size");
assertEquals(Utils.allInstanceLabels(keycloak), from.get(0).getPodSelector().getMatchLabels());
var ports = portAndProtocol(rule.get());
assertEquals(Map.of(
Constants.KEYCLOAK_JGROUPS_DATA_PORT, Constants.KEYCLOAK_JGROUPS_PROTOCOL,
Constants.KEYCLOAK_JGROUPS_FD_PORT, Constants.KEYCLOAK_JGROUPS_PROTOCOL
), ports);
}
private static void assertIngressRules(Keycloak keycloak, int httpPort, int httpsPort, int mgntPort) {
var networkPolicy = networkPolicy(keycloak);
Log.info(networkPolicy);
var expectedNumberOfRules = IntStream.of(httpPort, httpsPort, mgntPort)
.filter(value -> value > 0)
.count();
// +1 for JGRP
++expectedNumberOfRules;
long numberOfRules = Optional.ofNullable(networkPolicy.getSpec())
.map(io.fabric8.kubernetes.api.model.networking.v1.NetworkPolicySpec::getIngress)
.map(List::size)
.orElse(0);
assertEquals(expectedNumberOfRules, numberOfRules);
// Check selector
assertPodSelectorAndPolicy(keycloak, networkPolicy);
// JGroups is always present
assertJGroupsRulePresent(keycloak, networkPolicy);
if (httpPort > 0) {
assertApplicationRulePresent(networkPolicy, httpPort);
}
if (httpsPort > 0) {
assertApplicationRulePresent(networkPolicy, httpsPort);
}
if (mgntPort > 0) {
assertManagementRulePresent(networkPolicy, mgntPort);
}
}
private static Map<Integer, String> portAndProtocol(NetworkPolicyIngressRule rule) {
return rule.getPorts().stream()
.collect(Collectors.toMap(port -> port.getPort().getIntVal(), NetworkPolicyPort::getProtocol));
}
private static Optional<NetworkPolicyIngressRule> findIngressRuleWithPort(NetworkPolicy networkPolicy, int rulePort) {
return networkPolicy.getSpec().getIngress().stream()
.filter(rule -> rule.getPorts().stream().anyMatch(port -> port.getPort().getIntVal() == rulePort))
.findFirst();
}
private static void enableNetworkPolicy(Keycloak keycloak) {
var builder = new NetworkPolicySpecBuilder();
builder.withNetworkPolicyEnabled(true);
keycloak.getSpec().setNetworkPolicySpec(builder.build());
}
private static int configureManagement(Keycloak keycloak, boolean randomPort) {
if (!randomPort) {
return Constants.KEYCLOAK_MANAGEMENT_PORT;
}
var port = ThreadLocalRandom.current().nextInt(10_000, 10_100);
keycloak.getSpec().setHttpManagementSpec(new HttpManagementSpecBuilder().withPort(port).build());
return port;
}
private static int enableHttp(Keycloak keycloak, boolean randomPort) {
keycloak.getSpec().getHttpSpec().setHttpEnabled(true);
if (randomPort) {
var port = ThreadLocalRandom.current().nextInt(10_100, 10_200);
keycloak.getSpec().getHttpSpec().setHttpPort(port);
return port;
}
return Constants.KEYCLOAK_HTTP_PORT;
}
private static void disableHttps(Keycloak keycloak) {
keycloak.getSpec().getHttpSpec().setTlsSecret(null);
}
private static int configureHttps(Keycloak keycloak, boolean randomPort) {
if (randomPort) {
var port = ThreadLocalRandom.current().nextInt(10_200, 10_300);
keycloak.getSpec().getHttpSpec().setHttpsPort(port);
return port;
}
return Constants.KEYCLOAK_HTTPS_PORT;
}
private static void disableManagement(Keycloak keycloak, boolean legacyOption) {
if (legacyOption) {
keycloak.getSpec().getAdditionalOptions().add(new ValueOrSecret("legacy-observability-interface", "true"));
} else {
keycloak.getSpec().getAdditionalOptions().add(new ValueOrSecret("health-enabled", "false"));
}
// The custom image from GitHub Actions is optimized and does not allow to change the build time attributes
// Fallback to the default/nightly image.
if (getTestCustomImage() != null) {
keycloak.getSpec().setImage(null);
}
}
private static Keycloak create() {
var kc = getTestKeycloakDeployment(false);
kc.getSpec().setInstances(2);
var hostnameSpecBuilder = new HostnameSpecBuilder()
.withStrict(false)
.withStrictBackchannel(false);
if (isOpenShift) {
kc.getSpec().setIngressSpec(new IngressSpecBuilder().withIngressClassName(KeycloakController.OPENSHIFT_DEFAULT).build());
}
kc.getSpec().setHostnameSpec(hostnameSpecBuilder.build());
return kc;
}
}
@@ -45,6 +45,7 @@ import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class CRSerializationTest {
@@ -94,6 +95,9 @@ public class CRSerializationTest {
HttpManagementSpec managementSpec = keycloak.getSpec().getHttpManagementSpec();
assertNotNull(managementSpec);
assertEquals(9003, managementSpec.getPort());
assertNotNull(keycloak.getSpec().getNetworkPolicySpec());
assertTrue(keycloak.getSpec().getNetworkPolicySpec().isNetworkPolicyEnabled());
}
@Test
@@ -213,4 +217,4 @@ public class CRSerializationTest {
assertThat(limitMemQuantity.getFormat(), is("Gi"));
}
}
}
@@ -17,24 +17,45 @@
package org.keycloak.operator.testsuite.utils;
import java.io.ByteArrayOutputStream;
import java.net.HttpURLConnection;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import io.fabric8.kubernetes.api.model.PodBuilder;
import io.fabric8.kubernetes.api.model.Service;
import io.fabric8.kubernetes.api.model.ServicePort;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.KubernetesClientException;
import io.fabric8.kubernetes.client.dsl.ExecWatch;
import io.fabric8.kubernetes.client.utils.Serialization;
import io.quarkus.logging.Log;
import org.assertj.core.api.ObjectAssert;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.Assertions;
import org.keycloak.operator.Constants;
import org.keycloak.operator.controllers.KeycloakServiceDependentResource;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakStatus;
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakStatusCondition;
import org.keycloak.operator.crds.v2alpha1.realmimport.KeycloakRealmImport;
import java.util.Objects;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public final class CRAssert {
// ISPN000093 -> cluster view
// ISPN000094 -> merge view
private static final Pattern CLUSTER_SIZE_PATTERN = Pattern.compile("ISPN00009[34]: [^]]*] \\((\\d+)\\)");
public static void assertKeycloakStatusCondition(Keycloak kc, String condition, boolean status) {
assertKeycloakStatusCondition(kc, condition, status, null);
}
@@ -84,4 +105,134 @@ public final class CRAssert {
assertThat(kri.getStatus().getConditions())
.anyMatch(c -> c.getType().equals(condition) && Objects.equals(c.getStatus(), status));
}
public static void awaitClusterSize(KubernetesClient client, Keycloak keycloak, int expectedSize) {
Log.infof("Waiting for cluster size of %s", expectedSize);
Awaitility
.await()
.pollInterval(1, TimeUnit.SECONDS)
.timeout(Duration.ofMinutes(5))
.untilAsserted(() -> {
client.pods()
.inNamespace(namespaceOf(keycloak))
.withLabels(org.keycloak.operator.Utils.allInstanceLabels(keycloak))
.resources()
.forEach(pod -> {
var logs = pod.getLog();
var matcher = CLUSTER_SIZE_PATTERN.matcher(logs);
int size = 0;
// We want the last view change.
// The other alternative is to reverse the string.
while (matcher.find()) {
size = Integer.parseInt(matcher.group(1));
}
Assertions.assertEquals(expectedSize, size, "Wrong cluster size in pod " + pod);
});
});
}
public static void assertKeycloakAccessibleViaService(KubernetesClient client, Keycloak keycloak, boolean https, int port) {
Awaitility.await()
.ignoreExceptions()
.untilAsserted(() -> {
String protocol = https ? "https" : "http";
var namespace = namespaceOf(keycloak);
String serviceName = KeycloakServiceDependentResource.getServiceName(keycloak);
assertThat(client.resources(Service.class).withName(serviceName).require().getSpec().getPorts()
.stream().map(ServicePort::getName).anyMatch(protocol::equals)).isTrue();
String url = protocol + "://" + serviceName + "." + namespace + ":" + port + "/admin/master/console/";
Log.info("Checking url: " + url);
var curlOutput = K8sUtils.inClusterCurl(client, namespace, url);
Log.info("Curl Output: " + curlOutput);
assertEquals("200", curlOutput);
});
}
public static void assertManagementInterfaceAccessibleViaService(KubernetesClient client, Keycloak kc, boolean https) {
assertManagementInterfaceAccessibleViaService(client, kc, https, Constants.KEYCLOAK_MANAGEMENT_PORT);
}
public static void assertManagementInterfaceAccessibleViaService(KubernetesClient client, Keycloak keycloak, boolean https, int port) {
Awaitility.await()
.ignoreExceptions()
.untilAsserted(() -> {
String serviceName = KeycloakServiceDependentResource.getServiceName(keycloak);
var namespace = namespaceOf(keycloak);
assertThat(client.resources(Service.class).withName(serviceName).require().getSpec().getPorts()
.stream().map(ServicePort::getName).anyMatch(Constants.KEYCLOAK_MANAGEMENT_PORT_NAME::equals)).isTrue();
String protocol = https ? "https" : "http";
String url = protocol + "://" + serviceName + "." + namespace + ":" + port;
Log.info("Checking url: " + url);
var curlOutput = K8sUtils.inClusterCurl(client, namespace, url);
Log.info("Curl Output: " + curlOutput);
assertEquals("200", curlOutput);
});
}
public static void assertJGroupsConnection(KubernetesClient client, String podIp, String namespace, Map<String, String> labels, boolean connects) {
// Send a bogus command to JGroups port
// relevant exit codes:
// 28-Operation timeout.
// 48-Unknown option specified to libcurl.
int expectedExitCode = connects ? 48 : 28;
int exitCode;
try {
var builder = new PodBuilder();
builder.withNewMetadata()
.withName("curl-telnet-" + UUID.randomUUID())
.withNamespace(namespace)
.withLabels(labels)
.endMetadata();
builder.withNewSpec()
.addNewContainer()
.withImage("curlimages/curl:8.1.2")
.withCommand("sh")
.withName("curl")
.withStdin()
.endContainer()
.endSpec();
var curlPod = builder.build();
try {
client.resource(curlPod).create();
} catch (KubernetesClientException e) {
if (e.getCode() != HttpURLConnection.HTTP_CONFLICT) {
throw e;
}
}
var args = new String[]{
"curl",
"--telnet-option",
"'BOGUS=1'",
"--connect-timeout",
"2",
"-s",
"telnet://%s:7800".formatted(podIp)
};
Log.infof("Run telnet: %s", String.join(" ", args));
try (ExecWatch watch = client.pods().resource(curlPod).withReadyWaitTimeout(60000)
.writingOutput(new ByteArrayOutputStream())
.exec(args)) {
exitCode = watch.exitCode().get(15, TimeUnit.SECONDS);
}
} catch (Exception ex) {
throw KubernetesClientException.launderThrowable(ex);
}
assertEquals(expectedExitCode, exitCode);
}
private static String namespaceOf(Keycloak keycloak) {
return keycloak.getMetadata().getNamespace();
}
}
@@ -18,7 +18,6 @@
package org.keycloak.operator.testsuite.utils;
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.api.model.Pod;
import io.fabric8.kubernetes.api.model.PodBuilder;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.client.KubernetesClient;
@@ -116,15 +115,10 @@ public final class K8sUtils {
public static String inClusterCurl(KubernetesClient k8sclient, String namespace, String... args) {
var podName = "curl-pod";
try {
Pod curlPod = new PodBuilder().withNewMetadata().withName(podName).endMetadata().withNewSpec()
.addNewContainer()
.withImage("curlimages/curl:8.1.2")
.withCommand("sh")
.withName("curl")
.withStdin()
.endContainer()
.endSpec()
.build();
var builder = new PodBuilder();
builder.withNewMetadata().withName(podName).endMetadata();
createCurlContainer(builder);
var curlPod = builder.build();
try {
k8sclient.resource(curlPod).create();
@@ -147,4 +141,15 @@ public final class K8sUtils {
throw KubernetesClientException.launderThrowable(ex);
}
}
private static void createCurlContainer(PodBuilder builder) {
builder.withNewSpec()
.addNewContainer()
.withImage("curlimages/curl:8.1.2")
.withCommand("sh")
.withName("curl")
.withStdin()
.endContainer()
.endSpec();
}
}
@@ -32,6 +32,8 @@ spec:
annotations:
myAnnotation: myValue
anotherAnnotation: anotherValue
networkPolicy:
enabled: true
http:
httpEnabled: true
httpPort: 123