diff --git a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakUpdateJobDependentResource.java b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakUpdateJobDependentResource.java index ddac2a770ae..52013bcef2d 100644 --- a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakUpdateJobDependentResource.java +++ b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakUpdateJobDependentResource.java @@ -17,15 +17,18 @@ package org.keycloak.operator.controllers; -import java.util.Collection; -import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.keycloak.operator.Constants; +import org.keycloak.operator.ContextUtils; +import org.keycloak.operator.Utils; +import org.keycloak.operator.crds.v2alpha1.CRDUtils; +import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak; import io.fabric8.kubernetes.api.model.ContainerFluent; import io.fabric8.kubernetes.api.model.ObjectMeta; @@ -33,6 +36,7 @@ import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; import io.fabric8.kubernetes.api.model.PodSpec; import io.fabric8.kubernetes.api.model.PodSpecBuilder; import io.fabric8.kubernetes.api.model.Volume; +import io.fabric8.kubernetes.api.model.apps.StatefulSet; import io.fabric8.kubernetes.api.model.batch.v1.Job; import io.fabric8.kubernetes.api.model.batch.v1.JobBuilder; import io.fabric8.kubernetes.api.model.batch.v1.JobSpecFluent; @@ -41,18 +45,13 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfigBuilder; import jakarta.enterprise.context.ApplicationScoped; -import org.keycloak.operator.Constants; -import org.keycloak.operator.ContextUtils; -import org.keycloak.operator.Utils; -import org.keycloak.operator.crds.v2alpha1.CRDUtils; -import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak; @ApplicationScoped public class KeycloakUpdateJobDependentResource extends CRUDKubernetesDependentResource { // shared volume configuration - private static final String WORK_DIR_VOLUME_NAME = "workdir"; - private static final String WORK_DIR_VOLUME_MOUNT_PATH = "/mnt/workdir"; + private static final String WORK_DIR_VOLUME_NAME = "keycloak-update-job-temporary-workdir"; // unlikely to conflict + private static final String WORK_DIR_VOLUME_MOUNT_PATH = "/mnt/" + WORK_DIR_VOLUME_NAME; // unlikely to conflict private static final String UPDATES_FILE_PATH = WORK_DIR_VOLUME_MOUNT_PATH + "/updates.json"; // Annotations @@ -70,9 +69,6 @@ public class KeycloakUpdateJobDependentResource extends CRUDKubernetesDependentR // Job time to live private static final int JOB_TIME_TO_LIVE_SECONDS = (int) TimeUnit.MINUTES.toSeconds(30); - // container args to replace - private static final Set START_ARGS = Set.of("start", "start-dev"); - public KeycloakUpdateJobDependentResource() { super(Job.class); this.configureWith(new KubernetesDependentResourceConfigBuilder() @@ -127,21 +123,36 @@ public class KeycloakUpdateJobDependentResource extends CRUDKubernetesDependentR } private PodSpec createPodSpec(Context context) { - var allVolumes = getAllVolumes(context); - Collection requiredVolumes = new HashSet<>(); - var builder = new PodSpecBuilder(); + StatefulSet current = ContextUtils.getCurrentStatefulSet(context).orElseThrow(); + StatefulSet desired = ContextUtils.getDesiredStatefulSet(context); + + // start off with the desired statefulset state + var builder = desired.getSpec().getTemplate().getSpec().edit(); builder.withRestartPolicy("Never"); - addInitContainer(builder, context, allVolumes.keySet(), requiredVolumes); - addContainer(builder, context, allVolumes.keySet(), requiredVolumes); + + // remove things we don't want - the main keycloak container, and any sidecars added via the unsupported PodTemplate + builder.withContainers(); + + // We'll leave the scheduling fields alone - it should be fine to run these jobs where ever the keycloak workload is restricted to, + // if there is some corner case we are not considering that would leave the upgrade job unscheduable, we'll address that later + + // mix in the existing state + var desiredPullSecrets = builder.buildImagePullSecrets(); + current.getSpec().getTemplate().getSpec().getImagePullSecrets().stream().filter(s -> !desiredPullSecrets.contains(s)).forEach(builder::addToImagePullSecrets); + // TODO: if the name is the same, but the volume has changed this merging behavior will be inconsistent / incorrect. For example is someone changes which + // configmap the cache config is using + var desiredVolumes = builder.buildVolumes().stream().map(Volume::getName).collect(Collectors.toSet()); + current.getSpec().getTemplate().getSpec().getVolumes().stream().filter(v -> !desiredVolumes.contains(v.getName())).forEach(builder::addToVolumes); + // TODO: what else should get merged - there could be additional stuff from the unsupported PodTemplate + + addInitContainer(builder, current); + addContainer(builder, desired); builder.addNewVolume() .withName(WORK_DIR_VOLUME_NAME) .withNewEmptyDir() .endEmptyDir() .endVolume(); - // add volumes to the pod - requiredVolumes.stream() - .map(allVolumes::get) - .forEach(volume -> builder.addNewVolumeLike(volume).endVolume()); + // For test KeycloakDeploymentTest#testDeploymentDurability // it uses a pause image, which never ends. // After this seconds, the job is terminated allowing the test to complete. @@ -149,34 +160,28 @@ public class KeycloakUpdateJobDependentResource extends CRUDKubernetesDependentR return builder.build(); } - private static void addInitContainer(PodSpecBuilder builder, Context context, Collection availableVolumes, Collection requiredVolumes) { - var existing = CRDUtils.firstContainerOf(ContextUtils.getCurrentStatefulSet(context).orElseThrow()).orElseThrow(); + private static void addInitContainer(PodSpecBuilder builder, StatefulSet current) { + var existing = CRDUtils.firstContainerOf(current).orElseThrow(); var containerBuilder = builder.addNewInitContainerLike(existing); - configureContainer(containerBuilder, INIT_CONTAINER_NAME, INIT_CONTAINER_ARGS, availableVolumes, requiredVolumes); + configureContainer(containerBuilder, INIT_CONTAINER_NAME, INIT_CONTAINER_ARGS); containerBuilder.endInitContainer(); } - private static void addContainer(PodSpecBuilder builder, Context context, Collection availableVolumes, Collection requiredVolumes) { - var existing = CRDUtils.firstContainerOf(ContextUtils.getDesiredStatefulSet(context)).orElseThrow(); + private static void addContainer(PodSpecBuilder builder, StatefulSet desired) { + var existing = CRDUtils.firstContainerOf(desired).orElseThrow(); var containerBuilder = builder.addNewContainerLike(existing); - configureContainer(containerBuilder, CONTAINER_NAME, CONTAINER_ARGS, availableVolumes, requiredVolumes); + configureContainer(containerBuilder, CONTAINER_NAME, CONTAINER_ARGS); containerBuilder.endContainer(); } - private static void configureContainer(ContainerFluent containerBuilder, String name, List args, Collection availableVolumes, Collection requiredVolumes) { + private static void configureContainer(ContainerFluent containerBuilder, String name, List args) { containerBuilder.withName(name); containerBuilder.withArgs(replaceStartWithUpdateCommand(containerBuilder.getArgs(), args)); - // remove volume devices - containerBuilder.withVolumeDevices(); - - // add existing volume mounts var volumeMounts = containerBuilder.buildVolumeMounts(); if (volumeMounts != null) { var newVolumeMounts = volumeMounts.stream() - .filter(volumeMount -> availableVolumes.contains(volumeMount.getName())) .filter(volumeMount -> !volumeMount.getName().startsWith("kube-api")) - .peek(volumeMount -> requiredVolumes.add(volumeMount.getName())) .toList(); containerBuilder.withVolumeMounts(newVolumeMounts); } @@ -194,24 +199,10 @@ public class KeycloakUpdateJobDependentResource extends CRUDKubernetesDependentR .endVolumeMount(); } - private Map getAllVolumes(Context context) { - Map allVolumes = new HashMap<>(); - Consumer volumeConsumer = volume -> allVolumes.put(volume.getName(), volume); - CRDUtils.volumesFromStatefulSet(ContextUtils.getCurrentStatefulSet(context).orElseThrow()).forEach(volumeConsumer); - CRDUtils.volumesFromStatefulSet(ContextUtils.getDesiredStatefulSet(context)).forEach(volumeConsumer); - return allVolumes; - } - - private static List replaceStartWithUpdateCommand(List currentArgs, List updateArgs) { - return currentArgs.stream(). - mapMulti((arg, downstream) -> { - if (START_ARGS.contains(arg)) { - updateArgs.forEach(downstream); - return; - } - downstream.accept(arg); - }).toList(); + // note that using start-dev via the unsupported podTemplate will fail - that is fine as rolling updates shouldn't apply + // TODO: reuse ConfigArgConfigSource parsing, so that we don't confuse what the command is + return Stream.concat(updateArgs.stream(), currentArgs.stream().filter(arg -> !arg.equals("start"))).toList(); } private static String keycloakHash(Keycloak keycloak) {