diff --git a/util/src/main/java/io/kubernetes/client/util/Readiness.java b/util/src/main/java/io/kubernetes/client/util/Readiness.java
new file mode 100644
index 0000000000..a22946f584
--- /dev/null
+++ b/util/src/main/java/io/kubernetes/client/util/Readiness.java
@@ -0,0 +1,506 @@
+/*
+Copyright 2024 The Kubernetes 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
+http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package io.kubernetes.client.util;
+
+import io.kubernetes.client.common.KubernetesObject;
+import io.kubernetes.client.openapi.models.V1Deployment;
+import io.kubernetes.client.openapi.models.V1DeploymentCondition;
+import io.kubernetes.client.openapi.models.V1DeploymentStatus;
+import io.kubernetes.client.openapi.models.V1Node;
+import io.kubernetes.client.openapi.models.V1NodeCondition;
+import io.kubernetes.client.openapi.models.V1NodeStatus;
+import io.kubernetes.client.openapi.models.V1Pod;
+import io.kubernetes.client.openapi.models.V1PodCondition;
+import io.kubernetes.client.openapi.models.V1PodStatus;
+import io.kubernetes.client.openapi.models.V1ReplicaSet;
+import io.kubernetes.client.openapi.models.V1ReplicaSetStatus;
+import io.kubernetes.client.openapi.models.V1StatefulSet;
+import io.kubernetes.client.openapi.models.V1StatefulSetStatus;
+import io.kubernetes.client.openapi.models.V1DaemonSet;
+import io.kubernetes.client.openapi.models.V1DaemonSetStatus;
+import io.kubernetes.client.openapi.models.V1Job;
+import io.kubernetes.client.openapi.models.V1JobCondition;
+import io.kubernetes.client.openapi.models.V1JobStatus;
+import io.kubernetes.client.openapi.models.V1Service;
+import io.kubernetes.client.openapi.models.V1ReplicationController;
+import io.kubernetes.client.openapi.models.V1ReplicationControllerStatus;
+import io.kubernetes.client.openapi.models.V1PersistentVolumeClaim;
+import io.kubernetes.client.openapi.models.V1PersistentVolumeClaimStatus;
+import io.kubernetes.client.openapi.models.V1Endpoints;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Utilities for checking the readiness of various Kubernetes resources.
+ * Provides fabric8-style readiness checks for common resource types.
+ *
+ *
Example usage:
+ *
{@code
+ * V1Pod pod = coreV1Api.readNamespacedPod("my-pod", "default").execute();
+ * if (Readiness.isPodReady(pod)) {
+ * // Pod is ready
+ * }
+ *
+ * // Or use generic method:
+ * if (Readiness.isReady(pod)) {
+ * // Resource is ready
+ * }
+ * }
+ */
+public class Readiness {
+
+ private Readiness() {
+ // Utility class
+ }
+
+ /**
+ * Checks if a Kubernetes resource is ready.
+ * Supports Pod, Deployment, ReplicaSet, StatefulSet, DaemonSet, Job, Node,
+ * ReplicationController, PersistentVolumeClaim, Endpoints, and Service.
+ *
+ * @param resource the Kubernetes resource to check
+ * @return true if the resource is ready, false otherwise
+ */
+ public static boolean isReady(KubernetesObject resource) {
+ if (resource == null) {
+ return false;
+ }
+
+ if (resource instanceof V1Pod) {
+ return isPodReady((V1Pod) resource);
+ } else if (resource instanceof V1Deployment) {
+ return isDeploymentReady((V1Deployment) resource);
+ } else if (resource instanceof V1ReplicaSet) {
+ return isReplicaSetReady((V1ReplicaSet) resource);
+ } else if (resource instanceof V1StatefulSet) {
+ return isStatefulSetReady((V1StatefulSet) resource);
+ } else if (resource instanceof V1DaemonSet) {
+ return isDaemonSetReady((V1DaemonSet) resource);
+ } else if (resource instanceof V1Job) {
+ return isJobComplete((V1Job) resource);
+ } else if (resource instanceof V1Node) {
+ return isNodeReady((V1Node) resource);
+ } else if (resource instanceof V1ReplicationController) {
+ return isReplicationControllerReady((V1ReplicationController) resource);
+ } else if (resource instanceof V1PersistentVolumeClaim) {
+ return isPersistentVolumeClaimBound((V1PersistentVolumeClaim) resource);
+ } else if (resource instanceof V1Endpoints) {
+ return isEndpointsReady((V1Endpoints) resource);
+ } else if (resource instanceof V1Service) {
+ // Services are considered ready if they exist
+ return true;
+ }
+
+ // For unknown types, consider them ready if they exist
+ return true;
+ }
+
+ /**
+ * Checks if a Pod is ready.
+ * A Pod is considered ready if all containers are ready and the Pod
+ * has the "Ready" condition set to "True".
+ *
+ * @param pod the Pod to check
+ * @return true if the Pod is ready, false otherwise
+ */
+ public static boolean isPodReady(V1Pod pod) {
+ if (pod == null) {
+ return false;
+ }
+
+ V1PodStatus status = pod.getStatus();
+ if (status == null) {
+ return false;
+ }
+
+ // Check for Running phase
+ String phase = status.getPhase();
+ if (!"Running".equals(phase)) {
+ return false;
+ }
+
+ // Check Ready condition
+ List conditions = status.getConditions();
+ if (conditions == null || conditions.isEmpty()) {
+ return false;
+ }
+
+ return conditions.stream()
+ .filter(c -> "Ready".equals(c.getType()))
+ .findFirst()
+ .map(c -> "True".equals(c.getStatus()))
+ .orElse(false);
+ }
+
+ /**
+ * Checks if a Deployment is ready.
+ * A Deployment is considered ready when the number of available replicas
+ * equals the desired number of replicas.
+ *
+ * @param deployment the Deployment to check
+ * @return true if the Deployment is ready, false otherwise
+ */
+ public static boolean isDeploymentReady(V1Deployment deployment) {
+ if (deployment == null) {
+ return false;
+ }
+
+ V1DeploymentStatus status = deployment.getStatus();
+ if (status == null) {
+ return false;
+ }
+
+ // Check Available condition
+ List conditions = status.getConditions();
+ if (conditions != null) {
+ boolean available = conditions.stream()
+ .filter(c -> "Available".equals(c.getType()))
+ .anyMatch(c -> "True".equals(c.getStatus()));
+ if (!available) {
+ return false;
+ }
+ }
+
+ Integer replicas = deployment.getSpec() != null ? deployment.getSpec().getReplicas() : null;
+ Integer readyReplicas = status.getReadyReplicas();
+ Integer availableReplicas = status.getAvailableReplicas();
+
+ if (replicas == null) {
+ replicas = 1; // Default replicas is 1
+ }
+
+ return Objects.equals(replicas, readyReplicas) &&
+ Objects.equals(replicas, availableReplicas);
+ }
+
+ /**
+ * Checks if a ReplicaSet is ready.
+ * A ReplicaSet is considered ready when the number of ready replicas
+ * equals the desired number of replicas.
+ *
+ * @param replicaSet the ReplicaSet to check
+ * @return true if the ReplicaSet is ready, false otherwise
+ */
+ public static boolean isReplicaSetReady(V1ReplicaSet replicaSet) {
+ if (replicaSet == null) {
+ return false;
+ }
+
+ V1ReplicaSetStatus status = replicaSet.getStatus();
+ if (status == null) {
+ return false;
+ }
+
+ Integer replicas = replicaSet.getSpec() != null ? replicaSet.getSpec().getReplicas() : null;
+ Integer readyReplicas = status.getReadyReplicas();
+
+ if (replicas == null) {
+ replicas = 1;
+ }
+ if (readyReplicas == null) {
+ readyReplicas = 0;
+ }
+
+ return replicas.equals(readyReplicas);
+ }
+
+ /**
+ * Checks if a StatefulSet is ready.
+ * A StatefulSet is considered ready when the number of ready replicas
+ * equals the desired number of replicas.
+ *
+ * @param statefulSet the StatefulSet to check
+ * @return true if the StatefulSet is ready, false otherwise
+ */
+ public static boolean isStatefulSetReady(V1StatefulSet statefulSet) {
+ if (statefulSet == null) {
+ return false;
+ }
+
+ V1StatefulSetStatus status = statefulSet.getStatus();
+ if (status == null) {
+ return false;
+ }
+
+ Integer replicas = statefulSet.getSpec() != null ? statefulSet.getSpec().getReplicas() : null;
+ Integer readyReplicas = status.getReadyReplicas();
+
+ if (replicas == null) {
+ replicas = 1;
+ }
+ if (readyReplicas == null) {
+ readyReplicas = 0;
+ }
+
+ return replicas.equals(readyReplicas);
+ }
+
+ /**
+ * Checks if a DaemonSet is ready.
+ * A DaemonSet is considered ready when the number of ready nodes
+ * equals the desired number of scheduled nodes.
+ *
+ * @param daemonSet the DaemonSet to check
+ * @return true if the DaemonSet is ready, false otherwise
+ */
+ public static boolean isDaemonSetReady(V1DaemonSet daemonSet) {
+ if (daemonSet == null) {
+ return false;
+ }
+
+ V1DaemonSetStatus status = daemonSet.getStatus();
+ if (status == null) {
+ return false;
+ }
+
+ Integer desiredNumberScheduled = status.getDesiredNumberScheduled();
+ Integer numberReady = status.getNumberReady();
+
+ if (desiredNumberScheduled == null) {
+ desiredNumberScheduled = 0;
+ }
+ if (numberReady == null) {
+ numberReady = 0;
+ }
+
+ return desiredNumberScheduled > 0 && desiredNumberScheduled.equals(numberReady);
+ }
+
+ /**
+ * Checks if a Job has completed successfully.
+ * A Job is considered complete when it has a "Complete" condition set to "True".
+ *
+ * @param job the Job to check
+ * @return true if the Job is complete, false otherwise
+ */
+ public static boolean isJobComplete(V1Job job) {
+ if (job == null) {
+ return false;
+ }
+
+ V1JobStatus status = job.getStatus();
+ if (status == null) {
+ return false;
+ }
+
+ List conditions = status.getConditions();
+ if (conditions == null || conditions.isEmpty()) {
+ return false;
+ }
+
+ return conditions.stream()
+ .filter(c -> "Complete".equals(c.getType()))
+ .anyMatch(c -> "True".equals(c.getStatus()));
+ }
+
+ /**
+ * Checks if a Job has failed.
+ *
+ * @param job the Job to check
+ * @return true if the Job has failed, false otherwise
+ */
+ public static boolean isJobFailed(V1Job job) {
+ if (job == null) {
+ return false;
+ }
+
+ V1JobStatus status = job.getStatus();
+ if (status == null) {
+ return false;
+ }
+
+ List conditions = status.getConditions();
+ if (conditions == null || conditions.isEmpty()) {
+ return false;
+ }
+
+ return conditions.stream()
+ .filter(c -> "Failed".equals(c.getType()))
+ .anyMatch(c -> "True".equals(c.getStatus()));
+ }
+
+ /**
+ * Checks if a Node is ready.
+ * A Node is considered ready when it has the "Ready" condition set to "True".
+ *
+ * @param node the Node to check
+ * @return true if the Node is ready, false otherwise
+ */
+ public static boolean isNodeReady(V1Node node) {
+ if (node == null) {
+ return false;
+ }
+
+ V1NodeStatus status = node.getStatus();
+ if (status == null) {
+ return false;
+ }
+
+ List conditions = status.getConditions();
+ if (conditions == null || conditions.isEmpty()) {
+ return false;
+ }
+
+ return conditions.stream()
+ .filter(c -> "Ready".equals(c.getType()))
+ .anyMatch(c -> "True".equals(c.getStatus()));
+ }
+
+ /**
+ * Checks if a ReplicationController is ready.
+ * A ReplicationController is considered ready when the number of ready replicas
+ * equals the desired number of replicas.
+ *
+ * @param replicationController the ReplicationController to check
+ * @return true if the ReplicationController is ready, false otherwise
+ */
+ public static boolean isReplicationControllerReady(V1ReplicationController replicationController) {
+ if (replicationController == null) {
+ return false;
+ }
+
+ V1ReplicationControllerStatus status = replicationController.getStatus();
+ if (status == null) {
+ return false;
+ }
+
+ Integer replicas = replicationController.getSpec() != null
+ ? replicationController.getSpec().getReplicas() : null;
+ Integer readyReplicas = status.getReadyReplicas();
+
+ if (replicas == null) {
+ replicas = 1;
+ }
+ if (readyReplicas == null) {
+ readyReplicas = 0;
+ }
+
+ return replicas.equals(readyReplicas);
+ }
+
+ /**
+ * Checks if a PersistentVolumeClaim is bound.
+ *
+ * @param pvc the PersistentVolumeClaim to check
+ * @return true if the PVC is bound, false otherwise
+ */
+ public static boolean isPersistentVolumeClaimBound(V1PersistentVolumeClaim pvc) {
+ if (pvc == null) {
+ return false;
+ }
+
+ V1PersistentVolumeClaimStatus status = pvc.getStatus();
+ if (status == null) {
+ return false;
+ }
+
+ return "Bound".equals(status.getPhase());
+ }
+
+ /**
+ * Checks if Endpoints are ready (have at least one address).
+ *
+ * @param endpoints the Endpoints to check
+ * @return true if the Endpoints have at least one address, false otherwise
+ */
+ public static boolean isEndpointsReady(V1Endpoints endpoints) {
+ if (endpoints == null) {
+ return false;
+ }
+
+ if (endpoints.getSubsets() == null || endpoints.getSubsets().isEmpty()) {
+ return false;
+ }
+
+ return endpoints.getSubsets().stream()
+ .anyMatch(subset -> subset.getAddresses() != null && !subset.getAddresses().isEmpty());
+ }
+
+ /**
+ * Checks if a Pod is in a terminal state (Succeeded or Failed).
+ *
+ * @param pod the Pod to check
+ * @return true if the Pod is in a terminal state, false otherwise
+ */
+ public static boolean isPodTerminal(V1Pod pod) {
+ if (pod == null) {
+ return false;
+ }
+
+ V1PodStatus status = pod.getStatus();
+ if (status == null) {
+ return false;
+ }
+
+ String phase = status.getPhase();
+ return "Succeeded".equals(phase) || "Failed".equals(phase);
+ }
+
+ /**
+ * Checks if a Pod has succeeded.
+ *
+ * @param pod the Pod to check
+ * @return true if the Pod has succeeded, false otherwise
+ */
+ public static boolean isPodSucceeded(V1Pod pod) {
+ if (pod == null) {
+ return false;
+ }
+
+ V1PodStatus status = pod.getStatus();
+ if (status == null) {
+ return false;
+ }
+
+ return "Succeeded".equals(status.getPhase());
+ }
+
+ /**
+ * Checks if a Pod has failed.
+ *
+ * @param pod the Pod to check
+ * @return true if the Pod has failed, false otherwise
+ */
+ public static boolean isPodFailed(V1Pod pod) {
+ if (pod == null) {
+ return false;
+ }
+
+ V1PodStatus status = pod.getStatus();
+ if (status == null) {
+ return false;
+ }
+
+ return "Failed".equals(status.getPhase());
+ }
+
+ /**
+ * Checks if a Pod is running.
+ *
+ * @param pod the Pod to check
+ * @return true if the Pod is running, false otherwise
+ */
+ public static boolean isPodRunning(V1Pod pod) {
+ if (pod == null) {
+ return false;
+ }
+
+ V1PodStatus status = pod.getStatus();
+ if (status == null) {
+ return false;
+ }
+
+ return "Running".equals(status.getPhase());
+ }
+}
diff --git a/util/src/main/java/io/kubernetes/client/util/ResourceClient.java b/util/src/main/java/io/kubernetes/client/util/ResourceClient.java
new file mode 100644
index 0000000000..25a268ad0a
--- /dev/null
+++ b/util/src/main/java/io/kubernetes/client/util/ResourceClient.java
@@ -0,0 +1,612 @@
+/*
+Copyright 2024 The Kubernetes 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
+http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package io.kubernetes.client.util;
+
+import io.kubernetes.client.common.KubernetesListObject;
+import io.kubernetes.client.common.KubernetesObject;
+import io.kubernetes.client.custom.V1Patch;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.openapi.models.V1DeleteOptions;
+import io.kubernetes.client.util.generic.GenericKubernetesApi;
+import io.kubernetes.client.util.generic.KubernetesApiResponse;
+import io.kubernetes.client.util.generic.options.CreateOptions;
+import io.kubernetes.client.util.generic.options.DeleteOptions;
+import io.kubernetes.client.util.generic.options.GetOptions;
+import io.kubernetes.client.util.generic.options.ListOptions;
+import io.kubernetes.client.util.generic.options.PatchOptions;
+import io.kubernetes.client.util.generic.options.UpdateOptions;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.function.UnaryOperator;
+
+/**
+ * Fluent DSL for Kubernetes resource operations, similar to fabric8 Kubernetes client.
+ * Provides a chainable, intuitive API for CRUD operations on Kubernetes resources.
+ *
+ *
Example usage:
+ *
{@code
+ * // Create a client for pods
+ * ResourceClient pods =
+ * ResourceClient.create(apiClient, V1Pod.class, V1PodList.class, "", "v1", "pods");
+ *
+ * // Get a pod in a namespace
+ * V1Pod pod = pods.inNamespace("default").withName("my-pod").get();
+ *
+ * // List all pods with a label
+ * V1PodList podList = pods.inNamespace("default")
+ * .withLabel("app", "myapp")
+ * .list();
+ *
+ * // Create or replace a pod
+ * V1Pod created = pods.inNamespace("default").createOrReplace(myPod);
+ *
+ * // Delete a pod
+ * pods.inNamespace("default").withName("my-pod").delete();
+ *
+ * // Wait until ready
+ * V1Pod ready = pods.inNamespace("default")
+ * .withName("my-pod")
+ * .waitUntilReady(Duration.ofMinutes(5));
+ *
+ * // Edit a resource
+ * V1Pod edited = pods.inNamespace("default")
+ * .withName("my-pod")
+ * .edit(pod -> {
+ * pod.getMetadata().getLabels().put("newlabel", "value");
+ * return pod;
+ * });
+ * }
+ *
+ * @param the Kubernetes resource type
+ * @param the Kubernetes resource list type
+ */
+public class ResourceClient {
+
+ private final GenericKubernetesApi api;
+ private final ApiClient apiClient;
+ private final Class apiTypeClass;
+ private String namespace;
+ private String name;
+ private String labelSelector;
+ private String fieldSelector;
+
+ private ResourceClient(
+ ApiClient apiClient,
+ GenericKubernetesApi api,
+ Class apiTypeClass) {
+ this.apiClient = Objects.requireNonNull(apiClient, "ApiClient must not be null");
+ this.api = Objects.requireNonNull(api, "GenericKubernetesApi must not be null");
+ this.apiTypeClass = Objects.requireNonNull(apiTypeClass, "apiTypeClass must not be null");
+ }
+
+ /**
+ * Create a ResourceClient for a specific resource type.
+ *
+ * @param the resource type
+ * @param the resource list type
+ * @param apiClient the API client
+ * @param apiTypeClass the class of the resource type
+ * @param apiListTypeClass the class of the resource list type
+ * @param group the API group (empty string for core API)
+ * @param version the API version
+ * @param plural the plural resource name
+ * @return a new ResourceClient
+ */
+ public static
+ ResourceClient create(
+ ApiClient apiClient,
+ Class apiTypeClass,
+ Class apiListTypeClass,
+ String group,
+ String version,
+ String plural) {
+ GenericKubernetesApi api =
+ new GenericKubernetesApi<>(apiTypeClass, apiListTypeClass, group, version, plural, apiClient);
+ return new ResourceClient<>(apiClient, api, apiTypeClass);
+ }
+
+ /**
+ * Create a ResourceClient using an existing GenericKubernetesApi.
+ *
+ * @param the resource type
+ * @param the resource list type
+ * @param apiClient the API client
+ * @param api the GenericKubernetesApi
+ * @param apiTypeClass the class of the resource type
+ * @return a new ResourceClient
+ */
+ public static
+ ResourceClient create(
+ ApiClient apiClient,
+ GenericKubernetesApi api,
+ Class apiTypeClass) {
+ return new ResourceClient<>(apiClient, api, apiTypeClass);
+ }
+
+ /**
+ * Get the underlying GenericKubernetesApi.
+ *
+ * @return the GenericKubernetesApi
+ */
+ public GenericKubernetesApi getApi() {
+ return api;
+ }
+
+ /**
+ * Specify the namespace to operate in.
+ *
+ * @param namespace the namespace
+ * @return this client for chaining
+ */
+ public ResourceClient inNamespace(String namespace) {
+ ResourceClient copy = copy();
+ copy.namespace = namespace;
+ return copy;
+ }
+
+ /**
+ * Specify the resource name to operate on.
+ *
+ * @param name the resource name
+ * @return this client for chaining
+ */
+ public ResourceClient withName(String name) {
+ ResourceClient copy = copy();
+ copy.name = name;
+ return copy;
+ }
+
+ /**
+ * Add a label selector for list operations.
+ *
+ * @param key the label key
+ * @param value the label value
+ * @return this client for chaining
+ */
+ public ResourceClient withLabel(String key, String value) {
+ ResourceClient copy = copy();
+ String selector = key + "=" + value;
+ copy.labelSelector = copy.labelSelector == null ? selector : copy.labelSelector + "," + selector;
+ return copy;
+ }
+
+ /**
+ * Add a label selector for list operations (label must exist).
+ *
+ * @param key the label key
+ * @return this client for chaining
+ */
+ public ResourceClient withLabel(String key) {
+ ResourceClient copy = copy();
+ copy.labelSelector = copy.labelSelector == null ? key : copy.labelSelector + "," + key;
+ return copy;
+ }
+
+ /**
+ * Set the label selector for list operations.
+ *
+ * @param labelSelector the complete label selector string
+ * @return this client for chaining
+ */
+ public ResourceClient withLabelSelector(String labelSelector) {
+ ResourceClient copy = copy();
+ copy.labelSelector = labelSelector;
+ return copy;
+ }
+
+ /**
+ * Set the field selector for list operations.
+ *
+ * @param fieldSelector the field selector string
+ * @return this client for chaining
+ */
+ public ResourceClient withFieldSelector(String fieldSelector) {
+ ResourceClient copy = copy();
+ copy.fieldSelector = fieldSelector;
+ return copy;
+ }
+
+ /**
+ * Get a resource by name.
+ *
+ * @return the resource, or null if not found
+ * @throws ApiException if an API error occurs
+ */
+ public ApiType get() throws ApiException {
+ KubernetesApiResponse response;
+ if (namespace != null) {
+ response = api.get(namespace, name, new GetOptions());
+ } else {
+ response = api.get(name, new GetOptions());
+ }
+ if (!response.isSuccess() && response.getHttpStatusCode() == 404) {
+ return null;
+ }
+ return response.throwsApiException().getObject();
+ }
+
+ /**
+ * List resources.
+ *
+ * @return the list of resources
+ * @throws ApiException if an API error occurs
+ */
+ public ApiListType list() throws ApiException {
+ ListOptions options = new ListOptions();
+ if (labelSelector != null) {
+ options.setLabelSelector(labelSelector);
+ }
+ if (fieldSelector != null) {
+ options.setFieldSelector(fieldSelector);
+ }
+
+ KubernetesApiResponse response;
+ if (namespace != null) {
+ response = api.list(namespace, options);
+ } else {
+ response = api.list(options);
+ }
+ return response.throwsApiException().getObject();
+ }
+
+ /**
+ * Create a new resource.
+ *
+ * @param resource the resource to create
+ * @return the created resource
+ * @throws ApiException if an API error occurs
+ */
+ public ApiType create(ApiType resource) throws ApiException {
+ String ns = namespace != null ? namespace : getNamespaceFromResource(resource);
+ KubernetesApiResponse response;
+ if (ns != null) {
+ response = api.create(ns, resource, new CreateOptions());
+ } else {
+ response = api.create(resource, new CreateOptions());
+ }
+ return response.throwsApiException().getObject();
+ }
+
+ /**
+ * Update an existing resource.
+ *
+ * @param resource the resource to update
+ * @return the updated resource
+ * @throws ApiException if an API error occurs
+ */
+ public ApiType update(ApiType resource) throws ApiException {
+ KubernetesApiResponse response = api.update(resource, new UpdateOptions());
+ return response.throwsApiException().getObject();
+ }
+
+ /**
+ * Create or replace a resource.
+ * If the resource exists, it will be replaced; otherwise, it will be created.
+ *
+ * @param resource the resource to create or replace
+ * @return the created or replaced resource
+ * @throws ApiException if an API error occurs
+ */
+ public ApiType createOrReplace(ApiType resource) throws ApiException {
+ String ns = namespace != null ? namespace : getNamespaceFromResource(resource);
+ String resourceName = resource.getMetadata().getName();
+
+ // Try to get existing resource
+ KubernetesApiResponse existing;
+ if (ns != null) {
+ existing = api.get(ns, resourceName, new GetOptions());
+ } else {
+ existing = api.get(resourceName, new GetOptions());
+ }
+
+ if (existing.isSuccess()) {
+ // Replace existing resource
+ resource.getMetadata().setResourceVersion(existing.getObject().getMetadata().getResourceVersion());
+ return update(resource);
+ } else {
+ // Create new resource
+ return create(resource);
+ }
+ }
+
+ /**
+ * Delete a resource.
+ *
+ * @return true if deleted, false if not found
+ * @throws ApiException if an API error occurs
+ */
+ public boolean delete() throws ApiException {
+ Objects.requireNonNull(name, "Resource name must be specified");
+ KubernetesApiResponse response;
+ DeleteOptions options = new DeleteOptions();
+ if (namespace != null) {
+ response = api.delete(namespace, name, options);
+ } else {
+ response = api.delete(name, options);
+ }
+ if (response.getHttpStatusCode() == 404) {
+ return false;
+ }
+ response.throwsApiException();
+ return true;
+ }
+
+ /**
+ * Delete a resource with options.
+ *
+ * @param deleteOptions the delete options
+ * @return true if deleted, false if not found
+ * @throws ApiException if an API error occurs
+ */
+ public boolean delete(V1DeleteOptions deleteOptions) throws ApiException {
+ Objects.requireNonNull(name, "Resource name must be specified");
+ DeleteOptions options = new DeleteOptions();
+ if (deleteOptions.getGracePeriodSeconds() != null) {
+ options.setGracePeriodSeconds(deleteOptions.getGracePeriodSeconds());
+ }
+ if (deleteOptions.getPropagationPolicy() != null) {
+ options.setPropagationPolicy(deleteOptions.getPropagationPolicy());
+ }
+ KubernetesApiResponse response;
+ if (namespace != null) {
+ response = api.delete(namespace, name, options);
+ } else {
+ response = api.delete(name, options);
+ }
+ if (response.getHttpStatusCode() == 404) {
+ return false;
+ }
+ response.throwsApiException();
+ return true;
+ }
+
+ /**
+ * Patch a resource using strategic merge patch.
+ *
+ * @param patchContent the patch content (JSON)
+ * @return the patched resource
+ * @throws ApiException if an API error occurs
+ */
+ public ApiType patch(String patchContent) throws ApiException {
+ return patch(patchContent, V1Patch.PATCH_FORMAT_STRATEGIC_MERGE_PATCH);
+ }
+
+ /**
+ * Patch a resource with a specific patch format.
+ *
+ * @param patchContent the patch content
+ * @param patchFormat the patch format (e.g., application/strategic-merge-patch+json)
+ * @return the patched resource
+ * @throws ApiException if an API error occurs
+ */
+ public ApiType patch(String patchContent, String patchFormat) throws ApiException {
+ Objects.requireNonNull(name, "Resource name must be specified");
+ V1Patch patch = new V1Patch(patchContent);
+ KubernetesApiResponse response;
+ if (namespace != null) {
+ response = api.patch(namespace, name, patchFormat, patch);
+ } else {
+ response = api.patch(name, patchFormat, patch);
+ }
+ return response.throwsApiException().getObject();
+ }
+
+ /**
+ * Apply a resource using server-side apply.
+ *
+ * @param resource the resource to apply
+ * @param fieldManager the field manager name
+ * @return the applied resource
+ * @throws ApiException if an API error occurs
+ */
+ public ApiType serverSideApply(ApiType resource, String fieldManager) throws ApiException {
+ return serverSideApply(resource, fieldManager, false);
+ }
+
+ /**
+ * Apply a resource using server-side apply.
+ *
+ * @param resource the resource to apply
+ * @param fieldManager the field manager name
+ * @param forceConflicts whether to force ownership of conflicting fields
+ * @return the applied resource
+ * @throws ApiException if an API error occurs
+ */
+ public ApiType serverSideApply(ApiType resource, String fieldManager, boolean forceConflicts)
+ throws ApiException {
+ String ns = namespace != null ? namespace : getNamespaceFromResource(resource);
+ String resourceName = resource.getMetadata().getName();
+
+ PatchOptions options = new PatchOptions();
+ options.setFieldManager(fieldManager);
+ options.setForce(forceConflicts);
+
+ String yaml = Yaml.dump(resource);
+ V1Patch patch = new V1Patch(yaml);
+
+ KubernetesApiResponse response;
+ if (ns != null) {
+ response = api.patch(ns, resourceName, V1Patch.PATCH_FORMAT_APPLY_YAML, patch, options);
+ } else {
+ response = api.patch(resourceName, V1Patch.PATCH_FORMAT_APPLY_YAML, patch, options);
+ }
+ return response.throwsApiException().getObject();
+ }
+
+ /**
+ * Edit a resource using a function.
+ *
+ * @param editor function that modifies the resource
+ * @return the updated resource
+ * @throws ApiException if an API error occurs
+ */
+ public ApiType edit(UnaryOperator editor) throws ApiException {
+ ApiType current = get();
+ if (current == null) {
+ throw new ApiException(404, "Resource not found");
+ }
+ ApiType edited = editor.apply(current);
+ return update(edited);
+ }
+
+ /**
+ * Check if a resource exists.
+ *
+ * @return true if the resource exists
+ * @throws ApiException if an API error occurs
+ */
+ public boolean exists() throws ApiException {
+ return get() != null;
+ }
+
+ /**
+ * Check if the resource is ready.
+ *
+ * @return true if the resource is ready
+ * @throws ApiException if an API error occurs
+ */
+ public boolean isReady() throws ApiException {
+ ApiType resource = get();
+ if (resource == null) {
+ return false;
+ }
+ return Readiness.isReady(resource);
+ }
+
+ /**
+ * Wait until the resource is ready.
+ *
+ * @param timeout the maximum time to wait
+ * @return the ready resource
+ * @throws ApiException if an API error occurs
+ * @throws InterruptedException if the wait is interrupted
+ * @throws TimeoutException if the timeout is exceeded
+ */
+ public ApiType waitUntilReady(Duration timeout)
+ throws ApiException, InterruptedException, TimeoutException {
+ Objects.requireNonNull(name, "Resource name must be specified");
+ return WaitUtils.waitUntilReady(api, namespace, name, timeout);
+ }
+
+ /**
+ * Wait until the resource meets a condition.
+ *
+ * @param condition the condition to wait for
+ * @param timeout the maximum time to wait
+ * @return the resource meeting the condition
+ * @throws ApiException if an API error occurs
+ * @throws InterruptedException if the wait is interrupted
+ * @throws TimeoutException if the timeout is exceeded
+ */
+ public ApiType waitUntilCondition(Predicate condition, Duration timeout)
+ throws ApiException, InterruptedException, TimeoutException {
+ Objects.requireNonNull(name, "Resource name must be specified");
+ return WaitUtils.waitUntilCondition(api, namespace, name, condition, timeout);
+ }
+
+ /**
+ * Wait until the resource is deleted.
+ *
+ * @param timeout the maximum time to wait
+ * @throws ApiException if an API error occurs
+ * @throws InterruptedException if the wait is interrupted
+ * @throws TimeoutException if the timeout is exceeded
+ */
+ public void waitUntilDeleted(Duration timeout)
+ throws ApiException, InterruptedException, TimeoutException {
+ Objects.requireNonNull(name, "Resource name must be specified");
+ final String ns = namespace;
+ final String resourceName = name;
+ WaitUtils.waitUntilDeleted(
+ () -> {
+ KubernetesApiResponse response = ns != null
+ ? api.get(ns, resourceName)
+ : api.get(resourceName);
+ return response.isSuccess() ? response.getObject() : null;
+ },
+ timeout
+ );
+ }
+
+ /**
+ * Wait until the resource is ready, returning a CompletableFuture.
+ *
+ * @param timeout the maximum time to wait
+ * @return a CompletableFuture that completes with the ready resource
+ */
+ public CompletableFuture waitUntilReadyAsync(Duration timeout) {
+ Objects.requireNonNull(name, "Resource name must be specified");
+ final String ns = namespace;
+ final String resourceName = name;
+ return WaitUtils.waitUntilReadyAsync(
+ () -> {
+ KubernetesApiResponse response = ns != null
+ ? api.get(ns, resourceName)
+ : api.get(resourceName);
+ return response.isSuccess() ? response.getObject() : null;
+ },
+ timeout,
+ Duration.ofSeconds(1)
+ );
+ }
+
+ /**
+ * Wait until the resource meets a condition, returning a CompletableFuture.
+ *
+ * @param condition the condition to wait for
+ * @param timeout the maximum time to wait
+ * @return a CompletableFuture that completes with the resource
+ */
+ public CompletableFuture waitUntilConditionAsync(Predicate condition, Duration timeout) {
+ Objects.requireNonNull(name, "Resource name must be specified");
+ final String ns = namespace;
+ final String resourceName = name;
+ return WaitUtils.waitUntilConditionAsync(
+ () -> {
+ KubernetesApiResponse response = ns != null
+ ? api.get(ns, resourceName)
+ : api.get(resourceName);
+ return response.isSuccess() ? response.getObject() : null;
+ },
+ condition,
+ timeout,
+ Duration.ofSeconds(1)
+ );
+ }
+
+ /**
+ * Create a copy of this client with the current settings.
+ */
+ private ResourceClient copy() {
+ ResourceClient copy = new ResourceClient<>(apiClient, api, apiTypeClass);
+ copy.namespace = this.namespace;
+ copy.name = this.name;
+ copy.labelSelector = this.labelSelector;
+ copy.fieldSelector = this.fieldSelector;
+ return copy;
+ }
+
+ private String getNamespaceFromResource(ApiType resource) {
+ if (resource.getMetadata() != null && resource.getMetadata().getNamespace() != null) {
+ return resource.getMetadata().getNamespace();
+ }
+ return null;
+ }
+}
diff --git a/util/src/main/java/io/kubernetes/client/util/ResourceList.java b/util/src/main/java/io/kubernetes/client/util/ResourceList.java
new file mode 100644
index 0000000000..5502187bbb
--- /dev/null
+++ b/util/src/main/java/io/kubernetes/client/util/ResourceList.java
@@ -0,0 +1,648 @@
+/*
+Copyright 2024 The Kubernetes 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
+http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package io.kubernetes.client.util;
+
+import io.kubernetes.client.common.KubernetesObject;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import io.kubernetes.client.util.generic.KubernetesApiResponse;
+import io.kubernetes.client.util.generic.options.DeleteOptions;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+/**
+ * Batch operations for multiple Kubernetes resources.
+ * Provides fabric8-style resourceList() operations for creating, deleting,
+ * and managing multiple resources at once.
+ *
+ *
Example usage:
+ *
{@code
+ * // Load and create multiple resources from a file
+ * List
+ */
+public class ResourceList {
+
+ private final ApiClient apiClient;
+ private final List resources;
+ private String namespace;
+ private boolean continueOnError = false;
+
+ private ResourceList(ApiClient apiClient, List resources) {
+ this.apiClient = Objects.requireNonNull(apiClient, "ApiClient must not be null");
+ this.resources = new ArrayList<>(Objects.requireNonNull(resources, "Resources must not be null"));
+ }
+
+ /**
+ * Create a ResourceList from a list of resources.
+ *
+ * @param apiClient the API client
+ * @param resources the resources
+ * @return a new ResourceList
+ */
+ public static ResourceList from(ApiClient apiClient, List resources) {
+ return new ResourceList(apiClient, resources);
+ }
+
+ /**
+ * Create a ResourceList from resources.
+ *
+ * @param apiClient the API client
+ * @param resources the resources
+ * @return a new ResourceList
+ */
+ public static ResourceList from(ApiClient apiClient, Object... resources) {
+ return new ResourceList(apiClient, Arrays.asList(resources));
+ }
+
+ /**
+ * Load resources from a YAML file.
+ *
+ * @param apiClient the API client
+ * @param file the YAML file
+ * @return a new ResourceList
+ * @throws IOException if an error occurs reading the file
+ */
+ public static ResourceList fromFile(ApiClient apiClient, File file) throws IOException {
+ List resources = ResourceLoader.loadAll(file);
+ return new ResourceList(apiClient, resources);
+ }
+
+ /**
+ * Load resources from an InputStream.
+ *
+ * @param apiClient the API client
+ * @param inputStream the input stream
+ * @return a new ResourceList
+ * @throws IOException if an error occurs reading the stream
+ */
+ public static ResourceList fromStream(ApiClient apiClient, InputStream inputStream) throws IOException {
+ List resources = ResourceLoader.loadAll(inputStream);
+ return new ResourceList(apiClient, resources);
+ }
+
+ /**
+ * Load resources from a YAML string.
+ *
+ * @param apiClient the API client
+ * @param yaml the YAML content
+ * @return a new ResourceList
+ * @throws IOException if an error occurs parsing the YAML
+ */
+ public static ResourceList fromYaml(ApiClient apiClient, String yaml) throws IOException {
+ List resources = ResourceLoader.loadAll(yaml);
+ return new ResourceList(apiClient, resources);
+ }
+
+ /**
+ * Get the list of resources.
+ *
+ * @return the resources
+ */
+ public List getResources() {
+ return Collections.unmodifiableList(resources);
+ }
+
+ /**
+ * Specify the namespace for all resources.
+ * This will override the namespace specified in individual resources.
+ *
+ * @param namespace the namespace
+ * @return this ResourceList for chaining
+ */
+ public ResourceList inNamespace(String namespace) {
+ this.namespace = namespace;
+ return this;
+ }
+
+ /**
+ * Continue processing remaining resources even if one fails.
+ *
+ * @param continueOnError whether to continue on error
+ * @return this ResourceList for chaining
+ */
+ public ResourceList continueOnError(boolean continueOnError) {
+ this.continueOnError = continueOnError;
+ return this;
+ }
+
+ /**
+ * Create all resources in the cluster.
+ *
+ * @return list of created resources
+ * @throws ApiException if an API error occurs
+ */
+ public List create() throws ApiException {
+ List created = new ArrayList<>();
+ List errors = new ArrayList<>();
+
+ for (Object resource : resources) {
+ try {
+ Object result = createResource(resource);
+ created.add(result);
+ } catch (ApiException e) {
+ if (continueOnError) {
+ errors.add(e);
+ } else {
+ throw e;
+ }
+ }
+ }
+
+ if (!errors.isEmpty()) {
+ throw new BatchOperationException("Some resources failed to create", errors, created);
+ }
+ return created;
+ }
+
+ /**
+ * Create or replace all resources in the cluster.
+ *
+ * @return list of created or replaced resources
+ * @throws ApiException if an API error occurs
+ */
+ public List createOrReplace() throws ApiException {
+ List results = new ArrayList<>();
+ List errors = new ArrayList<>();
+
+ for (Object resource : resources) {
+ try {
+ Object result = createOrReplaceResource(resource);
+ results.add(result);
+ } catch (ApiException e) {
+ if (continueOnError) {
+ errors.add(e);
+ } else {
+ throw e;
+ }
+ }
+ }
+
+ if (!errors.isEmpty()) {
+ throw new BatchOperationException("Some resources failed to create/replace", errors, results);
+ }
+ return results;
+ }
+
+ /**
+ * Delete all resources from the cluster.
+ *
+ * @throws ApiException if an API error occurs
+ */
+ public void delete() throws ApiException {
+ List errors = new ArrayList<>();
+
+ // Delete in reverse order (dependency order)
+ for (int i = resources.size() - 1; i >= 0; i--) {
+ Object resource = resources.get(i);
+ try {
+ deleteResource(resource);
+ } catch (ApiException e) {
+ // Ignore 404 - resource already deleted
+ if (e.getCode() != 404) {
+ if (continueOnError) {
+ errors.add(e);
+ } else {
+ throw e;
+ }
+ }
+ }
+ }
+
+ if (!errors.isEmpty()) {
+ throw new BatchOperationException("Some resources failed to delete", errors, Collections.emptyList());
+ }
+ }
+
+ /**
+ * Apply all resources using server-side apply.
+ *
+ * @param fieldManager the field manager name
+ * @return list of applied resources
+ * @throws ApiException if an API error occurs
+ */
+ public List serverSideApply(String fieldManager) throws ApiException {
+ return serverSideApply(fieldManager, false);
+ }
+
+ /**
+ * Apply all resources using server-side apply.
+ *
+ * @param fieldManager the field manager name
+ * @param forceConflicts whether to force ownership of conflicting fields
+ * @return list of applied resources
+ * @throws ApiException if an API error occurs
+ */
+ public List serverSideApply(String fieldManager, boolean forceConflicts) throws ApiException {
+ List applied = new ArrayList<>();
+ List errors = new ArrayList<>();
+
+ for (Object resource : resources) {
+ try {
+ Object result = serverSideApplyResource(resource, fieldManager, forceConflicts);
+ applied.add(result);
+ } catch (ApiException e) {
+ if (continueOnError) {
+ errors.add(e);
+ } else {
+ throw e;
+ }
+ }
+ }
+
+ if (!errors.isEmpty()) {
+ throw new BatchOperationException("Some resources failed to apply", errors, applied);
+ }
+ return applied;
+ }
+
+ /**
+ * Wait until all resources are ready.
+ *
+ * @param timeout the maximum time to wait for each resource
+ * @throws ApiException if an API error occurs
+ * @throws InterruptedException if the wait is interrupted
+ * @throws TimeoutException if the timeout is exceeded
+ */
+ public void waitUntilAllReady(Duration timeout)
+ throws ApiException, InterruptedException, TimeoutException {
+ for (Object resource : resources) {
+ if (resource instanceof KubernetesObject) {
+ waitForResource((KubernetesObject) resource, timeout);
+ }
+ }
+ }
+
+ /**
+ * Wait until all resources are ready, returning a CompletableFuture.
+ *
+ * @param timeout the maximum time to wait for each resource
+ * @return a CompletableFuture that completes when all resources are ready
+ */
+ public CompletableFuture waitUntilAllReadyAsync(Duration timeout) {
+ List> futures = resources.stream()
+ .filter(r -> r instanceof KubernetesObject)
+ .map(r -> waitForResourceAsync((KubernetesObject) r, timeout))
+ .collect(Collectors.toList());
+
+ return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
+ }
+
+ /**
+ * Wait until all resources are deleted.
+ *
+ * @param timeout the maximum time to wait for each resource
+ * @throws ApiException if an API error occurs
+ * @throws InterruptedException if the wait is interrupted
+ * @throws TimeoutException if the timeout is exceeded
+ */
+ public void waitUntilAllDeleted(Duration timeout)
+ throws ApiException, InterruptedException, TimeoutException {
+ for (Object resource : resources) {
+ if (resource instanceof KubernetesObject) {
+ waitForDeletion((KubernetesObject) resource, timeout);
+ }
+ }
+ }
+
+ /**
+ * Check if all resources are ready.
+ *
+ * @return true if all resources are ready
+ * @throws ApiException if an API error occurs
+ */
+ public boolean areAllReady() throws ApiException {
+ for (Object resource : resources) {
+ if (resource instanceof KubernetesObject) {
+ KubernetesObject k8sObj = (KubernetesObject) resource;
+ DynamicKubernetesObject dynamicObj = toDynamicObject(k8sObj);
+ DynamicKubernetesApi dynamicApi = getDynamicApi(dynamicObj);
+
+ String ns = getEffectiveNamespace(dynamicObj);
+ String name = dynamicObj.getMetadata().getName();
+
+ KubernetesApiResponse response;
+ if (ns != null) {
+ response = dynamicApi.get(ns, name);
+ } else {
+ response = dynamicApi.get(name);
+ }
+
+ if (!response.isSuccess()) {
+ return false;
+ }
+
+ if (!Readiness.isReady(response.getObject())) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ // Internal methods
+
+ private Object createResource(Object resource) throws ApiException {
+ if (resource instanceof KubernetesObject) {
+ KubernetesObject k8sObj = (KubernetesObject) resource;
+ DynamicKubernetesObject dynamicObj = toDynamicObject(k8sObj);
+ DynamicKubernetesApi dynamicApi = getDynamicApi(dynamicObj);
+
+ String ns = getEffectiveNamespace(dynamicObj);
+ io.kubernetes.client.util.generic.options.CreateOptions createOpts =
+ new io.kubernetes.client.util.generic.options.CreateOptions();
+
+ KubernetesApiResponse response;
+ if (ns != null) {
+ response = dynamicApi.create(ns, dynamicObj, createOpts);
+ } else {
+ response = dynamicApi.create(dynamicObj, createOpts);
+ }
+
+ return response.throwsApiException().getObject();
+ }
+ throw new IllegalArgumentException("Resource must be a KubernetesObject");
+ }
+
+ private Object createOrReplaceResource(Object resource) throws ApiException {
+ if (resource instanceof KubernetesObject) {
+ KubernetesObject k8sObj = (KubernetesObject) resource;
+ DynamicKubernetesObject dynamicObj = toDynamicObject(k8sObj);
+ DynamicKubernetesApi dynamicApi = getDynamicApi(dynamicObj);
+
+ String ns = getEffectiveNamespace(dynamicObj);
+ String name = dynamicObj.getMetadata().getName();
+ io.kubernetes.client.util.generic.options.CreateOptions createOpts =
+ new io.kubernetes.client.util.generic.options.CreateOptions();
+
+ // Try to get existing
+ KubernetesApiResponse existing;
+ if (ns != null) {
+ existing = dynamicApi.get(ns, name);
+ } else {
+ existing = dynamicApi.get(name);
+ }
+
+ KubernetesApiResponse response;
+ if (existing.isSuccess()) {
+ // Update existing
+ dynamicObj.getMetadata().setResourceVersion(
+ existing.getObject().getMetadata().getResourceVersion());
+ response = dynamicApi.update(dynamicObj);
+ } else {
+ // Create new
+ if (ns != null) {
+ response = dynamicApi.create(ns, dynamicObj, createOpts);
+ } else {
+ response = dynamicApi.create(dynamicObj, createOpts);
+ }
+ }
+
+ return response.throwsApiException().getObject();
+ }
+ throw new IllegalArgumentException("Resource must be a KubernetesObject");
+ }
+
+ private void deleteResource(Object resource) throws ApiException {
+ if (resource instanceof KubernetesObject) {
+ KubernetesObject k8sObj = (KubernetesObject) resource;
+ DynamicKubernetesObject dynamicObj = toDynamicObject(k8sObj);
+ DynamicKubernetesApi dynamicApi = getDynamicApi(dynamicObj);
+
+ String ns = getEffectiveNamespace(dynamicObj);
+ String name = dynamicObj.getMetadata().getName();
+
+ KubernetesApiResponse response;
+ if (ns != null) {
+ response = dynamicApi.delete(ns, name);
+ } else {
+ response = dynamicApi.delete(name);
+ }
+
+ response.throwsApiException();
+ }
+ }
+
+ private Object serverSideApplyResource(Object resource, String fieldManager, boolean forceConflicts)
+ throws ApiException {
+ if (resource instanceof KubernetesObject) {
+ KubernetesObject k8sObj = (KubernetesObject) resource;
+ DynamicKubernetesObject dynamicObj = toDynamicObject(k8sObj);
+ DynamicKubernetesApi dynamicApi = getDynamicApi(dynamicObj);
+
+ // Override namespace if specified
+ if (namespace != null) {
+ dynamicObj.getMetadata().setNamespace(namespace);
+ }
+
+ String ns = dynamicObj.getMetadata().getNamespace();
+ String name = dynamicObj.getMetadata().getName();
+
+ io.kubernetes.client.util.generic.options.PatchOptions patchOptions =
+ new io.kubernetes.client.util.generic.options.PatchOptions();
+ patchOptions.setFieldManager(fieldManager);
+ patchOptions.setForce(forceConflicts);
+
+ String yaml = Yaml.dump(k8sObj);
+ io.kubernetes.client.custom.V1Patch patch = new io.kubernetes.client.custom.V1Patch(yaml);
+
+ KubernetesApiResponse response;
+ if (ns != null) {
+ response = dynamicApi.patch(ns, name,
+ io.kubernetes.client.custom.V1Patch.PATCH_FORMAT_APPLY_YAML, patch, patchOptions);
+ } else {
+ response = dynamicApi.patch(name,
+ io.kubernetes.client.custom.V1Patch.PATCH_FORMAT_APPLY_YAML, patch, patchOptions);
+ }
+
+ return response.throwsApiException().getObject();
+ }
+ throw new IllegalArgumentException("Resource must be a KubernetesObject");
+ }
+
+ private void waitForResource(KubernetesObject resource, Duration timeout)
+ throws ApiException, InterruptedException, TimeoutException {
+ DynamicKubernetesObject dynamicObj = toDynamicObject(resource);
+ DynamicKubernetesApi dynamicApi = getDynamicApi(dynamicObj);
+
+ String ns = getEffectiveNamespace(dynamicObj);
+ String name = dynamicObj.getMetadata().getName();
+
+ WaitUtils.waitUntilCondition(
+ () -> {
+ KubernetesApiResponse response = ns != null
+ ? dynamicApi.get(ns, name)
+ : dynamicApi.get(name);
+ return response.isSuccess() ? response.getObject() : null;
+ },
+ obj -> Readiness.isReady(obj),
+ timeout);
+ }
+
+ private CompletableFuture waitForResourceAsync(KubernetesObject resource, Duration timeout) {
+ return CompletableFuture.runAsync(() -> {
+ try {
+ waitForResource(resource, timeout);
+ } catch (ApiException | InterruptedException | TimeoutException e) {
+ throw new CompletionException(e);
+ }
+ });
+ }
+
+ private void waitForDeletion(KubernetesObject resource, Duration timeout)
+ throws ApiException, InterruptedException, TimeoutException {
+ DynamicKubernetesObject dynamicObj = toDynamicObject(resource);
+ DynamicKubernetesApi dynamicApi = getDynamicApi(dynamicObj);
+
+ String ns = getEffectiveNamespace(dynamicObj);
+ String name = dynamicObj.getMetadata().getName();
+
+ WaitUtils.waitUntilDeleted(
+ () -> {
+ KubernetesApiResponse response = ns != null
+ ? dynamicApi.get(ns, name)
+ : dynamicApi.get(name);
+ return response.isSuccess() ? response.getObject() : null;
+ },
+ timeout);
+ }
+
+ private DynamicKubernetesObject toDynamicObject(KubernetesObject obj) {
+ try {
+ String yaml = Yaml.dump(obj);
+ return Yaml.loadAs(yaml, DynamicKubernetesObject.class);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to convert to dynamic object", e);
+ }
+ }
+
+ private DynamicKubernetesApi getDynamicApi(DynamicKubernetesObject obj) {
+ String apiVersion = obj.getApiVersion();
+ String kind = obj.getKind();
+
+ String group;
+ String version;
+ if (apiVersion.contains("/")) {
+ String[] parts = apiVersion.split("/");
+ group = parts[0];
+ version = parts[1];
+ } else {
+ group = "";
+ version = apiVersion;
+ }
+
+ String plural = pluralize(kind);
+
+ return new DynamicKubernetesApi(group, version, plural, apiClient);
+ }
+
+ /**
+ * Simple pluralization for Kubernetes resource kinds.
+ */
+ private String pluralize(String kind) {
+ if (kind == null) {
+ return null;
+ }
+ String lower = kind.toLowerCase();
+ // Special cases for Kubernetes kinds
+ if (lower.endsWith("s") || lower.endsWith("x") || lower.endsWith("z")
+ || lower.endsWith("ch") || lower.endsWith("sh")) {
+ return lower + "es";
+ }
+ if (lower.endsWith("y") && lower.length() > 1) {
+ char beforeY = lower.charAt(lower.length() - 2);
+ if (beforeY != 'a' && beforeY != 'e' && beforeY != 'i' && beforeY != 'o' && beforeY != 'u') {
+ return lower.substring(0, lower.length() - 1) + "ies";
+ }
+ }
+ // Handle known Kubernetes kinds
+ switch (lower) {
+ case "endpoints":
+ return "endpoints";
+ case "ingress":
+ return "ingresses";
+ default:
+ return lower + "s";
+ }
+ }
+
+ private String getEffectiveNamespace(DynamicKubernetesObject obj) {
+ if (namespace != null) {
+ return namespace;
+ }
+ return obj.getMetadata().getNamespace();
+ }
+
+ /**
+ * Exception thrown when a batch operation has partial failures.
+ */
+ public static class BatchOperationException extends ApiException {
+ private final List failures;
+ private final List successfulResults;
+
+ public BatchOperationException(String message, List failures, List successfulResults) {
+ super(message);
+ this.failures = Collections.unmodifiableList(failures);
+ this.successfulResults = Collections.unmodifiableList(successfulResults);
+ }
+
+ /**
+ * Get the list of failures.
+ */
+ public List getFailures() {
+ return failures;
+ }
+
+ /**
+ * Get the list of successful results.
+ */
+ public List getSuccessfulResults() {
+ return successfulResults;
+ }
+ }
+}
diff --git a/util/src/main/java/io/kubernetes/client/util/ResourceLoader.java b/util/src/main/java/io/kubernetes/client/util/ResourceLoader.java
new file mode 100644
index 0000000000..dfba3a3476
--- /dev/null
+++ b/util/src/main/java/io/kubernetes/client/util/ResourceLoader.java
@@ -0,0 +1,504 @@
+/*
+Copyright 2024 The Kubernetes 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
+http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package io.kubernetes.client.util;
+
+import io.kubernetes.client.Discovery;
+import io.kubernetes.client.apimachinery.GroupVersionKind;
+import io.kubernetes.client.common.KubernetesObject;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.util.generic.GenericKubernetesApi;
+import io.kubernetes.client.util.generic.KubernetesApiResponse;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * Utility class for loading Kubernetes resources from various sources (files, streams, URLs).
+ * Provides fabric8-style resource loading capabilities.
+ *
+ *
Example usage:
+ *
{@code
+ * // Load a single resource from a file
+ * Object resource = ResourceLoader.load(new File("pod.yaml"));
+ *
+ * // Load multiple resources from a file
+ * List resources = ResourceLoader.loadAll(new File("multi-resource.yaml"));
+ *
+ * // Load and create resources using ApiClient
+ * List created = ResourceLoader.loadAndCreate(apiClient, new File("deployment.yaml"));
+ *
+ * // Load from InputStream
+ * try (InputStream is = getClass().getResourceAsStream("/my-pod.yaml")) {
+ * V1Pod pod = ResourceLoader.load(is, V1Pod.class);
+ * }
+ * }
+ */
+public class ResourceLoader {
+
+ private ResourceLoader() {
+ // Utility class
+ }
+
+ /**
+ * Load a Kubernetes resource from a file.
+ *
+ * @param file the file to load from
+ * @return the parsed Kubernetes resource
+ * @throws IOException if an error occurs reading the file
+ */
+ public static Object load(File file) throws IOException {
+ return Yaml.load(file);
+ }
+
+ /**
+ * Load a Kubernetes resource from a file as a specific type.
+ *
+ * @param the resource type
+ * @param file the file to load from
+ * @param clazz the class of the resource
+ * @return the parsed Kubernetes resource
+ * @throws IOException if an error occurs reading the file
+ */
+ public static T load(File file, Class clazz) throws IOException {
+ return Yaml.loadAs(file, clazz);
+ }
+
+ /**
+ * Load a Kubernetes resource from an InputStream.
+ *
+ * @param inputStream the input stream to load from
+ * @return the parsed Kubernetes resource
+ * @throws IOException if an error occurs reading the stream
+ */
+ public static Object load(InputStream inputStream) throws IOException {
+ String content = readInputStream(inputStream);
+ return Yaml.load(content);
+ }
+
+ /**
+ * Load a Kubernetes resource from an InputStream as a specific type.
+ *
+ * @param the resource type
+ * @param inputStream the input stream to load from
+ * @param clazz the class of the resource
+ * @return the parsed Kubernetes resource
+ * @throws IOException if an error occurs reading the stream
+ */
+ public static T load(InputStream inputStream, Class clazz) throws IOException {
+ try (Reader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) {
+ return Yaml.loadAs(reader, clazz);
+ }
+ }
+
+ /**
+ * Load a Kubernetes resource from a URL.
+ *
+ * @param url the URL to load from
+ * @return the parsed Kubernetes resource
+ * @throws IOException if an error occurs reading from the URL
+ */
+ public static Object load(URL url) throws IOException {
+ try (InputStream is = url.openStream()) {
+ return load(is);
+ }
+ }
+
+ /**
+ * Load a Kubernetes resource from a URL as a specific type.
+ *
+ * @param the resource type
+ * @param url the URL to load from
+ * @param clazz the class of the resource
+ * @return the parsed Kubernetes resource
+ * @throws IOException if an error occurs reading from the URL
+ */
+ public static T load(URL url, Class clazz) throws IOException {
+ try (InputStream is = url.openStream()) {
+ return load(is, clazz);
+ }
+ }
+
+ /**
+ * Load a Kubernetes resource from a string.
+ *
+ * @param content the YAML/JSON content
+ * @return the parsed Kubernetes resource
+ * @throws IOException if an error occurs parsing the content
+ */
+ public static Object load(String content) throws IOException {
+ return Yaml.load(content);
+ }
+
+ /**
+ * Load a Kubernetes resource from a string as a specific type.
+ *
+ * @param the resource type
+ * @param content the YAML/JSON content
+ * @param clazz the class of the resource
+ * @return the parsed Kubernetes resource
+ * @throws IOException if an error occurs parsing the content
+ */
+ public static T load(String content, Class clazz) throws IOException {
+ return Yaml.loadAs(content, clazz);
+ }
+
+ /**
+ * Load all Kubernetes resources from a multi-document YAML file.
+ *
+ * @param file the file to load from
+ * @return list of parsed Kubernetes resources
+ * @throws IOException if an error occurs reading the file
+ */
+ public static List loadAll(File file) throws IOException {
+ try (FileInputStream fis = new FileInputStream(file)) {
+ return loadAll(fis);
+ }
+ }
+
+ /**
+ * Load all Kubernetes resources from a multi-document YAML stream.
+ *
+ * @param inputStream the input stream to load from
+ * @return list of parsed Kubernetes resources
+ * @throws IOException if an error occurs reading the stream
+ */
+ public static List loadAll(InputStream inputStream) throws IOException {
+ String content = readInputStream(inputStream);
+ return loadAll(content);
+ }
+
+ /**
+ * Load all Kubernetes resources from a multi-document YAML string.
+ *
+ * @param content the YAML content (may contain multiple documents)
+ * @return list of parsed Kubernetes resources
+ * @throws IOException if an error occurs parsing the content
+ */
+ public static List loadAll(String content) throws IOException {
+ return Yaml.loadAll(content);
+ }
+
+ /**
+ * Load resources from a file and create them in the cluster.
+ *
+ * @param apiClient the API client to use
+ * @param file the file to load from
+ * @return list of created resources
+ * @throws IOException if an error occurs reading the file
+ * @throws ApiException if an error occurs creating resources
+ */
+ public static List loadAndCreate(ApiClient apiClient, File file)
+ throws IOException, ApiException {
+ List resources = loadAll(file);
+ return createResources(apiClient, resources);
+ }
+
+ /**
+ * Load resources from a stream and create them in the cluster.
+ *
+ * @param apiClient the API client to use
+ * @param inputStream the input stream to load from
+ * @return list of created resources
+ * @throws IOException if an error occurs reading the stream
+ * @throws ApiException if an error occurs creating resources
+ */
+ public static List loadAndCreate(ApiClient apiClient, InputStream inputStream)
+ throws IOException, ApiException {
+ List resources = loadAll(inputStream);
+ return createResources(apiClient, resources);
+ }
+
+ /**
+ * Load resources from a string and create them in the cluster.
+ *
+ * @param apiClient the API client to use
+ * @param content the YAML/JSON content
+ * @return list of created resources
+ * @throws IOException if an error occurs parsing the content
+ * @throws ApiException if an error occurs creating resources
+ */
+ public static List loadAndCreate(ApiClient apiClient, String content)
+ throws IOException, ApiException {
+ List resources = loadAll(content);
+ return createResources(apiClient, resources);
+ }
+
+ /**
+ * Load resources from a file and apply them (create or replace).
+ *
+ * @param apiClient the API client to use
+ * @param file the file to load from
+ * @return list of applied resources
+ * @throws IOException if an error occurs reading the file
+ * @throws ApiException if an error occurs applying resources
+ */
+ public static List loadAndApply(ApiClient apiClient, File file)
+ throws IOException, ApiException {
+ List resources = loadAll(file);
+ return applyResources(apiClient, resources);
+ }
+
+ /**
+ * Load resources from a stream and apply them (create or replace).
+ *
+ * @param apiClient the API client to use
+ * @param inputStream the input stream to load from
+ * @return list of applied resources
+ * @throws IOException if an error occurs reading the stream
+ * @throws ApiException if an error occurs applying resources
+ */
+ public static List loadAndApply(ApiClient apiClient, InputStream inputStream)
+ throws IOException, ApiException {
+ List resources = loadAll(inputStream);
+ return applyResources(apiClient, resources);
+ }
+
+ /**
+ * Load resources from a file and delete them.
+ *
+ * @param apiClient the API client to use
+ * @param file the file to load from
+ * @throws IOException if an error occurs reading the file
+ * @throws ApiException if an error occurs deleting resources
+ */
+ public static void loadAndDelete(ApiClient apiClient, File file)
+ throws IOException, ApiException {
+ List resources = loadAll(file);
+ deleteResources(apiClient, resources);
+ }
+
+ /**
+ * Load resources from a stream and delete them.
+ *
+ * @param apiClient the API client to use
+ * @param inputStream the input stream to load from
+ * @throws IOException if an error occurs reading the stream
+ * @throws ApiException if an error occurs deleting resources
+ */
+ public static void loadAndDelete(ApiClient apiClient, InputStream inputStream)
+ throws IOException, ApiException {
+ List resources = loadAll(inputStream);
+ deleteResources(apiClient, resources);
+ }
+
+ /**
+ * Create resources in the cluster using DynamicKubernetesApi.
+ */
+ private static List createResources(ApiClient apiClient, List resources)
+ throws ApiException {
+ List created = new ArrayList<>();
+ io.kubernetes.client.util.generic.options.CreateOptions createOpts =
+ new io.kubernetes.client.util.generic.options.CreateOptions();
+ for (Object resource : resources) {
+ if (resource instanceof KubernetesObject) {
+ KubernetesObject k8sObj = (KubernetesObject) resource;
+ DynamicKubernetesObject dynamicObj = toDynamicObject(k8sObj);
+ DynamicKubernetesApi dynamicApi = getDynamicApi(apiClient, dynamicObj);
+
+ String namespace = dynamicObj.getMetadata().getNamespace();
+ KubernetesApiResponse response;
+
+ if (namespace != null && !namespace.isEmpty()) {
+ response = dynamicApi.create(namespace, dynamicObj, createOpts);
+ } else {
+ response = dynamicApi.create(dynamicObj, createOpts);
+ }
+
+ if (response.isSuccess()) {
+ created.add(response.getObject());
+ } else {
+ throw new ApiException(response.getHttpStatusCode(),
+ "Failed to create resource: " + response.getStatus());
+ }
+ }
+ }
+ return created;
+ }
+
+ /**
+ * Apply (create or update) resources in the cluster.
+ */
+ private static List applyResources(ApiClient apiClient, List resources)
+ throws ApiException {
+ List applied = new ArrayList<>();
+ io.kubernetes.client.util.generic.options.CreateOptions createOpts =
+ new io.kubernetes.client.util.generic.options.CreateOptions();
+ for (Object resource : resources) {
+ if (resource instanceof KubernetesObject) {
+ KubernetesObject k8sObj = (KubernetesObject) resource;
+ DynamicKubernetesObject dynamicObj = toDynamicObject(k8sObj);
+ DynamicKubernetesApi dynamicApi = getDynamicApi(apiClient, dynamicObj);
+
+ String namespace = dynamicObj.getMetadata().getNamespace();
+ String name = dynamicObj.getMetadata().getName();
+
+ // Try to get existing resource
+ KubernetesApiResponse existing;
+ if (namespace != null && !namespace.isEmpty()) {
+ existing = dynamicApi.get(namespace, name);
+ } else {
+ existing = dynamicApi.get(name);
+ }
+
+ KubernetesApiResponse response;
+ if (existing.isSuccess()) {
+ // Update existing resource
+ dynamicObj.getMetadata().setResourceVersion(
+ existing.getObject().getMetadata().getResourceVersion());
+
+ response = dynamicApi.update(dynamicObj);
+ } else {
+ // Create new resource
+ if (namespace != null && !namespace.isEmpty()) {
+ response = dynamicApi.create(namespace, dynamicObj, createOpts);
+ } else {
+ response = dynamicApi.create(dynamicObj, createOpts);
+ }
+ }
+
+ if (response.isSuccess()) {
+ applied.add(response.getObject());
+ } else {
+ throw new ApiException(response.getHttpStatusCode(),
+ "Failed to apply resource: " + response.getStatus());
+ }
+ }
+ }
+ return applied;
+ }
+
+ /**
+ * Delete resources from the cluster.
+ */
+ private static void deleteResources(ApiClient apiClient, List resources)
+ throws ApiException {
+ for (Object resource : resources) {
+ if (resource instanceof KubernetesObject) {
+ KubernetesObject k8sObj = (KubernetesObject) resource;
+ DynamicKubernetesObject dynamicObj = toDynamicObject(k8sObj);
+ DynamicKubernetesApi dynamicApi = getDynamicApi(apiClient, dynamicObj);
+
+ String namespace = dynamicObj.getMetadata().getNamespace();
+ String name = dynamicObj.getMetadata().getName();
+
+ KubernetesApiResponse response;
+ if (namespace != null && !namespace.isEmpty()) {
+ response = dynamicApi.delete(namespace, name);
+ } else {
+ response = dynamicApi.delete(name);
+ }
+
+ // 404 is ok for delete (already deleted)
+ if (!response.isSuccess() && response.getHttpStatusCode() != 404) {
+ throw new ApiException(response.getHttpStatusCode(),
+ "Failed to delete resource: " + response.getStatus());
+ }
+ }
+ }
+ }
+
+ /**
+ * Convert a KubernetesObject to a DynamicKubernetesObject.
+ */
+ private static DynamicKubernetesObject toDynamicObject(KubernetesObject obj) {
+ String yaml;
+ try {
+ yaml = Yaml.dump(obj);
+ return Yaml.loadAs(yaml, DynamicKubernetesObject.class);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to convert to dynamic object", e);
+ }
+ }
+
+ /**
+ * Get a DynamicKubernetesApi for the given resource.
+ */
+ private static DynamicKubernetesApi getDynamicApi(ApiClient apiClient, DynamicKubernetesObject obj) {
+ String apiVersion = obj.getApiVersion();
+ String kind = obj.getKind();
+
+ // Parse apiVersion into group and version
+ String group;
+ String version;
+ if (apiVersion.contains("/")) {
+ String[] parts = apiVersion.split("/");
+ group = parts[0];
+ version = parts[1];
+ } else {
+ group = "";
+ version = apiVersion;
+ }
+
+ // Get the plural name using simple pluralization
+ // In a production system, you would use API discovery to get the correct plural
+ String plural = pluralize(kind);
+
+ return new DynamicKubernetesApi(group, version, plural, apiClient);
+ }
+
+ /**
+ * Simple pluralization for Kubernetes resource kinds.
+ */
+ private static String pluralize(String kind) {
+ if (kind == null) {
+ return null;
+ }
+ String lower = kind.toLowerCase();
+ // Special cases for Kubernetes kinds
+ if (lower.endsWith("s") || lower.endsWith("x") || lower.endsWith("z")
+ || lower.endsWith("ch") || lower.endsWith("sh")) {
+ return lower + "es";
+ }
+ if (lower.endsWith("y") && lower.length() > 1) {
+ char beforeY = lower.charAt(lower.length() - 2);
+ if (beforeY != 'a' && beforeY != 'e' && beforeY != 'i' && beforeY != 'o' && beforeY != 'u') {
+ return lower.substring(0, lower.length() - 1) + "ies";
+ }
+ }
+ // Handle known Kubernetes kinds
+ switch (lower) {
+ case "endpoints":
+ return "endpoints";
+ case "ingress":
+ return "ingresses";
+ default:
+ return lower + "s";
+ }
+ }
+
+ /**
+ * Read an InputStream to a String.
+ */
+ private static String readInputStream(InputStream inputStream) throws IOException {
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
+ return reader.lines().collect(Collectors.joining("\n"));
+ }
+ }
+}
diff --git a/util/src/main/java/io/kubernetes/client/util/WaitUtils.java b/util/src/main/java/io/kubernetes/client/util/WaitUtils.java
new file mode 100644
index 0000000000..ec1bad6ce8
--- /dev/null
+++ b/util/src/main/java/io/kubernetes/client/util/WaitUtils.java
@@ -0,0 +1,368 @@
+/*
+Copyright 2024 The Kubernetes 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
+http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package io.kubernetes.client.util;
+
+import io.kubernetes.client.common.KubernetesObject;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.util.generic.GenericKubernetesApi;
+import io.kubernetes.client.util.generic.KubernetesApiResponse;
+
+import java.time.Duration;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+/**
+ * Utilities for waiting on Kubernetes resources to reach desired conditions.
+ * Provides fabric8-style waitUntilReady and waitUntilCondition functionality.
+ *
+ *
Example usage:
+ *
{@code
+ * // Wait for a Pod to be ready
+ * V1Pod pod = WaitUtils.waitUntilReady(
+ * () -> coreV1Api.readNamespacedPod("my-pod", "default").execute(),
+ * Duration.ofMinutes(5),
+ * Duration.ofSeconds(1)
+ * );
+ *
+ * // Wait for a custom condition
+ * V1Pod runningPod = WaitUtils.waitUntilCondition(
+ * () -> coreV1Api.readNamespacedPod("my-pod", "default").execute(),
+ * pod -> "Running".equals(pod.getStatus().getPhase()),
+ * Duration.ofMinutes(5),
+ * Duration.ofSeconds(1)
+ * );
+ *
+ * // Using GenericKubernetesApi
+ * V1Pod readyPod = WaitUtils.waitUntilReady(
+ * podApi,
+ * "default",
+ * "my-pod",
+ * Duration.ofMinutes(5)
+ * );
+ * }
+ */
+public class WaitUtils {
+
+ private static final Duration DEFAULT_POLL_INTERVAL = Duration.ofSeconds(1);
+
+ private WaitUtils() {
+ // Utility class
+ }
+
+ /**
+ * Waits until the resource is ready using the Readiness utility.
+ *
+ * @param the resource type
+ * @param resourceSupplier supplier that fetches the current state of the resource
+ * @param timeout maximum time to wait
+ * @param pollInterval time between checks
+ * @return the ready resource
+ * @throws TimeoutException if the resource doesn't become ready within the timeout
+ * @throws InterruptedException if the thread is interrupted
+ */
+ public static T waitUntilReady(
+ Supplier resourceSupplier,
+ Duration timeout,
+ Duration pollInterval) throws TimeoutException, InterruptedException {
+ return waitUntilCondition(resourceSupplier, Readiness::isReady, timeout, pollInterval);
+ }
+
+ /**
+ * Waits until the resource is ready using the Readiness utility with default poll interval.
+ *
+ * @param the resource type
+ * @param resourceSupplier supplier that fetches the current state of the resource
+ * @param timeout maximum time to wait
+ * @return the ready resource
+ * @throws TimeoutException if the resource doesn't become ready within the timeout
+ * @throws InterruptedException if the thread is interrupted
+ */
+ public static T waitUntilReady(
+ Supplier resourceSupplier,
+ Duration timeout) throws TimeoutException, InterruptedException {
+ return waitUntilReady(resourceSupplier, timeout, DEFAULT_POLL_INTERVAL);
+ }
+
+ /**
+ * Waits until the resource satisfies the given condition.
+ *
+ * @param the resource type
+ * @param resourceSupplier supplier that fetches the current state of the resource
+ * @param condition predicate that tests if the condition is met
+ * @param timeout maximum time to wait
+ * @param pollInterval time between checks
+ * @return the resource that satisfies the condition
+ * @throws TimeoutException if the condition is not met within the timeout
+ * @throws InterruptedException if the thread is interrupted
+ */
+ public static T waitUntilCondition(
+ Supplier resourceSupplier,
+ Predicate condition,
+ Duration timeout,
+ Duration pollInterval) throws TimeoutException, InterruptedException {
+
+ Objects.requireNonNull(resourceSupplier, "resourceSupplier must not be null");
+ Objects.requireNonNull(condition, "condition must not be null");
+ Objects.requireNonNull(timeout, "timeout must not be null");
+ Objects.requireNonNull(pollInterval, "pollInterval must not be null");
+
+ CompletableFuture future = new CompletableFuture<>();
+ ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
+
+ try {
+ ScheduledFuture> scheduledTask = executor.scheduleAtFixedRate(() -> {
+ try {
+ T resource = resourceSupplier.get();
+ if (resource != null && condition.test(resource)) {
+ future.complete(resource);
+ }
+ } catch (Exception e) {
+ // Log but don't fail - resource might not exist yet
+ // We'll keep polling until timeout
+ }
+ }, 0, pollInterval.toMillis(), TimeUnit.MILLISECONDS);
+
+ try {
+ return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
+ } finally {
+ scheduledTask.cancel(true);
+ }
+ } catch (ExecutionException e) {
+ throw new RuntimeException("Unexpected error while waiting", e.getCause());
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ /**
+ * Waits until the resource satisfies the given condition with default poll interval.
+ *
+ * @param the resource type
+ * @param resourceSupplier supplier that fetches the current state of the resource
+ * @param condition predicate that tests if the condition is met
+ * @param timeout maximum time to wait
+ * @return the resource that satisfies the condition
+ * @throws TimeoutException if the condition is not met within the timeout
+ * @throws InterruptedException if the thread is interrupted
+ */
+ public static T waitUntilCondition(
+ Supplier resourceSupplier,
+ Predicate condition,
+ Duration timeout) throws TimeoutException, InterruptedException {
+ return waitUntilCondition(resourceSupplier, condition, timeout, DEFAULT_POLL_INTERVAL);
+ }
+
+ /**
+ * Waits until the resource is deleted (returns null or throws 404).
+ *
+ * @param the resource type
+ * @param resourceSupplier supplier that fetches the current state of the resource
+ * @param timeout maximum time to wait
+ * @param pollInterval time between checks
+ * @throws TimeoutException if the resource is not deleted within the timeout
+ * @throws InterruptedException if the thread is interrupted
+ */
+ public static void waitUntilDeleted(
+ Supplier resourceSupplier,
+ Duration timeout,
+ Duration pollInterval) throws TimeoutException, InterruptedException {
+
+ Objects.requireNonNull(resourceSupplier, "resourceSupplier must not be null");
+ Objects.requireNonNull(timeout, "timeout must not be null");
+ Objects.requireNonNull(pollInterval, "pollInterval must not be null");
+
+ CompletableFuture future = new CompletableFuture<>();
+ ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
+
+ try {
+ ScheduledFuture> scheduledTask = executor.scheduleAtFixedRate(() -> {
+ try {
+ T resource = resourceSupplier.get();
+ if (resource == null) {
+ future.complete(null);
+ }
+ } catch (Exception e) {
+ // Treat any exception as deleted (typically 404)
+ future.complete(null);
+ }
+ }, 0, pollInterval.toMillis(), TimeUnit.MILLISECONDS);
+
+ try {
+ future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
+ } finally {
+ scheduledTask.cancel(true);
+ }
+ } catch (ExecutionException e) {
+ throw new RuntimeException("Unexpected error while waiting for deletion", e.getCause());
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ /**
+ * Waits until the resource is deleted with default poll interval.
+ *
+ * @param the resource type
+ * @param resourceSupplier supplier that fetches the current state of the resource
+ * @param timeout maximum time to wait
+ * @throws TimeoutException if the resource is not deleted within the timeout
+ * @throws InterruptedException if the thread is interrupted
+ */
+ public static void waitUntilDeleted(
+ Supplier resourceSupplier,
+ Duration timeout) throws TimeoutException, InterruptedException {
+ waitUntilDeleted(resourceSupplier, timeout, DEFAULT_POLL_INTERVAL);
+ }
+
+ /**
+ * Waits until a resource is ready using GenericKubernetesApi.
+ *
+ * @param the resource type
+ * @param the list type
+ * @param api the GenericKubernetesApi
+ * @param namespace the namespace (null for cluster-scoped resources)
+ * @param name the resource name
+ * @param timeout maximum time to wait
+ * @return the ready resource
+ * @throws TimeoutException if the resource doesn't become ready within the timeout
+ * @throws InterruptedException if the thread is interrupted
+ */
+ public static T waitUntilReady(
+ GenericKubernetesApi api,
+ String namespace,
+ String name,
+ Duration timeout) throws TimeoutException, InterruptedException {
+
+ return waitUntilCondition(
+ () -> {
+ KubernetesApiResponse response = namespace != null
+ ? api.get(namespace, name)
+ : api.get(name);
+ return response.isSuccess() ? response.getObject() : null;
+ },
+ Readiness::isReady,
+ timeout,
+ DEFAULT_POLL_INTERVAL
+ );
+ }
+
+ /**
+ * Waits until a resource satisfies the given condition using GenericKubernetesApi.
+ *
+ * @param the resource type
+ * @param the list type
+ * @param api the GenericKubernetesApi
+ * @param namespace the namespace (null for cluster-scoped resources)
+ * @param name the resource name
+ * @param condition predicate that tests if the condition is met
+ * @param timeout maximum time to wait
+ * @return the resource that satisfies the condition
+ * @throws TimeoutException if the condition is not met within the timeout
+ * @throws InterruptedException if the thread is interrupted
+ */
+ public static T waitUntilCondition(
+ GenericKubernetesApi api,
+ String namespace,
+ String name,
+ Predicate condition,
+ Duration timeout) throws TimeoutException, InterruptedException {
+
+ return waitUntilCondition(
+ () -> {
+ KubernetesApiResponse response = namespace != null
+ ? api.get(namespace, name)
+ : api.get(name);
+ return response.isSuccess() ? response.getObject() : null;
+ },
+ condition,
+ timeout,
+ DEFAULT_POLL_INTERVAL
+ );
+ }
+
+ /**
+ * Waits until a cluster-scoped resource is ready.
+ *
+ * @param the resource type
+ * @param the list type
+ * @param api the GenericKubernetesApi
+ * @param name the resource name
+ * @param timeout maximum time to wait
+ * @return the ready resource
+ * @throws TimeoutException if the resource doesn't become ready within the timeout
+ * @throws InterruptedException if the thread is interrupted
+ */
+ public static T waitUntilReady(
+ GenericKubernetesApi api,
+ String name,
+ Duration timeout) throws TimeoutException, InterruptedException {
+ return waitUntilReady(api, null, name, timeout);
+ }
+
+ /**
+ * Asynchronously waits until the resource is ready.
+ *
+ * @param the resource type
+ * @param resourceSupplier supplier that fetches the current state of the resource
+ * @param timeout maximum time to wait
+ * @param pollInterval time between checks
+ * @return CompletableFuture that completes with the ready resource
+ */
+ public static CompletableFuture waitUntilReadyAsync(
+ Supplier resourceSupplier,
+ Duration timeout,
+ Duration pollInterval) {
+ return waitUntilConditionAsync(resourceSupplier, Readiness::isReady, timeout, pollInterval);
+ }
+
+ /**
+ * Asynchronously waits until the resource satisfies the given condition.
+ *
+ * @param the resource type
+ * @param resourceSupplier supplier that fetches the current state of the resource
+ * @param condition predicate that tests if the condition is met
+ * @param timeout maximum time to wait
+ * @param pollInterval time between checks
+ * @return CompletableFuture that completes with the resource or exceptionally with TimeoutException
+ */
+ public static CompletableFuture waitUntilConditionAsync(
+ Supplier resourceSupplier,
+ Predicate condition,
+ Duration timeout,
+ Duration pollInterval) {
+
+ CompletableFuture result = new CompletableFuture<>();
+
+ CompletableFuture.runAsync(() -> {
+ try {
+ T resource = waitUntilCondition(resourceSupplier, condition, timeout, pollInterval);
+ result.complete(resource);
+ } catch (TimeoutException e) {
+ result.completeExceptionally(e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ result.completeExceptionally(e);
+ }
+ });
+
+ return result;
+ }
+}
diff --git a/util/src/test/java/io/kubernetes/client/util/ReadinessTest.java b/util/src/test/java/io/kubernetes/client/util/ReadinessTest.java
new file mode 100644
index 0000000000..e1683f0c44
--- /dev/null
+++ b/util/src/test/java/io/kubernetes/client/util/ReadinessTest.java
@@ -0,0 +1,450 @@
+/*
+Copyright 2024 The Kubernetes 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
+http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package io.kubernetes.client.util;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import io.kubernetes.client.openapi.models.V1DaemonSet;
+import io.kubernetes.client.openapi.models.V1DaemonSetSpec;
+import io.kubernetes.client.openapi.models.V1DaemonSetStatus;
+import io.kubernetes.client.openapi.models.V1Deployment;
+import io.kubernetes.client.openapi.models.V1DeploymentCondition;
+import io.kubernetes.client.openapi.models.V1DeploymentSpec;
+import io.kubernetes.client.openapi.models.V1DeploymentStatus;
+import io.kubernetes.client.openapi.models.V1EndpointAddress;
+import io.kubernetes.client.openapi.models.V1EndpointSubset;
+import io.kubernetes.client.openapi.models.V1Endpoints;
+import io.kubernetes.client.openapi.models.V1Job;
+import io.kubernetes.client.openapi.models.V1JobCondition;
+import io.kubernetes.client.openapi.models.V1JobStatus;
+import io.kubernetes.client.openapi.models.V1Node;
+import io.kubernetes.client.openapi.models.V1NodeCondition;
+import io.kubernetes.client.openapi.models.V1NodeStatus;
+import io.kubernetes.client.openapi.models.V1ObjectMeta;
+import io.kubernetes.client.openapi.models.V1PersistentVolumeClaim;
+import io.kubernetes.client.openapi.models.V1PersistentVolumeClaimStatus;
+import io.kubernetes.client.openapi.models.V1Pod;
+import io.kubernetes.client.openapi.models.V1PodCondition;
+import io.kubernetes.client.openapi.models.V1PodStatus;
+import io.kubernetes.client.openapi.models.V1ReplicaSet;
+import io.kubernetes.client.openapi.models.V1ReplicaSetSpec;
+import io.kubernetes.client.openapi.models.V1ReplicaSetStatus;
+import io.kubernetes.client.openapi.models.V1ReplicationController;
+import io.kubernetes.client.openapi.models.V1ReplicationControllerStatus;
+import io.kubernetes.client.openapi.models.V1Service;
+import io.kubernetes.client.openapi.models.V1StatefulSet;
+import io.kubernetes.client.openapi.models.V1StatefulSetSpec;
+import io.kubernetes.client.openapi.models.V1StatefulSetStatus;
+import java.util.Collections;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class ReadinessTest {
+
+ // ========== Pod Tests ==========
+
+ @Test
+ void isPodReady_nullPod_returnsFalse() {
+ assertThat(Readiness.isPodReady(null)).isFalse();
+ }
+
+ @Test
+ void isPodReady_nullStatus_returnsFalse() {
+ V1Pod pod = new V1Pod().metadata(new V1ObjectMeta().name("test"));
+ assertThat(Readiness.isPodReady(pod)).isFalse();
+ }
+
+ @Test
+ void isPodReady_pendingPhase_returnsFalse() {
+ V1Pod pod = new V1Pod()
+ .metadata(new V1ObjectMeta().name("test"))
+ .status(new V1PodStatus().phase("Pending"));
+ assertThat(Readiness.isPodReady(pod)).isFalse();
+ }
+
+ @Test
+ void isPodReady_runningWithReadyConditionTrue_returnsTrue() {
+ V1Pod pod = new V1Pod()
+ .metadata(new V1ObjectMeta().name("test"))
+ .status(new V1PodStatus()
+ .phase("Running")
+ .conditions(List.of(
+ new V1PodCondition().type("Ready").status("True"))));
+ assertThat(Readiness.isPodReady(pod)).isTrue();
+ }
+
+ @Test
+ void isPodReady_runningWithReadyConditionFalse_returnsFalse() {
+ V1Pod pod = new V1Pod()
+ .metadata(new V1ObjectMeta().name("test"))
+ .status(new V1PodStatus()
+ .phase("Running")
+ .conditions(List.of(
+ new V1PodCondition().type("Ready").status("False"))));
+ assertThat(Readiness.isPodReady(pod)).isFalse();
+ }
+
+ @Test
+ void isPodReady_runningWithNoConditions_returnsFalse() {
+ V1Pod pod = new V1Pod()
+ .metadata(new V1ObjectMeta().name("test"))
+ .status(new V1PodStatus().phase("Running"));
+ assertThat(Readiness.isPodReady(pod)).isFalse();
+ }
+
+ // ========== Deployment Tests ==========
+
+ @Test
+ void isDeploymentReady_nullDeployment_returnsFalse() {
+ assertThat(Readiness.isDeploymentReady(null)).isFalse();
+ }
+
+ @Test
+ void isDeploymentReady_nullStatus_returnsFalse() {
+ V1Deployment deployment = new V1Deployment()
+ .metadata(new V1ObjectMeta().name("test"));
+ assertThat(Readiness.isDeploymentReady(deployment)).isFalse();
+ }
+
+ @Test
+ void isDeploymentReady_availableWithCorrectReplicas_returnsTrue() {
+ V1Deployment deployment = new V1Deployment()
+ .metadata(new V1ObjectMeta().name("test"))
+ .spec(new V1DeploymentSpec().replicas(3))
+ .status(new V1DeploymentStatus()
+ .replicas(3)
+ .readyReplicas(3)
+ .availableReplicas(3)
+ .conditions(List.of(
+ new V1DeploymentCondition()
+ .type("Available")
+ .status("True"))));
+ assertThat(Readiness.isDeploymentReady(deployment)).isTrue();
+ }
+
+ @Test
+ void isDeploymentReady_notAvailable_returnsFalse() {
+ V1Deployment deployment = new V1Deployment()
+ .metadata(new V1ObjectMeta().name("test"))
+ .spec(new V1DeploymentSpec().replicas(3))
+ .status(new V1DeploymentStatus()
+ .replicas(3)
+ .readyReplicas(1)
+ .availableReplicas(1)
+ .conditions(List.of(
+ new V1DeploymentCondition()
+ .type("Available")
+ .status("False"))));
+ assertThat(Readiness.isDeploymentReady(deployment)).isFalse();
+ }
+
+ @Test
+ void isDeploymentReady_fewerReadyReplicas_returnsFalse() {
+ V1Deployment deployment = new V1Deployment()
+ .metadata(new V1ObjectMeta().name("test"))
+ .spec(new V1DeploymentSpec().replicas(3))
+ .status(new V1DeploymentStatus()
+ .replicas(3)
+ .readyReplicas(2)
+ .availableReplicas(3)
+ .conditions(List.of(
+ new V1DeploymentCondition()
+ .type("Available")
+ .status("True"))));
+ assertThat(Readiness.isDeploymentReady(deployment)).isFalse();
+ }
+
+ // ========== StatefulSet Tests ==========
+
+ @Test
+ void isStatefulSetReady_nullStatefulSet_returnsFalse() {
+ assertThat(Readiness.isStatefulSetReady(null)).isFalse();
+ }
+
+ @Test
+ void isStatefulSetReady_allReplicasReady_returnsTrue() {
+ V1StatefulSet statefulSet = new V1StatefulSet()
+ .metadata(new V1ObjectMeta().name("test"))
+ .spec(new V1StatefulSetSpec().replicas(3))
+ .status(new V1StatefulSetStatus()
+ .replicas(3)
+ .readyReplicas(3));
+ assertThat(Readiness.isStatefulSetReady(statefulSet)).isTrue();
+ }
+
+ @Test
+ void isStatefulSetReady_notAllReplicasReady_returnsFalse() {
+ V1StatefulSet statefulSet = new V1StatefulSet()
+ .metadata(new V1ObjectMeta().name("test"))
+ .spec(new V1StatefulSetSpec().replicas(3))
+ .status(new V1StatefulSetStatus()
+ .replicas(3)
+ .readyReplicas(2));
+ assertThat(Readiness.isStatefulSetReady(statefulSet)).isFalse();
+ }
+
+ // ========== ReplicaSet Tests ==========
+
+ @Test
+ void isReplicaSetReady_nullReplicaSet_returnsFalse() {
+ assertThat(Readiness.isReplicaSetReady(null)).isFalse();
+ }
+
+ @Test
+ void isReplicaSetReady_allReplicasReady_returnsTrue() {
+ V1ReplicaSet replicaSet = new V1ReplicaSet()
+ .metadata(new V1ObjectMeta().name("test"))
+ .spec(new V1ReplicaSetSpec().replicas(3))
+ .status(new V1ReplicaSetStatus()
+ .replicas(3)
+ .readyReplicas(3));
+ assertThat(Readiness.isReplicaSetReady(replicaSet)).isTrue();
+ }
+
+ @Test
+ void isReplicaSetReady_notAllReplicasReady_returnsFalse() {
+ V1ReplicaSet replicaSet = new V1ReplicaSet()
+ .metadata(new V1ObjectMeta().name("test"))
+ .spec(new V1ReplicaSetSpec().replicas(3))
+ .status(new V1ReplicaSetStatus()
+ .replicas(3)
+ .readyReplicas(1));
+ assertThat(Readiness.isReplicaSetReady(replicaSet)).isFalse();
+ }
+
+ // ========== DaemonSet Tests ==========
+
+ @Test
+ void isDaemonSetReady_nullDaemonSet_returnsFalse() {
+ assertThat(Readiness.isDaemonSetReady(null)).isFalse();
+ }
+
+ @Test
+ void isDaemonSetReady_allNodesScheduled_returnsTrue() {
+ V1DaemonSet daemonSet = new V1DaemonSet()
+ .metadata(new V1ObjectMeta().name("test"))
+ .status(new V1DaemonSetStatus()
+ .desiredNumberScheduled(5)
+ .numberReady(5));
+ assertThat(Readiness.isDaemonSetReady(daemonSet)).isTrue();
+ }
+
+ @Test
+ void isDaemonSetReady_notAllNodesReady_returnsFalse() {
+ V1DaemonSet daemonSet = new V1DaemonSet()
+ .metadata(new V1ObjectMeta().name("test"))
+ .status(new V1DaemonSetStatus()
+ .desiredNumberScheduled(5)
+ .numberReady(3));
+ assertThat(Readiness.isDaemonSetReady(daemonSet)).isFalse();
+ }
+
+ // ========== Job Tests ==========
+
+ @Test
+ void isJobComplete_nullJob_returnsFalse() {
+ assertThat(Readiness.isJobComplete(null)).isFalse();
+ }
+
+ @Test
+ void isJobComplete_withCompleteCondition_returnsTrue() {
+ V1Job job = new V1Job()
+ .metadata(new V1ObjectMeta().name("test"))
+ .status(new V1JobStatus()
+ .conditions(List.of(
+ new V1JobCondition()
+ .type("Complete")
+ .status("True"))));
+ assertThat(Readiness.isJobComplete(job)).isTrue();
+ }
+
+ @Test
+ void isJobComplete_withFailedCondition_returnsFalse() {
+ V1Job job = new V1Job()
+ .metadata(new V1ObjectMeta().name("test"))
+ .status(new V1JobStatus()
+ .conditions(List.of(
+ new V1JobCondition()
+ .type("Failed")
+ .status("True"))));
+ assertThat(Readiness.isJobComplete(job)).isFalse();
+ }
+
+ @Test
+ void isJobComplete_noConditions_returnsFalse() {
+ V1Job job = new V1Job()
+ .metadata(new V1ObjectMeta().name("test"))
+ .status(new V1JobStatus());
+ assertThat(Readiness.isJobComplete(job)).isFalse();
+ }
+
+ // ========== Node Tests ==========
+
+ @Test
+ void isNodeReady_nullNode_returnsFalse() {
+ assertThat(Readiness.isNodeReady(null)).isFalse();
+ }
+
+ @Test
+ void isNodeReady_withReadyConditionTrue_returnsTrue() {
+ V1Node node = new V1Node()
+ .metadata(new V1ObjectMeta().name("test"))
+ .status(new V1NodeStatus()
+ .conditions(List.of(
+ new V1NodeCondition()
+ .type("Ready")
+ .status("True"))));
+ assertThat(Readiness.isNodeReady(node)).isTrue();
+ }
+
+ @Test
+ void isNodeReady_withReadyConditionFalse_returnsFalse() {
+ V1Node node = new V1Node()
+ .metadata(new V1ObjectMeta().name("test"))
+ .status(new V1NodeStatus()
+ .conditions(List.of(
+ new V1NodeCondition()
+ .type("Ready")
+ .status("False"))));
+ assertThat(Readiness.isNodeReady(node)).isFalse();
+ }
+
+ // ========== ReplicationController Tests ==========
+
+ @Test
+ void isReplicationControllerReady_nullController_returnsFalse() {
+ assertThat(Readiness.isReplicationControllerReady(null)).isFalse();
+ }
+
+ @Test
+ void isReplicationControllerReady_allReplicasReady_returnsTrue() {
+ V1ReplicationController rc = new V1ReplicationController()
+ .metadata(new V1ObjectMeta().name("test"))
+ .spec(new io.kubernetes.client.openapi.models.V1ReplicationControllerSpec().replicas(3))
+ .status(new V1ReplicationControllerStatus()
+ .replicas(3)
+ .readyReplicas(3));
+ assertThat(Readiness.isReplicationControllerReady(rc)).isTrue();
+ }
+
+ // ========== PersistentVolumeClaim Tests ==========
+
+ @Test
+ void isPersistentVolumeClaimBound_nullPvc_returnsFalse() {
+ assertThat(Readiness.isPersistentVolumeClaimBound(null)).isFalse();
+ }
+
+ @Test
+ void isPersistentVolumeClaimBound_boundPhase_returnsTrue() {
+ V1PersistentVolumeClaim pvc = new V1PersistentVolumeClaim()
+ .metadata(new V1ObjectMeta().name("test"))
+ .status(new V1PersistentVolumeClaimStatus().phase("Bound"));
+ assertThat(Readiness.isPersistentVolumeClaimBound(pvc)).isTrue();
+ }
+
+ @Test
+ void isPersistentVolumeClaimBound_pendingPhase_returnsFalse() {
+ V1PersistentVolumeClaim pvc = new V1PersistentVolumeClaim()
+ .metadata(new V1ObjectMeta().name("test"))
+ .status(new V1PersistentVolumeClaimStatus().phase("Pending"));
+ assertThat(Readiness.isPersistentVolumeClaimBound(pvc)).isFalse();
+ }
+
+ // ========== Endpoints Tests ==========
+
+ @Test
+ void isEndpointsReady_nullEndpoints_returnsFalse() {
+ assertThat(Readiness.isEndpointsReady(null)).isFalse();
+ }
+
+ @Test
+ void isEndpointsReady_withAddresses_returnsTrue() {
+ V1Endpoints endpoints = new V1Endpoints()
+ .metadata(new V1ObjectMeta().name("test"))
+ .subsets(List.of(
+ new V1EndpointSubset()
+ .addresses(List.of(
+ new V1EndpointAddress().ip("10.0.0.1")))));
+ assertThat(Readiness.isEndpointsReady(endpoints)).isTrue();
+ }
+
+ @Test
+ void isEndpointsReady_noSubsets_returnsFalse() {
+ V1Endpoints endpoints = new V1Endpoints()
+ .metadata(new V1ObjectMeta().name("test"));
+ assertThat(Readiness.isEndpointsReady(endpoints)).isFalse();
+ }
+
+ @Test
+ void isEndpointsReady_emptySubsets_returnsFalse() {
+ V1Endpoints endpoints = new V1Endpoints()
+ .metadata(new V1ObjectMeta().name("test"))
+ .subsets(Collections.emptyList());
+ assertThat(Readiness.isEndpointsReady(endpoints)).isFalse();
+ }
+
+ // ========== Service Tests ==========
+
+ @Test
+ void isReady_service_returnsTrue() {
+ V1Service service = new V1Service()
+ .metadata(new V1ObjectMeta().name("test"));
+ assertThat(Readiness.isReady(service)).isTrue();
+ }
+
+ // ========== Generic isReady Tests ==========
+
+ @Test
+ void isReady_nullResource_returnsFalse() {
+ assertThat(Readiness.isReady(null)).isFalse();
+ }
+
+ @Test
+ void isReady_delegatesToCorrectMethod_forPod() {
+ V1Pod pod = new V1Pod()
+ .metadata(new V1ObjectMeta().name("test"))
+ .status(new V1PodStatus()
+ .phase("Running")
+ .conditions(List.of(
+ new V1PodCondition().type("Ready").status("True"))));
+ assertThat(Readiness.isReady(pod)).isTrue();
+ }
+
+ @Test
+ void isReady_delegatesToCorrectMethod_forDeployment() {
+ V1Deployment deployment = new V1Deployment()
+ .metadata(new V1ObjectMeta().name("test"))
+ .spec(new V1DeploymentSpec().replicas(1))
+ .status(new V1DeploymentStatus()
+ .replicas(1)
+ .readyReplicas(1)
+ .availableReplicas(1)
+ .conditions(List.of(
+ new V1DeploymentCondition()
+ .type("Available")
+ .status("True"))));
+ assertThat(Readiness.isReady(deployment)).isTrue();
+ }
+
+ @Test
+ void isReady_delegatesToCorrectMethod_forJob() {
+ V1Job job = new V1Job()
+ .metadata(new V1ObjectMeta().name("test"))
+ .status(new V1JobStatus()
+ .conditions(List.of(
+ new V1JobCondition()
+ .type("Complete")
+ .status("True"))));
+ assertThat(Readiness.isReady(job)).isTrue();
+ }
+}
diff --git a/util/src/test/java/io/kubernetes/client/util/ResourceClientTest.java b/util/src/test/java/io/kubernetes/client/util/ResourceClientTest.java
new file mode 100644
index 0000000000..cdf58154b8
--- /dev/null
+++ b/util/src/test/java/io/kubernetes/client/util/ResourceClientTest.java
@@ -0,0 +1,460 @@
+/*
+Copyright 2024 The Kubernetes 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
+http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package io.kubernetes.client.util;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.delete;
+import static com.github.tomakehurst.wiremock.client.WireMock.deleteRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.patch;
+import static com.github.tomakehurst.wiremock.client.WireMock.patchRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.put;
+import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.openapi.JSON;
+import io.kubernetes.client.openapi.models.V1ListMeta;
+import io.kubernetes.client.openapi.models.V1ObjectMeta;
+import io.kubernetes.client.openapi.models.V1Pod;
+import io.kubernetes.client.openapi.models.V1PodCondition;
+import io.kubernetes.client.openapi.models.V1PodList;
+import io.kubernetes.client.openapi.models.V1PodSpec;
+import io.kubernetes.client.openapi.models.V1PodStatus;
+import io.kubernetes.client.openapi.models.V1Status;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+class ResourceClientTest {
+
+ @RegisterExtension
+ static WireMockExtension apiServer =
+ WireMockExtension.newInstance().options(wireMockConfig().dynamicPort()).build();
+
+ private final JSON json = new JSON();
+ private ApiClient apiClient;
+ private ResourceClient podClient;
+
+ @BeforeEach
+ void setup() {
+ apiClient = new ClientBuilder()
+ .setBasePath("http://localhost:" + apiServer.getPort())
+ .build();
+ podClient = ResourceClient.create(
+ apiClient, V1Pod.class, V1PodList.class, "", "v1", "pods");
+ }
+
+ // ========== Get Tests ==========
+
+ @Test
+ void get_namespacedPod_returnsResource() throws ApiException {
+ V1Pod pod = createPod("test-pod", "default");
+ apiServer.stubFor(
+ get(urlPathEqualTo("/api/v1/namespaces/default/pods/test-pod"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(json.serialize(pod))));
+
+ V1Pod result = podClient.inNamespace("default").withName("test-pod").get();
+
+ assertThat(result).isNotNull();
+ assertThat(result.getMetadata().getName()).isEqualTo("test-pod");
+ assertThat(result.getMetadata().getNamespace()).isEqualTo("default");
+
+ apiServer.verify(getRequestedFor(urlPathEqualTo("/api/v1/namespaces/default/pods/test-pod")));
+ }
+
+ @Test
+ void get_clusterScopedResource_returnsResource() throws ApiException {
+ V1Pod pod = createPod("cluster-pod", null);
+ apiServer.stubFor(
+ get(urlPathEqualTo("/api/v1/pods/cluster-pod"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(json.serialize(pod))));
+
+ V1Pod result = podClient.withName("cluster-pod").get();
+
+ assertThat(result).isNotNull();
+ assertThat(result.getMetadata().getName()).isEqualTo("cluster-pod");
+
+ apiServer.verify(getRequestedFor(urlPathEqualTo("/api/v1/pods/cluster-pod")));
+ }
+
+ @Test
+ void get_notFound_returnsNull() throws ApiException {
+ apiServer.stubFor(
+ get(urlPathEqualTo("/api/v1/namespaces/default/pods/missing"))
+ .willReturn(aResponse()
+ .withStatus(404)
+ .withBody("{\"kind\": \"Status\", \"code\": 404}")));
+
+ V1Pod result = podClient.inNamespace("default").withName("missing").get();
+
+ assertThat(result).isNull();
+ }
+
+ // ========== List Tests ==========
+
+ @Test
+ void list_namespacedPods_returnsList() throws ApiException {
+ V1PodList podList = new V1PodList()
+ .metadata(new V1ListMeta())
+ .items(List.of(
+ createPod("pod1", "default"),
+ createPod("pod2", "default")));
+
+ apiServer.stubFor(
+ get(urlPathEqualTo("/api/v1/namespaces/default/pods"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(json.serialize(podList))));
+
+ V1PodList result = podClient.inNamespace("default").list();
+
+ assertThat(result).isNotNull();
+ assertThat(result.getItems()).hasSize(2);
+
+ apiServer.verify(getRequestedFor(urlPathEqualTo("/api/v1/namespaces/default/pods")));
+ }
+
+ @Test
+ void list_allNamespaces_returnsList() throws ApiException {
+ V1PodList podList = new V1PodList()
+ .metadata(new V1ListMeta())
+ .items(List.of(
+ createPod("pod1", "ns1"),
+ createPod("pod2", "ns2")));
+
+ apiServer.stubFor(
+ get(urlPathEqualTo("/api/v1/pods"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(json.serialize(podList))));
+
+ V1PodList result = podClient.list();
+
+ assertThat(result).isNotNull();
+ assertThat(result.getItems()).hasSize(2);
+
+ apiServer.verify(getRequestedFor(urlPathEqualTo("/api/v1/pods")));
+ }
+
+ // ========== Create Tests ==========
+
+ @Test
+ void create_namespacedPod_createsResource() throws ApiException {
+ V1Pod pod = createPod("new-pod", "default");
+ apiServer.stubFor(
+ post(urlPathEqualTo("/api/v1/namespaces/default/pods"))
+ .willReturn(aResponse()
+ .withStatus(201)
+ .withHeader("Content-Type", "application/json")
+ .withBody(json.serialize(pod))));
+
+ V1Pod result = podClient.inNamespace("default").create(pod);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getMetadata().getName()).isEqualTo("new-pod");
+
+ apiServer.verify(postRequestedFor(urlPathEqualTo("/api/v1/namespaces/default/pods")));
+ }
+
+ @Test
+ void create_clusterScopedResource_createsResource() throws ApiException {
+ V1Pod pod = createPod("cluster-pod", null);
+ apiServer.stubFor(
+ post(urlPathEqualTo("/api/v1/pods"))
+ .willReturn(aResponse()
+ .withStatus(201)
+ .withHeader("Content-Type", "application/json")
+ .withBody(json.serialize(pod))));
+
+ V1Pod result = podClient.create(pod);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getMetadata().getName()).isEqualTo("cluster-pod");
+
+ apiServer.verify(postRequestedFor(urlPathEqualTo("/api/v1/pods")));
+ }
+
+ // ========== Update Tests ==========
+
+ @Test
+ void update_namespacedPod_updatesResource() throws ApiException {
+ V1Pod pod = createPod("existing-pod", "default");
+ pod.getMetadata().setResourceVersion("12345");
+
+ apiServer.stubFor(
+ put(urlPathEqualTo("/api/v1/namespaces/default/pods/existing-pod"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(json.serialize(pod))));
+
+ V1Pod result = podClient.inNamespace("default").withName("existing-pod").update(pod);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getMetadata().getName()).isEqualTo("existing-pod");
+
+ apiServer.verify(putRequestedFor(urlPathEqualTo("/api/v1/namespaces/default/pods/existing-pod")));
+ }
+
+ // ========== Delete Tests ==========
+
+ @Test
+ void delete_namespacedPod_deletesResource() throws ApiException {
+ V1Status status = new V1Status().kind("Status").code(200).message("deleted");
+ apiServer.stubFor(
+ delete(urlPathEqualTo("/api/v1/namespaces/default/pods/delete-me"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(json.serialize(status))));
+
+ boolean result = podClient.inNamespace("default").withName("delete-me").delete();
+
+ assertThat(result).isTrue();
+ apiServer.verify(deleteRequestedFor(urlPathEqualTo("/api/v1/namespaces/default/pods/delete-me")));
+ }
+
+ @Test
+ void delete_notFound_returnsFalse() throws ApiException {
+ apiServer.stubFor(
+ delete(urlPathEqualTo("/api/v1/namespaces/default/pods/not-found"))
+ .willReturn(aResponse()
+ .withStatus(404)
+ .withBody("{\"kind\": \"Status\", \"code\": 404}")));
+
+ boolean result = podClient.inNamespace("default").withName("not-found").delete();
+
+ assertThat(result).isFalse();
+ }
+
+ // ========== Server-Side Apply Tests ==========
+
+ @Test
+ void serverSideApply_namespacedPod_appliesResource() throws ApiException {
+ V1Pod pod = createPod("apply-pod", "default");
+ apiServer.stubFor(
+ patch(urlPathEqualTo("/api/v1/namespaces/default/pods/apply-pod"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(json.serialize(pod))));
+
+ V1Pod result = podClient.inNamespace("default").withName("apply-pod")
+ .serverSideApply(pod, "test-manager");
+
+ assertThat(result).isNotNull();
+ assertThat(result.getMetadata().getName()).isEqualTo("apply-pod");
+
+ apiServer.verify(
+ patchRequestedFor(urlPathEqualTo("/api/v1/namespaces/default/pods/apply-pod"))
+ .withQueryParam("fieldManager", equalTo("test-manager")));
+ }
+
+ @Test
+ void serverSideApply_withForceConflicts_sendsForceParameter() throws ApiException {
+ V1Pod pod = createPod("apply-pod", "default");
+ apiServer.stubFor(
+ patch(urlPathEqualTo("/api/v1/namespaces/default/pods/apply-pod"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(json.serialize(pod))));
+
+ V1Pod result = podClient.inNamespace("default").withName("apply-pod")
+ .serverSideApply(pod, "test-manager", true);
+
+ assertThat(result).isNotNull();
+
+ apiServer.verify(
+ patchRequestedFor(urlPathEqualTo("/api/v1/namespaces/default/pods/apply-pod"))
+ .withQueryParam("force", equalTo("true")));
+ }
+
+ // ========== Fluent Interface Tests ==========
+
+ @Test
+ void fluentInterface_chainedCalls_preservesState() throws ApiException {
+ V1Pod pod = createPod("chained-pod", "test-ns");
+ apiServer.stubFor(
+ get(urlPathEqualTo("/api/v1/namespaces/test-ns/pods/chained-pod"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(json.serialize(pod))));
+
+ ResourceClient namespacedClient = podClient.inNamespace("test-ns");
+ ResourceClient namedClient = namespacedClient.withName("chained-pod");
+ V1Pod result = namedClient.get();
+
+ assertThat(result).isNotNull();
+ assertThat(result.getMetadata().getName()).isEqualTo("chained-pod");
+ }
+
+ @Test
+ void inNamespace_returnsNewClient() {
+ ResourceClient namespacedClient = podClient.inNamespace("my-namespace");
+
+ assertThat(namespacedClient).isNotNull();
+ assertThat(namespacedClient).isNotSameAs(podClient);
+ }
+
+ @Test
+ void withName_returnsNewClient() {
+ ResourceClient namedClient = podClient.withName("my-pod");
+
+ assertThat(namedClient).isNotNull();
+ assertThat(namedClient).isNotSameAs(podClient);
+ }
+
+ @Test
+ void withLabel_addsLabelSelector() throws ApiException {
+ V1PodList podList = new V1PodList()
+ .metadata(new V1ListMeta())
+ .items(List.of(createPod("labeled-pod", "default")));
+
+ apiServer.stubFor(
+ get(urlPathEqualTo("/api/v1/namespaces/default/pods"))
+ .withQueryParam("labelSelector", equalTo("app=test"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(json.serialize(podList))));
+
+ V1PodList result = podClient.inNamespace("default").withLabel("app", "test").list();
+
+ assertThat(result.getItems()).hasSize(1);
+ }
+
+ // ========== Error Handling Tests ==========
+
+ @Test
+ void get_serverError_throwsApiException() {
+ apiServer.stubFor(
+ get(urlPathEqualTo("/api/v1/namespaces/default/pods/error-pod"))
+ .willReturn(aResponse()
+ .withStatus(500)
+ .withBody("{\"message\": \"Internal Server Error\"}")));
+
+ assertThatThrownBy(() ->
+ podClient.inNamespace("default").withName("error-pod").get())
+ .isInstanceOf(ApiException.class);
+ }
+
+ @Test
+ void create_conflict_throwsApiException() {
+ V1Pod pod = createPod("conflict-pod", "default");
+ apiServer.stubFor(
+ post(urlPathEqualTo("/api/v1/namespaces/default/pods"))
+ .willReturn(aResponse()
+ .withStatus(409)
+ .withBody("{\"message\": \"AlreadyExists\"}")));
+
+ assertThatThrownBy(() ->
+ podClient.inNamespace("default").create(pod))
+ .isInstanceOf(ApiException.class);
+ }
+
+ // ========== CreateOrReplace Tests ==========
+
+ @Test
+ void createOrReplace_existingResource_updatesResource() throws ApiException {
+ V1Pod existingPod = createPod("existing-pod", "default");
+ existingPod.getMetadata().setResourceVersion("12345");
+
+ V1Pod updatedPod = createPod("existing-pod", "default");
+ updatedPod.getMetadata().setResourceVersion("12346");
+
+ apiServer.stubFor(
+ get(urlPathEqualTo("/api/v1/namespaces/default/pods/existing-pod"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(json.serialize(existingPod))));
+
+ apiServer.stubFor(
+ put(urlPathEqualTo("/api/v1/namespaces/default/pods/existing-pod"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody(json.serialize(updatedPod))));
+
+ V1Pod result = podClient.inNamespace("default").createOrReplace(existingPod);
+
+ assertThat(result).isNotNull();
+ apiServer.verify(putRequestedFor(urlPathEqualTo("/api/v1/namespaces/default/pods/existing-pod")));
+ }
+
+ @Test
+ void createOrReplace_newResource_createsResource() throws ApiException {
+ V1Pod newPod = createPod("new-pod", "default");
+
+ apiServer.stubFor(
+ get(urlPathEqualTo("/api/v1/namespaces/default/pods/new-pod"))
+ .willReturn(aResponse()
+ .withStatus(404)
+ .withBody("{\"kind\": \"Status\", \"code\": 404}")));
+
+ apiServer.stubFor(
+ post(urlPathEqualTo("/api/v1/namespaces/default/pods"))
+ .willReturn(aResponse()
+ .withStatus(201)
+ .withHeader("Content-Type", "application/json")
+ .withBody(json.serialize(newPod))));
+
+ V1Pod result = podClient.inNamespace("default").createOrReplace(newPod);
+
+ assertThat(result).isNotNull();
+ apiServer.verify(postRequestedFor(urlPathEqualTo("/api/v1/namespaces/default/pods")));
+ }
+
+ private V1Pod createPod(String name, String namespace) {
+ V1ObjectMeta metadata = new V1ObjectMeta().name(name);
+ if (namespace != null) {
+ metadata.namespace(namespace);
+ }
+ return new V1Pod()
+ .apiVersion("v1")
+ .kind("Pod")
+ .metadata(metadata)
+ .spec(new V1PodSpec());
+ }
+
+ private V1Pod createReadyPod(String name, String namespace) {
+ return createPod(name, namespace)
+ .status(new V1PodStatus()
+ .phase("Running")
+ .conditions(List.of(
+ new V1PodCondition()
+ .type("Ready")
+ .status("True"))));
+ }
+}
diff --git a/util/src/test/java/io/kubernetes/client/util/ResourceListTest.java b/util/src/test/java/io/kubernetes/client/util/ResourceListTest.java
new file mode 100644
index 0000000000..57e8bb3882
--- /dev/null
+++ b/util/src/test/java/io/kubernetes/client/util/ResourceListTest.java
@@ -0,0 +1,225 @@
+/*
+Copyright 2024 The Kubernetes 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
+http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package io.kubernetes.client.util;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.models.V1ConfigMap;
+import io.kubernetes.client.openapi.models.V1ObjectMeta;
+import io.kubernetes.client.openapi.models.V1Pod;
+import io.kubernetes.client.openapi.models.V1PodSpec;
+import io.kubernetes.client.openapi.models.V1Secret;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class ResourceListTest {
+
+ private ApiClient apiClient;
+
+ private static final String MULTI_RESOURCE_YAML =
+ "apiVersion: v1\n" +
+ "kind: ConfigMap\n" +
+ "metadata:\n" +
+ " name: test-configmap\n" +
+ " namespace: default\n" +
+ "data:\n" +
+ " key: value\n" +
+ "---\n" +
+ "apiVersion: v1\n" +
+ "kind: Secret\n" +
+ "metadata:\n" +
+ " name: test-secret\n" +
+ " namespace: default\n" +
+ "type: Opaque\n" +
+ "data:\n" +
+ " password: cGFzc3dvcmQ=\n";
+
+ private static final String POD_YAML =
+ "apiVersion: v1\n" +
+ "kind: Pod\n" +
+ "metadata:\n" +
+ " name: test-pod\n" +
+ " namespace: default\n" +
+ "spec:\n" +
+ " containers:\n" +
+ " - name: nginx\n" +
+ " image: nginx:latest\n";
+
+ @BeforeEach
+ void setup() {
+ apiClient = new ClientBuilder().setBasePath("http://localhost:8080").build();
+ }
+
+ // ========== Loading Tests ==========
+
+ @Test
+ void fromStream_singleResource_createsResourceList() throws IOException {
+ InputStream is = new ByteArrayInputStream(POD_YAML.getBytes(StandardCharsets.UTF_8));
+
+ ResourceList resourceList = ResourceList.fromStream(apiClient, is);
+
+ assertThat(resourceList).isNotNull();
+ assertThat(resourceList.getResources()).hasSize(1);
+ assertThat(resourceList.getResources().get(0)).isInstanceOf(V1Pod.class);
+ }
+
+ @Test
+ void fromStream_multipleResources_createsResourceList() throws IOException {
+ InputStream is = new ByteArrayInputStream(MULTI_RESOURCE_YAML.getBytes(StandardCharsets.UTF_8));
+
+ ResourceList resourceList = ResourceList.fromStream(apiClient, is);
+
+ assertThat(resourceList).isNotNull();
+ assertThat(resourceList.getResources()).hasSize(2);
+ assertThat(resourceList.getResources().get(0)).isInstanceOf(V1ConfigMap.class);
+ assertThat(resourceList.getResources().get(1)).isInstanceOf(V1Secret.class);
+ }
+
+ @Test
+ void fromYaml_createsResourceList() throws IOException {
+ ResourceList resourceList = ResourceList.fromYaml(apiClient, POD_YAML);
+
+ assertThat(resourceList).isNotNull();
+ assertThat(resourceList.getResources()).hasSize(1);
+ }
+
+ @Test
+ void getResources_returnsImmutableList() throws IOException {
+ ResourceList resourceList = ResourceList.fromYaml(apiClient, POD_YAML);
+ List resources = resourceList.getResources();
+
+ assertThatThrownBy(() -> resources.add(new V1Pod()))
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+
+ // ========== from() Factory Tests ==========
+
+ @Test
+ void from_withResources_createsResourceList() {
+ V1Pod pod1 = createPod("pod1", "default");
+ V1Pod pod2 = createPod("pod2", "default");
+
+ ResourceList resourceList = ResourceList.from(apiClient, pod1, pod2);
+
+ assertThat(resourceList.getResources()).hasSize(2);
+ }
+
+ @Test
+ void from_withResourceList_createsResourceList() {
+ V1Pod pod1 = createPod("pod1", "default");
+ V1Pod pod2 = createPod("pod2", "default");
+
+ ResourceList resourceList = ResourceList.from(apiClient, List.of(pod1, pod2));
+
+ assertThat(resourceList.getResources()).hasSize(2);
+ }
+
+ // ========== Utility Method Tests ==========
+
+ @Test
+ void getResources_size_returnsCorrectCount() throws IOException {
+ ResourceList resourceList = ResourceList.fromYaml(apiClient, MULTI_RESOURCE_YAML);
+
+ assertThat(resourceList.getResources().size()).isEqualTo(2);
+ }
+
+ @Test
+ void getResources_isEmpty_emptyList_returnsTrue() {
+ ResourceList resourceList = ResourceList.from(apiClient, List.of());
+
+ assertThat(resourceList.getResources().isEmpty()).isTrue();
+ }
+
+ @Test
+ void getResources_isEmpty_nonEmptyList_returnsFalse() throws IOException {
+ ResourceList resourceList = ResourceList.fromYaml(apiClient, POD_YAML);
+
+ assertThat(resourceList.getResources().isEmpty()).isFalse();
+ }
+
+ // ========== Error Handling Tests ==========
+
+ @Test
+ void fromStream_nullInput_throwsNullPointerException() {
+ assertThatThrownBy(() -> ResourceList.fromStream(apiClient, null))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ // ========== Chaining Tests ==========
+
+ @Test
+ void inNamespace_returnsThis() throws IOException {
+ ResourceList resourceList = ResourceList.fromYaml(apiClient, POD_YAML);
+
+ ResourceList result = resourceList.inNamespace("custom-ns");
+
+ assertThat(result).isSameAs(resourceList);
+ }
+
+ @Test
+ void continueOnError_returnsThis() throws IOException {
+ ResourceList resourceList = ResourceList.fromYaml(apiClient, POD_YAML);
+
+ ResourceList result = resourceList.continueOnError(true);
+
+ assertThat(result).isSameAs(resourceList);
+ }
+
+ // ========== Resource Loading and Metadata Tests ==========
+
+ @Test
+ void fromYaml_loadsConfigMap_withCorrectMetadata() throws IOException {
+ ResourceList resourceList = ResourceList.fromYaml(apiClient, MULTI_RESOURCE_YAML);
+
+ V1ConfigMap configMap = (V1ConfigMap) resourceList.getResources().get(0);
+ assertThat(configMap.getMetadata().getName()).isEqualTo("test-configmap");
+ assertThat(configMap.getMetadata().getNamespace()).isEqualTo("default");
+ }
+
+ @Test
+ void fromYaml_loadsSecret_withCorrectMetadata() throws IOException {
+ ResourceList resourceList = ResourceList.fromYaml(apiClient, MULTI_RESOURCE_YAML);
+
+ V1Secret secret = (V1Secret) resourceList.getResources().get(1);
+ assertThat(secret.getMetadata().getName()).isEqualTo("test-secret");
+ assertThat(secret.getMetadata().getNamespace()).isEqualTo("default");
+ }
+
+ @Test
+ void fromYaml_loadsPod_withCorrectMetadata() throws IOException {
+ ResourceList resourceList = ResourceList.fromYaml(apiClient, POD_YAML);
+
+ V1Pod pod = (V1Pod) resourceList.getResources().get(0);
+ assertThat(pod.getMetadata().getName()).isEqualTo("test-pod");
+ assertThat(pod.getMetadata().getNamespace()).isEqualTo("default");
+ }
+
+ private V1Pod createPod(String name, String namespace) {
+ V1ObjectMeta metadata = new V1ObjectMeta().name(name);
+ if (namespace != null) {
+ metadata.namespace(namespace);
+ }
+ return new V1Pod()
+ .apiVersion("v1")
+ .kind("Pod")
+ .metadata(metadata)
+ .spec(new V1PodSpec());
+ }
+}
diff --git a/util/src/test/java/io/kubernetes/client/util/ResourceLoaderTest.java b/util/src/test/java/io/kubernetes/client/util/ResourceLoaderTest.java
new file mode 100644
index 0000000000..3802302ac3
--- /dev/null
+++ b/util/src/test/java/io/kubernetes/client/util/ResourceLoaderTest.java
@@ -0,0 +1,298 @@
+/*
+Copyright 2024 The Kubernetes 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
+http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package io.kubernetes.client.util;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import io.kubernetes.client.common.KubernetesObject;
+import io.kubernetes.client.openapi.models.V1ConfigMap;
+import io.kubernetes.client.openapi.models.V1Deployment;
+import io.kubernetes.client.openapi.models.V1ObjectMeta;
+import io.kubernetes.client.openapi.models.V1Pod;
+import io.kubernetes.client.openapi.models.V1Secret;
+import io.kubernetes.client.openapi.models.V1Service;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class ResourceLoaderTest {
+
+ private static final String POD_YAML =
+ "apiVersion: v1\n" +
+ "kind: Pod\n" +
+ "metadata:\n" +
+ " name: test-pod\n" +
+ " namespace: default\n" +
+ "spec:\n" +
+ " containers:\n" +
+ " - name: nginx\n" +
+ " image: nginx:latest\n";
+
+ private static final String DEPLOYMENT_YAML =
+ "apiVersion: apps/v1\n" +
+ "kind: Deployment\n" +
+ "metadata:\n" +
+ " name: test-deployment\n" +
+ " namespace: default\n" +
+ "spec:\n" +
+ " replicas: 3\n" +
+ " selector:\n" +
+ " matchLabels:\n" +
+ " app: test\n" +
+ " template:\n" +
+ " metadata:\n" +
+ " labels:\n" +
+ " app: test\n" +
+ " spec:\n" +
+ " containers:\n" +
+ " - name: nginx\n" +
+ " image: nginx:latest\n";
+
+ private static final String MULTI_RESOURCE_YAML =
+ "apiVersion: v1\n" +
+ "kind: ConfigMap\n" +
+ "metadata:\n" +
+ " name: test-configmap\n" +
+ "data:\n" +
+ " key: value\n" +
+ "---\n" +
+ "apiVersion: v1\n" +
+ "kind: Secret\n" +
+ "metadata:\n" +
+ " name: test-secret\n" +
+ "type: Opaque\n" +
+ "data:\n" +
+ " password: cGFzc3dvcmQ=\n";
+
+ @Test
+ void load_fromInputStream_returnsPod() throws IOException {
+ InputStream is = new ByteArrayInputStream(POD_YAML.getBytes(StandardCharsets.UTF_8));
+
+ Object result = ResourceLoader.load(is);
+
+ assertThat(result).isInstanceOf(V1Pod.class);
+ V1Pod pod = (V1Pod) result;
+ assertThat(pod.getMetadata().getName()).isEqualTo("test-pod");
+ assertThat(pod.getMetadata().getNamespace()).isEqualTo("default");
+ }
+
+ @Test
+ void load_fromInputStream_returnsDeployment() throws IOException {
+ InputStream is = new ByteArrayInputStream(DEPLOYMENT_YAML.getBytes(StandardCharsets.UTF_8));
+
+ Object result = ResourceLoader.load(is);
+
+ assertThat(result).isInstanceOf(V1Deployment.class);
+ V1Deployment deployment = (V1Deployment) result;
+ assertThat(deployment.getMetadata().getName()).isEqualTo("test-deployment");
+ assertThat(deployment.getSpec().getReplicas()).isEqualTo(3);
+ }
+
+ @Test
+ void load_nullInputStream_throwsNullPointerException() {
+ assertThatThrownBy(() -> ResourceLoader.load((InputStream) null))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void load_fromInputStreamWithType_returnsTypedResource() throws IOException {
+ InputStream is = new ByteArrayInputStream(POD_YAML.getBytes(StandardCharsets.UTF_8));
+
+ V1Pod pod = ResourceLoader.load(is, V1Pod.class);
+
+ assertThat(pod).isNotNull();
+ assertThat(pod.getMetadata().getName()).isEqualTo("test-pod");
+ }
+
+ @Test
+ void loadAll_fromInputStream_returnsMultipleResources() throws IOException {
+ InputStream is = new ByteArrayInputStream(MULTI_RESOURCE_YAML.getBytes(StandardCharsets.UTF_8));
+
+ List resources = ResourceLoader.loadAll(is);
+
+ assertThat(resources).hasSize(2);
+ assertThat(resources.get(0)).isInstanceOf(V1ConfigMap.class);
+ assertThat(resources.get(1)).isInstanceOf(V1Secret.class);
+
+ V1ConfigMap configMap = (V1ConfigMap) resources.get(0);
+ assertThat(configMap.getMetadata().getName()).isEqualTo("test-configmap");
+
+ V1Secret secret = (V1Secret) resources.get(1);
+ assertThat(secret.getMetadata().getName()).isEqualTo("test-secret");
+ }
+
+ @Test
+ void loadAll_singleResource_returnsSingleElementList() throws IOException {
+ InputStream is = new ByteArrayInputStream(POD_YAML.getBytes(StandardCharsets.UTF_8));
+
+ List resources = ResourceLoader.loadAll(is);
+
+ assertThat(resources).hasSize(1);
+ assertThat(resources.get(0)).isInstanceOf(V1Pod.class);
+ }
+
+ @Test
+ void loadAll_emptyStream_returnsEmptyList() throws IOException {
+ InputStream is = new ByteArrayInputStream("".getBytes(StandardCharsets.UTF_8));
+
+ List resources = ResourceLoader.loadAll(is);
+
+ assertThat(resources).isEmpty();
+ }
+
+ @Test
+ void load_fromString_returnsResource() throws IOException {
+ Object result = ResourceLoader.load(POD_YAML);
+
+ assertThat(result).isInstanceOf(V1Pod.class);
+ V1Pod pod = (V1Pod) result;
+ assertThat(pod.getMetadata().getName()).isEqualTo("test-pod");
+ }
+
+ @Test
+ void loadAll_fromString_returnsMultipleResources() throws IOException {
+ List resources = ResourceLoader.loadAll(MULTI_RESOURCE_YAML);
+
+ assertThat(resources).hasSize(2);
+ assertThat(resources.get(0)).isInstanceOf(V1ConfigMap.class);
+ assertThat(resources.get(1)).isInstanceOf(V1Secret.class);
+ }
+
+ @Test
+ void load_fromUrl_returnsResource() throws IOException {
+ // Use a test resource file
+ URL url = getClass().getClassLoader().getResource("test-pod.yaml");
+ if (url != null) {
+ Object result = ResourceLoader.load(url);
+ assertThat(result).isInstanceOf(V1Pod.class);
+ }
+ // Skip test if resource not available
+ }
+
+ @Test
+ void load_fromFile_returnsResource() throws IOException {
+ // Use a test resource file
+ URL url = getClass().getClassLoader().getResource("test-pod.yaml");
+ if (url != null) {
+ File file = new File(url.getFile());
+ if (file.exists()) {
+ Object result = ResourceLoader.load(file);
+ assertThat(result).isInstanceOf(V1Pod.class);
+ }
+ }
+ // Skip test if resource not available
+ }
+
+ @Test
+ void loadAllFromFile_multiDocument_returnsAllResources() throws IOException {
+ // Use the test.yaml which has multiple documents
+ URL url = getClass().getClassLoader().getResource("test.yaml");
+ if (url != null) {
+ File file = new File(url.getFile());
+ if (file.exists()) {
+ List resources = ResourceLoader.loadAll(file);
+ assertThat(resources).isNotEmpty();
+ // test.yaml has Service, Deployment, and Secret
+ assertThat(resources.size()).isGreaterThanOrEqualTo(3);
+ }
+ }
+ }
+
+ @Test
+ void pluralize_commonKinds_returnsCorrectPlurals() {
+ // Test through loading since pluralize is private
+ // The pluralize logic is used internally for API path construction
+ assertThat(getPluralForKind("Pod")).isEqualTo("pods");
+ assertThat(getPluralForKind("Deployment")).isEqualTo("deployments");
+ assertThat(getPluralForKind("Service")).isEqualTo("services");
+ assertThat(getPluralForKind("ConfigMap")).isEqualTo("configmaps");
+ assertThat(getPluralForKind("Secret")).isEqualTo("secrets");
+ assertThat(getPluralForKind("DaemonSet")).isEqualTo("daemonsets");
+ assertThat(getPluralForKind("ReplicaSet")).isEqualTo("replicasets");
+ assertThat(getPluralForKind("StatefulSet")).isEqualTo("statefulsets");
+ assertThat(getPluralForKind("Job")).isEqualTo("jobs");
+ assertThat(getPluralForKind("CronJob")).isEqualTo("cronjobs");
+ assertThat(getPluralForKind("Policy")).isEqualTo("policies");
+ }
+
+ /**
+ * Helper to test pluralization by using reflection on the private pluralize method.
+ * Note: this mimics the pluralization logic but may differ from the actual implementation
+ * for special cases like Endpoints and Ingress.
+ */
+ private String getPluralForKind(String kind) {
+ // Use simple pluralization rules matching the implementation
+ String lower = kind.toLowerCase();
+ if (lower.endsWith("y") && lower.length() > 1) {
+ char beforeY = lower.charAt(lower.length() - 2);
+ if (beforeY != 'a' && beforeY != 'e' && beforeY != 'i' && beforeY != 'o' && beforeY != 'u') {
+ return lower.substring(0, lower.length() - 1) + "ies";
+ }
+ }
+ if (lower.endsWith("s") || lower.endsWith("x") || lower.endsWith("z")
+ || lower.endsWith("ch") || lower.endsWith("sh")) {
+ return lower + "es";
+ }
+ return lower + "s";
+ }
+
+ @Test
+ void loadWithNamespace_overridesNamespace() throws IOException {
+ String yamlWithoutNamespace =
+ "apiVersion: v1\n" +
+ "kind: Pod\n" +
+ "metadata:\n" +
+ " name: test-pod\n" +
+ "spec:\n" +
+ " containers:\n" +
+ " - name: nginx\n" +
+ " image: nginx:latest\n";
+
+ InputStream is = new ByteArrayInputStream(yamlWithoutNamespace.getBytes(StandardCharsets.UTF_8));
+ Object result = ResourceLoader.load(is);
+
+ assertThat(result).isInstanceOf(V1Pod.class);
+ V1Pod pod = (V1Pod) result;
+ // Without namespace in YAML, it should be null
+ assertThat(pod.getMetadata().getNamespace()).isNull();
+ }
+
+ @Test
+ void loadAll_withYamlSeparators_handlesMultipleDocuments() throws IOException {
+ String yaml =
+ "---\n" +
+ "apiVersion: v1\n" +
+ "kind: ConfigMap\n" +
+ "metadata:\n" +
+ " name: config1\n" +
+ "---\n" +
+ "apiVersion: v1\n" +
+ "kind: ConfigMap\n" +
+ "metadata:\n" +
+ " name: config2\n" +
+ "---\n";
+
+ InputStream is = new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8));
+ List resources = ResourceLoader.loadAll(is);
+
+ assertThat(resources).hasSize(2);
+ assertThat(((V1ConfigMap) resources.get(0)).getMetadata().getName()).isEqualTo("config1");
+ assertThat(((V1ConfigMap) resources.get(1)).getMetadata().getName()).isEqualTo("config2");
+ }
+}
diff --git a/util/src/test/java/io/kubernetes/client/util/WaitUtilsTest.java b/util/src/test/java/io/kubernetes/client/util/WaitUtilsTest.java
new file mode 100644
index 0000000000..5bfdd5af28
--- /dev/null
+++ b/util/src/test/java/io/kubernetes/client/util/WaitUtilsTest.java
@@ -0,0 +1,263 @@
+/*
+Copyright 2024 The Kubernetes 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
+http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package io.kubernetes.client.util;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import io.kubernetes.client.openapi.models.V1ObjectMeta;
+import io.kubernetes.client.openapi.models.V1Pod;
+import io.kubernetes.client.openapi.models.V1PodCondition;
+import io.kubernetes.client.openapi.models.V1PodStatus;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+import org.junit.jupiter.api.Test;
+
+class WaitUtilsTest {
+
+ @Test
+ void waitUntilReady_alreadyReady_returnsImmediately() throws Exception {
+ V1Pod readyPod = createReadyPod();
+ Supplier supplier = () -> readyPod;
+
+ V1Pod result = WaitUtils.waitUntilReady(supplier, Duration.ofSeconds(5));
+
+ assertThat(result).isEqualTo(readyPod);
+ }
+
+ @Test
+ void waitUntilReady_becomesReady_returnsWhenReady() throws Exception {
+ AtomicInteger callCount = new AtomicInteger(0);
+ V1Pod readyPod = createReadyPod();
+ V1Pod notReadyPod = createNotReadyPod();
+
+ Supplier supplier = () -> {
+ if (callCount.incrementAndGet() >= 3) {
+ return readyPod;
+ }
+ return notReadyPod;
+ };
+
+ V1Pod result = WaitUtils.waitUntilReady(supplier, Duration.ofSeconds(5), Duration.ofMillis(100));
+
+ assertThat(result).isEqualTo(readyPod);
+ assertThat(callCount.get()).isGreaterThanOrEqualTo(3);
+ }
+
+ @Test
+ void waitUntilReady_timesOut_throwsTimeoutException() {
+ V1Pod notReadyPod = createNotReadyPod();
+ Supplier supplier = () -> notReadyPod;
+
+ assertThatThrownBy(() ->
+ WaitUtils.waitUntilReady(supplier, Duration.ofMillis(200), Duration.ofMillis(50)))
+ .isInstanceOf(TimeoutException.class);
+ }
+
+ @Test
+ void waitUntilCondition_conditionMet_returnsResource() throws Exception {
+ V1Pod pod = new V1Pod()
+ .metadata(new V1ObjectMeta().name("test"))
+ .status(new V1PodStatus().phase("Running"));
+
+ V1Pod result = WaitUtils.waitUntilCondition(
+ () -> pod,
+ p -> "Running".equals(p.getStatus().getPhase()),
+ Duration.ofSeconds(5),
+ Duration.ofMillis(100));
+
+ assertThat(result).isEqualTo(pod);
+ }
+
+ @Test
+ void waitUntilCondition_becomesTrue_returnsWhenConditionMet() throws Exception {
+ AtomicInteger callCount = new AtomicInteger(0);
+
+ Supplier supplier = () -> {
+ V1Pod pod = new V1Pod().metadata(new V1ObjectMeta().name("test"));
+ if (callCount.incrementAndGet() >= 3) {
+ pod.setStatus(new V1PodStatus().phase("Running"));
+ } else {
+ pod.setStatus(new V1PodStatus().phase("Pending"));
+ }
+ return pod;
+ };
+
+ V1Pod result = WaitUtils.waitUntilCondition(
+ supplier,
+ p -> "Running".equals(p.getStatus().getPhase()),
+ Duration.ofSeconds(5),
+ Duration.ofMillis(100));
+
+ assertThat(result.getStatus().getPhase()).isEqualTo("Running");
+ }
+
+ @Test
+ void waitUntilCondition_nullResource_keepsPolling() {
+ AtomicInteger callCount = new AtomicInteger(0);
+ Supplier supplier = () -> {
+ callCount.incrementAndGet();
+ return null;
+ };
+
+ assertThatThrownBy(() ->
+ WaitUtils.waitUntilCondition(
+ supplier,
+ p -> true,
+ Duration.ofMillis(300),
+ Duration.ofMillis(50)))
+ .isInstanceOf(TimeoutException.class);
+
+ assertThat(callCount.get()).isGreaterThan(1);
+ }
+
+ @Test
+ void waitUntilCondition_supplierThrows_keepsPolling() throws Exception {
+ AtomicInteger callCount = new AtomicInteger(0);
+ V1Pod readyPod = createReadyPod();
+
+ Supplier supplier = () -> {
+ if (callCount.incrementAndGet() < 3) {
+ throw new RuntimeException("Simulated error");
+ }
+ return readyPod;
+ };
+
+ V1Pod result = WaitUtils.waitUntilCondition(
+ supplier,
+ p -> true,
+ Duration.ofSeconds(5),
+ Duration.ofMillis(100));
+
+ assertThat(result).isEqualTo(readyPod);
+ }
+
+ @Test
+ void waitUntilDeleted_resourceGone_returns() throws Exception {
+ AtomicInteger callCount = new AtomicInteger(0);
+
+ Supplier supplier = () -> {
+ if (callCount.incrementAndGet() >= 3) {
+ return null;
+ }
+ return createReadyPod();
+ };
+
+ WaitUtils.waitUntilDeleted(supplier, Duration.ofSeconds(5), Duration.ofMillis(100));
+
+ assertThat(callCount.get()).isGreaterThanOrEqualTo(3);
+ }
+
+ @Test
+ void waitUntilDeleted_supplierThrows_treatsAsDeleted() throws Exception {
+ AtomicInteger callCount = new AtomicInteger(0);
+
+ Supplier supplier = () -> {
+ if (callCount.incrementAndGet() >= 2) {
+ throw new RuntimeException("Not found");
+ }
+ return createReadyPod();
+ };
+
+ WaitUtils.waitUntilDeleted(supplier, Duration.ofSeconds(5), Duration.ofMillis(100));
+
+ assertThat(callCount.get()).isGreaterThanOrEqualTo(2);
+ }
+
+ @Test
+ void waitUntilDeleted_notDeleted_timesOut() {
+ Supplier supplier = () -> createReadyPod();
+
+ assertThatThrownBy(() ->
+ WaitUtils.waitUntilDeleted(supplier, Duration.ofMillis(200), Duration.ofMillis(50)))
+ .isInstanceOf(TimeoutException.class);
+ }
+
+ @Test
+ void waitUntilReadyAsync_returnsCompletableFuture() throws Exception {
+ V1Pod readyPod = createReadyPod();
+ Supplier supplier = () -> readyPod;
+
+ CompletableFuture future = WaitUtils.waitUntilReadyAsync(
+ supplier, Duration.ofSeconds(5), Duration.ofMillis(100));
+
+ assertThat(future).isNotNull();
+ assertThat(future.get()).isEqualTo(readyPod);
+ }
+
+ @Test
+ void waitUntilConditionAsync_returnsCompletableFuture() throws Exception {
+ V1Pod pod = new V1Pod()
+ .metadata(new V1ObjectMeta().name("test"))
+ .status(new V1PodStatus().phase("Running"));
+
+ CompletableFuture future = WaitUtils.waitUntilConditionAsync(
+ () -> pod,
+ p -> "Running".equals(p.getStatus().getPhase()),
+ Duration.ofSeconds(5),
+ Duration.ofMillis(100));
+
+ assertThat(future).isNotNull();
+ assertThat(future.get()).isEqualTo(pod);
+ }
+
+ @Test
+ void waitUntilCondition_nullSupplier_throwsNullPointerException() {
+ assertThatThrownBy(() ->
+ WaitUtils.waitUntilCondition(null, p -> true, Duration.ofSeconds(1), Duration.ofMillis(100)))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("resourceSupplier");
+ }
+
+ @Test
+ void waitUntilCondition_nullCondition_throwsNullPointerException() {
+ assertThatThrownBy(() ->
+ WaitUtils.waitUntilCondition(() -> createReadyPod(), null, Duration.ofSeconds(1), Duration.ofMillis(100)))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("condition");
+ }
+
+ @Test
+ void waitUntilCondition_nullTimeout_throwsNullPointerException() {
+ assertThatThrownBy(() ->
+ WaitUtils.waitUntilCondition(() -> createReadyPod(), p -> true, null, Duration.ofMillis(100)))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("timeout");
+ }
+
+ private V1Pod createReadyPod() {
+ return new V1Pod()
+ .metadata(new V1ObjectMeta().name("test-pod"))
+ .status(new V1PodStatus()
+ .phase("Running")
+ .conditions(List.of(
+ new V1PodCondition()
+ .type("Ready")
+ .status("True"))));
+ }
+
+ private V1Pod createNotReadyPod() {
+ return new V1Pod()
+ .metadata(new V1ObjectMeta().name("test-pod"))
+ .status(new V1PodStatus()
+ .phase("Pending")
+ .conditions(List.of(
+ new V1PodCondition()
+ .type("Ready")
+ .status("False"))));
+ }
+}