Deploy a default ingress along with the Deployment

This commit is contained in:
andreaTP 2022-03-09 13:25:18 +00:00 committed by Bruno Oliveira da Silva
parent 0de7bae121
commit c3348c8931
9 changed files with 306 additions and 5 deletions

View file

@ -41,6 +41,7 @@ jobs:
kubernetes version: v1.22.3
github token: ${{ secrets.GITHUB_TOKEN }}
driver: docker
start args: '--addons=ingress'
- name: Build the Keycloak Docker image
run: |
@ -56,7 +57,8 @@ jobs:
mvn clean verify \
-Dquarkus.kubernetes.deployment-target=kubernetes \
-Doperator.keycloak.image=keycloak:${GITHUB_SHA} \
-Doperator.keycloak.image-pull-policy=Never
-Doperator.keycloak.image-pull-policy=Never \
-Dtest.operator.kubernetes.ip=$(minikube ip)
- name: Test operator running in cluster
working-directory: operator
@ -66,4 +68,5 @@ jobs:
-Dquarkus.container-image.build=true -Dquarkus.container-image.tag=test \
-Dquarkus.kubernetes.deployment-target=kubernetes \
-Dquarkus.jib.jvm-arguments="-Djava.util.logging.manager=org.jboss.logmanager.LogManager","-Doperator.keycloak.image=keycloak:${GITHUB_SHA}","-Doperator.keycloak.image-pull-policy=Never" \
--no-transfer-progress -Dtest.operator.deployment=remote
--no-transfer-progress -Dtest.operator.deployment=remote \
-Dtest.operator.kubernetes.ip=$(minikube ip)

View file

@ -80,3 +80,13 @@ mvn clean verify \
-Dquarkus.kubernetes.deployment-target=kubernetes \
-Dtest.operator.deployment=remote
```
To run tests on Mac with `minikube` and the `docker` driver you should run `minikube tunnel` in a separate shell and configure the Java properties as follows:
```bash
-Dtest.operator.kubernetes.ip=localhost
```
On Linux or on Mac using `minikube` on a VM, instead you should pass this additional property:
```bash
-Dtest.operator.kubernetes.ip=$(minikube ip)
```

View file

@ -52,6 +52,8 @@ public final class Constants {
public static final Integer KEYCLOAK_DISCOVERY_SERVICE_PORT = 7800;
public static final String KEYCLOAK_DISCOVERY_SERVICE_SUFFIX = "-discovery";
public static final String KEYCLOAK_INGRESS_SUFFIX = "-ingress";
public static final String INSECURE_DISABLE = "INSECURE-DISABLE";
public static final String CERTIFICATES_FOLDER = "/mnt/certificates";
}

View file

@ -18,6 +18,7 @@ package org.keycloak.operator.v2alpha1;
import io.fabric8.kubernetes.api.model.Service;
import io.fabric8.kubernetes.api.model.apps.Deployment;
import io.fabric8.kubernetes.api.model.networking.v1.Ingress;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.informers.SharedIndexInformer;
import io.javaoperatorsdk.operator.api.reconciler.Context;
@ -69,10 +70,16 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
.withLabels(Constants.DEFAULT_LABELS)
.runnableInformer(0);
SharedIndexInformer<Ingress> ingressesInformer =
client.network().v1().ingresses().inNamespace(context.getConfigurationService().getClientConfiguration().getNamespace())
.withLabels(Constants.DEFAULT_LABELS)
.runnableInformer(0);
EventSource deploymentEvent = new InformerEventSource<>(deploymentInformer, Mappers.fromOwnerReference());
EventSource servicesEvent = new InformerEventSource<>(servicesInformer, Mappers.fromOwnerReference());
EventSource ingressesEvent = new InformerEventSource<>(ingressesInformer, Mappers.fromOwnerReference());
return List.of(deploymentEvent, servicesEvent);
return List.of(deploymentEvent, servicesEvent, ingressesEvent);
}
@Override
@ -97,6 +104,10 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
kcDiscoveryService.updateStatus(statusBuilder);
kcDiscoveryService.createOrUpdateReconciled();
var kcIngress = new KeycloakIngress(client, kc);
kcIngress.updateStatus(statusBuilder);
kcIngress.createOrUpdateReconciled();
var status = statusBuilder.build();
Log.info("--- Reconciliation finished successfully");

View file

@ -0,0 +1,126 @@
/*
* 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.v2alpha1;
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.api.model.networking.v1.IngressBuilder;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.api.model.networking.v1.Ingress;
import org.keycloak.operator.Constants;
import org.keycloak.operator.OperatorManagedResource;
import org.keycloak.operator.StatusUpdater;
import org.keycloak.operator.v2alpha1.crds.Keycloak;
import org.keycloak.operator.v2alpha1.crds.KeycloakStatusBuilder;
import java.util.HashMap;
import java.util.Optional;
public class KeycloakIngress extends OperatorManagedResource implements StatusUpdater<KeycloakStatusBuilder> {
private Ingress existingIngress;
private final Keycloak keycloak;
public KeycloakIngress(KubernetesClient client, Keycloak keycloakCR) {
super(client, keycloakCR);
this.keycloak = keycloakCR;
this.existingIngress = fetchExistingIngress();
}
@Override
protected Optional<HasMetadata> getReconciledResource() {
var defaultIngress = newIngress();
if (keycloak.getSpec().isDefaultIngressDisabled() && existingIngress != null) {
client.network().v1().ingresses().delete(existingIngress);
return Optional.empty();
} else if (existingIngress == null) {
return Optional.of(defaultIngress);
} else {
if (existingIngress.getMetadata().getAnnotations() == null) {
existingIngress.getMetadata().setAnnotations(new HashMap<>());
}
existingIngress.getMetadata().getAnnotations().putAll(defaultIngress.getMetadata().getAnnotations());
existingIngress.setSpec(defaultIngress.getSpec());
return Optional.of(existingIngress);
}
}
private Ingress newIngress() {
var port = (keycloak.getSpec().isHttp()) ? Constants.KEYCLOAK_HTTP_PORT : Constants.KEYCLOAK_HTTPS_PORT;
var backendProtocol = (keycloak.getSpec().isHttp()) ? "HTTP" : "HTTPS";
Ingress ingress = new IngressBuilder()
.withNewMetadata()
.withName(getName())
.withNamespace(getNamespace())
.addToAnnotations("nginx.ingress.kubernetes.io/backend-protocol", backendProtocol)
.endMetadata()
.withNewSpec()
.withNewDefaultBackend()
.withNewService()
.withName(keycloak.getMetadata().getName() + Constants.KEYCLOAK_SERVICE_SUFFIX)
.withNewPort()
.withNumber(port)
.endPort()
.endService()
.endDefaultBackend()
.addNewRule()
.withNewHttp()
.addNewPath()
.withPath("/")
.withPathType("ImplementationSpecific")
.withNewBackend()
.withNewService()
.withName(keycloak.getMetadata().getName() + Constants.KEYCLOAK_SERVICE_SUFFIX)
.withNewPort()
.withNumber(port)
.endPort()
.endService()
.endBackend()
.endPath()
.endHttp()
.endRule()
.endSpec()
.build();
if (!keycloak.getSpec().isHostnameDisabled()) {
ingress.getSpec().getRules().get(0).setHost(keycloak.getSpec().getHostname());
}
return ingress;
}
private Ingress fetchExistingIngress() {
return client
.network()
.v1()
.ingresses()
.inNamespace(getNamespace())
.withName(getName())
.get();
}
public void updateStatus(KeycloakStatusBuilder status) {
if (existingIngress == null) {
status.addNotReadyMessage("No existing Keycloak Ingress found, waiting for creating a new one");
return;
}
}
public String getName() {
return cr.getMetadata().getName() + Constants.KEYCLOAK_INGRESS_SUFFIX;
}
}

View file

@ -39,7 +39,8 @@ public class KeycloakSpec {
@JsonPropertyDescription("A secret containing the TLS configuration for HTTPS. Reference: https://kubernetes.io/docs/concepts/configuration/secret/#tls-secrets.\n" +
"The special value `" + Constants.INSECURE_DISABLE + "` disables https.")
private String tlsSecret;
@JsonPropertyDescription("Disable the default ingress.")
private boolean disableDefaultIngress;
@JsonPropertyDescription("List of URLs to download Keycloak extensions.")
private List<String> extensions;
@JsonPropertyDescription(
@ -59,6 +60,14 @@ public class KeycloakSpec {
return this.hostname.equals(Constants.INSECURE_DISABLE);
}
public void setDefaultIngressDisabled(boolean value) {
this.disableDefaultIngress = value;
}
public boolean isDefaultIngressDisabled() {
return this.disableDefaultIngress;
}
public String getTlsSecret() {
return tlsSecret;
}

View file

@ -40,6 +40,18 @@ rules:
- delete
- patch
- update
- apiGroups:
- networking.k8s.io
resources:
- ingresses
verbs:
- get
- list
- watch
- create
- delete
- patch
- update
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding

View file

@ -39,6 +39,7 @@ public abstract class ClusterOperatorTest {
public static final String QUARKUS_KUBERNETES_DEPLOYMENT_TARGET = "quarkus.kubernetes.deployment-target";
public static final String OPERATOR_DEPLOYMENT_PROP = "test.operator.deployment";
public static final String TARGET_KUBERNETES_GENERATED_YML_FOLDER = "target/kubernetes/";
public static final String OPERATOR_KUBERNETES_IP = "test.operator.kubernetes.ip";
public static final String TEST_RESULTS_DIR = "target/operator-test-results/";
public static final String POD_LOGS_DIR = TEST_RESULTS_DIR + "pod-logs/";
@ -51,6 +52,7 @@ public abstract class ClusterOperatorTest {
protected static KubernetesClient k8sclient;
protected static String namespace;
protected static String deploymentTarget;
protected static String kubernetesIp;
private static Operator operator;
@ -60,6 +62,7 @@ public abstract class ClusterOperatorTest {
reconcilers = CDI.current().select(new TypeLiteral<>() {});
operatorDeployment = ConfigProvider.getConfig().getOptionalValue(OPERATOR_DEPLOYMENT_PROP, OperatorDeployment.class).orElse(OperatorDeployment.local);
deploymentTarget = ConfigProvider.getConfig().getOptionalValue(QUARKUS_KUBERNETES_DEPLOYMENT_TARGET, String.class).orElse("kubernetes");
kubernetesIp = ConfigProvider.getConfig().getOptionalValue(OPERATOR_KUBERNETES_IP, String.class).orElse("localhost");
setDefaultAwaitilityTimings();
calculateNamespace();

View file

@ -0,0 +1,125 @@
package org.keycloak.operator;
import io.fabric8.kubernetes.api.model.networking.v1.ServiceBackendPortBuilder;
import io.quarkus.logging.Log;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.Test;
import org.keycloak.operator.utils.K8sUtils;
import org.keycloak.operator.v2alpha1.KeycloakIngress;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
@QuarkusTest
public class KeycloakIngressE2EIT extends ClusterOperatorTest {
@Test
public void testIngressOnHTTP() {
var kc = K8sUtils.getDefaultKeycloakDeployment();
kc.getSpec().setHostname(Constants.INSECURE_DISABLE);
kc.getSpec().setTlsSecret(Constants.INSECURE_DISABLE);
K8sUtils.deployKeycloak(k8sclient, kc, true);
Awaitility.await()
.ignoreExceptions()
.untilAsserted(() -> {
var output = RestAssured.given()
.get("http://" + kubernetesIp + ":80/realms/master")
.body()
.jsonPath()
.getString("realm");
assertEquals("master", output);
});
}
@Test
public void testIngressOnHTTPS() {
var kc = K8sUtils.getDefaultKeycloakDeployment();
kc.getSpec().setHostname(Constants.INSECURE_DISABLE);
K8sUtils.deployKeycloak(k8sclient, kc, true);
Awaitility.await()
.ignoreExceptions()
.untilAsserted(() -> {
var output = RestAssured.given()
.relaxedHTTPSValidation()
.get("https://" + kubernetesIp + ":443/realms/master")
.body()
.jsonPath()
.getString("realm");
assertEquals("master", output);
});
}
@Test
public void testIngressHostname() {
var kc = K8sUtils.getDefaultKeycloakDeployment();
kc.getSpec().setHostname("foo.bar");
K8sUtils.deployKeycloak(k8sclient, kc, true);
var ingress = new KeycloakIngress(k8sclient, kc);
Awaitility.await()
.ignoreExceptions()
.untilAsserted(() -> {
var host = k8sclient
.network()
.v1()
.ingresses()
.inNamespace(namespace)
.withName(ingress.getName())
.get()
.getSpec()
.getRules()
.get(0)
.getHost();
assertEquals("foo.bar", host);
});
}
@Test
public void testMainIngressDurability() {
var kc = K8sUtils.getDefaultKeycloakDeployment();
K8sUtils.deployKeycloak(k8sclient, kc, true);
var ingress = new KeycloakIngress(k8sclient, kc);
var ingressSelector = k8sclient
.network()
.v1()
.ingresses()
.inNamespace(namespace)
.withName(ingress.getName());
Log.info("Trying to delete the ingress");
assertThat(ingressSelector.delete()).isTrue();
Awaitility.await()
.untilAsserted(() -> assertThat(ingressSelector.get()).isNotNull());
K8sUtils.waitForKeycloakToBeReady(k8sclient, kc); // wait for reconciler to calm down to avoid race condititon
Log.info("Trying to modify the ingress");
var currentIngress = ingressSelector.get();
var labels = Map.of("address", "EvergreenTerrace742");
currentIngress.getSpec().getDefaultBackend().getService().setPort(new ServiceBackendPortBuilder().withName("foo").build());
currentIngress.getMetadata().getAnnotations().clear();
currentIngress.getMetadata().getLabels().putAll(labels);
ingressSelector.createOrReplace(currentIngress);
Awaitility.await()
.untilAsserted(() -> {
var i = ingressSelector.get();
assertThat(i.getMetadata().getLabels().entrySet().containsAll(labels.entrySet())).isTrue(); // additional labels should not be overwritten
assertEquals("HTTPS", i.getMetadata().getAnnotations().get("nginx.ingress.kubernetes.io/backend-protocol"));
assertEquals(Constants.KEYCLOAK_HTTPS_PORT, i.getSpec().getDefaultBackend().getService().getPort().getNumber());
});
}
}