mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-05 06:30:09 -05:00
Enhance the Keycloak Operator with Network Policies (#34788)
Closes #34659 Signed-off-by: Pedro Ruivo <pruivo@redhat.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+1
-4
@@ -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)
|
||||
|
||||
+152
@@ -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();
|
||||
}
|
||||
}
|
||||
+6
-21
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+15
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+55
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+6
-2
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+6
-47
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+337
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+5
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user