diff --git a/operator/src/main/java/org/keycloak/operator/v2alpha1/KeycloakDeployment.java b/operator/src/main/java/org/keycloak/operator/v2alpha1/KeycloakDeployment.java index 134f5fc9da0..f9286d010a5 100644 --- a/operator/src/main/java/org/keycloak/operator/v2alpha1/KeycloakDeployment.java +++ b/operator/src/main/java/org/keycloak/operator/v2alpha1/KeycloakDeployment.java @@ -22,9 +22,12 @@ import io.fabric8.kubernetes.api.model.EnvVarBuilder; import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.api.model.VolumeBuilder; import io.fabric8.kubernetes.api.model.VolumeMountBuilder; +import io.fabric8.kubernetes.api.model.PodTemplateSpec; +import io.fabric8.kubernetes.api.model.ResourceRequirements; import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder; import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.utils.Serialization; import io.quarkus.logging.Log; import org.keycloak.operator.Config; import org.keycloak.operator.Constants; @@ -32,11 +35,13 @@ import org.keycloak.operator.OperatorManagedResource; import org.keycloak.operator.v2alpha1.crds.Keycloak; import org.keycloak.operator.v2alpha1.crds.KeycloakStatusBuilder; -import java.net.URL; import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.ArrayList; +import java.util.Map; import java.util.Optional; +import java.util.function.Consumer; import java.util.stream.Collectors; public class KeycloakDeployment extends OperatorManagedResource { @@ -66,7 +71,7 @@ public class KeycloakDeployment extends OperatorManagedResource { } @Override - protected Optional getReconciledResource() { + public Optional getReconciledResource() { Deployment baseDeployment = new DeploymentBuilder(this.baseDeployment).build(); // clone not to change the base template Deployment reconciledDeployment; if (existingDeployment == null) { @@ -146,9 +151,211 @@ public class KeycloakDeployment extends OperatorManagedResource { baseDeployment.getSpec().getTemplate().getSpec().setInitContainers(Collections.singletonList(initContainer)); } + public void validatePodTemplate(KeycloakStatusBuilder status) { + if (keycloakCR.getSpec() == null || + keycloakCR.getSpec().getUnsupported() == null || + keycloakCR.getSpec().getUnsupported().getPodTemplate() == null) { + return; + } + var overlayTemplate = this.keycloakCR.getSpec().getUnsupported().getPodTemplate(); + + if (overlayTemplate.getMetadata() != null && + overlayTemplate.getMetadata().getName() != null) { + status.addWarningMessage("The name of the podTemplate cannot be modified"); + } + + if (overlayTemplate.getMetadata() != null && + overlayTemplate.getMetadata().getNamespace() != null) { + status.addWarningMessage("The namespace of the podTemplate cannot be modified"); + } + + if (overlayTemplate.getSpec() != null && + overlayTemplate.getSpec().getContainers() != null && + overlayTemplate.getSpec().getContainers().get(0) != null && + overlayTemplate.getSpec().getContainers().get(0).getName() != null) { + status.addWarningMessage("The name of the keycloak container cannot be modified"); + } + + if (overlayTemplate.getSpec() != null && + overlayTemplate.getSpec().getContainers() != null && + overlayTemplate.getSpec().getContainers().get(0) != null && + overlayTemplate.getSpec().getContainers().get(0).getImage() != null) { + status.addWarningMessage("The image of the keycloak container cannot be modified using podTemplate"); + } + } + + private void mergeMaps(Map map1, Map map2, Consumer> consumer) { + var map = new HashMap(); + Optional.ofNullable(map1).ifPresent(e -> map.putAll(e)); + Optional.ofNullable(map2).ifPresent(e -> map.putAll(e)); + consumer.accept(map); + } + + private void mergeLists(List list1, List list2, Consumer> consumer) { + var list = new ArrayList(); + Optional.ofNullable(list1).ifPresent(e -> list.addAll(e)); + Optional.ofNullable(list2).ifPresent(e -> list.addAll(e)); + consumer.accept(list); + } + + private void mergeField(T value, Consumer consumer) { + if (value != null && (!(value instanceof List) || ((List) value).size() > 0)) { + consumer.accept(value); + } + } + + private void mergePodTemplate(PodTemplateSpec baseTemplate) { + if (keycloakCR.getSpec() == null || + keycloakCR.getSpec().getUnsupported() == null || + keycloakCR.getSpec().getUnsupported().getPodTemplate() == null) { + return; + } + + var overlayTemplate = keycloakCR.getSpec().getUnsupported().getPodTemplate(); + + mergeMaps( + Optional.ofNullable(baseTemplate.getMetadata()).map(m -> m.getLabels()).orElse(null), + Optional.ofNullable(overlayTemplate.getMetadata()).map(m -> m.getLabels()).orElse(null), + labels -> baseTemplate.getMetadata().setLabels(labels)); + + mergeMaps( + Optional.ofNullable(baseTemplate.getMetadata()).map(m -> m.getAnnotations()).orElse(null), + Optional.ofNullable(overlayTemplate.getMetadata()).map(m -> m.getAnnotations()).orElse(null), + annotations -> baseTemplate.getMetadata().setAnnotations(annotations)); + + var baseSpec = baseTemplate.getSpec(); + var overlaySpec = overlayTemplate.getSpec(); + + var containers = new ArrayList(); + var overlayContainers = + (overlaySpec == null || overlaySpec.getContainers() == null) ? + new ArrayList() : overlaySpec.getContainers(); + if (overlayContainers.size() >= 1) { + var keycloakBaseContainer = baseSpec.getContainers().get(0); + var keycloakOverlayContainer = overlayContainers.get(0); + mergeField(keycloakOverlayContainer.getCommand(), v -> keycloakBaseContainer.setCommand(v)); + mergeField(keycloakOverlayContainer.getReadinessProbe(), v -> keycloakBaseContainer.setReadinessProbe(v)); + mergeField(keycloakOverlayContainer.getLivenessProbe(), v -> keycloakBaseContainer.setLivenessProbe(v)); + mergeField(keycloakOverlayContainer.getStartupProbe(), v -> keycloakBaseContainer.setStartupProbe(v)); + mergeField(keycloakOverlayContainer.getArgs(), v -> keycloakBaseContainer.setArgs(v)); + mergeField(keycloakOverlayContainer.getImagePullPolicy(), v -> keycloakBaseContainer.setImagePullPolicy(v)); + mergeField(keycloakOverlayContainer.getLifecycle(), v -> keycloakBaseContainer.setLifecycle(v)); + mergeField(keycloakOverlayContainer.getSecurityContext(), v -> keycloakBaseContainer.setSecurityContext(v)); + mergeField(keycloakOverlayContainer.getWorkingDir(), v -> keycloakBaseContainer.setWorkingDir(v)); + + var resources = new ResourceRequirements(); + mergeMaps( + Optional.ofNullable(keycloakBaseContainer.getResources()).map(r -> r.getRequests()).orElse(null), + Optional.ofNullable(keycloakOverlayContainer.getResources()).map(r -> r.getRequests()).orElse(null), + requests -> resources.setRequests(requests)); + mergeMaps( + Optional.ofNullable(keycloakBaseContainer.getResources()).map(l -> l.getLimits()).orElse(null), + Optional.ofNullable(keycloakOverlayContainer.getResources()).map(l -> l.getLimits()).orElse(null), + limits -> resources.setLimits(limits)); + keycloakBaseContainer.setResources(resources); + + mergeLists( + keycloakBaseContainer.getPorts(), + keycloakOverlayContainer.getPorts(), + p -> keycloakBaseContainer.setPorts(p)); + mergeLists( + keycloakBaseContainer.getEnvFrom(), + keycloakOverlayContainer.getEnvFrom(), + e -> keycloakBaseContainer.setEnvFrom(e)); + mergeLists( + keycloakBaseContainer.getEnv(), + keycloakOverlayContainer.getEnv(), + e -> keycloakBaseContainer.setEnv(e)); + mergeLists( + keycloakBaseContainer.getVolumeMounts(), + keycloakOverlayContainer.getVolumeMounts(), + vm -> keycloakBaseContainer.setVolumeMounts(vm)); + mergeLists( + keycloakBaseContainer.getVolumeDevices(), + keycloakOverlayContainer.getVolumeDevices(), + vd -> keycloakBaseContainer.setVolumeDevices(vd)); + + containers.add(keycloakBaseContainer); + + // Skip keycloak container and add the rest + for (int i = 1; i < overlayContainers.size(); i++) { + containers.add(overlayContainers.get(i)); + } + + baseSpec.setContainers(containers); + } + + if (overlaySpec != null) { + mergeField(overlaySpec.getActiveDeadlineSeconds(), ads -> baseSpec.setActiveDeadlineSeconds(ads)); + mergeField(overlaySpec.getAffinity(), a -> baseSpec.setAffinity(a)); + mergeField(overlaySpec.getAutomountServiceAccountToken(), a -> baseSpec.setAutomountServiceAccountToken(a)); + mergeField(overlaySpec.getDnsConfig(), dc -> baseSpec.setDnsConfig(dc)); + mergeField(overlaySpec.getDnsPolicy(), dp -> baseSpec.setDnsPolicy(dp)); + mergeField(overlaySpec.getEnableServiceLinks(), esl -> baseSpec.setEnableServiceLinks(esl)); + mergeField(overlaySpec.getHostIPC(), h -> baseSpec.setHostIPC(h)); + mergeField(overlaySpec.getHostname(), h -> baseSpec.setHostname(h)); + mergeField(overlaySpec.getHostNetwork(), h -> baseSpec.setHostNetwork(h)); + mergeField(overlaySpec.getHostPID(), h -> baseSpec.setHostPID(h)); + mergeField(overlaySpec.getNodeName(), n -> baseSpec.setNodeName(n)); + mergeField(overlaySpec.getNodeSelector(), ns -> baseSpec.setNodeSelector(ns)); + mergeField(overlaySpec.getPreemptionPolicy(), pp -> baseSpec.setPreemptionPolicy(pp)); + mergeField(overlaySpec.getPriority(), p -> baseSpec.setPriority(p)); + mergeField(overlaySpec.getPriorityClassName(), pcn -> baseSpec.setPriorityClassName(pcn)); + mergeField(overlaySpec.getRestartPolicy(), rp -> baseSpec.setRestartPolicy(rp)); + mergeField(overlaySpec.getRuntimeClassName(), rcn -> baseSpec.setRuntimeClassName(rcn)); + mergeField(overlaySpec.getSchedulerName(), sn -> baseSpec.setSchedulerName(sn)); + mergeField(overlaySpec.getSecurityContext(), sc -> baseSpec.setSecurityContext(sc)); + mergeField(overlaySpec.getServiceAccount(), sa -> baseSpec.setServiceAccount(sa)); + mergeField(overlaySpec.getServiceAccountName(), san -> baseSpec.setServiceAccountName(san)); + mergeField(overlaySpec.getSetHostnameAsFQDN(), h -> baseSpec.setSetHostnameAsFQDN(h)); + mergeField(overlaySpec.getShareProcessNamespace(), spn -> baseSpec.setShareProcessNamespace(spn)); + mergeField(overlaySpec.getSubdomain(), s -> baseSpec.setSubdomain(s)); + mergeField(overlaySpec.getTerminationGracePeriodSeconds(), t -> baseSpec.setTerminationGracePeriodSeconds(t)); + + mergeLists( + baseSpec.getImagePullSecrets(), + overlaySpec.getImagePullSecrets(), + ips -> baseSpec.setImagePullSecrets(ips)); + mergeLists( + baseSpec.getHostAliases(), + overlaySpec.getHostAliases(), + ha -> baseSpec.setHostAliases(ha)); + mergeLists( + baseSpec.getEphemeralContainers(), + overlaySpec.getEphemeralContainers(), + ec -> baseSpec.setEphemeralContainers(ec)); + mergeLists( + baseSpec.getInitContainers(), + overlaySpec.getInitContainers(), + ic -> baseSpec.setInitContainers(ic)); + mergeLists( + baseSpec.getReadinessGates(), + overlaySpec.getReadinessGates(), + rg -> baseSpec.setReadinessGates(rg)); + mergeLists( + baseSpec.getTolerations(), + overlaySpec.getTolerations(), + t -> baseSpec.setTolerations(t)); + mergeLists( + baseSpec.getTopologySpreadConstraints(), + overlaySpec.getTopologySpreadConstraints(), + tpc -> baseSpec.setTopologySpreadConstraints(tpc)); + + mergeLists( + baseSpec.getVolumes(), + overlaySpec.getVolumes(), + v -> baseSpec.setVolumes(v)); + + mergeMaps( + baseSpec.getOverhead(), + overlaySpec.getOverhead(), + o -> baseSpec.setOverhead(o)); + } + } + private Deployment createBaseDeployment() { - URL url = this.getClass().getResource("/base-keycloak-deployment.yaml"); - Deployment baseDeployment = client.apps().deployments().load(url).get(); + var is = this.getClass().getResourceAsStream("/base-keycloak-deployment.yaml"); + Deployment baseDeployment = Serialization.unmarshal(is, Deployment.class); baseDeployment.getMetadata().setName(getName()); baseDeployment.getMetadata().setNamespace(getNamespace()); @@ -171,6 +378,7 @@ public class KeycloakDeployment extends OperatorManagedResource { .collect(Collectors.toList())); addInitContainer(baseDeployment, keycloakCR.getSpec().getExtensions()); + mergePodTemplate(baseDeployment.getSpec().getTemplate()); // Set configSecretsNames = new HashSet<>(); // List configEnvVars = serverConfig.entrySet().stream() @@ -199,6 +407,7 @@ public class KeycloakDeployment extends OperatorManagedResource { } public void updateStatus(KeycloakStatusBuilder status) { + validatePodTemplate(status); if (existingDeployment == null) { status.addNotReadyMessage("No existing Deployment found, waiting for creating a new one"); return; diff --git a/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/KeycloakSpec.java b/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/KeycloakSpec.java index 04b21d7e6c1..0583ad209bf 100644 --- a/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/KeycloakSpec.java +++ b/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/KeycloakSpec.java @@ -18,6 +18,8 @@ package org.keycloak.operator.v2alpha1.crds; import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import org.keycloak.operator.v2alpha1.crds.keycloakspec.Unsupported; + import java.util.List; import java.util.Map; @@ -26,9 +28,12 @@ public class KeycloakSpec { private int instances = 1; private String image; private Map serverConfiguration; - @JsonPropertyDescription("List of URLs to download Keycloak extensions.") private List extensions; + @JsonPropertyDescription( + "In this section you can configure podTemplate advanced features, not production-ready, and not supported settings.\n" + + "Use at your own risk and open an issue with your use-case if you don't find an alternative way.") + private Unsupported unsupported; public List getExtensions() { return extensions; @@ -38,6 +43,14 @@ public class KeycloakSpec { this.extensions = extensions; } + public Unsupported getUnsupported() { + return unsupported; + } + + public void setUnsupported(Unsupported unsupported) { + this.unsupported = unsupported; + } + public int getInstances() { return instances; } diff --git a/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/KeycloakStatusBuilder.java b/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/KeycloakStatusBuilder.java index 5f3ffdf1978..0034d461a58 100644 --- a/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/KeycloakStatusBuilder.java +++ b/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/KeycloakStatusBuilder.java @@ -52,6 +52,11 @@ public class KeycloakStatusBuilder { return this; } + public KeycloakStatusBuilder addWarningMessage(String message) { + errorMessages.add("warning: " + message); + return this; + } + public KeycloakStatus build() { readyCondition.setMessage(String.join("\n", notReadyMessages)); hasErrorsCondition.setMessage(String.join("\n", errorMessages)); diff --git a/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/keycloakspec/Unsupported.java b/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/keycloakspec/Unsupported.java new file mode 100644 index 00000000000..df91b945f8e --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/v2alpha1/crds/keycloakspec/Unsupported.java @@ -0,0 +1,34 @@ +package org.keycloak.operator.v2alpha1.crds.keycloakspec; + +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import io.fabric8.kubernetes.api.model.PodTemplateSpec; +import io.sundr.builder.annotations.Buildable; +import io.sundr.builder.annotations.BuildableReference; + +@Buildable(editableEnabled = false, builderPackage = "io.fabric8.kubernetes.api.builder", + lazyCollectionInitEnabled = false, refs = { + @BuildableReference(io.fabric8.kubernetes.api.model.ObjectMeta.class), + @BuildableReference(io.fabric8.kubernetes.api.model.PodTemplateSpec.class) +}) +public class Unsupported { + + @JsonPropertyDescription("You can configure that will be merged with the one configured by default by the operator.\n" + + "Use at your own risk, we reserve the possibility to remove/change the way any field gets merged in future releases without notice.\n" + + "Reference: https://kubernetes.io/docs/concepts/workloads/pods/#pod-templates") + private PodTemplateSpec podTemplate; + + public Unsupported() {} + + public Unsupported(PodTemplateSpec podTemplate) { + this.podTemplate = podTemplate; + } + + public PodTemplateSpec getPodTemplate() { + return podTemplate; + } + + public void setPodTeplate(PodTemplateSpec podTemplate) { + this.podTemplate = podTemplate; + } + +} diff --git a/operator/src/test/java/org/keycloak/operator/PodTemplateE2EIT.java b/operator/src/test/java/org/keycloak/operator/PodTemplateE2EIT.java new file mode 100644 index 00000000000..dd74a4a8e58 --- /dev/null +++ b/operator/src/test/java/org/keycloak/operator/PodTemplateE2EIT.java @@ -0,0 +1,167 @@ +package org.keycloak.operator; + +import io.fabric8.kubernetes.api.model.PodTemplateSpecBuilder; +import io.fabric8.kubernetes.client.dsl.Resource; +import io.fabric8.kubernetes.client.utils.Serialization; +import io.quarkus.logging.Log; +import io.quarkus.test.junit.QuarkusTest; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.keycloak.operator.utils.CRAssert; +import org.keycloak.operator.v2alpha1.crds.Keycloak; + +import static java.util.concurrent.TimeUnit.MINUTES; +import static org.assertj.core.api.Assertions.assertThat; +import static org.keycloak.operator.v2alpha1.crds.KeycloakStatusCondition.HAS_ERRORS; + +@QuarkusTest +public class PodTemplateE2EIT extends ClusterOperatorTest { + + private Keycloak getEmptyPodTemplateKeycloak() { + return Serialization.unmarshal(getClass().getResourceAsStream("/empty-podtemplate-keycloak.yml"), Keycloak.class); + } + + private Resource getCrSelector() { + return k8sclient + .resources(Keycloak.class) + .inNamespace(namespace) + .withName("example-podtemplate"); + } + + @Test + public void testPodTemplateIsMerged() { + // Arrange + var keycloakWithPodTemplate = k8sclient + .load(getClass().getResourceAsStream("/correct-podtemplate-keycloak.yml")); + + // Act + keycloakWithPodTemplate.createOrReplace(); + + // Assert + Awaitility + .await() + .ignoreExceptions() + .atMost(3, MINUTES).untilAsserted(() -> { + Log.info("Getting logs from Keycloak"); + + var keycloakPod = k8sclient + .pods() + .inNamespace(namespace) + .withLabel("app", "keycloak") + .list() + .getItems() + .get(0); + + var logs = k8sclient + .pods() + .inNamespace(namespace) + .withName(keycloakPod.getMetadata().getName()) + .getLog(); + + Log.info("Full logs are:\n" + logs); + assertThat(logs).contains("Hello World"); + assertThat(keycloakPod.getMetadata().getLabels().get("foo")).isEqualTo("bar"); + }); + } + + @Test + public void testPodTemplateIncorrectName() { + // Arrange + var plainKc = getEmptyPodTemplateKeycloak(); + var podTemplate = new PodTemplateSpecBuilder() + .withNewMetadata() + .withName("foo") + .endMetadata() + .build(); + plainKc.getSpec().getUnsupported().setPodTeplate(podTemplate); + + // Act + k8sclient.resource(plainKc).createOrReplace(); + + // Assert + Log.info("Getting status of Keycloak"); + Awaitility + .await() + .ignoreExceptions() + .atMost(3, MINUTES).untilAsserted(() -> { + CRAssert.assertKeycloakStatusCondition(getCrSelector().get(), HAS_ERRORS, false, "cannot be modified"); + }); + } + + @Test + public void testPodTemplateIncorrectNamespace() { + // Arrange + var plainKc = getEmptyPodTemplateKeycloak(); + var podTemplate = new PodTemplateSpecBuilder() + .withNewMetadata() + .withNamespace("bar") + .endMetadata() + .build(); + plainKc.getSpec().getUnsupported().setPodTeplate(podTemplate); + + // Act + k8sclient.resource(plainKc).createOrReplace(); + + // Assert + Log.info("Getting status of Keycloak"); + Awaitility + .await() + .ignoreExceptions() + .atMost(3, MINUTES).untilAsserted(() -> { + CRAssert.assertKeycloakStatusCondition(getCrSelector().get(), HAS_ERRORS, false, "cannot be modified"); + }); + } + + @Test + public void testPodTemplateIncorrectContainerName() { + // Arrange + var plainKc = getEmptyPodTemplateKeycloak(); + var podTemplate = new PodTemplateSpecBuilder() + .withNewSpec() + .addNewContainer() + .withName("baz") + .endContainer() + .endSpec() + .build(); + plainKc.getSpec().getUnsupported().setPodTeplate(podTemplate); + + // Act + k8sclient.resource(plainKc).createOrReplace(); + + // Assert + Log.info("Getting status of Keycloak"); + Awaitility + .await() + .ignoreExceptions() + .atMost(3, MINUTES).untilAsserted(() -> { + CRAssert.assertKeycloakStatusCondition(getCrSelector().get(), HAS_ERRORS, false, "cannot be modified"); + }); + } + + @Test + public void testPodTemplateIncorrectDockerImage() { + // Arrange + var plainKc = getEmptyPodTemplateKeycloak(); + var podTemplate = new PodTemplateSpecBuilder() + .withNewSpec() + .addNewContainer() + .withImage("foo") + .endContainer() + .endSpec() + .build(); + plainKc.getSpec().getUnsupported().setPodTeplate(podTemplate); + + // Act + k8sclient.resource(plainKc).createOrReplace(); + + // Assert + Log.info("Getting status of Keycloak"); + Awaitility + .await() + .ignoreExceptions() + .atMost(3, MINUTES).untilAsserted(() -> { + CRAssert.assertKeycloakStatusCondition(getCrSelector().get(), HAS_ERRORS, false, "cannot be modified"); + }); + } + +} diff --git a/operator/src/test/java/org/keycloak/operator/PodTemplateTest.java b/operator/src/test/java/org/keycloak/operator/PodTemplateTest.java new file mode 100644 index 00000000000..9a8e1c04a63 --- /dev/null +++ b/operator/src/test/java/org/keycloak/operator/PodTemplateTest.java @@ -0,0 +1,224 @@ +package org.keycloak.operator; + +import io.fabric8.kubernetes.api.model.IntOrString; +import io.fabric8.kubernetes.api.model.PodTemplateSpec; +import io.fabric8.kubernetes.api.model.PodTemplateSpecBuilder; +import io.fabric8.kubernetes.api.model.ProbeBuilder; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; +import org.keycloak.operator.v2alpha1.KeycloakDeployment; +import org.keycloak.operator.v2alpha1.crds.Keycloak; +import org.keycloak.operator.v2alpha1.crds.KeycloakSpec; +import org.keycloak.operator.v2alpha1.crds.keycloakspec.Unsupported; + +import java.net.URL; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@QuarkusTest +public class PodTemplateTest { + + Deployment getDeployment(PodTemplateSpec podTemplate) { + var config = new Config(){ + @Override + public Keycloak keycloak() { + return new Keycloak() { + @Override + public String image() { + return "dummy-image"; + } + @Override + public String imagePullPolicy() { + return "Never"; + } + @Override + public String initContainerImage() { return "quay.io/keycloak/keycloak-init-container:legacy"; } + @Override + public String initContainerImagePullPolicy() { return "Always"; } + }; + } + }; + var kc = new Keycloak(); + var spec = new KeycloakSpec(); + spec.setUnsupported(new Unsupported(podTemplate)); + kc.setSpec(spec); + var deployment = new KeycloakDeployment(null, config, kc, new Deployment()); + return (Deployment) deployment.getReconciledResource().get(); + } + + @Test + public void testEmpty() { + // Arrange + PodTemplateSpec additionalPodTemplate = null; + + // Act + var podTemplate = getDeployment(additionalPodTemplate).getSpec().getTemplate(); + + // Assert + assertEquals("keycloak", podTemplate.getSpec().getContainers().get(0).getName()); + } + + @Test + public void testMetadataIsMerged() { + // Arrange + var additionalPodTemplate = new PodTemplateSpecBuilder() + .withNewMetadata() + .addToLabels("one", "1") + .addToAnnotations("two", "2") + .endMetadata() + .build(); + + // Act + var podTemplate = getDeployment(additionalPodTemplate).getSpec().getTemplate(); + + // Assert + assertTrue(podTemplate.getMetadata().getLabels().containsKey("one")); + assertTrue(podTemplate.getMetadata().getLabels().containsValue("1")); + assertTrue(podTemplate.getMetadata().getAnnotations().containsKey("two")); + assertTrue(podTemplate.getMetadata().getAnnotations().containsValue("2")); + } + + @Test + public void testVolumesAreMerged() { + // Arrange + var volumeName = "foo-volume"; + var additionalPodTemplate = new PodTemplateSpecBuilder() + .withNewSpec() + .addNewVolume() + .withName("foo-volume") + .withNewEmptyDir() + .endEmptyDir() + .endVolume() + .endSpec() + .build(); + + // Act + var podTemplate = getDeployment(additionalPodTemplate).getSpec().getTemplate(); + + // Assert + assertEquals(volumeName, podTemplate.getSpec().getVolumes().get(0).getName()); + } + + @Test + public void testVolumeMountsAreMerged() { + // Arrange + var volumeMountName = "foo"; + var volumeMountPath = "/mnt/path"; + var additionalPodTemplate = new PodTemplateSpecBuilder() + .withNewSpec() + .addNewContainer() + .addNewVolumeMount() + .withName(volumeMountName) + .withMountPath(volumeMountPath) + .endVolumeMount() + .endContainer() + .endSpec() + .build(); + + // Act + var podTemplate = getDeployment(additionalPodTemplate).getSpec().getTemplate(); + + // Assert + assertEquals(volumeMountName, podTemplate.getSpec().getContainers().get(0).getVolumeMounts().get(0).getName()); + assertEquals(volumeMountPath, podTemplate.getSpec().getContainers().get(0).getVolumeMounts().get(0).getMountPath()); + } + + @Test + public void testCommandsAndArgsAreMerged() { + // Arrange + var command = "foo"; + var arg = "bar"; + var additionalPodTemplate = new PodTemplateSpecBuilder() + .withNewSpec() + .addNewContainer() + .withCommand(command) + .withArgs(arg) + .endContainer() + .endSpec() + .build(); + + // Act + var podTemplate = getDeployment(additionalPodTemplate).getSpec().getTemplate(); + + // Assert + assertEquals(1, podTemplate.getSpec().getContainers().get(0).getCommand().size()); + assertEquals(command, podTemplate.getSpec().getContainers().get(0).getCommand().get(0)); + assertEquals(1, podTemplate.getSpec().getContainers().get(0).getArgs().size()); + assertEquals(arg, podTemplate.getSpec().getContainers().get(0).getArgs().get(0)); + } + + @Test + public void testProbesAreMerged() { + // Arrange + var ready = new ProbeBuilder() + .withNewExec() + .withCommand("foo") + .endExec() + .withFailureThreshold(1) + .withInitialDelaySeconds(2) + .withTimeoutSeconds(3) + .build(); + var live = new ProbeBuilder() + .withNewHttpGet() + .withPort(new IntOrString(1000)) + .withScheme("UDP") + .withPath("/foo") + .endHttpGet() + .withFailureThreshold(4) + .withInitialDelaySeconds(5) + .withTimeoutSeconds(6) + .build(); + var additionalPodTemplate = new PodTemplateSpecBuilder() + .withNewSpec() + .addNewContainer() + .withReadinessProbe(ready) + .withLivenessProbe(live) + .endContainer() + .endSpec() + .build(); + + // Act + var podTemplate = getDeployment(additionalPodTemplate).getSpec().getTemplate(); + + // Assert + var readyProbe = podTemplate.getSpec().getContainers().get(0).getReadinessProbe(); + var liveProbe = podTemplate.getSpec().getContainers().get(0).getLivenessProbe(); + assertEquals("foo", ready.getExec().getCommand().get(0)); + assertEquals(1, readyProbe.getFailureThreshold()); + assertEquals(2, readyProbe.getInitialDelaySeconds()); + assertEquals(3, readyProbe.getTimeoutSeconds()); + assertEquals(1000, liveProbe.getHttpGet().getPort().getIntVal()); + assertEquals("UDP", liveProbe.getHttpGet().getScheme()); + assertEquals("/foo", liveProbe.getHttpGet().getPath()); + assertEquals(4, liveProbe.getFailureThreshold()); + assertEquals(5, liveProbe.getInitialDelaySeconds()); + assertEquals(6, liveProbe.getTimeoutSeconds()); + } + + @Test + public void testEnvVarsAreMerged() { + // Arrange + var env = "KC_SOMETHING"; + var value = "some-value"; + var additionalPodTemplate = new PodTemplateSpecBuilder() + .withNewSpec() + .addNewContainer() + .addNewEnv() + .withName(env) + .withValue(value) + .endEnv() + .endContainer() + .endSpec() + .build(); + + // Act + var podTemplate = getDeployment(additionalPodTemplate).getSpec().getTemplate(); + + // Assert + var envVar = podTemplate.getSpec().getContainers().get(0).getEnv().stream().filter(e -> e.getName().equals(env)).findFirst().get(); + assertEquals(env, envVar.getName()); + assertEquals(value, envVar.getValue()); + } +} diff --git a/operator/src/test/java/org/keycloak/operator/utils/CRAssert.java b/operator/src/test/java/org/keycloak/operator/utils/CRAssert.java index def1bcfd686..0d82663878d 100644 --- a/operator/src/test/java/org/keycloak/operator/utils/CRAssert.java +++ b/operator/src/test/java/org/keycloak/operator/utils/CRAssert.java @@ -18,6 +18,7 @@ package org.keycloak.operator.utils; import org.keycloak.operator.v2alpha1.crds.Keycloak; +import org.keycloak.operator.v2alpha1.crds.KeycloakRealmImport; import static org.assertj.core.api.Assertions.assertThat; @@ -25,8 +26,16 @@ import static org.assertj.core.api.Assertions.assertThat; * @author Vaclav Muzikar */ public final class CRAssert { + public static void assertKeycloakStatusCondition(Keycloak kc, String condition, boolean status) { + assertKeycloakStatusCondition(kc, condition, status, null); + } + public static void assertKeycloakStatusCondition(Keycloak kc, String condition, boolean status, String containedMessage) { assertThat(kc.getStatus().getConditions().stream() - .anyMatch(c -> c.getType().equals(condition) && c.getStatus() == status)).isTrue(); + .anyMatch(c -> + c.getType().equals(condition) && + c.getStatus() == status && + (containedMessage == null || c.getMessage().contains(containedMessage))) + ).isTrue(); } } diff --git a/operator/src/test/resources/correct-podtemplate-keycloak.yml b/operator/src/test/resources/correct-podtemplate-keycloak.yml new file mode 100644 index 00000000000..9457014d1c7 --- /dev/null +++ b/operator/src/test/resources/correct-podtemplate-keycloak.yml @@ -0,0 +1,34 @@ +apiVersion: keycloak.org/v2alpha1 +kind: Keycloak +metadata: + name: example-podtemplate-kc +spec: + instances: 1 + serverConfiguration: + KC_DB: postgres + KC_DB_URL_HOST: postgres-db + KC_DB_USERNAME: postgres + KC_DB_PASSWORD: testpassword + unsupported: + podTemplate: + metadata: + labels: + foo: "bar" + spec: + containers: + - volumeMounts: + - name: test-volume + mountPath: /mnt/test + command: [ "/bin/bash", "-c", "cat /mnt/test/test.txt && /opt/keycloak/bin/kc.sh start-dev" ] + volumes: + - name: test-volume + secret: + secretName: keycloak-podtemplate-secret +--- +apiVersion: v1 +kind: Secret +metadata: + name: keycloak-podtemplate-secret +data: + test.txt: "SGVsbG8gV29ybGQK" # Hello World +type: Opaque diff --git a/operator/src/test/resources/empty-podtemplate-keycloak.yml b/operator/src/test/resources/empty-podtemplate-keycloak.yml new file mode 100644 index 00000000000..4a99528823e --- /dev/null +++ b/operator/src/test/resources/empty-podtemplate-keycloak.yml @@ -0,0 +1,13 @@ +apiVersion: keycloak.org/v2alpha1 +kind: Keycloak +metadata: + name: example-podtemplate +spec: + instances: 1 + serverConfiguration: + KC_DB: postgres + KC_DB_URL_HOST: postgres-db + KC_DB_USERNAME: postgres + KC_DB_PASSWORD: testpassword + unsupported: + podTemplate: