diff --git a/docs/yaml-create-resource.md b/docs/yaml-create-resource.md new file mode 100644 index 0000000000..b872675498 --- /dev/null +++ b/docs/yaml-create-resource.md @@ -0,0 +1,114 @@ +# Creating Kubernetes Resources from YAML + +This feature allows you to create Kubernetes resources from YAML without having to specify the resource type upfront, similar to `kubectl create -f file.yaml`. + +## Overview + +The `Yaml.createResource()` methods automatically: +1. Parse the YAML to extract `apiVersion` and `kind` +2. Determine the appropriate Java class for the resource type +3. Load the YAML into that strongly-typed object +4. Use the GenericKubernetesApi to create the resource in the cluster + +## Usage + +### Create from YAML String + +```java +ApiClient client = Config.defaultClient(); + +String yaml = + "apiVersion: v1\n" + + "kind: ConfigMap\n" + + "metadata:\n" + + " name: my-config\n" + + " namespace: default\n" + + "data:\n" + + " key: value\n"; + +Object resource = Yaml.createResource(client, yaml); +``` + +### Create from YAML File + +```java +ApiClient client = Config.defaultClient(); +File yamlFile = new File("my-resource.yaml"); + +Object resource = Yaml.createResource(client, yamlFile); +``` + +### Create from Reader + +```java +ApiClient client = Config.defaultClient(); +Reader reader = new FileReader("my-resource.yaml"); + +Object resource = Yaml.createResource(client, reader); +``` + +## Type Casting + +The returned object is the strongly-typed Kubernetes object, so you can cast it if needed: + +```java +Object result = Yaml.createResource(client, yaml); + +if (result instanceof V1ConfigMap) { + V1ConfigMap configMap = (V1ConfigMap) result; + System.out.println("Created ConfigMap: " + configMap.getMetadata().getName()); +} +``` + +## Supported Resources + +This feature works with any Kubernetes resource type that is registered in the ModelMapper, including: +- Core resources (Pod, Service, ConfigMap, Secret, etc.) +- Apps resources (Deployment, StatefulSet, DaemonSet, etc.) +- Custom resources that have been registered + +## Error Handling + +The method throws: +- `IOException` if there's an error reading or parsing the YAML +- `ApiException` if there's an error creating the resource in the cluster + +```java +try { + Object resource = Yaml.createResource(client, yaml); + System.out.println("Resource created successfully"); +} catch (IOException e) { + System.err.println("Failed to parse YAML: " + e.getMessage()); +} catch (ApiException e) { + System.err.println("Failed to create resource: " + e.getMessage()); +} +``` + +## Comparison with Kubectl + +This feature provides Java equivalent functionality to: + +```bash +kubectl create -f resource.yaml +``` + +Instead of having to know the resource type in advance and use type-specific APIs: + +```java +// Old way - you need to know it's a ConfigMap +V1ConfigMap configMap = Yaml.loadAs(yaml, V1ConfigMap.class); +CoreV1Api api = new CoreV1Api(); +api.createNamespacedConfigMap("default", configMap).execute(); + +// New way - works with any resource type +Object resource = Yaml.createResource(client, yaml); +``` + +## Discovery and Resource Mapping + +The feature uses API discovery to determine the correct resource plural name and API group. On first use, it may perform discovery to refresh the ModelMapper cache. Subsequent calls will use the cached information. + +## See Also + +- [YamlCreateResourceExample.java](../examples/examples-release-latest/src/main/java/io/kubernetes/client/examples/YamlCreateResourceExample.java) - Complete working example +- [YamlCreateResourceTest.java](../util/src/test/java/io/kubernetes/client/util/YamlCreateResourceTest.java) - Comprehensive tests diff --git a/examples/examples-release-latest/src/main/java/io/kubernetes/client/examples/YamlCreateResourceExample.java b/examples/examples-release-latest/src/main/java/io/kubernetes/client/examples/YamlCreateResourceExample.java new file mode 100644 index 0000000000..873d066d46 --- /dev/null +++ b/examples/examples-release-latest/src/main/java/io/kubernetes/client/examples/YamlCreateResourceExample.java @@ -0,0 +1,105 @@ +/* +Copyright 2020 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.examples; + +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.Configuration; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import io.kubernetes.client.openapi.models.V1Pod; +import io.kubernetes.client.util.Config; +import io.kubernetes.client.util.Yaml; +import java.io.File; +import java.io.IOException; + +/** + * A simple example of how to use Yaml.createResource() to create Kubernetes resources from YAML + * without specifying the type upfront. This is equivalent to `kubectl create -f `. + * + *

Easiest way to run this: mvn exec:java + * -Dexec.mainClass="io.kubernetes.client.examples.YamlCreateResourceExample" + * + *

From inside $REPO_DIR/examples + */ +public class YamlCreateResourceExample { + public static void main(String[] args) throws IOException, ApiException { + // Initialize the API client + ApiClient client = Config.defaultClient(); + Configuration.setDefaultApiClient(client); + + // Example 1: Create a ConfigMap from YAML string + // This method automatically determines the resource type (ConfigMap) + // and uses the appropriate API to create it + String configMapYaml = + "apiVersion: v1\n" + + "kind: ConfigMap\n" + + "metadata:\n" + + " name: example-config\n" + + " namespace: default\n" + + "data:\n" + + " database.url: jdbc:postgresql://localhost/mydb\n" + + " database.user: admin\n"; + + System.out.println("Creating ConfigMap from YAML string..."); + Object configMapResult = Yaml.createResource(client, configMapYaml); + System.out.println("Created: " + configMapResult); + + // Example 2: Create a Pod from YAML string + // Again, no need to specify V1Pod.class - the method determines it automatically + String podYaml = + "apiVersion: v1\n" + + "kind: Pod\n" + + "metadata:\n" + + " name: example-pod\n" + + " namespace: default\n" + + "spec:\n" + + " containers:\n" + + " - name: nginx\n" + + " image: nginx:1.14.2\n" + + " ports:\n" + + " - containerPort: 80\n"; + + System.out.println("\nCreating Pod from YAML string..."); + Object podResult = Yaml.createResource(client, podYaml); + System.out.println("Created: " + podResult); + + // Example 3: Create a resource from a YAML file + // This works with any Kubernetes resource type + File yamlFile = new File("example-resource.yaml"); + if (yamlFile.exists()) { + System.out.println("\nCreating resource from YAML file..."); + Object fileResult = Yaml.createResource(client, yamlFile); + System.out.println("Created: " + fileResult); + } + + // Example 4: Type casting if you need to access specific fields + // The returned object is the strongly-typed Kubernetes object + V1ConfigMap configMap = (V1ConfigMap) configMapResult; + System.out.println("\nConfigMap name: " + configMap.getMetadata().getName()); + System.out.println("ConfigMap data: " + configMap.getData()); + + V1Pod pod = (V1Pod) podResult; + System.out.println("\nPod name: " + pod.getMetadata().getName()); + System.out.println("Pod phase: " + pod.getStatus().getPhase()); + + // Clean up - delete the created resources + CoreV1Api api = new CoreV1Api(); + System.out.println("\nCleaning up..."); + api.deleteNamespacedConfigMap("example-config", "default").execute(); + System.out.println("Deleted ConfigMap"); + + api.deleteNamespacedPod("example-pod", "default").execute(); + System.out.println("Deleted Pod"); + } +} diff --git a/util/src/main/java/io/kubernetes/client/util/Yaml.java b/util/src/main/java/io/kubernetes/client/util/Yaml.java index e8c02999ca..79ab06af9c 100644 --- a/util/src/main/java/io/kubernetes/client/util/Yaml.java +++ b/util/src/main/java/io/kubernetes/client/util/Yaml.java @@ -566,4 +566,162 @@ private static Object modelMapper(Map data) throws IOException { public static void addModelMap(String apiGroupVersion, String kind, Class clazz) { ModelMapper.addModelMap(apiGroupVersion, kind, clazz); } + + /** + * Create a Kubernetes resource from a YAML string. This method automatically determines the + * resource type from the YAML content (apiVersion and kind) and uses the appropriate API to + * create the resource. + * + *

This is equivalent to `kubectl create -f `. + * + *

Example usage: + *

{@code
+   * ApiClient client = Config.defaultClient();
+   * String yaml = "apiVersion: v1\n" +
+   *               "kind: ConfigMap\n" +
+   *               "metadata:\n" +
+   *               "  name: my-config\n" +
+   *               "  namespace: default\n";
+   * Object created = Yaml.createResource(client, yaml);
+   * }
+ * + * @param client The API client to use for creating the resource + * @param content The YAML content as a string + * @return The created resource object + * @throws IOException If an error occurs while reading or parsing the YAML + * @throws io.kubernetes.client.openapi.ApiException If an error occurs while creating the resource in the cluster + */ + public static Object createResource(io.kubernetes.client.openapi.ApiClient client, String content) + throws IOException, io.kubernetes.client.openapi.ApiException { + return createResource(client, new StringReader(content)); + } + + /** + * Create a Kubernetes resource from a YAML file. This method automatically determines the + * resource type from the YAML content (apiVersion and kind) and uses the appropriate API to + * create the resource. + * + *

This is equivalent to `kubectl create -f `. + * + * @param client The API client to use for creating the resource + * @param f The YAML file to load + * @return The created resource object + * @throws IOException If an error occurs while reading or parsing the YAML + * @throws io.kubernetes.client.openapi.ApiException If an error occurs while creating the resource in the cluster + */ + public static Object createResource(io.kubernetes.client.openapi.ApiClient client, File f) + throws IOException, io.kubernetes.client.openapi.ApiException { + return createResource(client, new FileReader(f)); + } + + /** + * Create a Kubernetes resource from a YAML stream. This method automatically determines the + * resource type from the YAML content (apiVersion and kind) and uses the appropriate API to + * create the resource. + * + *

This is equivalent to `kubectl create -f `. + * + * @param client The API client to use for creating the resource + * @param reader The stream to load + * @return The created resource object + * @throws IOException If an error occurs while reading or parsing the YAML + * @throws io.kubernetes.client.openapi.ApiException If an error occurs while creating the resource in the cluster + */ + public static Object createResource(io.kubernetes.client.openapi.ApiClient client, Reader reader) + throws IOException, io.kubernetes.client.openapi.ApiException { + // Load the YAML as a map to extract apiVersion and kind + // Note: The getSnakeYaml() method already configures LoaderOptions with appropriate + // security settings to prevent YAML bombs and other attacks + Map data = getSnakeYaml(null).load(reader); + + String kind = (String) data.get("kind"); + if (kind == null) { + throw new IOException("Missing kind in YAML!"); + } + String apiVersion = (String) data.get("apiVersion"); + if (apiVersion == null) { + throw new IOException("Missing apiVersion in YAML!"); + } + + // Use ModelMapper to get the appropriate class for this resource type + Class clazz = ModelMapper.getApiTypeClass(apiVersion, kind); + if (clazz == null) { + throw new IOException( + "Unknown apiVersion/kind: " + apiVersion + "/" + kind + ". Is it registered?"); + } + + // Load the YAML into the strongly typed object + // Note: This double-loading approach (first as Map, then as typed object) follows the + // design recommended in the issue discussion to properly handle type determination + Object resource = loadAs(new StringReader(getSnakeYaml(clazz).dump(data)), clazz); + + // Ensure the resource is a KubernetesObject + if (!(resource instanceof io.kubernetes.client.common.KubernetesObject)) { + throw new IOException( + "Resource is not a KubernetesObject: " + resource.getClass().getName()); + } + + io.kubernetes.client.common.KubernetesObject k8sObject = + (io.kubernetes.client.common.KubernetesObject) resource; + + // Parse apiVersion to extract group and version + io.kubernetes.client.apimachinery.GroupVersionKind gvk = + ModelMapper.groupVersionKindFromApiVersionAndKind(apiVersion, kind); + + // Get the resource metadata to determine the plural name + io.kubernetes.client.apimachinery.GroupVersionResource gvr = + ModelMapper.getGroupVersionResourceByClass(clazz); + + if (gvr == null) { + // If no GVR mapping exists, we need to perform discovery + io.kubernetes.client.Discovery discovery = new io.kubernetes.client.Discovery(client); + ModelMapper.refresh(discovery); + gvr = ModelMapper.getGroupVersionResourceByClass(clazz); + + if (gvr == null) { + throw new IOException( + "Unable to determine resource plural name for " + apiVersion + "/" + kind); + } + } + + // Create a GenericKubernetesApi for this resource type + io.kubernetes.client.util.generic.GenericKubernetesApi< + io.kubernetes.client.common.KubernetesObject, + io.kubernetes.client.common.KubernetesListObject> + api = + new io.kubernetes.client.util.generic.GenericKubernetesApi<>( + (Class) clazz, + io.kubernetes.client.common.KubernetesListObject.class, + gvk.getGroup(), + gvk.getVersion(), + gvr.getResource(), + client); + + // Create the resource + io.kubernetes.client.util.generic.KubernetesApiResponse< + io.kubernetes.client.common.KubernetesObject> + response; + + Boolean isNamespaced = ModelMapper.isNamespaced(clazz); + if (isNamespaced != null && isNamespaced) { + // For namespaced resources + String namespace = k8sObject.getMetadata().getNamespace(); + if (namespace == null || namespace.isEmpty()) { + // Default to "default" namespace, matching kubectl behavior + namespace = "default"; + } + response = api.create(namespace, k8sObject, new io.kubernetes.client.util.generic.options.CreateOptions()); + } else { + // For cluster-scoped resources + response = api.create(k8sObject, new io.kubernetes.client.util.generic.options.CreateOptions()); + } + + if (!response.isSuccess()) { + throw new io.kubernetes.client.openapi.ApiException( + response.getHttpStatusCode(), + "Failed to create resource: " + response.getStatus()); + } + + return response.getObject(); + } } diff --git a/util/src/test/java/io/kubernetes/client/util/YamlCreateResourceTest.java b/util/src/test/java/io/kubernetes/client/util/YamlCreateResourceTest.java new file mode 100644 index 0000000000..75169c549c --- /dev/null +++ b/util/src/test/java/io/kubernetes/client/util/YamlCreateResourceTest.java @@ -0,0 +1,252 @@ +/* +Copyright 2020 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.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import io.kubernetes.client.openapi.models.V1Deployment; +import io.kubernetes.client.openapi.models.V1Pod; +import io.kubernetes.client.util.ClientBuilder; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** Test for Yaml.createResource() functionality */ +class YamlCreateResourceTest { + + private static final String DISCOVERY_API = + new File( + YamlCreateResourceTest.class + .getClassLoader() + .getResource("discovery-api.json") + .getPath()) + .toString(); + + private static final String DISCOVERY_APIV1 = + new File( + YamlCreateResourceTest.class + .getClassLoader() + .getResource("discovery-api-v1.json") + .getPath()) + .toString(); + + private static final String DISCOVERY_APIS = + new File( + YamlCreateResourceTest.class + .getClassLoader() + .getResource("discovery-apis-with-apps.json") + .getPath()) + .toString(); + + private static final String DISCOVERY_APPS_V1 = + new File( + YamlCreateResourceTest.class + .getClassLoader() + .getResource("discovery-apps-v1.json") + .getPath()) + .toString(); + + private ApiClient apiClient; + + @RegisterExtension + static WireMockExtension apiServer = + WireMockExtension.newInstance().options(wireMockConfig().dynamicPort()).build(); + + @BeforeEach + void setup() { + apiClient = new ClientBuilder().setBasePath("http://localhost:" + apiServer.getPort()).build(); + } + + @Test + void createConfigMapFromYaml() throws IOException, ApiException { + String configMapYaml = + "apiVersion: v1\n" + + "kind: ConfigMap\n" + + "metadata:\n" + + " name: test-config\n" + + " namespace: default\n" + + "data:\n" + + " key1: value1\n"; + + apiServer.stubFor( + post(urlPathEqualTo("/api/v1/namespaces/default/configmaps")) + .willReturn( + aResponse() + .withStatus(200) + .withBody( + "{\"apiVersion\":\"v1\",\"kind\":\"ConfigMap\"," + + "\"metadata\":{\"name\":\"test-config\",\"namespace\":\"default\"}," + + "\"data\":{\"key1\":\"value1\"}}"))); + apiServer.stubFor( + get(urlPathEqualTo("/api")) + .willReturn( + aResponse() + .withStatus(200) + .withBody(new String(Files.readAllBytes(Paths.get(DISCOVERY_API)))))); + apiServer.stubFor( + get(urlPathEqualTo("/apis")) + .willReturn( + aResponse() + .withStatus(200) + .withBody(new String(Files.readAllBytes(Paths.get(DISCOVERY_APIS)))))); + apiServer.stubFor( + get(urlPathEqualTo("/api/v1")) + .willReturn( + aResponse() + .withStatus(200) + .withBody(new String(Files.readAllBytes(Paths.get(DISCOVERY_APIV1)))))); + + Object result = Yaml.createResource(apiClient, configMapYaml); + + apiServer.verify(1, postRequestedFor(urlPathEqualTo("/api/v1/namespaces/default/configmaps"))); + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(V1ConfigMap.class); + + V1ConfigMap configMap = (V1ConfigMap) result; + assertThat(configMap.getMetadata().getName()).isEqualTo("test-config"); + assertThat(configMap.getMetadata().getNamespace()).isEqualTo("default"); + assertThat(configMap.getData()).containsEntry("key1", "value1"); + } + + @Test + void createPodFromYaml() throws IOException, ApiException { + String podYaml = + "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"; + + apiServer.stubFor( + post(urlPathEqualTo("/api/v1/namespaces/default/pods")) + .willReturn( + aResponse() + .withStatus(200) + .withBody( + "{\"apiVersion\":\"v1\",\"kind\":\"Pod\"," + + "\"metadata\":{\"name\":\"test-pod\",\"namespace\":\"default\"}," + + "\"spec\":{\"containers\":[{\"name\":\"nginx\",\"image\":\"nginx:latest\"}]}}"))); + apiServer.stubFor( + get(urlPathEqualTo("/api")) + .willReturn( + aResponse() + .withStatus(200) + .withBody(new String(Files.readAllBytes(Paths.get(DISCOVERY_API)))))); + apiServer.stubFor( + get(urlPathEqualTo("/apis")) + .willReturn( + aResponse() + .withStatus(200) + .withBody(new String(Files.readAllBytes(Paths.get(DISCOVERY_APIS)))))); + apiServer.stubFor( + get(urlPathEqualTo("/api/v1")) + .willReturn( + aResponse() + .withStatus(200) + .withBody(new String(Files.readAllBytes(Paths.get(DISCOVERY_APIV1)))))); + + Object result = Yaml.createResource(apiClient, podYaml); + + apiServer.verify(1, postRequestedFor(urlPathEqualTo("/api/v1/namespaces/default/pods"))); + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(V1Pod.class); + + V1Pod pod = (V1Pod) result; + assertThat(pod.getMetadata().getName()).isEqualTo("test-pod"); + assertThat(pod.getMetadata().getNamespace()).isEqualTo("default"); + } + + @Test + void createDeploymentFromYaml() throws IOException, ApiException { + String deploymentYaml = + "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"; + + apiServer.stubFor( + post(urlPathEqualTo("/apis/apps/v1/namespaces/default/deployments")) + .willReturn( + aResponse() + .withStatus(200) + .withBody( + "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\"," + + "\"metadata\":{\"name\":\"test-deployment\",\"namespace\":\"default\"}," + + "\"spec\":{\"replicas\":3,\"selector\":{\"matchLabels\":{\"app\":\"test\"}}," + + "\"template\":{\"metadata\":{\"labels\":{\"app\":\"test\"}}," + + "\"spec\":{\"containers\":[{\"name\":\"nginx\",\"image\":\"nginx:latest\"}]}}}}"))); + apiServer.stubFor( + get(urlPathEqualTo("/api")) + .willReturn( + aResponse() + .withStatus(200) + .withBody(new String(Files.readAllBytes(Paths.get(DISCOVERY_API)))))); + apiServer.stubFor( + get(urlPathEqualTo("/apis")) + .willReturn( + aResponse() + .withStatus(200) + .withBody(new String(Files.readAllBytes(Paths.get(DISCOVERY_APIS)))))); + apiServer.stubFor( + get(urlPathEqualTo("/api/v1")) + .willReturn( + aResponse() + .withStatus(200) + .withBody(new String(Files.readAllBytes(Paths.get(DISCOVERY_APIV1)))))); + apiServer.stubFor( + get(urlPathEqualTo("/apis/apps/v1")) + .willReturn( + aResponse() + .withStatus(200) + .withBody(new String(Files.readAllBytes(Paths.get(DISCOVERY_APPS_V1)))))); + + Object result = Yaml.createResource(apiClient, deploymentYaml); + + apiServer.verify( + 1, postRequestedFor(urlPathEqualTo("/apis/apps/v1/namespaces/default/deployments"))); + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(V1Deployment.class); + + V1Deployment deployment = (V1Deployment) result; + assertThat(deployment.getMetadata().getName()).isEqualTo("test-deployment"); + assertThat(deployment.getMetadata().getNamespace()).isEqualTo("default"); + } +} diff --git a/util/src/test/java/io/kubernetes/client/util/YamlTest.java b/util/src/test/java/io/kubernetes/client/util/YamlTest.java index 26f536a9a7..0a54c27851 100644 --- a/util/src/test/java/io/kubernetes/client/util/YamlTest.java +++ b/util/src/test/java/io/kubernetes/client/util/YamlTest.java @@ -18,6 +18,7 @@ import io.kubernetes.client.Resources; import io.kubernetes.client.common.KubernetesType; +import io.kubernetes.client.openapi.models.V1ConfigMap; import io.kubernetes.client.openapi.models.V1CustomResourceDefinition; import io.kubernetes.client.openapi.models.V1Deployment; import io.kubernetes.client.openapi.models.V1ObjectMeta; @@ -265,4 +266,34 @@ void loadDumpCRDWithIntOrStringExtension() { String dumped = Yaml.dump(crd); assertThat(dumped).isEqualTo(data); } + + @Test + void createResourceFromYaml() throws Exception { + // This test validates that the createResource method can parse YAML + // and determine the correct resource type without requiring the caller + // to specify the type upfront. + + String configMapYaml = + "apiVersion: v1\n" + + "kind: ConfigMap\n" + + "metadata:\n" + + " name: test-config\n" + + " namespace: default\n" + + "data:\n" + + " key1: value1\n"; + + // Note: This test only validates that the YAML can be parsed and the + // correct type is determined. It does not actually create the resource + // in a cluster, as that would require a real or mocked API server. + // The actual creation logic is tested in integration tests. + + // Test that we can load the YAML and determine the type + Object obj = Yaml.load(configMapYaml); + assertThat(obj).isInstanceOf(V1ConfigMap.class); + + V1ConfigMap configMap = (V1ConfigMap) obj; + assertThat(configMap.getMetadata().getName()).isEqualTo("test-config"); + assertThat(configMap.getMetadata().getNamespace()).isEqualTo("default"); + assertThat(configMap.getData()).containsEntry("key1", "value1"); + } } diff --git a/util/src/test/resources/discovery-api-v1.json b/util/src/test/resources/discovery-api-v1.json new file mode 100644 index 0000000000..5518eae2c1 --- /dev/null +++ b/util/src/test/resources/discovery-api-v1.json @@ -0,0 +1 @@ +{"kind":"APIResourceList","groupVersion":"v1","resources":[{"name":"bindings","singularName":"","namespaced":true,"kind":"Binding","verbs":["create"]},{"name":"componentstatuses","singularName":"","namespaced":false,"kind":"ComponentStatus","verbs":["get","list"],"shortNames":["cs"]},{"name":"configmaps","singularName":"","namespaced":true,"kind":"ConfigMap","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"shortNames":["cm"],"storageVersionHash":"qFsyl6wFWjQ="},{"name":"endpoints","singularName":"","namespaced":true,"kind":"Endpoints","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"shortNames":["ep"],"storageVersionHash":"fWeeMqaN/OA="},{"name":"events","singularName":"","namespaced":true,"kind":"Event","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"shortNames":["ev"],"storageVersionHash":"r2yiGXH7wu8="},{"name":"limitranges","singularName":"","namespaced":true,"kind":"LimitRange","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"shortNames":["limits"],"storageVersionHash":"EBKMFVe6cwo="},{"name":"namespaces","singularName":"","namespaced":false,"kind":"Namespace","verbs":["create","delete","get","list","patch","update","watch"],"shortNames":["ns"],"storageVersionHash":"Q3oi5N2YM8M="},{"name":"namespaces/finalize","singularName":"","namespaced":false,"kind":"Namespace","verbs":["update"]},{"name":"namespaces/status","singularName":"","namespaced":false,"kind":"Namespace","verbs":["get","patch","update"]},{"name":"nodes","singularName":"","namespaced":false,"kind":"Node","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"shortNames":["no"],"storageVersionHash":"XwShjMxG9Fs="},{"name":"nodes/proxy","singularName":"","namespaced":false,"kind":"NodeProxyOptions","verbs":["create","delete","get","patch","update"]},{"name":"nodes/status","singularName":"","namespaced":false,"kind":"Node","verbs":["get","patch","update"]},{"name":"persistentvolumeclaims","singularName":"","namespaced":true,"kind":"PersistentVolumeClaim","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"shortNames":["pvc"],"storageVersionHash":"QWTyNDq0dC4="},{"name":"persistentvolumeclaims/status","singularName":"","namespaced":true,"kind":"PersistentVolumeClaim","verbs":["get","patch","update"]},{"name":"persistentvolumes","singularName":"","namespaced":false,"kind":"PersistentVolume","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"shortNames":["pv"],"storageVersionHash":"HN/zwEC+JgM="},{"name":"persistentvolumes/status","singularName":"","namespaced":false,"kind":"PersistentVolume","verbs":["get","patch","update"]},{"name":"pods","singularName":"","namespaced":true,"kind":"Pod","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"shortNames":["po"],"categories":["all"],"storageVersionHash":"xPOwRZ+Yhw8="},{"name":"pods/attach","singularName":"","namespaced":true,"kind":"PodAttachOptions","verbs":["create","get"]},{"name":"pods/binding","singularName":"","namespaced":true,"kind":"Binding","verbs":["create"]},{"name":"pods/eviction","singularName":"","namespaced":true,"group":"policy","version":"v1beta1","kind":"Eviction","verbs":["create"]},{"name":"pods/exec","singularName":"","namespaced":true,"kind":"PodExecOptions","verbs":["create","get"]},{"name":"pods/log","singularName":"","namespaced":true,"kind":"Pod","verbs":["get"]},{"name":"pods/portforward","singularName":"","namespaced":true,"kind":"PodPortForwardOptions","verbs":["create","get"]},{"name":"pods/proxy","singularName":"","namespaced":true,"kind":"PodProxyOptions","verbs":["create","delete","get","patch","update"]},{"name":"pods/status","singularName":"","namespaced":true,"kind":"Pod","verbs":["get","patch","update"]},{"name":"podtemplates","singularName":"","namespaced":true,"kind":"PodTemplate","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"storageVersionHash":"LIXB2x4IFpk="},{"name":"replicationcontrollers","singularName":"","namespaced":true,"kind":"ReplicationController","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"shortNames":["rc"],"categories":["all"],"storageVersionHash":"Jond2If31h0="},{"name":"replicationcontrollers/scale","singularName":"","namespaced":true,"group":"autoscaling","version":"v1","kind":"Scale","verbs":["get","patch","update"]},{"name":"replicationcontrollers/status","singularName":"","namespaced":true,"kind":"ReplicationController","verbs":["get","patch","update"]},{"name":"resourcequotas","singularName":"","namespaced":true,"kind":"ResourceQuota","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"shortNames":["quota"],"storageVersionHash":"8uhSgffRX6w="},{"name":"resourcequotas/status","singularName":"","namespaced":true,"kind":"ResourceQuota","verbs":["get","patch","update"]},{"name":"secrets","singularName":"","namespaced":true,"kind":"Secret","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"storageVersionHash":"S6u1pOWzb84="},{"name":"serviceaccounts","singularName":"","namespaced":true,"kind":"ServiceAccount","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"shortNames":["sa"],"storageVersionHash":"pbx9ZvyFpBE="},{"name":"services","singularName":"","namespaced":true,"kind":"Service","verbs":["create","delete","get","list","patch","update","watch"],"shortNames":["svc"],"categories":["all"],"storageVersionHash":"0/CO1lhkEBI="},{"name":"services/proxy","singularName":"","namespaced":true,"kind":"ServiceProxyOptions","verbs":["create","delete","get","patch","update"]},{"name":"services/status","singularName":"","namespaced":true,"kind":"Service","verbs":["get","patch","update"]}]} \ No newline at end of file diff --git a/util/src/test/resources/discovery-api.json b/util/src/test/resources/discovery-api.json new file mode 100644 index 0000000000..15ae6ec90e --- /dev/null +++ b/util/src/test/resources/discovery-api.json @@ -0,0 +1 @@ +{"kind":"APIVersions","versions":["v1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0","serverAddress":"192.168.65.3:6443"}]} \ No newline at end of file diff --git a/util/src/test/resources/discovery-apis-with-apps.json b/util/src/test/resources/discovery-apis-with-apps.json new file mode 100644 index 0000000000..de01c17579 --- /dev/null +++ b/util/src/test/resources/discovery-apis-with-apps.json @@ -0,0 +1 @@ +{"kind":"APIGroupList","apiVersion":"v1","groups":[{"name":"apps","versions":[{"groupVersion":"apps/v1","version":"v1"}],"preferredVersion":{"groupVersion":"apps/v1","version":"v1"}}]} diff --git a/util/src/test/resources/discovery-apis.json b/util/src/test/resources/discovery-apis.json new file mode 100644 index 0000000000..de832c919e --- /dev/null +++ b/util/src/test/resources/discovery-apis.json @@ -0,0 +1 @@ +{"kind":"APIGroupList","apiVersion":"v1","groups":[]} \ No newline at end of file diff --git a/util/src/test/resources/discovery-apps-v1.json b/util/src/test/resources/discovery-apps-v1.json new file mode 100644 index 0000000000..236778e886 --- /dev/null +++ b/util/src/test/resources/discovery-apps-v1.json @@ -0,0 +1 @@ +{"kind":"APIResourceList","groupVersion":"apps/v1","resources":[{"name":"controllerrevisions","singularName":"","namespaced":true,"kind":"ControllerRevision","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"storageVersionHash":"85nkx63pcBU="},{"name":"daemonsets","singularName":"","namespaced":true,"kind":"DaemonSet","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"shortNames":["ds"],"categories":["all"],"storageVersionHash":"dd7pWHUlMKQ="},{"name":"daemonsets/status","singularName":"","namespaced":true,"kind":"DaemonSet","verbs":["get","patch","update"]},{"name":"deployments","singularName":"","namespaced":true,"kind":"Deployment","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"shortNames":["deploy"],"categories":["all"],"storageVersionHash":"8aSe+NMegvE="},{"name":"deployments/scale","singularName":"","namespaced":true,"group":"autoscaling","version":"v1","kind":"Scale","verbs":["get","patch","update"]},{"name":"deployments/status","singularName":"","namespaced":true,"kind":"Deployment","verbs":["get","patch","update"]},{"name":"replicasets","singularName":"","namespaced":true,"kind":"ReplicaSet","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"shortNames":["rs"],"categories":["all"],"storageVersionHash":"P1RzHs8/mWQ="},{"name":"replicasets/scale","singularName":"","namespaced":true,"group":"autoscaling","version":"v1","kind":"Scale","verbs":["get","patch","update"]},{"name":"replicasets/status","singularName":"","namespaced":true,"kind":"ReplicaSet","verbs":["get","patch","update"]},{"name":"statefulsets","singularName":"","namespaced":true,"kind":"StatefulSet","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"shortNames":["sts"],"categories":["all"],"storageVersionHash":"H+vl74LkKdo="},{"name":"statefulsets/scale","singularName":"","namespaced":true,"group":"autoscaling","version":"v1","kind":"Scale","verbs":["get","patch","update"]},{"name":"statefulsets/status","singularName":"","namespaced":true,"kind":"StatefulSet","verbs":["get","patch","update"]}]}