diff --git a/CHANGELOG.md b/CHANGELOG.md index 82137498335..734d413bbbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ #### Dependency Upgrade #### New Features +* Fix #2701: Support for strategic merge patching in KuberntesClient ### 5.3.0 (2021-04-08) 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..4caefa473a3 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.api.model.PatchOptions; + public interface Patchable { + /** + * Update field(s) of a resource using strategic 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 or a JSON 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 strategic merge patch or a JSON patch. + * + * @param patchOptions PatchOptions 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(PatchOptions patchOptions, 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..f39d44b765f 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 @@ -16,6 +16,7 @@ package io.fabric8.kubernetes.client.dsl.base; import io.fabric8.kubernetes.api.model.ObjectReference; +import io.fabric8.kubernetes.api.model.PatchOptions; import io.fabric8.kubernetes.client.WatcherException; import io.fabric8.kubernetes.client.dsl.WritableOperation; import io.fabric8.kubernetes.client.utils.CreateOrReplaceHelper; @@ -856,6 +857,11 @@ public T patch(T item) { throw new KubernetesClientException("Cannot update read-only resources"); } + @Override + public T patch(PatchOptions patchOptions, String patch) { + throw new KubernetesClientException("Cannot update read-only resources"); + } + @Override public boolean isResourceNamespaced() { return Utils.isResourceNamespaced(getType()); 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..dd3bf7dab18 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 @@ -19,12 +19,18 @@ import io.fabric8.kubernetes.api.model.DeletionPropagation; import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.api.model.KubernetesResourceList; +import io.fabric8.kubernetes.api.model.PatchOptions; import io.fabric8.kubernetes.client.KubernetesClientException; import io.fabric8.kubernetes.client.dsl.Resource; +import io.fabric8.kubernetes.client.utils.Serialization; + +import java.util.Map; 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; @@ -144,4 +150,17 @@ public T patch(T item) { } throw KubernetesClientException.launderThrowable(forOperationType("patch"), caught); } + + @Override + public T patch(PatchOptions patchOptions, String patch) { + try { + final T got = fromServer().get(); + if (got == null) { + return null; + } + return handlePatch(patchOptions, got, convertToJson(patch), getType()); + } catch (Exception e) { + throw KubernetesClientException.launderThrowable(forOperationType("patch"), 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..ecc1cc96568 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 @@ -22,6 +22,7 @@ import io.fabric8.kubernetes.api.model.DeleteOptions; import io.fabric8.kubernetes.api.model.DeletionPropagation; import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.PatchOptions; import io.fabric8.kubernetes.api.model.Status; import io.fabric8.kubernetes.api.model.StatusBuilder; import io.fabric8.kubernetes.api.model.autoscaling.v1.Scale; @@ -174,6 +175,23 @@ public URL getResourceURLForWriteOperation(URL resourceURL) throws MalformedURLE return resourceURL; } + public URL getResourceURLForPatchOperation(URL resourceUrl, PatchOptions patchOptions) throws MalformedURLException { + if (patchOptions != null) { + String url = resourceUrl.toString(); + if (patchOptions.getForce() != null) { + url = URLUtils.join(url, "?force=" + patchOptions.getForce()); + } + if ((patchOptions.getDryRun() != null && !patchOptions.getDryRun().isEmpty()) || dryRun) { + url = URLUtils.join(url, "?dryRun=All"); + } + if (patchOptions.getFieldManager() != null) { + url = URLUtils.join(url, "?fieldManager=" + patchOptions.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 patchOptions 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(PatchOptions patchOptions, T current, String patchForUpdate, Class type) throws ExecutionException, InterruptedException, IOException { + MediaType bodyMediaType = getMediaTypeFromBodyContent(patchForUpdate); + RequestBody body = RequestBody.create(bodyMediaType, patchForUpdate); + Request.Builder requestBuilder = new Request.Builder().patch(body).url(getResourceURLForPatchOperation(getResourceUrl(checkNamespace(current), checkName(current)), patchOptions)); + return handleResponse(requestBuilder, type, Collections.emptyMap()); + } + /** * Replace Scale of specified Kubernetes Resource * @@ -611,4 +649,12 @@ protected static Map getObjectValueAsMap(T object) { public Config getConfig() { return config; } + + private MediaType getMediaTypeFromBodyContent(String requestBodyContent) { + if (requestBodyContent.contains("\"op\"")) { + return JSON_PATCH; + } else { + return STRATEGIC_MERGE_JSON_PATCH; + } + } } 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..0b04e75af72 --- /dev/null +++ b/kubernetes-client/src/test/java/io/fabric8/kubernetes/client/PatchTest.java @@ -0,0 +1,163 @@ +/** + * 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.PatchOptions; +import io.fabric8.kubernetes.api.model.PatchOptionsBuilder; +import io.fabric8.kubernetes.api.model.Pod; +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 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 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); + + // When + kubernetesClient.pods().inNamespace("ns1").withName("foo") + .patch("[{\"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 PatchOptionsBuilder() + .withFieldManager("fabric8") + .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"); + 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..3e92925bedc --- /dev/null +++ b/kubernetes-itests/src/test/java/io/fabric8/kubernetes/PatchIT.java @@ -0,0 +1,120 @@ +/** + * 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 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 testJsonPatchWithPositionalArrays() { + // Given + String name = "patchit-testjsonpatchpositionalarray"; + + // When + ReplicaSet patchedReplicaSet = client.apps().replicaSets().inNamespace(currentNamespace).withName(name) + .patch("[{\"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..af97354da74 --- /dev/null +++ b/kubernetes-itests/src/test/resources/patch-it.yml @@ -0,0 +1,52 @@ +# +# 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: 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