Skip to content

Commit

Permalink
Fix fabric8io#2701: Add Support for Merge Patch for Kubernetes resources
Browse files Browse the repository at this point in the history
  • Loading branch information
rohanKanojia committed Apr 8, 2021
1 parent 74cc63d commit 67562b8
Show file tree
Hide file tree
Showing 9 changed files with 441 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#### Dependency Upgrade

#### New Features
* Fix #2701: Support for strategic merge patching in KuberntesClient

### 5.3.0 (2021-04-08)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,35 @@
*/
package io.fabric8.kubernetes.client.dsl;

import io.fabric8.kubernetes.api.model.PatchOptions;

public interface Patchable<T> {

/**
* 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);

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends HasMetadata, L extends KubernetesResourceList<T>, R extends Resource<T>> extends BaseOperation< T, L, R> {
public static final DeletionPropagation DEFAULT_PROPAGATION_POLICY = DeletionPropagation.BACKGROUND;
public static final long DEFAULT_GRACE_PERIOD_IN_SECONDS = -1L;
Expand Down Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <T> String checkNamespace(T item) {
String operationNs = getNamespace();
String itemNs = (item instanceof HasMetadata && ((HasMetadata)item).getMetadata() != null) ? ((HasMetadata) item).getMetadata().getNamespace() : null;
Expand Down Expand Up @@ -344,6 +362,26 @@ protected <T> T handlePatch(T current, Map<String, Object> patchForUpdate, Class
return handleResponse(requestBuilder, type, Collections.<String, String>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 <T> 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> T handlePatch(PatchOptions patchOptions, T current, String patchForUpdate, Class<T> 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
*
Expand Down Expand Up @@ -611,4 +649,12 @@ protected static <T> 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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

}
Original file line number Diff line number Diff line change
@@ -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<Request> 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<Request> 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<Request> 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<Request> 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<Request> 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);
}
}
Loading

0 comments on commit 67562b8

Please sign in to comment.