From 915a10c0c5ac862202ee4d01f021ab1bf41ca981 Mon Sep 17 00:00:00 2001 From: wind57 Date: Fri, 19 Dec 2025 18:34:32 +0200 Subject: [PATCH 01/15] started basic work Signed-off-by: wind57 --- pom.xml | 1 + spring-cloud-kubernetes-client-leader/pom.xml | 56 +++++++++++++++++++ .../pom.xml | 18 +----- 3 files changed, 58 insertions(+), 17 deletions(-) create mode 100644 spring-cloud-kubernetes-client-leader/pom.xml diff --git a/pom.xml b/pom.xml index 78bd9172ba..6663e06272 100644 --- a/pom.xml +++ b/pom.xml @@ -131,6 +131,7 @@ spring-cloud-starter-kubernetes-fabric8-loadbalancer spring-cloud-kubernetes-discovery spring-cloud-starter-kubernetes-discoveryclient + spring-cloud-kubernetes-client-leader diff --git a/spring-cloud-kubernetes-client-leader/pom.xml b/spring-cloud-kubernetes-client-leader/pom.xml new file mode 100644 index 0000000000..b40b7ecf38 --- /dev/null +++ b/spring-cloud-kubernetes-client-leader/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + org.springframework.cloud + spring-cloud-kubernetes + 5.0.1-SNAPSHOT + + + spring-cloud-kubernetes-client-leader + K8s Client Spring Cloud Kubernetes :: Leader + + + + org.springframework.cloud + spring-cloud-kubernetes-client-autoconfig + + + org.springframework.boot + spring-boot-starter-actuator + true + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-starter-web + test + + + org.springframework.boot + spring-boot-starter-webflux + test + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.cloud + spring-cloud-kubernetes-test-support + test + + + org.springframework.boot + spring-boot-webtestclient + test + + + diff --git a/spring-cloud-kubernetes-fabric8-leader/pom.xml b/spring-cloud-kubernetes-fabric8-leader/pom.xml index 05094ab998..3e7a4930ee 100644 --- a/spring-cloud-kubernetes-fabric8-leader/pom.xml +++ b/spring-cloud-kubernetes-fabric8-leader/pom.xml @@ -1,20 +1,4 @@ - @@ -26,7 +10,7 @@ spring-cloud-kubernetes-fabric8-leader - Spring Cloud Kubernetes :: Leader + Fabric8 Spring Cloud Kubernetes :: Leader From 2060ccea05f208ef5c464ffe083a5b1962f7dbf6 Mon Sep 17 00:00:00 2001 From: wind57 Date: Mon, 22 Dec 2025 14:06:16 +0200 Subject: [PATCH 02/15] started some basic work Signed-off-by: wind57 --- .../client/leader/election/Example.java | 63 +++++++ ...ClientLeaderElectionAutoConfiguration.java | 156 ++++++++++++++++++ ...bernetesClientLeaderElectionCallbacks.java | 26 +++ ...derElectionCallbacksAutoConfiguration.java | 55 ++++++ ...ot.autoconfigure.AutoConfiguration.imports | 2 + 5 files changed, 302 insertions(+) create mode 100644 spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/Example.java create mode 100644 spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java create mode 100644 spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacks.java create mode 100644 spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacksAutoConfiguration.java create mode 100644 spring-cloud-kubernetes-client-leader/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/Example.java b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/Example.java new file mode 100644 index 0000000000..e58c88d8a6 --- /dev/null +++ b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/Example.java @@ -0,0 +1,63 @@ +package org.springframework.cloud.kubernetes.client.leader.election; + +import io.kubernetes.client.extended.leaderelection.LeaderElectionConfig; +import io.kubernetes.client.extended.leaderelection.LeaderElector; +import io.kubernetes.client.extended.leaderelection.resourcelock.EndpointsLock; +import io.kubernetes.client.extended.leaderelection.resourcelock.LeaseLock; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.Configuration; +import io.kubernetes.client.openapi.apis.CustomObjectsApi; +import io.kubernetes.client.openapi.models.V1APIResource; +import io.kubernetes.client.util.Config; + +import java.time.Duration; +import java.util.List; +import java.util.UUID; + +import static org.springframework.cloud.kubernetes.commons.leader.LeaderUtils.COORDINATION_GROUP; +import static org.springframework.cloud.kubernetes.commons.leader.LeaderUtils.COORDINATION_VERSION; + +public class Example { + + public static void main(String[] args) throws Exception { +// ApiClient client = Config.defaultClient(); +// //Configuration.setDefaultApiClient(client); +// +// // New +// String appNamespace = "default"; +// String appName = "leader-election-foobar"; +// String lockHolderIdentityName = UUID.randomUUID().toString(); // Anything unique +// LeaseLock lock = new LeaseLock(appNamespace, appName, lockHolderIdentityName, client); +// +// LeaderElectionConfig leaderElectionConfig = +// new LeaderElectionConfig( +// lock, Duration.ofMillis(10000), Duration.ofMillis(8000), Duration.ofMillis(2000)); +// try (LeaderElector leaderElector = new LeaderElector(leaderElectionConfig)) { +// leaderElector.run( +// () -> { +// System.out.println("Do something when getting leadership."); +// }, +// () -> { +// System.out.println("Do something when losing leadership."); +// }, +// x -> { +// System.out.println("current leader was elected : " + x); +// }); +// } +// +// System.out.println("here"); + + ApiClient client = Config.defaultClient(); + CustomObjectsApi customObjectsApi = new CustomObjectsApi(client); + + List resources = customObjectsApi.getAPIResources(COORDINATION_GROUP, COORDINATION_VERSION) + .execute() + .getResources(); + + boolean found = resources.stream().map(V1APIResource::getKind).anyMatch("Lease"::equals); + + System.out.println("test"); + + } + +} diff --git a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java new file mode 100644 index 0000000000..d1603bcc1d --- /dev/null +++ b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java @@ -0,0 +1,156 @@ +/* + * Copyright 2013-present the original author or authors. + * + * 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 + * + * https://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.springframework.cloud.kubernetes.client.leader.election; + +import io.kubernetes.client.extended.leaderelection.Lock; +import io.kubernetes.client.extended.leaderelection.resourcelock.ConfigMapLock; +import io.kubernetes.client.extended.leaderelection.resourcelock.LeaseLock; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.apis.CustomObjectsApi; +import io.kubernetes.client.openapi.models.V1APIResource; +import io.kubernetes.client.openapi.models.V1Pod; +import org.springframework.boot.actuate.info.InfoContributor; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnCloudPlatform; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.cloud.CloudPlatform; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.health.autoconfigure.contributor.ConditionalOnEnabledHealthIndicator; +import org.springframework.cloud.kubernetes.commons.leader.election.ConditionalOnLeaderElectionEnabled; +import org.springframework.cloud.kubernetes.commons.leader.election.LeaderElectionProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.log.LogAccessor; + +import java.util.List; +import java.util.function.BooleanSupplier; + +import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.DISCOVERY_GROUP; +import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.DISCOVERY_VERSION; +import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.ENDPOINT_SLICE; +import static org.springframework.cloud.kubernetes.commons.leader.LeaderUtils.COORDINATION_GROUP; +import static org.springframework.cloud.kubernetes.commons.leader.LeaderUtils.COORDINATION_VERSION; +import static org.springframework.cloud.kubernetes.commons.leader.LeaderUtils.LEASE; + +/** + * @author wind57 + */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(LeaderElectionProperties.class) +@ConditionalOnBean(ApiClient.class) +@ConditionalOnLeaderElectionEnabled +@ConditionalOnCloudPlatform(CloudPlatform.KUBERNETES) +@AutoConfigureAfter(KubernetesClientLeaderElectionCallbacksAutoConfiguration.class) +class KubernetesClientLeaderElectionAutoConfiguration { + + private static final String COORDINATION_VERSION_GROUP = COORDINATION_GROUP + "/" + COORDINATION_VERSION; + + private static final LogAccessor LOG = new LogAccessor(KubernetesClientLeaderElectionAutoConfiguration.class); + +// @Bean +// @ConditionalOnClass(InfoContributor.class) +// @ConditionalOnEnabledHealthIndicator("leader.election") +// Fabric8LeaderElectionInfoContributor leaderElectionInfoContributor(String candidateIdentity, +// LeaderElectionConfig leaderElectionConfig, KubernetesClient fabric8KubernetesClient) { +// return new Fabric8LeaderElectionInfoContributor(candidateIdentity, leaderElectionConfig, +// fabric8KubernetesClient); +// } +// +// @Bean +// @ConditionalOnMissingBean +// Fabric8LeaderElectionInitiator fabric8LeaderElectionInitiator(String candidateIdentity, String podNamespace, +// KubernetesClient fabric8KubernetesClient, LeaderElectionConfig fabric8LeaderElectionConfig, +// LeaderElectionProperties leaderElectionProperties, BooleanSupplier podReadySupplier) { +// return new Fabric8LeaderElectionInitiator(candidateIdentity, podNamespace, fabric8KubernetesClient, +// fabric8LeaderElectionConfig, leaderElectionProperties, podReadySupplier); +// } +// + @Bean + BooleanSupplier podReadySupplier(CoreV1Api coreV1Api, String candidateIdentity, + String podNamespace) { + return () -> { + try { + V1Pod pod = coreV1Api.readNamespacedPod(candidateIdentity, podNamespace).execute(); + return isPodReady(pod); + } catch (ApiException e) { + throw new RuntimeException(e); + } + }; + } +// +// @Bean +// @ConditionalOnMissingBean +// LeaderElectionConfig fabric8LeaderElectionConfig(LeaderElectionProperties properties, Lock lock, +// Fabric8LeaderElectionCallbacks fabric8LeaderElectionCallbacks) { +// return new LeaderElectionConfigBuilder().withReleaseOnCancel() +// .withName("Spring k8s leader election") +// .withLeaseDuration(properties.leaseDuration()) +// .withLock(lock) +// .withRenewDeadline(properties.renewDeadline()) +// .withRetryPeriod(properties.retryPeriod()) +// .withLeaderCallbacks(fabric8LeaderElectionCallbacks) +// .build(); +// } + + @Bean + @ConditionalOnMissingBean + Lock lock(ApiClient apiClient, LeaderElectionProperties properties, String candidateIdentity) { + + CustomObjectsApi customObjectsApi = new CustomObjectsApi(apiClient); + boolean leaseSupported = false; + try { + List resources = customObjectsApi.getAPIResources(COORDINATION_GROUP, COORDINATION_VERSION) + .execute() + .getResources(); + + leaseSupported = resources.stream().map(V1APIResource::getKind).anyMatch("Lease"::equals); + } catch (ApiException e) { + throw new RuntimeException(e); + } + + if (leaseSupported) { + if (properties.useConfigMapAsLock()) { + LOG.info(() -> "leases are supported on the cluster, but config map will be used " + + "(because 'spring.cloud.kubernetes.leader.election.use-config-map-as-lock=true')"); + return new ConfigMapLock(properties.lockNamespace(), properties.lockName(), candidateIdentity); + } + else { + LOG.info(() -> "will use lease as the lock for leader election"); + return new LeaseLock(properties.lockNamespace(), properties.lockName(), candidateIdentity, apiClient); + } + } + else { + LOG.info(() -> "will use configmap as the lock for leader election"); + return new ConfigMapLock(properties.lockNamespace(), properties.lockName(), candidateIdentity, apiClient); + } + } + + private boolean isPodReady(V1Pod pod) { + return pod != null + && pod.getStatus() != null + && pod.getStatus().getConditions() != null + && pod.getStatus().getConditions().stream() + .anyMatch(c -> + "Ready".equals(c.getType()) && "True".equals(c.getStatus()) + ); + } + +} diff --git a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacks.java b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacks.java new file mode 100644 index 0000000000..79be46ce32 --- /dev/null +++ b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacks.java @@ -0,0 +1,26 @@ +/* + * Copyright 2013-present the original author or authors. + * + * 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 + * + * https://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.springframework.cloud.kubernetes.client.leader.election; + +import java.util.function.Consumer; + +/** + * @author wind57 + */ +record KubernetesClientLeaderElectionCallbacks(Runnable onStartLeadingCallback, Runnable onStopLeadingCallback, + Consumer onNewLeaderCallback) { +} diff --git a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacksAutoConfiguration.java b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacksAutoConfiguration.java new file mode 100644 index 0000000000..d6b911abc4 --- /dev/null +++ b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacksAutoConfiguration.java @@ -0,0 +1,55 @@ +/* + * Copyright 2013-present the original author or authors. + * + * 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 + * + * https://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.springframework.cloud.kubernetes.client.leader.election; + +import java.util.function.Consumer; + +import io.kubernetes.client.openapi.ApiClient; + +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnCloudPlatform; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.cloud.CloudPlatform; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.kubernetes.client.KubernetesClientAutoConfiguration; +import org.springframework.cloud.kubernetes.commons.leader.election.ConditionalOnLeaderElectionEnabled; +import org.springframework.cloud.kubernetes.commons.leader.election.LeaderElectionCallbacks; +import org.springframework.cloud.kubernetes.commons.leader.election.LeaderElectionProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author wind57 + */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(LeaderElectionProperties.class) +@ConditionalOnBean(ApiClient.class) +@ConditionalOnCloudPlatform(CloudPlatform.KUBERNETES) +@ConditionalOnLeaderElectionEnabled +@AutoConfigureAfter({ KubernetesClientAutoConfiguration.class }) +final class KubernetesClientLeaderElectionCallbacksAutoConfiguration extends LeaderElectionCallbacks { + + @Bean + @ConditionalOnMissingBean + KubernetesClientLeaderElectionCallbacks kubernetesClientLeaderElectionCallbacks(Runnable onStartLeadingCallback, + Runnable onStopLeadingCallback, Consumer onNewLeaderCallback) { + return new KubernetesClientLeaderElectionCallbacks(onStartLeadingCallback, onStopLeadingCallback, + onNewLeaderCallback); + } + +} diff --git a/spring-cloud-kubernetes-client-leader/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-cloud-kubernetes-client-leader/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..3e40fe917a --- /dev/null +++ b/spring-cloud-kubernetes-client-leader/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +org.springframework.cloud.kubernetes.client.leader.election.KubernetesClientLeaderElectionCallbacksAutoConfiguration +org.springframework.cloud.kubernetes.client.leader.election.KubernetesClientLeaderElectionAutoConfiguration From e6e732bd732c71467a57467765df110e14a1795c Mon Sep 17 00:00:00 2001 From: wind57 Date: Mon, 22 Dec 2025 20:58:52 +0200 Subject: [PATCH 03/15] before any tests Signed-off-by: wind57 --- .../client/leader/election/Example.java | 63 ------- ...ClientLeaderElectionAutoConfiguration.java | 96 +++++----- ...bernetesClientLeaderElectionCallbacks.java | 2 +- ...derElectionCallbacksAutoConfiguration.java | 2 +- ...esClientLeaderElectionInfoContributor.java | 63 +++++++ ...bernetesClientLeaderElectionInitiator.java | 171 ++++++++++++++++++ .../election/LeaderElectionInitiatorUtil.java | 66 +++---- .../Fabric8LeaderElectionInitiator.java | 11 +- 8 files changed, 313 insertions(+), 161 deletions(-) delete mode 100644 spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/Example.java create mode 100644 spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributor.java create mode 100644 spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInitiator.java rename spring-cloud-kubernetes-fabric8-leader/src/main/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionInitiatorUtil.java => spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/leader/election/LeaderElectionInitiatorUtil.java (63%) diff --git a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/Example.java b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/Example.java deleted file mode 100644 index e58c88d8a6..0000000000 --- a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/Example.java +++ /dev/null @@ -1,63 +0,0 @@ -package org.springframework.cloud.kubernetes.client.leader.election; - -import io.kubernetes.client.extended.leaderelection.LeaderElectionConfig; -import io.kubernetes.client.extended.leaderelection.LeaderElector; -import io.kubernetes.client.extended.leaderelection.resourcelock.EndpointsLock; -import io.kubernetes.client.extended.leaderelection.resourcelock.LeaseLock; -import io.kubernetes.client.openapi.ApiClient; -import io.kubernetes.client.openapi.Configuration; -import io.kubernetes.client.openapi.apis.CustomObjectsApi; -import io.kubernetes.client.openapi.models.V1APIResource; -import io.kubernetes.client.util.Config; - -import java.time.Duration; -import java.util.List; -import java.util.UUID; - -import static org.springframework.cloud.kubernetes.commons.leader.LeaderUtils.COORDINATION_GROUP; -import static org.springframework.cloud.kubernetes.commons.leader.LeaderUtils.COORDINATION_VERSION; - -public class Example { - - public static void main(String[] args) throws Exception { -// ApiClient client = Config.defaultClient(); -// //Configuration.setDefaultApiClient(client); -// -// // New -// String appNamespace = "default"; -// String appName = "leader-election-foobar"; -// String lockHolderIdentityName = UUID.randomUUID().toString(); // Anything unique -// LeaseLock lock = new LeaseLock(appNamespace, appName, lockHolderIdentityName, client); -// -// LeaderElectionConfig leaderElectionConfig = -// new LeaderElectionConfig( -// lock, Duration.ofMillis(10000), Duration.ofMillis(8000), Duration.ofMillis(2000)); -// try (LeaderElector leaderElector = new LeaderElector(leaderElectionConfig)) { -// leaderElector.run( -// () -> { -// System.out.println("Do something when getting leadership."); -// }, -// () -> { -// System.out.println("Do something when losing leadership."); -// }, -// x -> { -// System.out.println("current leader was elected : " + x); -// }); -// } -// -// System.out.println("here"); - - ApiClient client = Config.defaultClient(); - CustomObjectsApi customObjectsApi = new CustomObjectsApi(client); - - List resources = customObjectsApi.getAPIResources(COORDINATION_GROUP, COORDINATION_VERSION) - .execute() - .getResources(); - - boolean found = resources.stream().map(V1APIResource::getKind).anyMatch("Lease"::equals); - - System.out.println("test"); - - } - -} diff --git a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java index d1603bcc1d..3ee9bb6411 100644 --- a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java +++ b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java @@ -16,6 +16,10 @@ package org.springframework.cloud.kubernetes.client.leader.election; +import java.util.List; +import java.util.function.BooleanSupplier; + +import io.kubernetes.client.extended.leaderelection.LeaderElectionConfig; import io.kubernetes.client.extended.leaderelection.Lock; import io.kubernetes.client.extended.leaderelection.resourcelock.ConfigMapLock; import io.kubernetes.client.extended.leaderelection.resourcelock.LeaseLock; @@ -25,6 +29,7 @@ import io.kubernetes.client.openapi.apis.CustomObjectsApi; import io.kubernetes.client.openapi.models.V1APIResource; import io.kubernetes.client.openapi.models.V1Pod; + import org.springframework.boot.actuate.info.InfoContributor; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -40,15 +45,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.log.LogAccessor; -import java.util.List; -import java.util.function.BooleanSupplier; - -import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.DISCOVERY_GROUP; -import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.DISCOVERY_VERSION; -import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.ENDPOINT_SLICE; import static org.springframework.cloud.kubernetes.commons.leader.LeaderUtils.COORDINATION_GROUP; import static org.springframework.cloud.kubernetes.commons.leader.LeaderUtils.COORDINATION_VERSION; -import static org.springframework.cloud.kubernetes.commons.leader.LeaderUtils.LEASE; /** * @author wind57 @@ -61,75 +59,67 @@ @AutoConfigureAfter(KubernetesClientLeaderElectionCallbacksAutoConfiguration.class) class KubernetesClientLeaderElectionAutoConfiguration { - private static final String COORDINATION_VERSION_GROUP = COORDINATION_GROUP + "/" + COORDINATION_VERSION; - private static final LogAccessor LOG = new LogAccessor(KubernetesClientLeaderElectionAutoConfiguration.class); -// @Bean -// @ConditionalOnClass(InfoContributor.class) -// @ConditionalOnEnabledHealthIndicator("leader.election") -// Fabric8LeaderElectionInfoContributor leaderElectionInfoContributor(String candidateIdentity, -// LeaderElectionConfig leaderElectionConfig, KubernetesClient fabric8KubernetesClient) { -// return new Fabric8LeaderElectionInfoContributor(candidateIdentity, leaderElectionConfig, -// fabric8KubernetesClient); -// } -// -// @Bean -// @ConditionalOnMissingBean -// Fabric8LeaderElectionInitiator fabric8LeaderElectionInitiator(String candidateIdentity, String podNamespace, -// KubernetesClient fabric8KubernetesClient, LeaderElectionConfig fabric8LeaderElectionConfig, -// LeaderElectionProperties leaderElectionProperties, BooleanSupplier podReadySupplier) { -// return new Fabric8LeaderElectionInitiator(candidateIdentity, podNamespace, fabric8KubernetesClient, -// fabric8LeaderElectionConfig, leaderElectionProperties, podReadySupplier); -// } -// @Bean - BooleanSupplier podReadySupplier(CoreV1Api coreV1Api, String candidateIdentity, - String podNamespace) { + @ConditionalOnClass(InfoContributor.class) + @ConditionalOnEnabledHealthIndicator("leader.election") + KubernetesClientLeaderElectionInfoContributor leaderElectionInfoContributor(String candidateIdentity, + LeaderElectionConfig leaderElectionConfig) { + return new KubernetesClientLeaderElectionInfoContributor(candidateIdentity, leaderElectionConfig); + } + + @Bean + @ConditionalOnMissingBean + KubernetesClientLeaderElectionInitiator kubernetesClientLeaderElectionInitiator(String candidateIdentity, + String podNamespace, LeaderElectionConfig leaderElectionConfig, + LeaderElectionProperties leaderElectionProperties, BooleanSupplier podReadySupplier, + KubernetesClientLeaderElectionCallbacks callbacks) { + return new KubernetesClientLeaderElectionInitiator(candidateIdentity, podNamespace, leaderElectionConfig, + leaderElectionProperties, podReadySupplier, callbacks); + } + + @Bean + BooleanSupplier podReadySupplier(CoreV1Api coreV1Api, String candidateIdentity, String podNamespace) { return () -> { try { V1Pod pod = coreV1Api.readNamespacedPod(candidateIdentity, podNamespace).execute(); return isPodReady(pod); - } catch (ApiException e) { + } + catch (ApiException e) { throw new RuntimeException(e); } }; } -// -// @Bean -// @ConditionalOnMissingBean -// LeaderElectionConfig fabric8LeaderElectionConfig(LeaderElectionProperties properties, Lock lock, -// Fabric8LeaderElectionCallbacks fabric8LeaderElectionCallbacks) { -// return new LeaderElectionConfigBuilder().withReleaseOnCancel() -// .withName("Spring k8s leader election") -// .withLeaseDuration(properties.leaseDuration()) -// .withLock(lock) -// .withRenewDeadline(properties.renewDeadline()) -// .withRetryPeriod(properties.retryPeriod()) -// .withLeaderCallbacks(fabric8LeaderElectionCallbacks) -// .build(); -// } + + @Bean + @ConditionalOnMissingBean + LeaderElectionConfig fabric8LeaderElectionConfig(LeaderElectionProperties properties, Lock lock) { + return new LeaderElectionConfig(lock, properties.leaseDuration(), properties.renewDeadline(), + properties.retryPeriod()); + } @Bean @ConditionalOnMissingBean Lock lock(ApiClient apiClient, LeaderElectionProperties properties, String candidateIdentity) { CustomObjectsApi customObjectsApi = new CustomObjectsApi(apiClient); - boolean leaseSupported = false; + boolean leaseSupported; try { List resources = customObjectsApi.getAPIResources(COORDINATION_GROUP, COORDINATION_VERSION) .execute() .getResources(); leaseSupported = resources.stream().map(V1APIResource::getKind).anyMatch("Lease"::equals); - } catch (ApiException e) { + } + catch (ApiException e) { throw new RuntimeException(e); } if (leaseSupported) { if (properties.useConfigMapAsLock()) { LOG.info(() -> "leases are supported on the cluster, but config map will be used " - + "(because 'spring.cloud.kubernetes.leader.election.use-config-map-as-lock=true')"); + + "(because 'spring.cloud.kubernetes.leader.election.use-config-map-as-lock=true')"); return new ConfigMapLock(properties.lockNamespace(), properties.lockName(), candidateIdentity); } else { @@ -144,13 +134,11 @@ Lock lock(ApiClient apiClient, LeaderElectionProperties properties, String candi } private boolean isPodReady(V1Pod pod) { - return pod != null - && pod.getStatus() != null - && pod.getStatus().getConditions() != null - && pod.getStatus().getConditions().stream() - .anyMatch(c -> - "Ready".equals(c.getType()) && "True".equals(c.getStatus()) - ); + return pod != null && pod.getStatus() != null && pod.getStatus().getConditions() != null + && pod.getStatus() + .getConditions() + .stream() + .anyMatch(c -> "Ready".equals(c.getType()) && "True".equals(c.getStatus())); } } diff --git a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacks.java b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacks.java index 79be46ce32..780cc862e2 100644 --- a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacks.java +++ b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacks.java @@ -22,5 +22,5 @@ * @author wind57 */ record KubernetesClientLeaderElectionCallbacks(Runnable onStartLeadingCallback, Runnable onStopLeadingCallback, - Consumer onNewLeaderCallback) { + Consumer onNewLeaderCallback) { } diff --git a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacksAutoConfiguration.java b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacksAutoConfiguration.java index d6b911abc4..e7e122f1d9 100644 --- a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacksAutoConfiguration.java +++ b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacksAutoConfiguration.java @@ -49,7 +49,7 @@ final class KubernetesClientLeaderElectionCallbacksAutoConfiguration extends Lea KubernetesClientLeaderElectionCallbacks kubernetesClientLeaderElectionCallbacks(Runnable onStartLeadingCallback, Runnable onStopLeadingCallback, Consumer onNewLeaderCallback) { return new KubernetesClientLeaderElectionCallbacks(onStartLeadingCallback, onStopLeadingCallback, - onNewLeaderCallback); + onNewLeaderCallback); } } diff --git a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributor.java b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributor.java new file mode 100644 index 0000000000..728b71f93e --- /dev/null +++ b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributor.java @@ -0,0 +1,63 @@ +/* + * Copyright 2013-present the original author or authors. + * + * 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 + * + * https://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.springframework.cloud.kubernetes.client.leader.election; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import io.kubernetes.client.extended.leaderelection.LeaderElectionConfig; +import io.kubernetes.client.openapi.ApiException; + +import org.springframework.boot.actuate.info.Info; +import org.springframework.boot.actuate.info.InfoContributor; +import org.springframework.core.log.LogAccessor; + +/** + * @author wind57 + */ +final class KubernetesClientLeaderElectionInfoContributor implements InfoContributor { + + private static final LogAccessor LOG = new LogAccessor(KubernetesClientLeaderElectionInfoContributor.class); + + private final String candidateIdentity; + + private final LeaderElectionConfig leaderElectionConfig; + + KubernetesClientLeaderElectionInfoContributor(String candidateIdentity, LeaderElectionConfig leaderElectionConfig) { + this.candidateIdentity = candidateIdentity; + this.leaderElectionConfig = leaderElectionConfig; + } + + @Override + public void contribute(Info.Builder builder) { + Map details = new HashMap<>(); + try { + Optional.ofNullable(leaderElectionConfig.getLock().get()).ifPresentOrElse(leaderRecord -> { + boolean isLeader = candidateIdentity.equals(leaderRecord.getHolderIdentity()); + details.put("leaderId", candidateIdentity); + details.put("isLeader", isLeader); + }, () -> details.put("leaderId", "Unknown")); + } + catch (ApiException e) { + LOG.error(e, "error in leader election info contributor"); + } + + builder.withDetail("leaderElection", details); + } + +} diff --git a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInitiator.java b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInitiator.java new file mode 100644 index 0000000000..43273faf17 --- /dev/null +++ b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInitiator.java @@ -0,0 +1,171 @@ +/* + * Copyright 2013-present the original author or authors. + * + * 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 + * + * https://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.springframework.cloud.kubernetes.client.leader.election; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.function.BooleanSupplier; + +import io.kubernetes.client.extended.leaderelection.LeaderElectionConfig; +import io.kubernetes.client.extended.leaderelection.LeaderElector; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; + +import org.springframework.cloud.kubernetes.commons.leader.election.LeaderElectionProperties; +import org.springframework.cloud.kubernetes.commons.leader.election.PodReadyRunner; +import org.springframework.core.log.LogAccessor; + +import static java.util.concurrent.Executors.newSingleThreadExecutor; +import static org.springframework.cloud.kubernetes.commons.leader.election.LeaderElectionInitiatorUtil.attachReadinessLoggerPipeline; +import static org.springframework.cloud.kubernetes.commons.leader.election.LeaderElectionInitiatorUtil.blockReadinessCheck; +import static org.springframework.cloud.kubernetes.commons.leader.election.LeaderElectionInitiatorUtil.shutDownExecutor; +import static org.springframework.cloud.kubernetes.commons.leader.election.LeaderElectionInitiatorUtil.sleep; + +/** + * @author wind57 + */ +final class KubernetesClientLeaderElectionInitiator { + + private static final LogAccessor LOG = new LogAccessor(KubernetesClientLeaderElectionInitiator.class); + + private final PodReadyRunner podReadyRunner; + + private final String candidateIdentity; + + private final LeaderElectionConfig leaderElectionConfig; + + private final LeaderElectionProperties leaderElectionProperties; + + private final boolean waitForPodReady; + + private final ExecutorService podReadyWaitingExecutor; + + private final BooleanSupplier podReadySupplier; + + private final KubernetesClientLeaderElectionCallbacks callbacks; + + private volatile LeaderElector leaderElector; + + private volatile CompletableFuture podReadyFuture; + + KubernetesClientLeaderElectionInitiator(String candidateIdentity, String candidateNamespace, + LeaderElectionConfig leaderElectionConfig, LeaderElectionProperties leaderElectionProperties, + BooleanSupplier podReadySupplier, KubernetesClientLeaderElectionCallbacks callbacks) { + this.candidateIdentity = candidateIdentity; + this.leaderElectionConfig = leaderElectionConfig; + this.leaderElectionProperties = leaderElectionProperties; + this.waitForPodReady = leaderElectionProperties.waitForPodReady(); + this.podReadySupplier = podReadySupplier; + this.callbacks = callbacks; + + this.podReadyWaitingExecutor = newSingleThreadExecutor( + runnable -> new Thread(runnable, "Fabric8LeaderElectionInitiator-" + candidateIdentity)); + + this.podReadyRunner = new PodReadyRunner(candidateIdentity, candidateNamespace); + } + + /** + *
+	 * 	We first try to see if we need to wait for the pod to be ready
+	 * 	before starting the leader election process.
+	 * 
+ * + */ + @PostConstruct + void postConstruct() { + + LOG.info(() -> "starting leader initiator : " + candidateIdentity); + + // wait until the pod is ready + if (waitForPodReady) { + LOG.info(() -> "will wait until pod " + candidateIdentity + " is ready"); + podReadyFuture = podReadyRunner.podReady(podReadySupplier); + } + else { + podReadyFuture = CompletableFuture.completedFuture(null); + } + + // wait in a different thread until the pod is ready + // and don't block the main application from starting + podReadyWaitingExecutor.execute(() -> { + try { + if (waitForPodReady) { + CompletableFuture ready = attachReadinessLoggerPipeline(podReadyFuture, candidateIdentity); + blockReadinessCheck(ready); + startLeaderElection(); + } + else { + startLeaderElection(); + } + } + catch (Exception e) { + LOG.error(e, () -> "failure : " + e.getMessage()); + } + }); + + } + + @PreDestroy + void preDestroy() { + LOG.info(() -> "preDestroy called on the leader initiator : " + candidateIdentity); + + if (podReadyFuture != null && !podReadyFuture.isDone()) { + // if the task is not running, this has no effect. + // if the task is running, calling this will also make sure + // that the caching executor will shut down too. + LOG.debug(() -> "podReadyFuture will be canceled for : " + candidateIdentity); + podReadyFuture.cancel(true); + } + + if (!podReadyWaitingExecutor.isShutdown()) { + shutDownExecutor(podReadyWaitingExecutor, candidateIdentity); + } + + if (leaderElector != null) { + leaderElector.close(); + } + } + + private void startLeaderElection() { + + boolean failedDuringStartup = false; + leaderElector = new LeaderElector(leaderElectionConfig); + try { + // this runs in a while(true) loop and every throwable is just logged, + // it does not spill over to our code + leaderElector.run(callbacks.onStartLeadingCallback(), callbacks.onStopLeadingCallback(), + callbacks.onNewLeaderCallback()); + } + catch (Exception e) { + // this is only possible when we can't start leader election, not during + // its inner workings + LOG.error(e, () -> "failure starting leader election: " + e.getMessage()); + failedDuringStartup = true; + } + finally { + leaderElector.close(); + } + + if (!failedDuringStartup) { + // as soon as leader election is over, re-start it + sleep(leaderElectionProperties); + podReadyWaitingExecutor.execute(this::startLeaderElection); + } + + } + +} diff --git a/spring-cloud-kubernetes-fabric8-leader/src/main/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionInitiatorUtil.java b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/leader/election/LeaderElectionInitiatorUtil.java similarity index 63% rename from spring-cloud-kubernetes-fabric8-leader/src/main/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionInitiatorUtil.java rename to spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/leader/election/LeaderElectionInitiatorUtil.java index e3a97ee59c..a5cc7a3a4a 100644 --- a/spring-cloud-kubernetes-fabric8-leader/src/main/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionInitiatorUtil.java +++ b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/leader/election/LeaderElectionInitiatorUtil.java @@ -14,32 +14,51 @@ * limitations under the License. */ -package org.springframework.cloud.kubernetes.fabric8.leader.election; +package org.springframework.cloud.kubernetes.commons.leader.election; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; -import io.fabric8.kubernetes.client.KubernetesClient; -import io.fabric8.kubernetes.client.extended.leaderelection.LeaderElectionConfig; -import io.fabric8.kubernetes.client.extended.leaderelection.LeaderElector; - -import org.springframework.cloud.kubernetes.commons.leader.election.LeaderElectionProperties; import org.springframework.core.log.LogAccessor; -final class Fabric8LeaderElectionInitiatorUtil { +/** + * @author wind57 + */ +public final class LeaderElectionInitiatorUtil { + + private static final LogAccessor LOG = new LogAccessor(LeaderElectionInitiatorUtil.class); - private static final LogAccessor LOG = new LogAccessor(Fabric8LeaderElectionInitiatorUtil.class); + private LeaderElectionInitiatorUtil() { - private Fabric8LeaderElectionInitiatorUtil() { + } + public static void blockReadinessCheck(CompletableFuture ready) { + try { + ready.get(); + } + catch (Exception e) { + LOG.error(e, () -> "block readiness check failed with : " + e.getMessage()); + throw new RuntimeException(e); + } + } + + public static void shutDownExecutor(ExecutorService podReadyWaitingExecutor, String candidateIdentity) { + LOG.debug(() -> "podReadyWaitingExecutor will be shutdown for : " + candidateIdentity); + podReadyWaitingExecutor.shutdownNow(); + try { + podReadyWaitingExecutor.awaitTermination(3, TimeUnit.SECONDS); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } } /** * if 'ready' is already completed at this point, thread will run this, otherwise it * will attach the pipeline and move on to 'blockReadinessCheck'. */ - static CompletableFuture attachReadinessLoggerPipeline(CompletableFuture innerPodReadyFuture, + public static CompletableFuture attachReadinessLoggerPipeline(CompletableFuture innerPodReadyFuture, String candidateIdentity) { return innerPodReadyFuture.whenComplete((ok, error) -> { if (error != null) { @@ -51,7 +70,7 @@ static CompletableFuture attachReadinessLoggerPipeline(CompletableFuture i }); } - static void sleep(LeaderElectionProperties leaderElectionProperties) { + public static void sleep(LeaderElectionProperties leaderElectionProperties) { try { TimeUnit.SECONDS.sleep(leaderElectionProperties.waitAfterRenewalFailure().toSeconds()); } @@ -60,29 +79,4 @@ static void sleep(LeaderElectionProperties leaderElectionProperties) { } } - static LeaderElector leaderElector(LeaderElectionConfig config, KubernetesClient fabric8KubernetesClient) { - return fabric8KubernetesClient.leaderElector().withConfig(config).build(); - } - - static void blockReadinessCheck(CompletableFuture ready) { - try { - ready.get(); - } - catch (Exception e) { - LOG.error(e, () -> "block readiness check failed with : " + e.getMessage()); - throw new RuntimeException(e); - } - } - - static void shutDownExecutor(ExecutorService podReadyWaitingExecutor, String candidateIdentity) { - LOG.debug(() -> "podReadyWaitingExecutor will be shutdown for : " + candidateIdentity); - podReadyWaitingExecutor.shutdownNow(); - try { - podReadyWaitingExecutor.awaitTermination(3, TimeUnit.SECONDS); - } - catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - } diff --git a/spring-cloud-kubernetes-fabric8-leader/src/main/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionInitiator.java b/spring-cloud-kubernetes-fabric8-leader/src/main/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionInitiator.java index 17bae60488..58fdb2b577 100644 --- a/spring-cloud-kubernetes-fabric8-leader/src/main/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionInitiator.java +++ b/spring-cloud-kubernetes-fabric8-leader/src/main/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionInitiator.java @@ -31,11 +31,10 @@ import org.springframework.core.log.LogAccessor; import static java.util.concurrent.Executors.newSingleThreadExecutor; -import static org.springframework.cloud.kubernetes.fabric8.leader.election.Fabric8LeaderElectionInitiatorUtil.attachReadinessLoggerPipeline; -import static org.springframework.cloud.kubernetes.fabric8.leader.election.Fabric8LeaderElectionInitiatorUtil.blockReadinessCheck; -import static org.springframework.cloud.kubernetes.fabric8.leader.election.Fabric8LeaderElectionInitiatorUtil.leaderElector; -import static org.springframework.cloud.kubernetes.fabric8.leader.election.Fabric8LeaderElectionInitiatorUtil.shutDownExecutor; -import static org.springframework.cloud.kubernetes.fabric8.leader.election.Fabric8LeaderElectionInitiatorUtil.sleep; +import static org.springframework.cloud.kubernetes.commons.leader.election.LeaderElectionInitiatorUtil.attachReadinessLoggerPipeline; +import static org.springframework.cloud.kubernetes.commons.leader.election.LeaderElectionInitiatorUtil.blockReadinessCheck; +import static org.springframework.cloud.kubernetes.commons.leader.election.LeaderElectionInitiatorUtil.shutDownExecutor; +import static org.springframework.cloud.kubernetes.commons.leader.election.LeaderElectionInitiatorUtil.sleep; /** * @author wind57 @@ -152,7 +151,7 @@ CompletableFuture leaderFeature() { private void startLeaderElection() { - leaderFuture = leaderElector(leaderElectionConfig, fabric8KubernetesClient).start(); + leaderFuture = fabric8KubernetesClient.leaderElector().withConfig(leaderElectionConfig).build().start(); leaderFuture.whenComplete((ok, error) -> { From 3e5e3b4de2fdac2226f473deaaa4ea7c4a4f0091 Mon Sep 17 00:00:00 2001 From: wind57 Date: Wed, 24 Dec 2025 14:58:44 +0200 Subject: [PATCH 04/15] before integration tests Signed-off-by: wind57 --- spring-cloud-kubernetes-client-leader/pom.xml | 5 + ...ClientLeaderElectionAutoConfiguration.java | 16 +- ...tLeaderElectionAutoConfigurationTests.java | 206 ++++++++++++++++++ ...erElectionInfoContributorIsLeaderTest.java | 94 ++++++++ ...lectionInfoContributorIsNotLeaderTest.java | 77 +++++++ ...KubernetesClientLeaderElectionTestApp.java | 32 +++ .../KubernetesClientLeaderElectionUtil.java | 98 +++++++++ ...abric8LeaderElectionAutoConfiguration.java | 6 +- ...LeaderElectionAutoConfigurationTests.java} | 2 +- 9 files changed, 525 insertions(+), 11 deletions(-) create mode 100644 spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfigurationTests.java create mode 100644 spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsLeaderTest.java create mode 100644 spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsNotLeaderTest.java create mode 100644 spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionTestApp.java create mode 100644 spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionUtil.java rename spring-cloud-kubernetes-fabric8-leader/src/test/java/org/springframework/cloud/kubernetes/fabric8/leader/election/{Fabric8LeaderAutoConfigurationTests.java => Fabric8LeaderElectionAutoConfigurationTests.java} (99%) diff --git a/spring-cloud-kubernetes-client-leader/pom.xml b/spring-cloud-kubernetes-client-leader/pom.xml index b40b7ecf38..ac7ee0c289 100644 --- a/spring-cloud-kubernetes-client-leader/pom.xml +++ b/spring-cloud-kubernetes-client-leader/pom.xml @@ -52,5 +52,10 @@ spring-boot-webtestclient test
+ + org.wiremock + wiremock-standalone + test +
diff --git a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java index 3ee9bb6411..aee7be0b80 100644 --- a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java +++ b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java @@ -30,6 +30,7 @@ import io.kubernetes.client.openapi.models.V1APIResource; import io.kubernetes.client.openapi.models.V1Pod; +import org.springframework.boot.actuate.autoconfigure.info.ConditionalOnEnabledInfoContributor; import org.springframework.boot.actuate.info.InfoContributor; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -38,7 +39,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.cloud.CloudPlatform; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.health.autoconfigure.contributor.ConditionalOnEnabledHealthIndicator; import org.springframework.cloud.kubernetes.commons.leader.election.ConditionalOnLeaderElectionEnabled; import org.springframework.cloud.kubernetes.commons.leader.election.LeaderElectionProperties; import org.springframework.context.annotation.Bean; @@ -63,9 +63,9 @@ class KubernetesClientLeaderElectionAutoConfiguration { @Bean @ConditionalOnClass(InfoContributor.class) - @ConditionalOnEnabledHealthIndicator("leader.election") - KubernetesClientLeaderElectionInfoContributor leaderElectionInfoContributor(String candidateIdentity, - LeaderElectionConfig leaderElectionConfig) { + @ConditionalOnEnabledInfoContributor("leader.election") + KubernetesClientLeaderElectionInfoContributor kubernetesClientLeaderElectionInfoContributor( + String candidateIdentity, LeaderElectionConfig leaderElectionConfig) { return new KubernetesClientLeaderElectionInfoContributor(candidateIdentity, leaderElectionConfig); } @@ -80,7 +80,8 @@ KubernetesClientLeaderElectionInitiator kubernetesClientLeaderElectionInitiator( } @Bean - BooleanSupplier podReadySupplier(CoreV1Api coreV1Api, String candidateIdentity, String podNamespace) { + BooleanSupplier kubernetesClientPodReadySupplier(CoreV1Api coreV1Api, String candidateIdentity, + String podNamespace) { return () -> { try { V1Pod pod = coreV1Api.readNamespacedPod(candidateIdentity, podNamespace).execute(); @@ -94,14 +95,15 @@ BooleanSupplier podReadySupplier(CoreV1Api coreV1Api, String candidateIdentity, @Bean @ConditionalOnMissingBean - LeaderElectionConfig fabric8LeaderElectionConfig(LeaderElectionProperties properties, Lock lock) { + LeaderElectionConfig kubernetesClientLeaderElectionConfig(LeaderElectionProperties properties, Lock lock) { return new LeaderElectionConfig(lock, properties.leaseDuration(), properties.renewDeadline(), properties.retryPeriod()); } @Bean @ConditionalOnMissingBean - Lock lock(ApiClient apiClient, LeaderElectionProperties properties, String candidateIdentity) { + Lock kubernetesClientLeaderElectionLock(ApiClient apiClient, LeaderElectionProperties properties, + String candidateIdentity) { CustomObjectsApi customObjectsApi = new CustomObjectsApi(apiClient); boolean leaseSupported; diff --git a/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfigurationTests.java b/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfigurationTests.java new file mode 100644 index 0000000000..06b59e942c --- /dev/null +++ b/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfigurationTests.java @@ -0,0 +1,206 @@ +/* + * Copyright 2013-present the original author or authors. + * + * 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 + * + * https://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.springframework.cloud.kubernetes.client.leader.election; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import io.kubernetes.client.openapi.ApiClient; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cloud.kubernetes.client.KubernetesClientAutoConfiguration; +import org.springframework.cloud.kubernetes.commons.KubernetesCommonsAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +import static org.springframework.cloud.kubernetes.client.leader.election.KubernetesClientLeaderElectionUtil.apiClientWithLeaseSupport; +import static org.springframework.cloud.kubernetes.client.leader.election.KubernetesClientLeaderElectionUtil.wireMockServer; + +/** + * @author wind57 + */ +class KubernetesClientLeaderElectionAutoConfigurationTests { + + private static WireMockServer wireMockServer; + + @BeforeAll + static void beforeAll() { + wireMockServer = wireMockServer(); + } + + @AfterAll + static void after() { + wireMockServer.stop(); + } + + @AfterEach + void afterEach() { + WireMock.reset(); + } + + /** + *
+	 *     - spring.cloud.kubernetes.leader.election is not present
+	 *
+	 *     As such:
+	 *
+	 *     - KubernetesClientLeaderElectionAutoConfiguration          is not present
+	 *     - KubernetesClientLeaderElectionCallbacksAutoConfiguration is not present
+	 * 
+ */ + @Test + void leaderElectionAnnotationMissing() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(KubernetesCommonsAutoConfiguration.class, + KubernetesClientAutoConfiguration.class, KubernetesClientLeaderElectionAutoConfiguration.class, + KubernetesClientLeaderElectionCallbacksAutoConfiguration.class)) + .withAllowBeanDefinitionOverriding(true) + .withUserConfiguration(ApiClientConfiguration.class) + .run(context -> { + Assertions.assertThat(context).doesNotHaveBean(KubernetesClientLeaderElectionAutoConfiguration.class); + Assertions.assertThat(context) + .doesNotHaveBean(KubernetesClientLeaderElectionCallbacksAutoConfiguration.class); + }); + } + + /** + *
+	 *     - spring.cloud.kubernetes.leader.election = false
+	 *
+	 *     As such:
+	 *
+	 *     - KubernetesClientLeaderElectionAutoConfiguration          is not present
+	 *     - KubernetesClientLeaderElectionCallbacksAutoConfiguration is not present
+	 * 
+ */ + @Test + void leaderElectionAnnotationPresentEqualToFalse() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(KubernetesCommonsAutoConfiguration.class, + KubernetesClientAutoConfiguration.class, KubernetesClientLeaderElectionAutoConfiguration.class, + KubernetesClientLeaderElectionCallbacksAutoConfiguration.class)) + .withAllowBeanDefinitionOverriding(true) + .withUserConfiguration(ApiClientConfiguration.class) + .withPropertyValues("spring.cloud.kubernetes.leader.election.enabled=false") + .run(context -> { + Assertions.assertThat(context).doesNotHaveBean(KubernetesClientLeaderElectionAutoConfiguration.class); + Assertions.assertThat(context) + .doesNotHaveBean(KubernetesClientLeaderElectionCallbacksAutoConfiguration.class); + }); + } + + /** + *
+	 *     - spring.cloud.kubernetes.leader.election = true
+	 *
+	 *     As such:
+	 *
+	 *     - KubernetesClientLeaderElectionAutoConfiguration          is present
+	 *     - KubernetesClientLeaderElectionCallbacksAutoConfiguration is present
+	 * 
+ */ + @Test + void leaderElectionAnnotationPresentEqualToTrue() { + new ApplicationContextRunner().withAllowBeanDefinitionOverriding(true) + .withUserConfiguration(ApiClientConfiguration.class) + .withConfiguration(AutoConfigurations.of(KubernetesCommonsAutoConfiguration.class, + KubernetesClientAutoConfiguration.class, KubernetesClientLeaderElectionAutoConfiguration.class, + KubernetesClientLeaderElectionCallbacksAutoConfiguration.class)) + .withPropertyValues("spring.cloud.kubernetes.leader.election.enabled=true", + "spring.main.cloud-platform=kubernetes") + .run(context -> { + Assertions.assertThat(context).hasSingleBean(KubernetesClientLeaderElectionAutoConfiguration.class); + Assertions.assertThat(context) + .hasSingleBean(KubernetesClientLeaderElectionCallbacksAutoConfiguration.class); + }); + } + + /** + *
+	 *     - spring.cloud.kubernetes.leader.election = true
+	 *     - management.info.leader.election.enabled = true
+	 *
+	 *     As such:
+	 *
+	 *     - KubernetesClientLeaderElectionAutoConfiguration          is present
+	 *     - KubernetesClientLeaderElectionCallbacksAutoConfiguration is present
+	 *     - KubernetesClientLeaderElectionInfoContributor            is present
+	 * 
+ */ + @Test + void leaderInfoContributorPresent() { + new ApplicationContextRunner().withUserConfiguration(ApiClientConfiguration.class) + .withConfiguration(AutoConfigurations.of(KubernetesCommonsAutoConfiguration.class, + KubernetesClientAutoConfiguration.class, KubernetesClientLeaderElectionAutoConfiguration.class, + KubernetesClientLeaderElectionCallbacksAutoConfiguration.class)) + .withPropertyValues("spring.main.cloud-platform=kubernetes", "management.info.leader.election.enabled=true", + "spring.cloud.kubernetes.leader.election.enabled=true") + .run(context -> { + Assertions.assertThat(context).hasSingleBean(KubernetesClientLeaderElectionAutoConfiguration.class); + Assertions.assertThat(context) + .hasSingleBean(KubernetesClientLeaderElectionCallbacksAutoConfiguration.class); + Assertions.assertThat(context).hasSingleBean(KubernetesClientLeaderElectionInfoContributor.class); + }); + } + + /** + *
+	 *     - spring.cloud.kubernetes.leader.election = true
+	 *     - management.info.leader.election.enabled = false
+	 *
+	 *     As such:
+	 *
+	 *     - KubernetesClientLeaderElectionAutoConfiguration          is present
+	 *     - KubernetesClientLeaderElectionCallbacksAutoConfiguration is present
+	 *     - KubernetesClientLeaderElectionInfoContributor            is not present
+	 * 
+ */ + @Test + void leaderInfoContributorMissing() { + new ApplicationContextRunner().withUserConfiguration(ApiClientConfiguration.class) + .withConfiguration(AutoConfigurations.of(KubernetesCommonsAutoConfiguration.class, + KubernetesClientAutoConfiguration.class, KubernetesClientLeaderElectionAutoConfiguration.class, + KubernetesClientLeaderElectionCallbacksAutoConfiguration.class)) + .withPropertyValues("spring.main.cloud-platform=kubernetes", + "management.info.leader.election.enabled=false", + "spring.cloud.kubernetes.leader.election.enabled=true") + .run(context -> { + Assertions.assertThat(context).hasSingleBean(KubernetesClientLeaderElectionAutoConfiguration.class); + Assertions.assertThat(context) + .hasSingleBean(KubernetesClientLeaderElectionCallbacksAutoConfiguration.class); + Assertions.assertThat(context).doesNotHaveBean(KubernetesClientLeaderElectionInfoContributor.class); + }); + } + + @Configuration + static class ApiClientConfiguration { + + @Bean + @Primary + ApiClient apiClient() { + return apiClientWithLeaseSupport(wireMockServer); + } + + } + +} diff --git a/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsLeaderTest.java b/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsLeaderTest.java new file mode 100644 index 0000000000..839c27d85c --- /dev/null +++ b/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsLeaderTest.java @@ -0,0 +1,94 @@ +/* + * Copyright 2013-present the original author or authors. + * + * 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 + * + * https://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.springframework.cloud.kubernetes.client.leader.election; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalManagementPort; +import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; +import org.springframework.cloud.kubernetes.commons.leader.LeaderUtils; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; + +import static org.springframework.cloud.kubernetes.client.leader.election.KubernetesClientLeaderElectionUtil.HOLDER_IDENTITY; +import static org.springframework.cloud.kubernetes.client.leader.election.KubernetesClientLeaderElectionUtil.wireMockServer; + +/** + * @author wind57 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { "spring.main.cloud-platform=KUBERNETES", "management.endpoints.web.exposure.include=info", + "management.endpoint.info.show-details=always", "spring.cloud.kubernetes.leader.election.enabled=true", + "spring.main.allow-bean-definition-overriding=true" }, + classes = { KubernetesClientLeaderElectionTestApp.class, + KubernetesClientLeaderElectionUtil.ApiClientConfiguration.class }) +@AutoConfigureWebTestClient +class KubernetesClientLeaderElectionInfoContributorIsLeaderTest { + + @LocalManagementPort + private int port; + + @Autowired + private WebTestClient webClient; + + private static MockedStatic leaderUtilsMockedStatic; + + private static WireMockServer wireMockServer; + + @BeforeAll + static void beforeAll() { + leaderUtilsMockedStatic = Mockito.mockStatic(LeaderUtils.class); + leaderUtilsMockedStatic.when(LeaderUtils::hostName).thenReturn("non-" + HOLDER_IDENTITY); + wireMockServer = wireMockServer(); + } + + @AfterAll + static void afterAll() { + leaderUtilsMockedStatic.close(); + wireMockServer.stop(); + } + + @AfterEach + void afterEach() { + WireMock.reset(); + } + + @Test + void infoEndpointIsNotLeaderTest() { + webClient.get() + .uri("http://localhost:{port}/actuator/info", port) + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("leaderElection.isLeader") + .isEqualTo(false) + .jsonPath("leaderElection.leaderId") + .isEqualTo("non-" + HOLDER_IDENTITY); + } + +} diff --git a/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsNotLeaderTest.java b/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsNotLeaderTest.java new file mode 100644 index 0000000000..ed483dad66 --- /dev/null +++ b/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsNotLeaderTest.java @@ -0,0 +1,77 @@ +/* + * Copyright 2013-present the original author or authors. + * + * 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 + * + * https://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.springframework.cloud.kubernetes.client.leader.election; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalManagementPort; +import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; +import org.springframework.cloud.kubernetes.commons.leader.LeaderUtils; +import org.springframework.test.web.reactive.server.WebTestClient; + +import static org.springframework.cloud.kubernetes.client.leader.election.KubernetesClientLeaderElectionUtil.HOLDER_IDENTITY; +import static org.springframework.cloud.kubernetes.client.leader.election.KubernetesClientLeaderElectionUtil.wireMockServer; + +/** + * @author wind57 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { "spring.main.cloud-platform=KUBERNETES", "management.endpoints.web.exposure.include=info", + "management.endpoint.info.show-details=always", "spring.cloud.kubernetes.leader.election.enabled=true", + "spring.main.allow-bean-definition-overriding=true" }, + classes = { KubernetesClientLeaderElectionTestApp.class, + KubernetesClientLeaderElectionUtil.ApiClientConfiguration.class }) +@AutoConfigureWebTestClient +class KubernetesClientLeaderElectionInfoContributorIsNotLeaderTest { + + @LocalManagementPort + private int port; + + @Autowired + private WebTestClient webClient; + + private static MockedStatic leaderUtilsMockedStatic; + + private static WireMockServer wireMockServer; + + @BeforeAll + static void beforeAll() { + leaderUtilsMockedStatic = Mockito.mockStatic(LeaderUtils.class); + leaderUtilsMockedStatic.when(LeaderUtils::hostName).thenReturn(HOLDER_IDENTITY); + wireMockServer = wireMockServer(); + } + + @AfterAll + static void afterAll() { + leaderUtilsMockedStatic.close(); + wireMockServer.stop(); + } + + @AfterEach + void afterEach() { + WireMock.reset(); + } + +} diff --git a/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionTestApp.java b/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionTestApp.java new file mode 100644 index 0000000000..7a6307b9b2 --- /dev/null +++ b/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionTestApp.java @@ -0,0 +1,32 @@ +/* + * Copyright 2013-present the original author or authors. + * + * 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 + * + * https://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.springframework.cloud.kubernetes.client.leader.election; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author wind57 + */ +@SpringBootApplication +class KubernetesClientLeaderElectionTestApp { + + public static void main(String[] args) { + SpringApplication.run(KubernetesClientLeaderElectionTestApp.class, args); + } + +} diff --git a/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionUtil.java b/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionUtil.java new file mode 100644 index 0000000000..ed3bfec3c5 --- /dev/null +++ b/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionUtil.java @@ -0,0 +1,98 @@ +/* + * Copyright 2013-present the original author or authors. + * + * 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 + * + * https://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.springframework.cloud.kubernetes.client.leader.election; + +import java.time.OffsetDateTime; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.JSON; +import io.kubernetes.client.openapi.models.V1APIResource; +import io.kubernetes.client.openapi.models.V1APIResourceList; +import io.kubernetes.client.openapi.models.V1APIResourceListBuilder; +import io.kubernetes.client.openapi.models.V1Lease; +import io.kubernetes.client.openapi.models.V1LeaseBuilder; +import io.kubernetes.client.openapi.models.V1LeaseSpecBuilder; +import io.kubernetes.client.util.ClientBuilder; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; + +/** + * @author wind57 + */ +final class KubernetesClientLeaderElectionUtil { + + static final String HOLDER_IDENTITY = "leader"; + + private KubernetesClientLeaderElectionUtil() { + + } + + static WireMockServer wireMockServer() { + WireMockServer wireMockServer = new WireMockServer(WireMockConfiguration.options().dynamicPort()); + wireMockServer.start(); + WireMock.configureFor(wireMockServer.port()); + return wireMockServer; + } + + static ApiClient apiClientWithLeaseSupport(WireMockServer wireMockServer) { + + // lease lock is supported + V1APIResourceList leaseList = new V1APIResourceListBuilder() + .addToResources(new V1APIResource().kind("Lease").name("my-lease").namespaced(false).singularName("Lease")) + .withApiVersion("v1") + .withGroupVersion("v1") + .withKind("Foo") + .build(); + stubFor(get("/apis/coordination.k8s.io/v1") + .willReturn(aResponse().withStatus(200).withBody(JSON.serialize(leaseList)))); + + // lease that is requested + V1Lease lease = new V1LeaseBuilder().withKind("Lease") + .withSpec(new V1LeaseSpecBuilder().withLeaseTransitions(1) + .withAcquireTime(OffsetDateTime.now()) + .withLeaseDurationSeconds(2) + .withRenewTime(OffsetDateTime.now()) + .withHolderIdentity(HOLDER_IDENTITY) + .build()) + .build(); + stubFor(get("/apis/coordination.k8s.io/v1/namespaces/default/leases/spring-k8s-leader-election-lock") + .willReturn(aResponse().withStatus(200).withBody(JSON.serialize(lease)))); + + return new ClientBuilder().setBasePath(wireMockServer.baseUrl()).build(); + } + + @Configuration + static class ApiClientConfiguration { + + @Bean + @Primary + ApiClient apiClient() { + return apiClientWithLeaseSupport(wireMockServer()); + } + + } + +} diff --git a/spring-cloud-kubernetes-fabric8-leader/src/main/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionAutoConfiguration.java b/spring-cloud-kubernetes-fabric8-leader/src/main/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionAutoConfiguration.java index d9cef01a59..f20e84bfe0 100644 --- a/spring-cloud-kubernetes-fabric8-leader/src/main/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionAutoConfiguration.java +++ b/spring-cloud-kubernetes-fabric8-leader/src/main/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionAutoConfiguration.java @@ -67,7 +67,7 @@ class Fabric8LeaderElectionAutoConfiguration { @Bean @ConditionalOnClass(InfoContributor.class) @ConditionalOnEnabledInfoContributor("leader.election") - Fabric8LeaderElectionInfoContributor leaderElectionInfoContributor(String candidateIdentity, + Fabric8LeaderElectionInfoContributor fabric8LeaderElectionInfoContributor(String candidateIdentity, LeaderElectionConfig leaderElectionConfig, KubernetesClient fabric8KubernetesClient) { return new Fabric8LeaderElectionInfoContributor(candidateIdentity, leaderElectionConfig, fabric8KubernetesClient); @@ -83,7 +83,7 @@ Fabric8LeaderElectionInitiator fabric8LeaderElectionInitiator(String candidateId } @Bean - BooleanSupplier podReadySupplier(KubernetesClient fabric8KubernetesClient, String candidateIdentity, + BooleanSupplier fabric8PodReadySupplier(KubernetesClient fabric8KubernetesClient, String candidateIdentity, String podNamespace) { return () -> { Pod pod = fabric8KubernetesClient.pods().inNamespace(podNamespace).withName(candidateIdentity).get(); @@ -107,7 +107,7 @@ LeaderElectionConfig fabric8LeaderElectionConfig(LeaderElectionProperties proper @Bean @ConditionalOnMissingBean - Lock lock(KubernetesClient fabric8KubernetesClient, LeaderElectionProperties properties, String candidateIdentity) { + Lock fabric8LeaderElectionLock(KubernetesClient fabric8KubernetesClient, LeaderElectionProperties properties, String candidateIdentity) { boolean leaseSupported = fabric8KubernetesClient.getApiGroups() .getGroups() .stream() diff --git a/spring-cloud-kubernetes-fabric8-leader/src/test/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderAutoConfigurationTests.java b/spring-cloud-kubernetes-fabric8-leader/src/test/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionAutoConfigurationTests.java similarity index 99% rename from spring-cloud-kubernetes-fabric8-leader/src/test/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderAutoConfigurationTests.java rename to spring-cloud-kubernetes-fabric8-leader/src/test/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionAutoConfigurationTests.java index 05918d2bbf..6afd5dade4 100644 --- a/spring-cloud-kubernetes-fabric8-leader/src/test/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderAutoConfigurationTests.java +++ b/spring-cloud-kubernetes-fabric8-leader/src/test/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionAutoConfigurationTests.java @@ -29,7 +29,7 @@ * * @author wind57 */ -class Fabric8LeaderAutoConfigurationTests { +class Fabric8LeaderElectionAutoConfigurationTests { /** *

From fcde63cef5010683a73f392c5c8eaea11607ba52 Mon Sep 17 00:00:00 2001
From: wind57 
Date: Fri, 26 Dec 2025 20:07:32 +0200
Subject: [PATCH 05/15] wip

Signed-off-by: wind57 
---
 ...ClientLeaderElectionAutoConfiguration.java |  30 ++-
 spring-cloud-kubernetes-dependencies/pom.xml  |   6 +
 .../pom.xml                                   |   3 +-
 .../pom.xml                                   |  39 ++++
 .../election/AbstractLeaderElection.java      | 171 ++++++++++++++++++
 .../client/leader/election/App.java           |  29 +++
 .../client/leader/election/Assertions.java    |  92 ++++++++++
 ...aderElectionCanceledAndNotRestartedIT.java |  48 +++++
 .../K8sClientLeaderElectionSimpleIT.java      |  76 ++++++++
 9 files changed, 487 insertions(+), 7 deletions(-)
 create mode 100644 spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/pom.xml
 create mode 100644 spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/AbstractLeaderElection.java
 create mode 100644 spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/App.java
 create mode 100644 spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/Assertions.java
 create mode 100644 spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionCanceledAndNotRestartedIT.java
 create mode 100644 spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionSimpleIT.java

diff --git a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java
index aee7be0b80..487abbea09 100644
--- a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java
+++ b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java
@@ -17,6 +17,7 @@
 package org.springframework.cloud.kubernetes.client.leader.election;
 
 import java.util.List;
+import java.util.Objects;
 import java.util.function.BooleanSupplier;
 
 import io.kubernetes.client.extended.leaderelection.LeaderElectionConfig;
@@ -30,6 +31,7 @@
 import io.kubernetes.client.openapi.models.V1APIResource;
 import io.kubernetes.client.openapi.models.V1Pod;
 
+import io.kubernetes.client.openapi.models.V1PodCondition;
 import org.springframework.boot.actuate.autoconfigure.info.ConditionalOnEnabledInfoContributor;
 import org.springframework.boot.actuate.info.InfoContributor;
 import org.springframework.boot.autoconfigure.AutoConfigureAfter;
@@ -135,12 +137,28 @@ Lock kubernetesClientLeaderElectionLock(ApiClient apiClient, LeaderElectionPrope
 		}
 	}
 
-	private boolean isPodReady(V1Pod pod) {
-		return pod != null && pod.getStatus() != null && pod.getStatus().getConditions() != null
-				&& pod.getStatus()
-					.getConditions()
-					.stream()
-					.anyMatch(c -> "Ready".equals(c.getType()) && "True".equals(c.getStatus()));
+	// above two methods are a verbatim copy of the fabric8 implementation
+	private static boolean isPodReady(V1Pod pod) {
+		Objects.requireNonNull(pod, "Pod can't be null.");
+		V1PodCondition condition = getPodReadyCondition(pod);
+
+		if (condition == null) {
+			return false;
+		}
+		return condition.getStatus().equalsIgnoreCase("True");
+	}
+
+	private static V1PodCondition getPodReadyCondition(V1Pod pod) {
+		if (pod.getStatus() == null || pod.getStatus().getConditions() == null) {
+			return null;
+		}
+
+		for (V1PodCondition condition : pod.getStatus().getConditions()) {
+			if ("Ready".equals(condition.getType())) {
+				return condition;
+			}
+		}
+		return null;
 	}
 
 }
diff --git a/spring-cloud-kubernetes-dependencies/pom.xml b/spring-cloud-kubernetes-dependencies/pom.xml
index 6ea725bf6f..e657da8086 100644
--- a/spring-cloud-kubernetes-dependencies/pom.xml
+++ b/spring-cloud-kubernetes-dependencies/pom.xml
@@ -105,6 +105,12 @@
 				${project.version}
 			
 
+			
+				org.springframework.cloud
+				spring-cloud-kubernetes-client-leader
+				${project.version}
+			
+
 			
 				org.springframework.cloud
 				spring-cloud-kubernetes-commons
diff --git a/spring-cloud-kubernetes-integration-tests/pom.xml b/spring-cloud-kubernetes-integration-tests/pom.xml
index 1908c2ead4..73ac47ca64 100644
--- a/spring-cloud-kubernetes-integration-tests/pom.xml
+++ b/spring-cloud-kubernetes-integration-tests/pom.xml
@@ -115,6 +115,7 @@
 
 		
         spring-cloud-kubernetes-fabric8-leader-election
+		spring-cloud-kubernetes-k8s-client-leader-election
 
-    
+	
 
diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/pom.xml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/pom.xml
new file mode 100644
index 0000000000..68865a336c
--- /dev/null
+++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/pom.xml
@@ -0,0 +1,39 @@
+
+
+	4.0.0
+	
+		org.springframework.cloud
+		spring-cloud-kubernetes-integration-tests
+		5.0.1-SNAPSHOT
+	
+
+	
+		true
+		true
+	
+
+	spring-cloud-kubernetes-k8s-client-leader-election
+
+	
+		
+			org.springframework.boot
+			spring-boot-starter-test
+			test
+		
+		
+			org.springframework.cloud
+			spring-cloud-kubernetes-test-support
+			test
+		
+		
+			org.springframework.cloud
+			spring-cloud-kubernetes-client-leader
+		
+		
+			org.springframework.boot
+			spring-boot-starter-actuator
+		
+	
+
diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/AbstractLeaderElection.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/AbstractLeaderElection.java
new file mode 100644
index 0000000000..c759b78964
--- /dev/null
+++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/AbstractLeaderElection.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright 2013-present the original author or authors.
+ *
+ * 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
+ *
+ *      https://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.springframework.cloud.kubernetes.client.leader.election;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BooleanSupplier;
+
+import io.fabric8.kubernetes.api.model.coordination.v1.Lease;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.openapi.apis.CoordinationV1Api;
+import io.kubernetes.client.openapi.apis.CoreV1Api;
+import io.kubernetes.client.openapi.models.V1DeleteOptions;
+import io.kubernetes.client.openapi.models.V1Lease;
+import io.kubernetes.client.openapi.models.V1Status;
+import io.kubernetes.client.util.Config;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.cloud.kubernetes.commons.leader.LeaderUtils;
+import org.springframework.cloud.kubernetes.integration.tests.commons.Commons;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+import org.springframework.test.annotation.DirtiesContext;
+import org.testcontainers.k3s.K3sContainer;
+
+/**
+ * @author wind57
+ */
+@ExtendWith(OutputCaptureExtension.class)
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
+	properties = { "spring.main.cloud-platform=KUBERNETES", "spring.cloud.kubernetes.leader.election.enabled=true",
+		"spring.cloud.kubernetes.leader.election.lease-duration=6s",
+		"spring.cloud.kubernetes.leader.election.renew-deadline=5s",
+		"logging.level.org.springframework.cloud.kubernetes.commons.leader.election=debug",
+		"logging.level.org.springframework.cloud.kubernetes.client.leader.election=debug",
+		"logging.level.io.kubernetes.client.extended.leaderelection=debug" },
+	classes = { App.class, AbstractLeaderElection.TestConfig.class,
+		AbstractLeaderElection.PodReadyTestConfiguration.class })
+@DirtiesContext
+abstract class AbstractLeaderElection {
+
+	@Autowired
+	private ApiClient apiClient;
+
+	private static K3sContainer container;
+
+	private static MockedStatic LEADER_UTILS_MOCKED_STATIC;
+
+	static void beforeAll(String candidateIdentity) {
+		container = Commons.container();
+		container.start();
+
+		LEADER_UTILS_MOCKED_STATIC = Mockito.mockStatic(LeaderUtils.class);
+		LEADER_UTILS_MOCKED_STATIC.when(LeaderUtils::hostName).thenReturn(candidateIdentity);
+	}
+
+	@AfterAll
+	static void afterAll() {
+		LEADER_UTILS_MOCKED_STATIC.close();
+	}
+
+	void stopLeaderAndDeleteLease(KubernetesClientLeaderElectionInitiator initiator) {
+		initiator.preDestroy();
+
+		CoordinationV1Api api = new CoordinationV1Api(apiClient);
+
+		try {
+			api.deleteNamespacedLease("spring-k8s-leader-election-lock", "default").execute();
+		} catch (ApiException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	V1Lease getLease() {
+		CoordinationV1Api api = new CoordinationV1Api(apiClient);
+		try {
+			return api.readNamespacedLease("spring-k8s-leader-election-lock", "default").execute();
+		} catch (ApiException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	@TestConfiguration
+	static class TestConfig {
+
+		@Bean
+		@Primary
+		ApiClient client() {
+			String kubeConfigYaml = container.getKubeConfigYaml();
+
+			ApiClient client;
+			try {
+				client = Config.fromConfig(new StringReader(kubeConfigYaml));
+			}
+			catch (IOException e) {
+				throw new RuntimeException(e);
+			}
+			return client;
+		}
+
+	}
+
+	@TestConfiguration
+	static class PodReadyTestConfiguration {
+
+		// readiness passes after 2 retries
+		@Bean
+		@Primary
+		@ConditionalOnProperty(value = "readiness.passes", havingValue = "true", matchIfMissing = false)
+		BooleanSupplier readinessSupplierPasses() {
+			AtomicInteger counter = new AtomicInteger(0);
+			return () -> {
+				if (counter.get() != 2) {
+					counter.incrementAndGet();
+					return false;
+				}
+				return true;
+			};
+		}
+
+		// readiness fails after 2 retries
+		@Bean
+		@Primary
+		@ConditionalOnProperty(value = "readiness.fails", havingValue = "true", matchIfMissing = false)
+		BooleanSupplier readinessSupplierFails() {
+			AtomicInteger counter = new AtomicInteger(0);
+			return () -> {
+				if (counter.get() != 2) {
+					counter.incrementAndGet();
+					return false;
+				}
+				throw new RuntimeException("readiness fails");
+			};
+		}
+
+		// readiness always fails
+		@Bean
+		@Primary
+		@ConditionalOnProperty(value = "readiness.never.finishes", havingValue = "true", matchIfMissing = false)
+		BooleanSupplier readinessNeverFinishes() {
+			return () -> false;
+		}
+
+	}
+
+}
diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/App.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/App.java
new file mode 100644
index 0000000000..7b5bcc26f1
--- /dev/null
+++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/App.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2013-present the original author or authors.
+ *
+ * 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
+ *
+ *      https://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.springframework.cloud.kubernetes.client.leader.election;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class App {
+
+	public static void main(String[] args) {
+		SpringApplication.run(App.class, args);
+	}
+
+}
diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/Assertions.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/Assertions.java
new file mode 100644
index 0000000000..8dac34d6d5
--- /dev/null
+++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/Assertions.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2013-present the original author or authors.
+ *
+ * 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
+ *
+ *      https://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.springframework.cloud.kubernetes.client.leader.election;
+
+import io.kubernetes.client.openapi.models.V1Lease;
+import org.springframework.boot.test.system.CapturedOutput;
+
+import java.time.OffsetDateTime;
+import java.util.function.Supplier;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.cloud.kubernetes.integration.tests.commons.Awaitilities.awaitUntil;
+
+/**
+ * @author wind57
+ */
+final class Assertions {
+
+	private Assertions() {
+
+	}
+
+	/**
+	 * lease was acquired and we renewed it, at least once.
+	 */
+	static void assertAcquireAndRenew(CapturedOutput output, Supplier leaseSupplier, String candidateIdentity) {
+		// we have become the leader
+		awaitUntil(60, 100, () -> output.getOut().contains(candidateIdentity + " is the new leader"));
+
+		// let's unwind some logs to see that the process is how we expect it to be
+
+		// 1. lease is used as the lock (comes from our code)
+		awaitUntil(5, 100, () -> output.getOut().contains("will use lease as the lock for leader election"));
+
+		// 2. we start leader initiator for our hostname (comes from our code)
+		awaitUntil(5, 100, () -> output.getOut().contains("starting leader initiator : " + candidateIdentity));
+
+		// 3. start leader election with the configured lock
+		awaitUntil(10, 100, () -> output.getOut().contains(
+			"Start leader election with lock default/spring-k8s-leader-election-lock"));
+
+		// 4. we try to acquire the lease
+		awaitUntil(5, 100, () -> output.getOut().contains("Attempting to acquire leader lease"));
+
+		// 5. lease has been acquired
+		awaitUntil(5, 100, () -> output.getOut().contains("LeaderElection lock is currently held by " + candidateIdentity));
+
+		try {
+			Thread.sleep(5_000);
+		} catch (InterruptedException e) {
+			throw new RuntimeException(e);
+		}
+
+		// 6. we are the leader
+		awaitUntil(10, 100, () -> output.getOut().contains("Successfully acquired lease, became leader"));
+
+		// 7. wait until a renewal happens
+		// this one means that we have extended our leadership
+		awaitUntil(10, 100, () -> output.getOut().contains("Successfully renewed lease"));
+
+		V1Lease lease = leaseSupplier.get();
+
+		OffsetDateTime currentAcquiredTime = lease.getSpec().getAcquireTime();
+		assertThat(currentAcquiredTime).isNotNull();
+		assertThat(lease.getSpec().getLeaseDurationSeconds()).isEqualTo(6);
+		assertThat(lease.getSpec().getLeaseTransitions()).isEqualTo(0);
+
+		OffsetDateTime currentRenewalTime = lease.getSpec().getRenewTime();
+		assertThat(currentRenewalTime).isNotNull();
+
+		// 8. renewal happens
+		awaitUntil(4, 500, () -> {
+			OffsetDateTime newRenewalTime = leaseSupplier.get().getSpec().getRenewTime();
+			return newRenewalTime.isAfter(currentRenewalTime);
+		});
+	}
+
+}
diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionCanceledAndNotRestartedIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionCanceledAndNotRestartedIT.java
new file mode 100644
index 0000000000..17ca172084
--- /dev/null
+++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionCanceledAndNotRestartedIT.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2013-present the original author or authors.
+ *
+ * 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
+ *
+ *      https://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.springframework.cloud.kubernetes.client.leader.election;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.test.context.TestPropertySource;
+
+/**
+ * 
+ *     - we acquire the leadership
+ *     - leadership feature fails
+ * 
+ * + * @author wind57 + */ +@TestPropertySource(properties = { "spring.cloud.kubernetes.leader.election.wait-for-pod-ready=true", + "spring.cloud.kubernetes.leader.election.restart-on-failure=true", "readiness.passes=true" }) +class K8sClientLeaderElectionCanceledAndNotRestartedIT extends AbstractLeaderElection { + + private static final String NAME = "acquired-then-canceled"; + + @BeforeAll + static void beforeAll() { + AbstractLeaderElection.beforeAll(NAME); + } + + @Test + void test() { + + } + +} diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionSimpleIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionSimpleIT.java new file mode 100644 index 0000000000..72ac4d767e --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionSimpleIT.java @@ -0,0 +1,76 @@ +/* + * Copyright 2013-present the original author or authors. + * + * 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 + * + * https://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.springframework.cloud.kubernetes.client.leader.election; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.test.context.TestPropertySource; + +import static org.springframework.cloud.kubernetes.client.leader.election.Assertions.assertAcquireAndRenew; +import static org.springframework.cloud.kubernetes.integration.tests.commons.Awaitilities.awaitUntil; + + +/** + * A simple test where we are the sole participant in the leader election and everything + * goes fine from start to end. It's a happy path scenario test. + * + * @author wind57 + */ + +@TestPropertySource(properties = { "spring.cloud.kubernetes.leader.election.wait-for-pod-ready=true", + "readiness.passes=true" }) +class K8sClientLeaderElectionSimpleIT extends AbstractLeaderElection { + + @Autowired + private KubernetesClientLeaderElectionInitiator initiator; + + private static final String NAME = "simple-it"; + + @BeforeAll + static void beforeAll() { + AbstractLeaderElection.beforeAll(NAME); + } + + @AfterEach + void afterEach() { + stopLeaderAndDeleteLease(initiator); + } + + /** + *
+	 *     - readiness is checked
+	 *     - leader election process happens after that
+	 *     - we establish leadership and renew it
+	 * 
+ */ + @Test + void test(CapturedOutput output) { + + awaitUntil(10, 100, () -> output.getOut() + .contains("Pod : simple-it in namespace : default is not ready, will retry in one second")); + awaitUntil(10, 100, () -> output.getOut().contains("Pod : simple-it in namespace : default is ready")); + awaitUntil(10, 100, () -> output.getOut().contains("simple-it is ready")); + awaitUntil(10, 100, () -> output.getOut().contains("canceling scheduled future because readiness succeeded")); + + assertAcquireAndRenew(output, this::getLease, NAME); + } + +} From 7e5f5f478abf3d2a66704154c77850a751596868 Mon Sep 17 00:00:00 2001 From: wind57 Date: Fri, 26 Dec 2025 20:11:41 +0200 Subject: [PATCH 06/15] wip Signed-off-by: wind57 --- ...pleIT.java => K8sClientLeaderElectionReadinessPassesIT.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/{K8sClientLeaderElectionSimpleIT.java => K8sClientLeaderElectionReadinessPassesIT.java} (96%) diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionSimpleIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessPassesIT.java similarity index 96% rename from spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionSimpleIT.java rename to spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessPassesIT.java index 72ac4d767e..ccb8788a24 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionSimpleIT.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessPassesIT.java @@ -37,7 +37,7 @@ @TestPropertySource(properties = { "spring.cloud.kubernetes.leader.election.wait-for-pod-ready=true", "readiness.passes=true" }) -class K8sClientLeaderElectionSimpleIT extends AbstractLeaderElection { +class K8sClientLeaderElectionReadinessPassesIT extends AbstractLeaderElection { @Autowired private KubernetesClientLeaderElectionInitiator initiator; From 989ba08820d152df20d98a646028a148f35df10c Mon Sep 17 00:00:00 2001 From: wind57 Date: Sat, 27 Dec 2025 19:46:17 +0200 Subject: [PATCH 07/15] wip Signed-off-by: wind57 --- ...bernetesClientLeaderElectionInitiator.java | 2 + .../election/AbstractLeaderElection.java | 21 ++- ...entLeaderElectionIsLostAndRestartedIT.java | 123 ++++++++++++++++++ ...ientLeaderElectionReadinessCanceledIT.java | 88 +++++++++++++ ...sClientLeaderElectionReadinessFailsIT.java | 101 ++++++++++++++ ...ClientLeaderElectionReadinessPassesIT.java | 15 ++- 6 files changed, 340 insertions(+), 10 deletions(-) create mode 100644 spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionIsLostAndRestartedIT.java create mode 100644 spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessCanceledIT.java create mode 100644 spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessFailsIT.java diff --git a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInitiator.java b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInitiator.java index 43273faf17..7cb22e3ca9 100644 --- a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInitiator.java +++ b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInitiator.java @@ -142,6 +142,8 @@ void preDestroy() { private void startLeaderElection() { + LOG.info(() -> "starting leader initiator : " + candidateIdentity); + boolean failedDuringStartup = false; leaderElector = new LeaderElector(leaderElectionConfig); try { diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/AbstractLeaderElection.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/AbstractLeaderElection.java index c759b78964..adf4900171 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/AbstractLeaderElection.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/AbstractLeaderElection.java @@ -84,22 +84,33 @@ static void afterAll() { LEADER_UTILS_MOCKED_STATIC.close(); } - void stopLeaderAndDeleteLease(KubernetesClientLeaderElectionInitiator initiator) { + void stopLeaderAndDeleteLease(KubernetesClientLeaderElectionInitiator initiator, boolean deleteLease) { initiator.preDestroy(); - CoordinationV1Api api = new CoordinationV1Api(apiClient); + if (deleteLease) { + CoordinationV1Api api = new CoordinationV1Api(apiClient); + + try { + api.deleteNamespacedLease("spring-k8s-leader-election-lock", "default").execute(); + } catch (ApiException e) { + throw new RuntimeException(e); + } + } + } + V1Lease getLease() { + CoordinationV1Api api = new CoordinationV1Api(apiClient); try { - api.deleteNamespacedLease("spring-k8s-leader-election-lock", "default").execute(); + return api.readNamespacedLease("spring-k8s-leader-election-lock", "default").execute(); } catch (ApiException e) { throw new RuntimeException(e); } } - V1Lease getLease() { + V1Lease updateLease(V1Lease lease) { CoordinationV1Api api = new CoordinationV1Api(apiClient); try { - return api.readNamespacedLease("spring-k8s-leader-election-lock", "default").execute(); + return api.replaceNamespacedLease("spring-k8s-leader-election-lock", "default", lease).execute(); } catch (ApiException e) { throw new RuntimeException(e); } diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionIsLostAndRestartedIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionIsLostAndRestartedIT.java new file mode 100644 index 0000000000..94f2d0b53a --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionIsLostAndRestartedIT.java @@ -0,0 +1,123 @@ +/* + * Copyright 2013-present the original author or authors. + * + * 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 + * + * https://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.springframework.cloud.kubernetes.client.leader.election; + +import io.fabric8.kubernetes.api.model.coordination.v1.Lease; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.models.V1Lease; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.test.context.TestPropertySource; + +import static org.springframework.cloud.kubernetes.client.leader.election.Assertions.assertAcquireAndRenew; +import static org.springframework.cloud.kubernetes.integration.tests.commons.Awaitilities.awaitUntil; + +/** + * We acquire leadership, then lose it, then acquire it back. This tests the "leaderFuture + * finished normally, will re-start it for" branch + * + * @author wind57 + */ +@TestPropertySource( + properties = { "spring.cloud.kubernetes.leader.election.wait-for-pod-ready=true", "readiness.passes=true" }) +class K8sClientLeaderElectionIsLostAndRestartedIT extends AbstractLeaderElection { + + private static final String NAME = "leader-lost-then-recovers-it"; + + @Autowired + private KubernetesClientLeaderElectionInitiator initiator; + + @BeforeAll + static void beforeAll() { + AbstractLeaderElection.beforeAll(NAME); + } + + @AfterEach + void afterEach() { + stopLeaderAndDeleteLease(initiator, true); + } + + @Test + void test(CapturedOutput output) { + + assertAcquireAndRenew(output, this::getLease, NAME); + + // 8. simulate that leadership has changed + V1Lease lease = getLease(); + lease.getSpec().setHolderIdentity("leader-lost-then-recovers-it-is-not-the-leader-anymore"); + updateLease(lease); + +// // 9. leader has changed +// awaitUntil(10, 20, () -> output.getOut() +// .contains("Leader changed from " + NAME + " to leader-lost-then-recovers-it-is-not-the-leader-anymore")); +// +// // 10. our onNewLeaderCallback is triggered +// awaitUntil(10, 20, +// () -> output.getOut().contains("leader-lost-then-recovers-it-is-not-the-leader-anymore is the new leader")); +// +// // 11. our onStopLeading callback is triggered +// awaitUntil(10, 20, () -> output.getOut().contains(NAME + " stopped being a leader")); +// +// // 12. we gave up on leadership, so we will re-start the process +// awaitUntil(10, 20, () -> output.getOut() +// .contains("leaderFuture finished normally, will re-start it for : " + NAME)); +// +// int leadershipFinished = output.getOut() +// .indexOf("leaderFuture finished normally, will re-start it for : " + NAME); +// +// afterLeadershipRestart(output, leadershipFinished); + + } + + private void afterLeadershipRestart(CapturedOutput output, int leadershipFinished) { + + // 13. once we start leadership again, we try to acquire the new lock + awaitUntil(10, 20, + () -> output.getOut() + .substring(leadershipFinished) + .contains("Attempting to acquire leader lease 'LeaseLock: " + + "default - spring-k8s-leader-election-lock (" + NAME + ")")); + + // 14. we can not acquire the new lock, since it did not yet expire + // (the new leader is not going to renew it since it's an artificial leader) + awaitUntil(10, 20, + () -> output.getOut() + .substring(leadershipFinished) + .contains("Failed to acquire lease 'LeaseLock: " + + "default - spring-k8s-leader-election-lock (" + NAME + ")' retrying...")); + + // 15. leader is again us + awaitUntil(10, 500, () -> output.getOut() + .substring(leadershipFinished) + .contains("Leader changed from leader-lost-then-recovers-it-is-not-the-leader-anymore to " + NAME)); + + // 16. callback is again triggered + awaitUntil(10, 500, + () -> output.getOut() + .substring(leadershipFinished) + .contains("id : " + NAME + " is the new leader")); + + // 17. the other callback is triggered also + awaitUntil(10, 500, + () -> output.getOut().substring(leadershipFinished).contains(NAME + " is now a leader")); + } + + +} diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessCanceledIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessCanceledIT.java new file mode 100644 index 0000000000..0d536d7cac --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessCanceledIT.java @@ -0,0 +1,88 @@ +/* + * Copyright 2013-present the original author or authors. + * + * 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 + * + * https://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.springframework.cloud.kubernetes.client.leader.election; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.test.context.TestPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.cloud.kubernetes.integration.tests.commons.Awaitilities.awaitUntil; + +/** + * Readiness is canceled. This is the case when pod is shut down gracefully + * + * @author wind57 + */ +@TestPropertySource(properties = { "readiness.never.finishes=true", + "spring.cloud.kubernetes.leader.election.wait-for-pod-ready=true" }) +class K8sClientLeaderElectionReadinessCanceledIT extends AbstractLeaderElection { + + private static final String NAME = "readiness-canceled-it"; + + @Autowired + private KubernetesClientLeaderElectionInitiator initiator; + + @BeforeAll + static void beforeAll() { + AbstractLeaderElection.beforeAll(NAME); + } + + @AfterEach + void afterEach() { + stopLeaderAndDeleteLease(initiator, false); + } + + @Test + void test(CapturedOutput output) { + + // we are trying readiness at least once + awaitUntil(60, 500, () -> output.getOut() + .contains( + "Pod : " + NAME + " in namespace : " + "default is not ready, will retry in one second")); + + initiator.preDestroy(); + + // 1. preDestroy method logs what it will do + assertThat(output.getOut()).contains("podReadyFuture will be canceled for : " + NAME); + + // 2. readiness failed + assertThat(output.getOut()) + .contains("readiness failed for : " + NAME + ", leader election will not start"); + + // 3. will cancel the future that is supposed to do the readiness + assertThat(output.getOut()).contains("canceling scheduled future because completable future was cancelled"); + + // 4. podReadyWaitingExecutor is shut down also + assertThat(output.getOut()).contains("podReadyWaitingExecutor will be shutdown for : " + NAME); + + // 5. the scheduled executor where pod readiness is checked is shut down also + awaitUntil(2, 100, () -> output.getOut().contains("Shutting down executor : podReadyExecutor")); + + // we need to call preDestroy again, to make sure that leaderFuture was not + // started + initiator.preDestroy(); + + // 6. leader election is not started, since readiness does not finish + assertThat(output.getOut()).doesNotContain("starting leader initiator :" + NAME); + + } + +} diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessFailsIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessFailsIT.java new file mode 100644 index 0000000000..e8d6b240ef --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessFailsIT.java @@ -0,0 +1,101 @@ +/* + * Copyright 2013-present the original author or authors. + * + * 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 + * + * https://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.springframework.cloud.kubernetes.client.leader.election; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.test.context.TestPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.cloud.kubernetes.integration.tests.commons.Awaitilities.awaitUntil; + +/** + * Readiness fails with an Exception, and we don't establish leadership + * + * @author wind57 + */ +@TestPropertySource( + properties = { "readiness.fails=true", "spring.cloud.kubernetes.leader.election.wait-for-pod-ready=true" }) +class K8sClientLeaderElectionReadinessFailsIT extends AbstractLeaderElection { + + private static final String NAME = "readiness-fails-it"; + + @Autowired + private KubernetesClientLeaderElectionInitiator initiator; + + @BeforeAll + static void beforeAll() { + AbstractLeaderElection.beforeAll(NAME); + } + + @AfterEach + void afterEach() { + stopLeaderAndDeleteLease(initiator, false); + } + + /** + *
+	 *   - readiness fails after 2 seconds - leader election process is not started at all
+	 * 
+ */ + @Test + void test(CapturedOutput output) { + + // we do not start leader election at all + awaitUntil(60, 1000, () -> output.getOut() + .contains("readiness failed for : " + NAME + ", leader election will not start")); + + // 1. lease is used as the lock (comes from our code) + assertThat(output.getOut()).contains("will use lease as the lock for leader election"); + + // 2. leader initiator is started + assertThat(output.getOut()).contains("starting leader initiator : " + NAME); + + // 3. wait for when pod is ready (we mock this one) + assertThat(output.getOut()).contains("will wait until pod " + NAME + " is ready"); + + // 4. we run readiness check in podReadyExecutor + assertThat(output.getOut()).contains("Scheduling command to run in : podReadyExecutor"); + + // 5. pod fails on the first two attempts + assertThat(output.getOut()) + .contains("Pod : " + NAME + " in namespace : default is not ready, will retry in one second"); + + // 6. readiness fails + assertThat(output.getOut()).contains("exception waiting for pod : " + NAME); + + // 7. readiness failed + assertThat(output.getOut()) + .contains("pod readiness for : " + NAME + " failed with : readiness fails"); + + // 8. we shut down the executor + assertThat(output.getOut()).contains("canceling scheduled future because readiness failed"); + + // 9. leader election did not even start properly + assertThat(output.getOut()) + .contains("pod readiness for : " + NAME + " failed with : readiness fails"); + + // 10. executor is shutdown, even when readiness failed + awaitUntil(60, 100, () -> output.getOut() + .contains("readiness failed for : " + NAME + ", leader election will not start")); + + } + +} diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessPassesIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessPassesIT.java index ccb8788a24..41f85b6190 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessPassesIT.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessPassesIT.java @@ -42,7 +42,7 @@ class K8sClientLeaderElectionReadinessPassesIT extends AbstractLeaderElection { @Autowired private KubernetesClientLeaderElectionInitiator initiator; - private static final String NAME = "simple-it"; + private static final String NAME = "readiness-passes-it"; @BeforeAll static void beforeAll() { @@ -51,7 +51,7 @@ static void beforeAll() { @AfterEach void afterEach() { - stopLeaderAndDeleteLease(initiator); + stopLeaderAndDeleteLease(initiator, true); } /** @@ -65,12 +65,17 @@ void afterEach() { void test(CapturedOutput output) { awaitUntil(10, 100, () -> output.getOut() - .contains("Pod : simple-it in namespace : default is not ready, will retry in one second")); - awaitUntil(10, 100, () -> output.getOut().contains("Pod : simple-it in namespace : default is ready")); - awaitUntil(10, 100, () -> output.getOut().contains("simple-it is ready")); + .contains("Pod : " + NAME + " in namespace : default is not ready, will retry in one second")); + awaitUntil(10, 100, () -> output.getOut().contains("Pod : " + NAME + " in namespace : default is ready")); + awaitUntil(10, 100, () -> output.getOut().contains(NAME + " is ready")); awaitUntil(10, 100, () -> output.getOut().contains("canceling scheduled future because readiness succeeded")); assertAcquireAndRenew(output, this::getLease, NAME); + + // comes from the callback, where we post the spring lifecycle event + awaitUntil(5, 100, () -> output.getOut().contains(NAME + " is the new leader")); + + awaitUntil(60, 100, () -> output.getOut().contains("Shutting down executor : podReadyExecutor")); } } From 9d6e6ab14402d5a0ba94f119328772eb4b6946e3 Mon Sep 17 00:00:00 2001 From: wind57 Date: Sat, 27 Dec 2025 20:38:07 +0200 Subject: [PATCH 08/15] wip Signed-off-by: wind57 --- ...ClientLeaderElectionAutoConfiguration.java | 2 +- .../election/AbstractLeaderElection.java | 32 ++++---- .../client/leader/election/Assertions.java | 23 +++--- ...aderElectionCanceledAndNotRestartedIT.java | 2 +- ...entLeaderElectionIsLostAndRestartedIT.java | 78 +++++++++---------- ...ientLeaderElectionReadinessCanceledIT.java | 9 +-- ...sClientLeaderElectionReadinessFailsIT.java | 17 ++-- ...ClientLeaderElectionReadinessPassesIT.java | 5 +- 8 files changed, 80 insertions(+), 88 deletions(-) diff --git a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java index 487abbea09..ee8056442f 100644 --- a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java +++ b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java @@ -30,8 +30,8 @@ import io.kubernetes.client.openapi.apis.CustomObjectsApi; import io.kubernetes.client.openapi.models.V1APIResource; import io.kubernetes.client.openapi.models.V1Pod; - import io.kubernetes.client.openapi.models.V1PodCondition; + import org.springframework.boot.actuate.autoconfigure.info.ConditionalOnEnabledInfoContributor; import org.springframework.boot.actuate.info.InfoContributor; import org.springframework.boot.autoconfigure.AutoConfigureAfter; diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/AbstractLeaderElection.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/AbstractLeaderElection.java index adf4900171..d23e23a057 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/AbstractLeaderElection.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/AbstractLeaderElection.java @@ -18,23 +18,19 @@ import java.io.IOException; import java.io.StringReader; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BooleanSupplier; -import io.fabric8.kubernetes.api.model.coordination.v1.Lease; import io.kubernetes.client.openapi.ApiClient; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.apis.CoordinationV1Api; -import io.kubernetes.client.openapi.apis.CoreV1Api; -import io.kubernetes.client.openapi.models.V1DeleteOptions; import io.kubernetes.client.openapi.models.V1Lease; -import io.kubernetes.client.openapi.models.V1Status; import io.kubernetes.client.util.Config; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.MockedStatic; import org.mockito.Mockito; +import org.testcontainers.k3s.K3sContainer; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -46,21 +42,20 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; import org.springframework.test.annotation.DirtiesContext; -import org.testcontainers.k3s.K3sContainer; /** * @author wind57 */ @ExtendWith(OutputCaptureExtension.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, - properties = { "spring.main.cloud-platform=KUBERNETES", "spring.cloud.kubernetes.leader.election.enabled=true", - "spring.cloud.kubernetes.leader.election.lease-duration=6s", - "spring.cloud.kubernetes.leader.election.renew-deadline=5s", - "logging.level.org.springframework.cloud.kubernetes.commons.leader.election=debug", - "logging.level.org.springframework.cloud.kubernetes.client.leader.election=debug", - "logging.level.io.kubernetes.client.extended.leaderelection=debug" }, - classes = { App.class, AbstractLeaderElection.TestConfig.class, - AbstractLeaderElection.PodReadyTestConfiguration.class }) + properties = { "spring.main.cloud-platform=KUBERNETES", "spring.cloud.kubernetes.leader.election.enabled=true", + "spring.cloud.kubernetes.leader.election.lease-duration=6s", + "spring.cloud.kubernetes.leader.election.renew-deadline=5s", + "logging.level.org.springframework.cloud.kubernetes.commons.leader.election=debug", + "logging.level.org.springframework.cloud.kubernetes.client.leader.election=debug", + "logging.level.io.kubernetes.client.extended.leaderelection=debug" }, + classes = { App.class, AbstractLeaderElection.TestConfig.class, + AbstractLeaderElection.PodReadyTestConfiguration.class }) @DirtiesContext abstract class AbstractLeaderElection { @@ -92,7 +87,8 @@ void stopLeaderAndDeleteLease(KubernetesClientLeaderElectionInitiator initiator, try { api.deleteNamespacedLease("spring-k8s-leader-election-lock", "default").execute(); - } catch (ApiException e) { + } + catch (ApiException e) { throw new RuntimeException(e); } } @@ -102,7 +98,8 @@ V1Lease getLease() { CoordinationV1Api api = new CoordinationV1Api(apiClient); try { return api.readNamespacedLease("spring-k8s-leader-election-lock", "default").execute(); - } catch (ApiException e) { + } + catch (ApiException e) { throw new RuntimeException(e); } } @@ -111,7 +108,8 @@ V1Lease updateLease(V1Lease lease) { CoordinationV1Api api = new CoordinationV1Api(apiClient); try { return api.replaceNamespacedLease("spring-k8s-leader-election-lock", "default", lease).execute(); - } catch (ApiException e) { + } + catch (ApiException e) { throw new RuntimeException(e); } } diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/Assertions.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/Assertions.java index 8dac34d6d5..1d0f752dee 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/Assertions.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/Assertions.java @@ -16,12 +16,13 @@ package org.springframework.cloud.kubernetes.client.leader.election; -import io.kubernetes.client.openapi.models.V1Lease; -import org.springframework.boot.test.system.CapturedOutput; - import java.time.OffsetDateTime; import java.util.function.Supplier; +import io.kubernetes.client.openapi.models.V1Lease; + +import org.springframework.boot.test.system.CapturedOutput; + import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.cloud.kubernetes.integration.tests.commons.Awaitilities.awaitUntil; @@ -37,7 +38,8 @@ private Assertions() { /** * lease was acquired and we renewed it, at least once. */ - static void assertAcquireAndRenew(CapturedOutput output, Supplier leaseSupplier, String candidateIdentity) { + static void assertAcquireAndRenew(CapturedOutput output, Supplier leaseSupplier, + String candidateIdentity) { // we have become the leader awaitUntil(60, 100, () -> output.getOut().contains(candidateIdentity + " is the new leader")); @@ -50,20 +52,15 @@ static void assertAcquireAndRenew(CapturedOutput output, Supplier lease awaitUntil(5, 100, () -> output.getOut().contains("starting leader initiator : " + candidateIdentity)); // 3. start leader election with the configured lock - awaitUntil(10, 100, () -> output.getOut().contains( - "Start leader election with lock default/spring-k8s-leader-election-lock")); + awaitUntil(10, 100, () -> output.getOut() + .contains("Start leader election with lock default/spring-k8s-leader-election-lock")); // 4. we try to acquire the lease awaitUntil(5, 100, () -> output.getOut().contains("Attempting to acquire leader lease")); // 5. lease has been acquired - awaitUntil(5, 100, () -> output.getOut().contains("LeaderElection lock is currently held by " + candidateIdentity)); - - try { - Thread.sleep(5_000); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } + awaitUntil(5, 100, + () -> output.getOut().contains("LeaderElection lock is currently held by " + candidateIdentity)); // 6. we are the leader awaitUntil(10, 100, () -> output.getOut().contains("Successfully acquired lease, became leader")); diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionCanceledAndNotRestartedIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionCanceledAndNotRestartedIT.java index 17ca172084..9c24639b79 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionCanceledAndNotRestartedIT.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionCanceledAndNotRestartedIT.java @@ -30,7 +30,7 @@ * @author wind57 */ @TestPropertySource(properties = { "spring.cloud.kubernetes.leader.election.wait-for-pod-ready=true", - "spring.cloud.kubernetes.leader.election.restart-on-failure=true", "readiness.passes=true" }) + "spring.cloud.kubernetes.leader.election.restart-on-failure=true", "readiness.passes=true" }) class K8sClientLeaderElectionCanceledAndNotRestartedIT extends AbstractLeaderElection { private static final String NAME = "acquired-then-canceled"; diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionIsLostAndRestartedIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionIsLostAndRestartedIT.java index 94f2d0b53a..d2bfaadb11 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionIsLostAndRestartedIT.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionIsLostAndRestartedIT.java @@ -16,12 +16,11 @@ package org.springframework.cloud.kubernetes.client.leader.election; -import io.fabric8.kubernetes.api.model.coordination.v1.Lease; -import io.kubernetes.client.openapi.ApiClient; import io.kubernetes.client.openapi.models.V1Lease; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.system.CapturedOutput; import org.springframework.test.context.TestPropertySource; @@ -36,7 +35,7 @@ * @author wind57 */ @TestPropertySource( - properties = { "spring.cloud.kubernetes.leader.election.wait-for-pod-ready=true", "readiness.passes=true" }) + properties = { "spring.cloud.kubernetes.leader.election.wait-for-pod-ready=true", "readiness.passes=true" }) class K8sClientLeaderElectionIsLostAndRestartedIT extends AbstractLeaderElection { private static final String NAME = "leader-lost-then-recovers-it"; @@ -64,25 +63,29 @@ void test(CapturedOutput output) { lease.getSpec().setHolderIdentity("leader-lost-then-recovers-it-is-not-the-leader-anymore"); updateLease(lease); -// // 9. leader has changed -// awaitUntil(10, 20, () -> output.getOut() -// .contains("Leader changed from " + NAME + " to leader-lost-then-recovers-it-is-not-the-leader-anymore")); -// -// // 10. our onNewLeaderCallback is triggered -// awaitUntil(10, 20, -// () -> output.getOut().contains("leader-lost-then-recovers-it-is-not-the-leader-anymore is the new leader")); -// -// // 11. our onStopLeading callback is triggered -// awaitUntil(10, 20, () -> output.getOut().contains(NAME + " stopped being a leader")); -// -// // 12. we gave up on leadership, so we will re-start the process -// awaitUntil(10, 20, () -> output.getOut() -// .contains("leaderFuture finished normally, will re-start it for : " + NAME)); -// -// int leadershipFinished = output.getOut() -// .indexOf("leaderFuture finished normally, will re-start it for : " + NAME); -// -// afterLeadershipRestart(output, leadershipFinished); + // // 9. leader has changed + // awaitUntil(10, 20, () -> output.getOut() + // .contains("Leader changed from " + NAME + " to + // leader-lost-then-recovers-it-is-not-the-leader-anymore")); + // + // // 10. our onNewLeaderCallback is triggered + // awaitUntil(10, 20, + // () -> + // output.getOut().contains("leader-lost-then-recovers-it-is-not-the-leader-anymore + // is the new leader")); + // + // // 11. our onStopLeading callback is triggered + // awaitUntil(10, 20, () -> output.getOut().contains(NAME + " stopped being a + // leader")); + // + // // 12. we gave up on leadership, so we will re-start the process + // awaitUntil(10, 20, () -> output.getOut() + // .contains("leaderFuture finished normally, will re-start it for : " + NAME)); + // + // int leadershipFinished = output.getOut() + // .indexOf("leaderFuture finished normally, will re-start it for : " + NAME); + // + // afterLeadershipRestart(output, leadershipFinished); } @@ -90,34 +93,31 @@ private void afterLeadershipRestart(CapturedOutput output, int leadershipFinishe // 13. once we start leadership again, we try to acquire the new lock awaitUntil(10, 20, - () -> output.getOut() - .substring(leadershipFinished) - .contains("Attempting to acquire leader lease 'LeaseLock: " - + "default - spring-k8s-leader-election-lock (" + NAME + ")")); + () -> output.getOut() + .substring(leadershipFinished) + .contains("Attempting to acquire leader lease 'LeaseLock: " + + "default - spring-k8s-leader-election-lock (" + NAME + ")")); // 14. we can not acquire the new lock, since it did not yet expire // (the new leader is not going to renew it since it's an artificial leader) awaitUntil(10, 20, - () -> output.getOut() - .substring(leadershipFinished) - .contains("Failed to acquire lease 'LeaseLock: " - + "default - spring-k8s-leader-election-lock (" + NAME + ")' retrying...")); + () -> output.getOut() + .substring(leadershipFinished) + .contains("Failed to acquire lease 'LeaseLock: " + "default - spring-k8s-leader-election-lock (" + + NAME + ")' retrying...")); // 15. leader is again us - awaitUntil(10, 500, () -> output.getOut() - .substring(leadershipFinished) - .contains("Leader changed from leader-lost-then-recovers-it-is-not-the-leader-anymore to " + NAME)); + awaitUntil(10, 500, + () -> output.getOut() + .substring(leadershipFinished) + .contains("Leader changed from leader-lost-then-recovers-it-is-not-the-leader-anymore to " + NAME)); // 16. callback is again triggered awaitUntil(10, 500, - () -> output.getOut() - .substring(leadershipFinished) - .contains("id : " + NAME + " is the new leader")); + () -> output.getOut().substring(leadershipFinished).contains("id : " + NAME + " is the new leader")); // 17. the other callback is triggered also - awaitUntil(10, 500, - () -> output.getOut().substring(leadershipFinished).contains(NAME + " is now a leader")); + awaitUntil(10, 500, () -> output.getOut().substring(leadershipFinished).contains(NAME + " is now a leader")); } - } diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessCanceledIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessCanceledIT.java index 0d536d7cac..2feadf94a0 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessCanceledIT.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessCanceledIT.java @@ -19,6 +19,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.system.CapturedOutput; import org.springframework.test.context.TestPropertySource; @@ -32,7 +33,7 @@ * @author wind57 */ @TestPropertySource(properties = { "readiness.never.finishes=true", - "spring.cloud.kubernetes.leader.election.wait-for-pod-ready=true" }) + "spring.cloud.kubernetes.leader.election.wait-for-pod-ready=true" }) class K8sClientLeaderElectionReadinessCanceledIT extends AbstractLeaderElection { private static final String NAME = "readiness-canceled-it"; @@ -55,8 +56,7 @@ void test(CapturedOutput output) { // we are trying readiness at least once awaitUntil(60, 500, () -> output.getOut() - .contains( - "Pod : " + NAME + " in namespace : " + "default is not ready, will retry in one second")); + .contains("Pod : " + NAME + " in namespace : " + "default is not ready, will retry in one second")); initiator.preDestroy(); @@ -64,8 +64,7 @@ void test(CapturedOutput output) { assertThat(output.getOut()).contains("podReadyFuture will be canceled for : " + NAME); // 2. readiness failed - assertThat(output.getOut()) - .contains("readiness failed for : " + NAME + ", leader election will not start"); + assertThat(output.getOut()).contains("readiness failed for : " + NAME + ", leader election will not start"); // 3. will cancel the future that is supposed to do the readiness assertThat(output.getOut()).contains("canceling scheduled future because completable future was cancelled"); diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessFailsIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessFailsIT.java index e8d6b240ef..6a01152195 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessFailsIT.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessFailsIT.java @@ -19,6 +19,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.system.CapturedOutput; import org.springframework.test.context.TestPropertySource; @@ -32,7 +33,7 @@ * @author wind57 */ @TestPropertySource( - properties = { "readiness.fails=true", "spring.cloud.kubernetes.leader.election.wait-for-pod-ready=true" }) + properties = { "readiness.fails=true", "spring.cloud.kubernetes.leader.election.wait-for-pod-ready=true" }) class K8sClientLeaderElectionReadinessFailsIT extends AbstractLeaderElection { private static final String NAME = "readiness-fails-it"; @@ -59,8 +60,8 @@ void afterEach() { void test(CapturedOutput output) { // we do not start leader election at all - awaitUntil(60, 1000, () -> output.getOut() - .contains("readiness failed for : " + NAME + ", leader election will not start")); + awaitUntil(60, 1000, + () -> output.getOut().contains("readiness failed for : " + NAME + ", leader election will not start")); // 1. lease is used as the lock (comes from our code) assertThat(output.getOut()).contains("will use lease as the lock for leader election"); @@ -82,19 +83,17 @@ void test(CapturedOutput output) { assertThat(output.getOut()).contains("exception waiting for pod : " + NAME); // 7. readiness failed - assertThat(output.getOut()) - .contains("pod readiness for : " + NAME + " failed with : readiness fails"); + assertThat(output.getOut()).contains("pod readiness for : " + NAME + " failed with : readiness fails"); // 8. we shut down the executor assertThat(output.getOut()).contains("canceling scheduled future because readiness failed"); // 9. leader election did not even start properly - assertThat(output.getOut()) - .contains("pod readiness for : " + NAME + " failed with : readiness fails"); + assertThat(output.getOut()).contains("pod readiness for : " + NAME + " failed with : readiness fails"); // 10. executor is shutdown, even when readiness failed - awaitUntil(60, 100, () -> output.getOut() - .contains("readiness failed for : " + NAME + ", leader election will not start")); + awaitUntil(60, 100, + () -> output.getOut().contains("readiness failed for : " + NAME + ", leader election will not start")); } diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessPassesIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessPassesIT.java index 41f85b6190..a5fab8d35d 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessPassesIT.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessPassesIT.java @@ -27,7 +27,6 @@ import static org.springframework.cloud.kubernetes.client.leader.election.Assertions.assertAcquireAndRenew; import static org.springframework.cloud.kubernetes.integration.tests.commons.Awaitilities.awaitUntil; - /** * A simple test where we are the sole participant in the leader election and everything * goes fine from start to end. It's a happy path scenario test. @@ -35,8 +34,8 @@ * @author wind57 */ -@TestPropertySource(properties = { "spring.cloud.kubernetes.leader.election.wait-for-pod-ready=true", - "readiness.passes=true" }) +@TestPropertySource( + properties = { "spring.cloud.kubernetes.leader.election.wait-for-pod-ready=true", "readiness.passes=true" }) class K8sClientLeaderElectionReadinessPassesIT extends AbstractLeaderElection { @Autowired From c64075335073af807785a64971db19d8e421610e Mon Sep 17 00:00:00 2001 From: wind57 Date: Sun, 28 Dec 2025 20:35:50 +0200 Subject: [PATCH 09/15] before doc Signed-off-by: wind57 --- ...bernetesClientLeaderElectionInitiator.java | 30 ++- ...aderElectionCanceledAndNotRestartedIT.java | 2 + ...aderElectionCanceledAndNotRestartedIT.java | 27 +- ...nCompletedExceptionallyAndRestartedIT.java | 85 +++++++ .../K8sClientLeaderElectionConcurrentIT.java | 230 ++++++++++++++++++ ...entLeaderElectionIsLostAndRestartedIT.java | 70 ++---- .../src/test/resources/logback-test.xml | 17 ++ 7 files changed, 407 insertions(+), 54 deletions(-) create mode 100644 spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionCompletedExceptionallyAndRestartedIT.java create mode 100644 spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionConcurrentIT.java create mode 100644 spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/resources/logback-test.xml diff --git a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInitiator.java b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInitiator.java index 7cb22e3ca9..35030bff57 100644 --- a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInitiator.java +++ b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInitiator.java @@ -62,6 +62,8 @@ final class KubernetesClientLeaderElectionInitiator { private volatile CompletableFuture podReadyFuture; + private volatile boolean shutDownCalled = false; + KubernetesClientLeaderElectionInitiator(String candidateIdentity, String candidateNamespace, LeaderElectionConfig leaderElectionConfig, LeaderElectionProperties leaderElectionProperties, BooleanSupplier podReadySupplier, KubernetesClientLeaderElectionCallbacks callbacks) { @@ -78,6 +80,11 @@ final class KubernetesClientLeaderElectionInitiator { this.podReadyRunner = new PodReadyRunner(candidateIdentity, candidateNamespace); } + // visible for testing only + LeaderElector leaderElector() { + return leaderElector; + } + /** *
 	 * 	We first try to see if we need to wait for the pod to be ready
@@ -122,6 +129,7 @@ void postConstruct() {
 	@PreDestroy
 	void preDestroy() {
 		LOG.info(() -> "preDestroy called on the leader initiator : " + candidateIdentity);
+		shutDownCalled = true;
 
 		if (podReadyFuture != null && !podReadyFuture.isDone()) {
 			// if the task is not running, this has no effect.
@@ -148,7 +156,10 @@ private void startLeaderElection() {
 		leaderElector = new LeaderElector(leaderElectionConfig);
 		try {
 			// this runs in a while(true) loop and every throwable is just logged,
-			// it does not spill over to our code
+			// it does not spill over to our code. It means that 'failedDuringStartup'
+			// can only be true before we enter the while(true) loop. This can be some
+			// basic validations
+			// like not sufficient RBAC, for example.
 			leaderElector.run(callbacks.onStartLeadingCallback(), callbacks.onStopLeadingCallback(),
 					callbacks.onNewLeaderCallback());
 		}
@@ -162,12 +173,21 @@ private void startLeaderElection() {
 			leaderElector.close();
 		}
 
-		if (!failedDuringStartup) {
-			// as soon as leader election is over, re-start it
-			sleep(leaderElectionProperties);
-			podReadyWaitingExecutor.execute(this::startLeaderElection);
+		if (shutDownCalled) {
+			LOG.debug(() -> "leadership terminated for : " + candidateIdentity);
+			return;
 		}
 
+		if (failedDuringStartup) {
+			LOG.error(() -> "leadership failed during startup for : " + candidateIdentity);
+			return;
+		}
+
+		// as soon as leader election is over, re-start it
+		LOG.debug(() -> "will re-start leader election for : " + candidateIdentity);
+		sleep(leaderElectionProperties);
+		podReadyWaitingExecutor.execute(this::startLeaderElection);
+
 	}
 
 }
diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-leader-election/src/test/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionCanceledAndNotRestartedIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-leader-election/src/test/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionCanceledAndNotRestartedIT.java
index dee050c416..d18d0e82c3 100644
--- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-leader-election/src/test/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionCanceledAndNotRestartedIT.java
+++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-leader-election/src/test/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionCanceledAndNotRestartedIT.java
@@ -66,6 +66,8 @@ void test(CapturedOutput output) {
 		// lease is going to reset
 		awaitUntil(10, 100, () -> getLease().getSpec().getHolderIdentity().isEmpty());
 
+		awaitUntil(10, 100, () -> output.getOut().contains("terminating leadership for : " + NAME));
+
 	}
 
 }
diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionCanceledAndNotRestartedIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionCanceledAndNotRestartedIT.java
index 9c24639b79..9e040693e4 100644
--- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionCanceledAndNotRestartedIT.java
+++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionCanceledAndNotRestartedIT.java
@@ -16,11 +16,17 @@
 
 package org.springframework.cloud.kubernetes.client.leader.election;
 
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.system.CapturedOutput;
 import org.springframework.test.context.TestPropertySource;
 
+import static org.springframework.cloud.kubernetes.client.leader.election.Assertions.assertAcquireAndRenew;
+import static org.springframework.cloud.kubernetes.integration.tests.commons.Awaitilities.awaitUntil;
+
 /**
  * 
  *     - we acquire the leadership
@@ -33,15 +39,32 @@
 		"spring.cloud.kubernetes.leader.election.restart-on-failure=true", "readiness.passes=true" })
 class K8sClientLeaderElectionCanceledAndNotRestartedIT extends AbstractLeaderElection {
 
-	private static final String NAME = "acquired-then-canceled";
+	private static final String NAME = "leader-acquired-then-canceled-it";
+
+	@Autowired
+	private KubernetesClientLeaderElectionInitiator initiator;
 
 	@BeforeAll
 	static void beforeAll() {
 		AbstractLeaderElection.beforeAll(NAME);
 	}
 
+	@AfterEach
+	void afterEach() {
+		stopLeaderAndDeleteLease(initiator, true);
+	}
+
 	@Test
-	void test() {
+	void test(CapturedOutput output) {
+
+		assertAcquireAndRenew(output, this::getLease, NAME);
+
+		// this will kill leadership and it will not be re-started
+		initiator.preDestroy();
+
+		awaitUntil(10, 100, () -> output.getOut().contains("leadership terminated for : " + NAME));
+
+		awaitUntil(10, 100, () -> output.getOut().contains("Giving up the lock"));
 
 	}
 
diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionCompletedExceptionallyAndRestartedIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionCompletedExceptionallyAndRestartedIT.java
new file mode 100644
index 0000000000..c098671180
--- /dev/null
+++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionCompletedExceptionallyAndRestartedIT.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2013-present the original author or authors.
+ *
+ * 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
+ *
+ *      https://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.springframework.cloud.kubernetes.client.leader.election;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.test.context.TestPropertySource;
+
+import static org.springframework.cloud.kubernetes.client.leader.election.Assertions.assertAcquireAndRenew;
+import static org.springframework.cloud.kubernetes.integration.tests.commons.Awaitilities.awaitUntil;
+
+/**
+ * 
+ *     - we acquire the leadership
+ *     - leadership feature fails
+ *     - we retry and acquire it again
+ * 
+ * + * @author wind57 + */ +@TestPropertySource(properties = { "spring.cloud.kubernetes.leader.election.wait-for-pod-ready=true", + "spring.cloud.kubernetes.leader.election.restart-on-failure=true", "readiness.passes=true" }) +class K8sClientLeaderElectionCompletedExceptionallyAndRestartedIT extends AbstractLeaderElection { + + private static final String NAME = "leader-completed-and-restarted-it"; + + @Autowired + private KubernetesClientLeaderElectionInitiator initiator; + + @BeforeAll + static void beforeAll() { + AbstractLeaderElection.beforeAll(NAME); + } + + @AfterEach + void afterEach() { + stopLeaderAndDeleteLease(initiator, true); + } + + @Test + void test(CapturedOutput output) { + + assertAcquireAndRenew(output, this::getLease, NAME); + + // simulate that the lock is released + initiator.leaderElector().close(); + + // from the callback + awaitUntil(5, 50, () -> output.getOut().contains("id : " + NAME + " stopped being a leader")); + + awaitUntil(5, 50, () -> output.getOut().contains("will re-start leader election for : " + NAME)); + + int afterLeaderFailure = output.getOut().indexOf("will re-start leader election for : " + NAME); + + afterLeaderFailure(afterLeaderFailure, output); + + } + + private void afterLeaderFailure(int afterLeaderFailure, CapturedOutput output) { + awaitUntil(60, 100, () -> output.getOut().substring(afterLeaderFailure).contains(NAME + " is the new leader")); + awaitUntil(5, 100, () -> output.getOut().contains("Update lock to renew lease")); + awaitUntil(5, 100, () -> output.getOut().contains("TryAcquireOrRenew return success")); + awaitUntil(5, 100, () -> output.getOut().contains("Successfully renewed lease")); + awaitUntil(5, 100, () -> output.getOut().contains("Update lock to renew lease")); + } + +} diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionConcurrentIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionConcurrentIT.java new file mode 100644 index 0000000000..0ad66c7136 --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionConcurrentIT.java @@ -0,0 +1,230 @@ +/* + * Copyright 2013-present the original author or authors. + * + * 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 + * + * https://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.springframework.cloud.kubernetes.client.leader.election; + +import java.io.IOException; +import java.io.StringReader; +import java.time.Duration; +import java.util.function.Consumer; + +import io.kubernetes.client.extended.leaderelection.LeaderElectionConfig; +import io.kubernetes.client.extended.leaderelection.Lock; +import io.kubernetes.client.extended.leaderelection.resourcelock.LeaseLock; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.apis.CoordinationV1Api; +import io.kubernetes.client.util.Config; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.k3s.K3sContainer; + +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.cloud.kubernetes.commons.leader.election.LeaderElectionProperties; +import org.springframework.cloud.kubernetes.integration.tests.commons.Commons; + +import static org.springframework.cloud.kubernetes.integration.tests.commons.Awaitilities.awaitUntil; + +@ExtendWith(OutputCaptureExtension.class) +class K8sClientLeaderElectionConcurrentIT { + + private static final String LEASE_NAME = "lease-lock"; + + private static final LeaderElectionProperties PROPERTIES = new LeaderElectionProperties(false, false, + Duration.ofSeconds(15), "default", LEASE_NAME, Duration.ofSeconds(5), Duration.ofSeconds(2), + Duration.ofSeconds(5), false, true); + + private static final String CANDIDATE_IDENTITY_ONE = "one"; + + private static final String CANDIDATE_IDENTITY_TWO = "two"; + + private KubernetesClientLeaderElectionInitiator one; + + private KubernetesClientLeaderElectionInitiator two; + + private static ApiClient apiClient; + + @BeforeAll + static void beforeAll() { + + K3sContainer container = Commons.container(); + container.start(); + + String kubeConfigYaml = container.getKubeConfigYaml(); + + try { + apiClient = Config.fromConfig(new StringReader(kubeConfigYaml)); + } + catch (IOException e) { + throw new RuntimeException(e); + } + + } + + @AfterAll + static void afterAll() { + + CoordinationV1Api api = new CoordinationV1Api(apiClient); + + try { + api.deleteNamespacedLease(LEASE_NAME, "default").execute(); + } + catch (ApiException e) { + throw new RuntimeException(e); + } + + } + + @AfterEach + void afterEach() { + one.preDestroy(); + two.preDestroy(); + } + + @Test + void test(CapturedOutput output) { + + LeaderElectionConfig leaderElectionConfigOne = leaderElectionConfig(CANDIDATE_IDENTITY_ONE); + KubernetesClientLeaderElectionCallbacks callbacksOne = callbacks(CANDIDATE_IDENTITY_ONE); + one = new KubernetesClientLeaderElectionInitiator(CANDIDATE_IDENTITY_ONE, "default", leaderElectionConfigOne, + PROPERTIES, () -> true, callbacksOne); + + LeaderElectionConfig leaderElectionConfigTwo = leaderElectionConfig(CANDIDATE_IDENTITY_TWO); + KubernetesClientLeaderElectionCallbacks callbacksTwo = callbacks(CANDIDATE_IDENTITY_TWO); + two = new KubernetesClientLeaderElectionInitiator(CANDIDATE_IDENTITY_TWO, "default", leaderElectionConfigTwo, + PROPERTIES, () -> true, callbacksTwo); + + one.postConstruct(); + two.postConstruct(); + + // both try to acquire the lock + awaitUntil(5, 100, () -> output.getOut().contains("starting leader initiator : one")); + awaitUntil(5, 100, () -> output.getOut().contains("starting leader initiator : two")); + + // someone has become the leader + awaitUntil(5, 100, () -> output.getOut().contains("LeaderElection lock is currently held by")); + + LeaderAndFollower leaderAndFollower = leaderAndFollower(leaderElectionConfigOne); + String leader = leaderAndFollower.leader(); + String follower = leaderAndFollower.follower(); + + // someone has become the leader + awaitUntil(5, 100, () -> output.getOut().contains("LeaderElection lock is currently held by " + leader)); + awaitUntil(3, 100, () -> output.getOut().contains("id : " + leader + " is the new leader")); + + // the other elector says it can't acquire the lock + awaitUntil(10, 100, () -> output.getOut().contains("Lock is held by " + leader + " and has not yet expired")); + awaitUntil(10, 100, () -> output.getOut().contains("The tryAcquireOrRenew result is false")); + + int beforeRelease = output.getOut().length(); + failLeaderRenewal(leader, one, two); + + /* + * we simulated above that renewal failed and leader future was canceled. In this + * case, the 'notLeader' picks up the leadership, the 'leader' is now a + * "follower", it re-tries to take leadership. + */ + awaitUntil(10, 100, + () -> output.getOut().substring(beforeRelease).contains("id : " + follower + " is the new leader")); + awaitUntil(10, 100, + () -> output.getOut().substring(beforeRelease).contains("Failed to renew lease, lose leadership")); + + // the other candidate still tries to become leader + awaitUntil(10, 100, + () -> output.getOut() + .substring(beforeRelease) + .contains("Lock is held by " + follower + " and has not yet expired")); + + /* + * we simulate the renewal failure one more time. we know that leader = 'follower' + */ + int failAgain = output.getOut().length(); + failLeaderRenewal(follower, one, two); + + awaitUntil(10, 100, + () -> output.getOut().substring(failAgain).contains("id : " + leader + " is the new leader")); + // the other candidate still tries to become leader + awaitUntil(10, 100, + () -> output.getOut() + .substring(beforeRelease) + .contains("Lock is held by " + leader + " and has not yet expired")); + } + + private LeaderElectionConfig leaderElectionConfig(String holderIdentity) { + + Lock lock = leaseLock(holderIdentity); + + LeaderElectionConfig leaderElectionConfig = new LeaderElectionConfig(); + leaderElectionConfig.setLock(lock); + leaderElectionConfig.setLeaseDuration(PROPERTIES.leaseDuration()); + leaderElectionConfig.setRenewDeadline(PROPERTIES.renewDeadline()); + leaderElectionConfig.setRetryPeriod(PROPERTIES.retryPeriod()); + + return leaderElectionConfig; + } + + private LeaseLock leaseLock(String holderIdentity) { + return new LeaseLock("default", LEASE_NAME, holderIdentity, apiClient); + } + + private KubernetesClientLeaderElectionCallbacks callbacks(String holderIdentity) { + KubernetesClientLeaderElectionCallbacksAutoConfiguration configuration = new KubernetesClientLeaderElectionCallbacksAutoConfiguration(); + + Runnable onStartLeadingCallback = configuration.onStartLeadingCallback(null, holderIdentity, PROPERTIES); + Runnable onStopLeadingCallback = configuration.onStopLeadingCallback(null, holderIdentity, PROPERTIES); + Consumer onNewLeaderCallback = configuration.onNewLeaderCallback(null, PROPERTIES); + + return new KubernetesClientLeaderElectionCallbacks(onStartLeadingCallback, onStopLeadingCallback, + onNewLeaderCallback); + } + + private LeaderAndFollower leaderAndFollower(LeaderElectionConfig leaderElectionConfig) { + + boolean oneIsLeader; + try { + oneIsLeader = leaderElectionConfig.getLock().get().getHolderIdentity().equals(CANDIDATE_IDENTITY_ONE); + } + catch (ApiException e) { + throw new RuntimeException(e); + } + + if (oneIsLeader) { + return new LeaderAndFollower("one", "two"); + } + else { + return new LeaderAndFollower("two", "one"); + } + } + + private void failLeaderRenewal(String currentLeader, KubernetesClientLeaderElectionInitiator one, + KubernetesClientLeaderElectionInitiator two) { + if (currentLeader.equals("one")) { + one.leaderElector().close(); + } + else { + two.leaderElector().close(); + } + } + + private record LeaderAndFollower(String leader, String follower) { + + } + +} diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionIsLostAndRestartedIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionIsLostAndRestartedIT.java index d2bfaadb11..46fc18e1c9 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionIsLostAndRestartedIT.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionIsLostAndRestartedIT.java @@ -63,61 +63,37 @@ void test(CapturedOutput output) { lease.getSpec().setHolderIdentity("leader-lost-then-recovers-it-is-not-the-leader-anymore"); updateLease(lease); - // // 9. leader has changed - // awaitUntil(10, 20, () -> output.getOut() - // .contains("Leader changed from " + NAME + " to - // leader-lost-then-recovers-it-is-not-the-leader-anymore")); - // - // // 10. our onNewLeaderCallback is triggered - // awaitUntil(10, 20, - // () -> - // output.getOut().contains("leader-lost-then-recovers-it-is-not-the-leader-anymore - // is the new leader")); - // - // // 11. our onStopLeading callback is triggered - // awaitUntil(10, 20, () -> output.getOut().contains(NAME + " stopped being a - // leader")); - // - // // 12. we gave up on leadership, so we will re-start the process - // awaitUntil(10, 20, () -> output.getOut() - // .contains("leaderFuture finished normally, will re-start it for : " + NAME)); - // - // int leadershipFinished = output.getOut() - // .indexOf("leaderFuture finished normally, will re-start it for : " + NAME); - // - // afterLeadershipRestart(output, leadershipFinished); + // 9. we lost leadership + awaitUntil(10, 100, () -> output.getOut().contains("Failed to renew lease, lose leadership")); + + // 10. callback confirms we lost leadership + awaitUntil(10, 100, () -> output.getOut().contains("id : " + NAME + " stopped being a leader")); + + // 11. leader has changed + awaitUntil(10, 20, () -> output.getOut() + .contains( + "LeaderElection lock is currently held by leader-lost-then-recovers-it-is-not-the-leader-anymore")); + + // 12. from our callback + awaitUntil(10, 100, () -> output.getOut() + .contains("id : leader-lost-then-recovers-it-is-not-the-leader-anymore is the new leader")); + + // 13. leadership is restarted for us + awaitUntil(10, 100, () -> output.getOut().contains("will re-start leader election for : " + NAME)); + + int leadershipFinished = output.getOut().indexOf("will re-start leader election for : " + NAME); + afterLeadershipRestart(output, leadershipFinished); } private void afterLeadershipRestart(CapturedOutput output, int leadershipFinished) { - // 13. once we start leadership again, we try to acquire the new lock - awaitUntil(10, 20, - () -> output.getOut() - .substring(leadershipFinished) - .contains("Attempting to acquire leader lease 'LeaseLock: " - + "default - spring-k8s-leader-election-lock (" + NAME + ")")); - - // 14. we can not acquire the new lock, since it did not yet expire - // (the new leader is not going to renew it since it's an artificial leader) - awaitUntil(10, 20, - () -> output.getOut() - .substring(leadershipFinished) - .contains("Failed to acquire lease 'LeaseLock: " + "default - spring-k8s-leader-election-lock (" - + NAME + ")' retrying...")); - - // 15. leader is again us - awaitUntil(10, 500, - () -> output.getOut() - .substring(leadershipFinished) - .contains("Leader changed from leader-lost-then-recovers-it-is-not-the-leader-anymore to " + NAME)); + // 14. since the new leader is artificial, renew is not going to happen for it + awaitUntil(60, 100, () -> output.getOut().substring(leadershipFinished).contains(NAME + " is the new leader")); // 16. callback is again triggered - awaitUntil(10, 500, - () -> output.getOut().substring(leadershipFinished).contains("id : " + NAME + " is the new leader")); + awaitUntil(10, 100, () -> output.getOut().substring(leadershipFinished).contains(NAME + " is now a leader")); - // 17. the other callback is triggered also - awaitUntil(10, 500, () -> output.getOut().substring(leadershipFinished).contains(NAME + " is now a leader")); } } diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/resources/logback-test.xml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..33cd90c3b9 --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/resources/logback-test.xml @@ -0,0 +1,17 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n + + + + + + + + + + + + + From e6fb0b017e7bd9cc961635b2d2303ac914cece8b Mon Sep 17 00:00:00 2001 From: wind57 Date: Sun, 28 Dec 2025 20:40:07 +0200 Subject: [PATCH 10/15] documentation adjusted Signed-off-by: wind57 --- docs/modules/ROOT/pages/leader-election.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/ROOT/pages/leader-election.adoc b/docs/modules/ROOT/pages/leader-election.adoc index 58f3bf3b47..3e497775e2 100644 --- a/docs/modules/ROOT/pages/leader-election.adoc +++ b/docs/modules/ROOT/pages/leader-election.adoc @@ -53,7 +53,7 @@ management.info.leader.enabled=false ''' -There is another way you can configure leader election, and it comes with native support in the fabric8 library (k8s native client support is not yet implemented). In the long run, this will be the default way to configure leader election, while the previous one will be dropped. You can treat this one much like the JDK's "preview" features. +There is another way you can configure leader election, and it comes with native support in both fabric8 and kubernetes client. In the long run, this will be the default way to configure leader election, while the previous one will be dropped. You can treat this one much like the JDK's "preview" features. To be able to use it, you need to set the property: From 50e77a8d0649ac5d79a76c699771f73323a105be Mon Sep 17 00:00:00 2001 From: wind57 Date: Sun, 28 Dec 2025 21:03:33 +0200 Subject: [PATCH 11/15] remove logback test file Signed-off-by: wind57 --- .../src/test/resources/logback-test.xml | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/resources/logback-test.xml diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/resources/logback-test.xml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/resources/logback-test.xml deleted file mode 100644 index 33cd90c3b9..0000000000 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/resources/logback-test.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n - - - - - - - - - - - - - From e4a976df966525fd03a0e11b64ad738f0cc31501 Mon Sep 17 00:00:00 2001 From: wind57 Date: Sun, 28 Dec 2025 21:26:49 +0200 Subject: [PATCH 12/15] revert file Signed-off-by: wind57 --- .../src/test/resources/logback-test.xml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/resources/logback-test.xml diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/resources/logback-test.xml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..33cd90c3b9 --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/resources/logback-test.xml @@ -0,0 +1,17 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n + + + + + + + + + + + + + From ae92f089beaa7fc8f39127e7ed80e0b3ad3db0d9 Mon Sep 17 00:00:00 2001 From: wind57 Date: Tue, 30 Dec 2025 04:12:02 +0200 Subject: [PATCH 13/15] started review comments from copilot Signed-off-by: wind57 --- ...bernetesClientLeaderElectionInitiator.java | 4 ++-- ...erElectionInfoContributorIsLeaderTest.java | 11 +++++----- ...lectionInfoContributorIsNotLeaderTest.java | 22 +++++++++++++++++-- ...ientLeaderElectionReadinessCanceledIT.java | 2 +- ...sClientLeaderElectionReadinessFailsIT.java | 9 +++----- 5 files changed, 32 insertions(+), 16 deletions(-) diff --git a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInitiator.java b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInitiator.java index 35030bff57..9abd7f86a8 100644 --- a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInitiator.java +++ b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInitiator.java @@ -75,7 +75,7 @@ final class KubernetesClientLeaderElectionInitiator { this.callbacks = callbacks; this.podReadyWaitingExecutor = newSingleThreadExecutor( - runnable -> new Thread(runnable, "Fabric8LeaderElectionInitiator-" + candidateIdentity)); + runnable -> new Thread(runnable, "KubernetesClientLeaderElectionInitiator-" + candidateIdentity)); this.podReadyRunner = new PodReadyRunner(candidateIdentity, candidateNamespace); } @@ -150,7 +150,7 @@ void preDestroy() { private void startLeaderElection() { - LOG.info(() -> "starting leader initiator : " + candidateIdentity); + LOG.info(() -> "starting leader election : " + candidateIdentity); boolean failedDuringStartup = false; leaderElector = new LeaderElector(leaderElectionConfig); diff --git a/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsLeaderTest.java b/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsLeaderTest.java index 839c27d85c..5c72345233 100644 --- a/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsLeaderTest.java +++ b/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsLeaderTest.java @@ -42,7 +42,8 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { "spring.main.cloud-platform=KUBERNETES", "management.endpoints.web.exposure.include=info", "management.endpoint.info.show-details=always", "spring.cloud.kubernetes.leader.election.enabled=true", - "spring.main.allow-bean-definition-overriding=true" }, + "spring.main.allow-bean-definition-overriding=true", + "spring.cloud.kubernetes.leader.election.wait-for-pod-ready=false" }, classes = { KubernetesClientLeaderElectionTestApp.class, KubernetesClientLeaderElectionUtil.ApiClientConfiguration.class }) @AutoConfigureWebTestClient @@ -61,7 +62,7 @@ class KubernetesClientLeaderElectionInfoContributorIsLeaderTest { @BeforeAll static void beforeAll() { leaderUtilsMockedStatic = Mockito.mockStatic(LeaderUtils.class); - leaderUtilsMockedStatic.when(LeaderUtils::hostName).thenReturn("non-" + HOLDER_IDENTITY); + leaderUtilsMockedStatic.when(LeaderUtils::hostName).thenReturn(HOLDER_IDENTITY); wireMockServer = wireMockServer(); } @@ -77,7 +78,7 @@ void afterEach() { } @Test - void infoEndpointIsNotLeaderTest() { + void infoEndpointIsLeaderTest() { webClient.get() .uri("http://localhost:{port}/actuator/info", port) .accept(MediaType.APPLICATION_JSON) @@ -86,9 +87,9 @@ void infoEndpointIsNotLeaderTest() { .isOk() .expectBody() .jsonPath("leaderElection.isLeader") - .isEqualTo(false) + .isEqualTo(true) .jsonPath("leaderElection.leaderId") - .isEqualTo("non-" + HOLDER_IDENTITY); + .isEqualTo(HOLDER_IDENTITY); } } diff --git a/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsNotLeaderTest.java b/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsNotLeaderTest.java index ed483dad66..8b3b41f4b4 100644 --- a/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsNotLeaderTest.java +++ b/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsNotLeaderTest.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -29,6 +30,7 @@ import org.springframework.boot.test.web.server.LocalManagementPort; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.cloud.kubernetes.commons.leader.LeaderUtils; +import org.springframework.http.MediaType; import org.springframework.test.web.reactive.server.WebTestClient; import static org.springframework.cloud.kubernetes.client.leader.election.KubernetesClientLeaderElectionUtil.HOLDER_IDENTITY; @@ -40,7 +42,8 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { "spring.main.cloud-platform=KUBERNETES", "management.endpoints.web.exposure.include=info", "management.endpoint.info.show-details=always", "spring.cloud.kubernetes.leader.election.enabled=true", - "spring.main.allow-bean-definition-overriding=true" }, + "spring.main.allow-bean-definition-overriding=true", + "spring.cloud.kubernetes.leader.election.wait-for-pod-ready=false" }, classes = { KubernetesClientLeaderElectionTestApp.class, KubernetesClientLeaderElectionUtil.ApiClientConfiguration.class }) @AutoConfigureWebTestClient @@ -59,7 +62,7 @@ class KubernetesClientLeaderElectionInfoContributorIsNotLeaderTest { @BeforeAll static void beforeAll() { leaderUtilsMockedStatic = Mockito.mockStatic(LeaderUtils.class); - leaderUtilsMockedStatic.when(LeaderUtils::hostName).thenReturn(HOLDER_IDENTITY); + leaderUtilsMockedStatic.when(LeaderUtils::hostName).thenReturn("non-" + HOLDER_IDENTITY); wireMockServer = wireMockServer(); } @@ -74,4 +77,19 @@ void afterEach() { WireMock.reset(); } + @Test + void infoEndpointIsNotLeaderTest() { + webClient.get() + .uri("http://localhost:{port}/actuator/info", port) + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("leaderElection.isLeader") + .isEqualTo(false) + .jsonPath("leaderElection.leaderId") + .isEqualTo("non-" + HOLDER_IDENTITY); + } + } diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessCanceledIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessCanceledIT.java index 2feadf94a0..1e8c19e143 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessCanceledIT.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessCanceledIT.java @@ -80,7 +80,7 @@ void test(CapturedOutput output) { initiator.preDestroy(); // 6. leader election is not started, since readiness does not finish - assertThat(output.getOut()).doesNotContain("starting leader initiator :" + NAME); + assertThat(output.getOut()).doesNotContain("starting leader initiator : " + NAME); } diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessFailsIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessFailsIT.java index 6a01152195..82dc925551 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessFailsIT.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessFailsIT.java @@ -82,16 +82,13 @@ void test(CapturedOutput output) { // 6. readiness fails assertThat(output.getOut()).contains("exception waiting for pod : " + NAME); - // 7. readiness failed - assertThat(output.getOut()).contains("pod readiness for : " + NAME + " failed with : readiness fails"); - - // 8. we shut down the executor + // 7. we shut down the executor assertThat(output.getOut()).contains("canceling scheduled future because readiness failed"); - // 9. leader election did not even start properly + // 8. leader election did not even start properly assertThat(output.getOut()).contains("pod readiness for : " + NAME + " failed with : readiness fails"); - // 10. executor is shutdown, even when readiness failed + // 9. executor is shutdown, even when readiness failed awaitUntil(60, 100, () -> output.getOut().contains("readiness failed for : " + NAME + ", leader election will not start")); From 62057140eec9198cee3a1e2257a1fa139d222e76 Mon Sep 17 00:00:00 2001 From: wind57 Date: Tue, 30 Dec 2025 04:22:34 +0200 Subject: [PATCH 14/15] review comments from copilot Signed-off-by: wind57 --- ...bernetesClientLeaderElectionInfoContributorIsLeaderTest.java | 2 ++ ...netesClientLeaderElectionInfoContributorIsNotLeaderTest.java | 2 ++ .../election/K8sClientLeaderElectionReadinessCanceledIT.java | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsLeaderTest.java b/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsLeaderTest.java index 5c72345233..b0261df998 100644 --- a/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsLeaderTest.java +++ b/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsLeaderTest.java @@ -31,6 +31,7 @@ import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.cloud.kubernetes.commons.leader.LeaderUtils; import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.web.reactive.server.WebTestClient; import static org.springframework.cloud.kubernetes.client.leader.election.KubernetesClientLeaderElectionUtil.HOLDER_IDENTITY; @@ -47,6 +48,7 @@ classes = { KubernetesClientLeaderElectionTestApp.class, KubernetesClientLeaderElectionUtil.ApiClientConfiguration.class }) @AutoConfigureWebTestClient +@DirtiesContext class KubernetesClientLeaderElectionInfoContributorIsLeaderTest { @LocalManagementPort diff --git a/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsNotLeaderTest.java b/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsNotLeaderTest.java index 8b3b41f4b4..4035b3e536 100644 --- a/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsNotLeaderTest.java +++ b/spring-cloud-kubernetes-client-leader/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionInfoContributorIsNotLeaderTest.java @@ -31,6 +31,7 @@ import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.cloud.kubernetes.commons.leader.LeaderUtils; import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.web.reactive.server.WebTestClient; import static org.springframework.cloud.kubernetes.client.leader.election.KubernetesClientLeaderElectionUtil.HOLDER_IDENTITY; @@ -47,6 +48,7 @@ classes = { KubernetesClientLeaderElectionTestApp.class, KubernetesClientLeaderElectionUtil.ApiClientConfiguration.class }) @AutoConfigureWebTestClient +@DirtiesContext class KubernetesClientLeaderElectionInfoContributorIsNotLeaderTest { @LocalManagementPort diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessCanceledIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessCanceledIT.java index 1e8c19e143..177dcec022 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessCanceledIT.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionReadinessCanceledIT.java @@ -80,7 +80,7 @@ void test(CapturedOutput output) { initiator.preDestroy(); // 6. leader election is not started, since readiness does not finish - assertThat(output.getOut()).doesNotContain("starting leader initiator : " + NAME); + assertThat(output.getOut()).doesNotContain("starting leader election : " + NAME); } From 1819a50e6b0e1fa0c6380f5fdce5146fba8264a6 Mon Sep 17 00:00:00 2001 From: wind57 Date: Wed, 31 Dec 2025 09:57:31 +0000 Subject: [PATCH 15/15] review comments Signed-off-by: wind57 --- .../KubernetesClientLeaderElectionAutoConfiguration.java | 3 ++- ...netesClientLeaderElectionCallbacksAutoConfiguration.java | 2 +- .../Fabric8LeaderElectionCallbacksAutoConfiguration.java | 2 +- spring-cloud-kubernetes-integration-tests/pom.xml | 6 +++--- .../K8sClientLeaderElectionIsLostAndRestartedIT.java | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java index ee8056442f..57377c3766 100644 --- a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java +++ b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionAutoConfiguration.java @@ -82,6 +82,7 @@ KubernetesClientLeaderElectionInitiator kubernetesClientLeaderElectionInitiator( } @Bean + @ConditionalOnMissingBean BooleanSupplier kubernetesClientPodReadySupplier(CoreV1Api coreV1Api, String candidateIdentity, String podNamespace) { return () -> { @@ -137,7 +138,7 @@ Lock kubernetesClientLeaderElectionLock(ApiClient apiClient, LeaderElectionPrope } } - // above two methods are a verbatim copy of the fabric8 implementation + // following two methods are a verbatim copy of the fabric8 implementation private static boolean isPodReady(V1Pod pod) { Objects.requireNonNull(pod, "Pod can't be null."); V1PodCondition condition = getPodReadyCondition(pod); diff --git a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacksAutoConfiguration.java b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacksAutoConfiguration.java index e7e122f1d9..02af5386e7 100644 --- a/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacksAutoConfiguration.java +++ b/spring-cloud-kubernetes-client-leader/src/main/java/org/springframework/cloud/kubernetes/client/leader/election/KubernetesClientLeaderElectionCallbacksAutoConfiguration.java @@ -42,7 +42,7 @@ @ConditionalOnCloudPlatform(CloudPlatform.KUBERNETES) @ConditionalOnLeaderElectionEnabled @AutoConfigureAfter({ KubernetesClientAutoConfiguration.class }) -final class KubernetesClientLeaderElectionCallbacksAutoConfiguration extends LeaderElectionCallbacks { +public class KubernetesClientLeaderElectionCallbacksAutoConfiguration extends LeaderElectionCallbacks { @Bean @ConditionalOnMissingBean diff --git a/spring-cloud-kubernetes-fabric8-leader/src/main/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionCallbacksAutoConfiguration.java b/spring-cloud-kubernetes-fabric8-leader/src/main/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionCallbacksAutoConfiguration.java index 14f4255985..1d0c157cc2 100644 --- a/spring-cloud-kubernetes-fabric8-leader/src/main/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionCallbacksAutoConfiguration.java +++ b/spring-cloud-kubernetes-fabric8-leader/src/main/java/org/springframework/cloud/kubernetes/fabric8/leader/election/Fabric8LeaderElectionCallbacksAutoConfiguration.java @@ -40,7 +40,7 @@ @ConditionalOnCloudPlatform(CloudPlatform.KUBERNETES) @ConditionalOnLeaderElectionEnabled @AutoConfigureAfter({ Fabric8AutoConfiguration.class, KubernetesCommonsAutoConfiguration.class }) -final class Fabric8LeaderElectionCallbacksAutoConfiguration extends LeaderElectionCallbacks { +public class Fabric8LeaderElectionCallbacksAutoConfiguration extends LeaderElectionCallbacks { @Bean @ConditionalOnMissingBean diff --git a/spring-cloud-kubernetes-integration-tests/pom.xml b/spring-cloud-kubernetes-integration-tests/pom.xml index 73ac47ca64..a1a9a6b108 100644 --- a/spring-cloud-kubernetes-integration-tests/pom.xml +++ b/spring-cloud-kubernetes-integration-tests/pom.xml @@ -108,13 +108,13 @@ spring-cloud-kubernetes-k8s-client-configuration-watcher - spring-cloud-kubernetes-k8s-client-kafka-configmap-reload + spring-cloud-kubernetes-k8s-client-kafka-configmap-reload - spring-cloud-kubernetes-k8s-client-rabbitmq-secret-reload + spring-cloud-kubernetes-k8s-client-rabbitmq-secret-reload - spring-cloud-kubernetes-fabric8-leader-election + spring-cloud-kubernetes-fabric8-leader-election spring-cloud-kubernetes-k8s-client-leader-election diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionIsLostAndRestartedIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionIsLostAndRestartedIT.java index 46fc18e1c9..c3c6e8a2f2 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionIsLostAndRestartedIT.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-leader-election/src/test/java/org/springframework/cloud/kubernetes/client/leader/election/K8sClientLeaderElectionIsLostAndRestartedIT.java @@ -91,7 +91,7 @@ private void afterLeadershipRestart(CapturedOutput output, int leadershipFinishe // 14. since the new leader is artificial, renew is not going to happen for it awaitUntil(60, 100, () -> output.getOut().substring(leadershipFinished).contains(NAME + " is the new leader")); - // 16. callback is again triggered + // 15. callback is again triggered awaitUntil(10, 100, () -> output.getOut().substring(leadershipFinished).contains(NAME + " is now a leader")); }