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 created = ResourceList.fromFile(apiClient, new File("app.yaml"))
+ *     .create();
+ *
+ * // Create multiple resources
+ * List resources = Arrays.asList(deployment, service, configMap);
+ * List created = ResourceList.from(apiClient, resources)
+ *     .inNamespace("my-namespace")
+ *     .create();
+ *
+ * // Delete multiple resources
+ * ResourceList.from(apiClient, resources)
+ *     .inNamespace("my-namespace")
+ *     .delete();
+ *
+ * // Wait for all resources to be ready
+ * ResourceList.from(apiClient, resources)
+ *     .waitUntilAllReady(Duration.ofMinutes(5));
+ *
+ * // Server-side apply all resources
+ * List applied = ResourceList.fromFile(apiClient, new File("app.yaml"))
+ *     .serverSideApply("my-controller");
+ * }
+ */
+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")))); + } +}