diff --git a/CHANGELOG.md b/CHANGELOG.md index 62ee9f03ac5..39269beca6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ * Fix #2980: Add DSL Support for `apps/v1#ControllerRevision` resource * Fix #2981: Add DSL support for `storage.k8s.io/v1beta1` CSIDriver, CSINode and VolumeAttachment * Fix #2912: Add DSL support for `storage.k8s.io/v1beta1` CSIStorageCapacity +* Fix #2701: Better support for patching in KuberntesClient #### _**Note**_: Breaking changes in the API ##### DSL Changes: diff --git a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/Patchable.java b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/Patchable.java index 5b963fd6a29..850c7d94a85 100644 --- a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/Patchable.java +++ b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/Patchable.java @@ -15,8 +15,35 @@ */ package io.fabric8.kubernetes.client.dsl; +import io.fabric8.kubernetes.client.dsl.base.PatchContext; + public interface Patchable { + /** + * Update field(s) of a resource using a JSON patch. + * + * @param item item to be patched with patched values + * @return returns deserialized version of api server response + */ T patch(T item); + /** + * Update field(s) of a resource using strategic merge patch. + * + * @param patch The patch to be applied to the resource JSON file. + * @return returns deserialized version of api server response + */ + default T patch(String patch) { + return patch(null, patch); + } + + /** + * Update field(s) of a resource using type specified in {@link PatchContext}(defaults to strategic merge if not specified). + * + * @param patchContext {@link PatchContext} for patch request + * @param patch The patch to be applied to the resource JSON file. + * @return The patch to be applied to the resource JSON file. + */ + T patch(PatchContext patchContext, String patch); + } diff --git a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/base/BaseOperation.java b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/base/BaseOperation.java index 9173ab3be9d..827076b71b7 100755 --- a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/base/BaseOperation.java +++ b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/base/BaseOperation.java @@ -98,6 +98,8 @@ public class BaseOperation function) { - throw new KubernetesClientException("Cannot edit read-only resources"); + throw new KubernetesClientException(READ_ONLY_EDIT_EXCEPTION_MESSAGE); } @Override public T edit(Visitor... visitors) { - throw new KubernetesClientException("Cannot edit read-only resources"); + throw new KubernetesClientException(READ_ONLY_EDIT_EXCEPTION_MESSAGE); } @Override @@ -270,7 +272,7 @@ public void visit(V item) { @Override public T accept(Consumer consumer) { - throw new KubernetesClientException("Cannot edit read-only resources"); + throw new KubernetesClientException(READ_ONLY_EDIT_EXCEPTION_MESSAGE); } @Override @@ -848,12 +850,17 @@ public Watch watch(ListOptions options, final Watcher watcher) { @Override public T replace(T item) { - throw new KubernetesClientException("Cannot update read-only resources"); + throw new KubernetesClientException(READ_ONLY_UPDATE_EXCEPTION_MESSAGE); } @Override public T patch(T item) { - throw new KubernetesClientException("Cannot update read-only resources"); + throw new KubernetesClientException(READ_ONLY_UPDATE_EXCEPTION_MESSAGE); + } + + @Override + public T patch(PatchContext patchContext, String patch) { + throw new KubernetesClientException(READ_ONLY_UPDATE_EXCEPTION_MESSAGE); } @Override diff --git a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/base/HasMetadataOperation.java b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/base/HasMetadataOperation.java index 9b28d64994b..b600ed78d33 100644 --- a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/base/HasMetadataOperation.java +++ b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/base/HasMetadataOperation.java @@ -21,13 +21,19 @@ import io.fabric8.kubernetes.api.model.KubernetesResourceList; import io.fabric8.kubernetes.client.KubernetesClientException; import io.fabric8.kubernetes.client.dsl.Resource; + +import java.io.IOException; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.function.UnaryOperator; +import static io.fabric8.kubernetes.client.utils.IOHelpers.convertToJson; + public class HasMetadataOperation, R extends Resource> extends BaseOperation< T, L, R> { public static final DeletionPropagation DEFAULT_PROPAGATION_POLICY = DeletionPropagation.BACKGROUND; public static final long DEFAULT_GRACE_PERIOD_IN_SECONDS = -1L; + private static final String PATCH_OPERATION = "patch"; public HasMetadataOperation(OperationContext ctx) { super(ctx); @@ -119,7 +125,7 @@ public T patch(T item) { resource.getMetadata().setResourceVersion(resourceVersion); return handlePatch(got, resource); } catch (Exception e) { - throw KubernetesClientException.launderThrowable(forOperationType("patch"), e); + throw KubernetesClientException.launderThrowable(forOperationType(PATCH_OPERATION), e); } }; return visitor.apply(item); @@ -142,6 +148,22 @@ public T patch(T item) { caught = e; } } - throw KubernetesClientException.launderThrowable(forOperationType("patch"), caught); + throw KubernetesClientException.launderThrowable(forOperationType(PATCH_OPERATION), caught); + } + + @Override + public T patch(PatchContext patchContext, String patch) { + try { + final T got = fromServer().get(); + if (got == null) { + return null; + } + return handlePatch(patchContext, got, convertToJson(patch), getType()); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw KubernetesClientException.launderThrowable(forOperationType(PATCH_OPERATION), interruptedException); + } catch (IOException | ExecutionException e) { + throw KubernetesClientException.launderThrowable(forOperationType(PATCH_OPERATION), e); + } } } diff --git a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/base/OperationSupport.java b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/base/OperationSupport.java index 499f7982a3e..8918e2d284d 100644 --- a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/base/OperationSupport.java +++ b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/base/OperationSupport.java @@ -55,6 +55,7 @@ public class OperationSupport { public static final MediaType JSON = MediaType.parse("application/json"); public static final MediaType JSON_PATCH = MediaType.parse("application/json-patch+json"); public static final MediaType STRATEGIC_MERGE_JSON_PATCH = MediaType.parse("application/strategic-merge-patch+json"); + public static final MediaType JSON_MERGE_PATCH = MediaType.parse("application/merge-patch+json"); protected static final ObjectMapper JSON_MAPPER = Serialization.jsonMapper(); protected static final ObjectMapper YAML_MAPPER = Serialization.yamlMapper(); private static final String CLIENT_STATUS_FLAG = "CLIENT_STATUS_FLAG"; @@ -174,6 +175,23 @@ public URL getResourceURLForWriteOperation(URL resourceURL) throws MalformedURLE return resourceURL; } + public URL getResourceURLForPatchOperation(URL resourceUrl, PatchContext patchContext) throws MalformedURLException { + if (patchContext != null) { + String url = resourceUrl.toString(); + if (patchContext.getForce() != null) { + url = URLUtils.join(url, "?force=" + patchContext.getForce()); + } + if ((patchContext.getDryRun() != null && !patchContext.getDryRun().isEmpty()) || dryRun) { + url = URLUtils.join(url, "?dryRun=All"); + } + if (patchContext.getFieldManager() != null) { + url = URLUtils.join(url, "?fieldManager=" + patchContext.getFieldManager()); + } + return new URL(url); + } + return resourceUrl; + } + protected String checkNamespace(T item) { String operationNs = getNamespace(); String itemNs = (item instanceof HasMetadata && ((HasMetadata)item).getMetadata() != null) ? ((HasMetadata) item).getMetadata().getNamespace() : null; @@ -344,6 +362,26 @@ protected T handlePatch(T current, Map patchForUpdate, Class return handleResponse(requestBuilder, type, Collections.emptyMap()); } + /** + * Send an http patch and handle the response. + * + * @param patchContext patch options for patch request + * @param current current object + * @param patchForUpdate Patch string + * @param type type of object + * @param template argument provided + * @return returns de-serialized version of api server response + * @throws ExecutionException Execution Exception + * @throws InterruptedException Interrupted Exception + * @throws IOException IOException in case of network errors + */ + protected T handlePatch(PatchContext patchContext, T current, String patchForUpdate, Class type) throws ExecutionException, InterruptedException, IOException { + MediaType bodyMediaType = getMediaTypeFromPatchContextOrDefault(patchContext); + RequestBody body = RequestBody.create(bodyMediaType, patchForUpdate); + Request.Builder requestBuilder = new Request.Builder().patch(body).url(getResourceURLForPatchOperation(getResourceUrl(checkNamespace(current), checkName(current)), patchContext)); + return handleResponse(requestBuilder, type, Collections.emptyMap()); + } + /** * Replace Scale of specified Kubernetes Resource * @@ -611,4 +649,11 @@ protected static Map getObjectValueAsMap(T object) { public Config getConfig() { return config; } + + private MediaType getMediaTypeFromPatchContextOrDefault(PatchContext patchContext) { + if (patchContext != null && patchContext.getPatchType() != null) { + return patchContext.getPatchType().getMediaType(); + } + return STRATEGIC_MERGE_JSON_PATCH; + } } diff --git a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/base/PatchContext.java b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/base/PatchContext.java new file mode 100644 index 00000000000..0b5b3a9f23b --- /dev/null +++ b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/base/PatchContext.java @@ -0,0 +1,89 @@ +/** + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.dsl.base; + +import java.util.List; + +public class PatchContext { + private List dryRun; + private String fieldManager; + private Boolean force; + private PatchType patchType; + + public List getDryRun() { + return dryRun; + } + + public void setDryRun(List dryRun) { + this.dryRun = dryRun; + } + + public String getFieldManager() { + return fieldManager; + } + + public void setFieldManager(String fieldManager) { + this.fieldManager = fieldManager; + } + + public Boolean getForce() { + return force; + } + + public void setForce(Boolean force) { + this.force = force; + } + + public PatchType getPatchType() { + return patchType; + } + + public void setPatchType(PatchType patchType) { + this.patchType = patchType; + } + + public static class Builder { + private final PatchContext patchContext; + + public Builder() { + this.patchContext = new PatchContext(); + } + + public Builder withDryRun(List dryRun) { + this.patchContext.setDryRun(dryRun); + return this; + } + + public Builder withFieldManager(String fieldManager) { + this.patchContext.setFieldManager(fieldManager); + return this; + } + + public Builder withForce(Boolean force) { + this.patchContext.setForce(force); + return this; + } + + public Builder withPatchType(PatchType patchType) { + this.patchContext.setPatchType(patchType); + return this; + } + + public PatchContext build() { + return this.patchContext; + } + } +} diff --git a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/base/PatchType.java b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/base/PatchType.java new file mode 100644 index 00000000000..e530ce8fd19 --- /dev/null +++ b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/dsl/base/PatchType.java @@ -0,0 +1,41 @@ +/** + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client.dsl.base; + +import okhttp3.MediaType; + +import static io.fabric8.kubernetes.client.dsl.base.OperationSupport.JSON_PATCH; +import static io.fabric8.kubernetes.client.dsl.base.OperationSupport.JSON_MERGE_PATCH; +import static io.fabric8.kubernetes.client.dsl.base.OperationSupport.STRATEGIC_MERGE_JSON_PATCH; + +/** + * Enum for different Patch types supported by Patch + */ +public enum PatchType { + JSON(JSON_PATCH), + JSON_MERGE(JSON_MERGE_PATCH), + STRATEGIC_MERGE(STRATEGIC_MERGE_JSON_PATCH); + + private final MediaType mediaType; + + PatchType(MediaType mediaType) { + this.mediaType = mediaType; + } + + public MediaType getMediaType() { + return this.mediaType; + } +} diff --git a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/IOHelpers.java b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/IOHelpers.java index 0306534b035..f7abf283848 100644 --- a/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/IOHelpers.java +++ b/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/utils/IOHelpers.java @@ -75,4 +75,11 @@ public static String convertYamlToJson(String yaml) throws IOException { return jsonWriter.writeValueAsString(obj); } + public static String convertToJson(String jsonOrYaml) throws IOException { + if (isJSONValid(jsonOrYaml)) { + return jsonOrYaml; + } + return convertYamlToJson(jsonOrYaml); + } + } diff --git a/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/PatchTest.java b/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/PatchTest.java new file mode 100644 index 00000000000..6935996ce5c --- /dev/null +++ b/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/PatchTest.java @@ -0,0 +1,186 @@ +/** + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes.client; + +import io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.client.dsl.base.PatchContext; +import io.fabric8.kubernetes.client.dsl.base.PatchType; +import okhttp3.Call; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.assertj.core.api.AssertionsForClassTypes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.util.Collections; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PatchTest { + private Call mockCall; + private OkHttpClient mockClient; + private KubernetesClient kubernetesClient; + + @BeforeEach + public void setUp() throws IOException { + this.mockClient = Mockito.mock(OkHttpClient.class, Mockito.RETURNS_DEEP_STUBS); + Config config = new ConfigBuilder().withMasterUrl("https://localhost:8443/").build(); + mockCall = mock(Call.class); + Response mockResponse = new Response.Builder() + .request(new Request.Builder().url("http://mock").build()) + .protocol(Protocol.HTTP_1_1) + .code(HttpURLConnection.HTTP_OK) + .body(ResponseBody.create(MediaType.get("application/json"), "{\"metadata\":{\"name\":\"foo\"}}")) + .message("mock") + .build(); + when(mockCall.execute()) + .thenReturn(mockResponse, mockResponse); + when(mockClient.newCall(any())).thenReturn(mockCall); + kubernetesClient = new DefaultKubernetesClient(mockClient, config); + } + + @Test + void testJsonPatch() { + // Given + ArgumentCaptor captor = ArgumentCaptor.forClass(Request.class); + + // When + kubernetesClient.pods().inNamespace("ns1").withName("foo") + .patch("{\"metadata\":{\"annotations\":{\"bob\":\"martin\"}}}"); + + // Then + verify(mockClient, times(2)).newCall(captor.capture()); + assertRequest(captor.getAllValues().get(0), "GET", "/api/v1/namespaces/ns1/pods/foo", null); + assertRequest(captor.getAllValues().get(1), "PATCH", "/api/v1/namespaces/ns1/pods/foo", null); + assertBodyContentType("strategic-merge-patch+json", captor.getAllValues().get(1)); + } + + @Test + void testJsonMergePatch() { + // Given + ArgumentCaptor captor = ArgumentCaptor.forClass(Request.class); + PatchContext patchContext = new PatchContext.Builder() + .withPatchType(PatchType.JSON_MERGE) + .build(); + + // When + kubernetesClient.pods().inNamespace("ns1").withName("foo") + .patch(patchContext, "{\"metadata\":{\"annotations\":{\"bob\":\"martin\"}}}"); + + // Then + verify(mockClient, times(2)).newCall(captor.capture()); + assertRequest(captor.getAllValues().get(0), "GET", "/api/v1/namespaces/ns1/pods/foo", null); + assertRequest(captor.getAllValues().get(1), "PATCH", "/api/v1/namespaces/ns1/pods/foo", null); + assertBodyContentType("merge-patch+json", captor.getAllValues().get(1)); + } + + @Test + void testYamlPatchConvertedToJson() { + // Given + ArgumentCaptor captor = ArgumentCaptor.forClass(Request.class); + + // When + kubernetesClient.pods().inNamespace("ns1").withName("foo").patch("metadata:\n annotations:\n bob: martin"); + + // Then + verify(mockClient, times(2)).newCall(captor.capture()); + assertRequest(captor.getAllValues().get(0), "GET", "/api/v1/namespaces/ns1/pods/foo", null); + assertRequest(captor.getAllValues().get(1), "PATCH", "/api/v1/namespaces/ns1/pods/foo", null); + assertBodyContentType("strategic-merge-patch+json", captor.getAllValues().get(1)); + } + + @Test + void testPatchReturnsNullWhenResourceNotFound() throws IOException { + // Given + when(mockCall.execute()).thenReturn(new Response.Builder() + .request(new Request.Builder().url("http://mock").build()) + .protocol(Protocol.HTTP_1_1) + .code(HttpURLConnection.HTTP_NOT_FOUND) + .body(ResponseBody.create(MediaType.get("application/json"), "{}")) + .message("mock") + .build()); + ArgumentCaptor captor = ArgumentCaptor.forClass(Request.class); + + // When + Pod pod = kubernetesClient.pods().inNamespace("ns1").withName("foo").patch("{\"metadata\":{\"annotations\":{\"bob\":\"martin\"}}}"); + + // Then + verify(mockClient, times(1)).newCall(captor.capture()); + assertRequest(captor.getValue(), "GET", "/api/v1/namespaces/ns1/pods/foo", null); + assertThat(pod).isNull(); + } + + @Test + void testJsonPatchWithPositionalArrays() { + // Given + ArgumentCaptor captor = ArgumentCaptor.forClass(Request.class); + PatchContext patchContext = new PatchContext.Builder().withPatchType(PatchType.JSON).build(); + + // When + kubernetesClient.pods().inNamespace("ns1").withName("foo") + .patch(patchContext, "[{\"op\": \"replace\", \"path\":\"/spec/containers/0/image\", \"value\":\"foo/gb-frontend:v4\"}]"); + + // Then + verify(mockClient, times(2)).newCall(captor.capture()); + assertRequest(captor.getAllValues().get(0), "GET", "/api/v1/namespaces/ns1/pods/foo", null); + assertRequest(captor.getAllValues().get(1), "PATCH", "/api/v1/namespaces/ns1/pods/foo", null); + assertBodyContentType("json-patch+json", captor.getAllValues().get(1)); + } + + @Test + void testPatchWithPatchOptions() { + // Given + ArgumentCaptor captor = ArgumentCaptor.forClass(Request.class); + + // When + kubernetesClient.pods().inNamespace("ns1").withName("foo") + .patch(new PatchContext.Builder() + .withFieldManager("fabric8") + .withDryRun(Collections.singletonList("All")) + .build(), "{\"metadata\":{\"annotations\":{\"bob\":\"martin\"}}}"); + + // Then + verify(mockClient, times(2)).newCall(captor.capture()); + assertRequest(captor.getAllValues().get(0), "GET", "/api/v1/namespaces/ns1/pods/foo", null); + assertRequest(captor.getAllValues().get(1), "PATCH", "/api/v1/namespaces/ns1/pods/foo", "fieldManager=fabric8&dryRun=All"); + assertBodyContentType("strategic-merge-patch+json", captor.getAllValues().get(1)); + } + + private void assertRequest(Request request, String method, String url, String queryParam) { + assertThat(request.url().encodedPath()).isEqualTo(url); + assertThat(request.method()).isEqualTo(method); + assertThat(request.url().encodedQuery()).isEqualTo(queryParam); + } + + private void assertBodyContentType(String expectedContentSubtype, Request request) { + AssertionsForClassTypes.assertThat(request.body().contentType()).isNotNull(); + AssertionsForClassTypes.assertThat(request.body().contentType().type()).isEqualTo("application"); + AssertionsForClassTypes.assertThat(request.body().contentType().subtype()).isEqualTo(expectedContentSubtype); + } +} diff --git a/kubernetes-itests/src/test/java/io/fabric8/kubernetes/PatchIT.java b/kubernetes-itests/src/test/java/io/fabric8/kubernetes/PatchIT.java new file mode 100644 index 00000000000..e3d88f718f5 --- /dev/null +++ b/kubernetes-itests/src/test/java/io/fabric8/kubernetes/PatchIT.java @@ -0,0 +1,139 @@ +/** + * Copyright (C) 2015 Red Hat, Inc. + * + * 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.fabric8.kubernetes; + +import io.fabric8.commons.ClusterEntity; +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.apps.ReplicaSet; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.dsl.base.PatchContext; +import io.fabric8.kubernetes.client.dsl.base.PatchType; +import org.arquillian.cube.kubernetes.api.Session; +import org.arquillian.cube.kubernetes.impl.requirement.RequiresKubernetes; +import org.arquillian.cube.requirement.ArquillianConditionalRunner; +import org.jboss.arquillian.test.api.ArquillianResource; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Collections; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +@RunWith(ArquillianConditionalRunner.class) +@RequiresKubernetes +public class PatchIT { + @ArquillianResource + KubernetesClient client; + + @ArquillianResource + Session session; + + private String currentNamespace; + + @BeforeClass + public static void init() { + ClusterEntity.apply(CronJobIT.class.getResourceAsStream("/patch-it.yml")); + } + + @Before + public void initNamespace() { + this.currentNamespace = session.getNamespace(); + } + + @Test + public void testJsonPatch() { + // Given + String name = "patchit-testjsonpatch"; + + // When + ConfigMap patchedConfigMap = client.configMaps().inNamespace(currentNamespace).withName(name).patch("{\"metadata\":{\"labels\":{\"version\":\"v1\"}}}"); + + // Then + assertThat(patchedConfigMap).isNotNull(); + assertThat(patchedConfigMap.getMetadata().getLabels()).isNotNull() + .hasFieldOrPropertyWithValue("version", "v1"); + } + + @Test + public void testJsonMergePatch() { + // Given + String name = "patchit-testjsonmergepatch"; + PatchContext patchContext = new PatchContext.Builder().withPatchType(PatchType.JSON_MERGE).build(); + + // When + ConfigMap patchedConfigMap = client.configMaps().inNamespace(currentNamespace).withName(name) + .patch(patchContext, "{\"metadata\":{\"annotations\":{\"foo\":null}}}"); + + // Then + assertThat(patchedConfigMap).isNotNull(); + assertThat(patchedConfigMap.getMetadata().getAnnotations()).isNull(); + } + + @Test + public void testJsonPatchWithPositionalArrays() { + // Given + String name = "patchit-testjsonpatchpositionalarray"; + PatchContext patchContext = new PatchContext.Builder().withPatchType(PatchType.JSON).build(); + + // When + ReplicaSet patchedReplicaSet = client.apps().replicaSets().inNamespace(currentNamespace).withName(name) + .patch(patchContext + , "[{\"op\": \"replace\", \"path\":\"/spec/template/spec/containers/0/image\", \"value\":\"foo/gb-frontend:v4\"}]"); + + // Then + assertThat(patchedReplicaSet).isNotNull(); + assertThat(patchedReplicaSet.getSpec().getTemplate().getSpec().getContainers().get(0).getImage()).isEqualTo("foo/gb-frontend:v4"); + } + + @Test + public void testYamlPatch() { + // Given + String name = "patchit-testyamlpatch"; + + // When + ConfigMap patchedConfigMap = client.configMaps().inNamespace(currentNamespace).withName(name) + .patch("data:\n version: v1\n status: patched"); + + // Then + assertThat(patchedConfigMap).isNotNull(); + assertThat(patchedConfigMap.getData()) + .hasFieldOrPropertyWithValue("version", "v1") + .hasFieldOrPropertyWithValue("status", "patched"); + } + + @Test + public void testFullObjectPatch() { + // Given + String name = "patchit-fullobjectpatch"; + + // When + ConfigMap configMapFromServer = client.configMaps().inNamespace(currentNamespace).withName(name).get(); + configMapFromServer.setData(Collections.singletonMap("foo", "bar")); + ConfigMap patchedConfigMap = client.configMaps().inNamespace(currentNamespace).withName(name).patch(configMapFromServer); + + // Then + assertThat(patchedConfigMap).isNotNull(); + assertThat(patchedConfigMap.getData()).hasFieldOrPropertyWithValue("foo", "bar"); + } + + @AfterClass + public static void cleanup() { + ClusterEntity.remove(CronJobIT.class.getResourceAsStream("/patch-it.yml")); + } +} diff --git a/kubernetes-itests/src/test/resources/patch-it.yml b/kubernetes-itests/src/test/resources/patch-it.yml new file mode 100644 index 00000000000..111a00f80df --- /dev/null +++ b/kubernetes-itests/src/test/resources/patch-it.yml @@ -0,0 +1,59 @@ +# +# Copyright (C) 2015 Red Hat, Inc. +# +# 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. +# + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: patchit-testjsonpatch +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: patchit-testjsonmergepatch + annotations: + foo: bar +--- +apiVersion: apps/v1 +kind: ReplicaSet +metadata: + name: patchit-testjsonpatchpositionalarray + labels: + app: guestbook + tier: frontend +spec: + replicas: 0 + selector: + matchLabels: + tier: frontend + template: + metadata: + labels: + tier: frontend + spec: + containers: + - name: php-redis + image: foo/gb-frontend:v3 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: patchit-testyamlpatch +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: patchit-fullobjectpatch