Skip to content

Commit a50e283

Browse files
authored
Merge pull request #4536 from brendandburns/feat/server-side-apply
feat(util): add ServerSideApply for declarative resource management
2 parents 35cf63a + e2d8574 commit a50e283

File tree

2 files changed

+782
-0
lines changed

2 files changed

+782
-0
lines changed
Lines changed: 394 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,394 @@
1+
/*
2+
Copyright 2024 The Kubernetes Authors.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
http://www.apache.org/licenses/LICENSE-2.0
7+
Unless required by applicable law or agreed to in writing, software
8+
distributed under the License is distributed on an "AS IS" BASIS,
9+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
See the License for the specific language governing permissions and
11+
limitations under the License.
12+
*/
13+
package io.kubernetes.client.util;
14+
15+
import io.kubernetes.client.common.KubernetesListObject;
16+
import io.kubernetes.client.common.KubernetesObject;
17+
import io.kubernetes.client.custom.V1Patch;
18+
import io.kubernetes.client.openapi.ApiClient;
19+
import io.kubernetes.client.openapi.ApiException;
20+
import io.kubernetes.client.util.generic.GenericKubernetesApi;
21+
import io.kubernetes.client.util.generic.KubernetesApiResponse;
22+
import io.kubernetes.client.util.generic.options.PatchOptions;
23+
24+
import java.util.Objects;
25+
26+
/**
27+
* Utility class for server-side apply operations on Kubernetes resources.
28+
* Server-side apply allows declarative configuration management where the server
29+
* determines the merge strategy based on field ownership.
30+
*
31+
* <p>This provides fabric8-style convenience methods for server-side apply operations.
32+
*
33+
* <p>Example usage:
34+
* <pre>{@code
35+
* // Simple server-side apply
36+
* V1Pod appliedPod = ServerSideApply.apply(
37+
* apiClient,
38+
* V1Pod.class,
39+
* V1PodList.class,
40+
* deployment,
41+
* "my-field-manager"
42+
* );
43+
*
44+
* // Force conflicts (take ownership of fields)
45+
* V1Deployment deployment = ServerSideApply.builder(apiClient)
46+
* .apiTypeClass(V1Deployment.class)
47+
* .apiListTypeClass(V1DeploymentList.class)
48+
* .resource(myDeployment)
49+
* .fieldManager("my-controller")
50+
* .forceConflicts(true)
51+
* .apply();
52+
*
53+
* // Dry run
54+
* V1ConfigMap result = ServerSideApply.builder(apiClient)
55+
* .apiTypeClass(V1ConfigMap.class)
56+
* .apiListTypeClass(V1ConfigMapList.class)
57+
* .resource(configMap)
58+
* .fieldManager("my-app")
59+
* .dryRun()
60+
* .apply();
61+
* }</pre>
62+
*/
63+
public class ServerSideApply {
64+
65+
public static final String DEFAULT_FIELD_MANAGER = "kubernetes-java-client";
66+
public static final String DRY_RUN_ALL = "All";
67+
68+
private ServerSideApply() {
69+
// Utility class
70+
}
71+
72+
/**
73+
* Apply a Kubernetes resource using server-side apply.
74+
*
75+
* @param <ApiType> the resource type
76+
* @param <ApiListType> the resource list type
77+
* @param apiClient the API client
78+
* @param apiTypeClass the class of the resource type
79+
* @param apiListTypeClass the class of the resource list type
80+
* @param resource the resource to apply
81+
* @param fieldManager the field manager name (identifies the actor making changes)
82+
* @return the applied resource
83+
* @throws ApiException if an API error occurs
84+
*/
85+
public static <ApiType extends KubernetesObject, ApiListType extends KubernetesListObject>
86+
ApiType apply(
87+
ApiClient apiClient,
88+
Class<ApiType> apiTypeClass,
89+
Class<ApiListType> apiListTypeClass,
90+
ApiType resource,
91+
String fieldManager)
92+
throws ApiException {
93+
Builder<ApiType, ApiListType> b = new Builder<>(apiClient);
94+
return b.apiTypeClass(apiTypeClass)
95+
.apiListTypeClass(apiListTypeClass)
96+
.resource(resource)
97+
.fieldManager(fieldManager)
98+
.apply();
99+
}
100+
101+
/**
102+
* Apply a Kubernetes resource using server-side apply with force conflicts.
103+
*
104+
* @param <ApiType> the resource type
105+
* @param <ApiListType> the resource list type
106+
* @param apiClient the API client
107+
* @param apiTypeClass the class of the resource type
108+
* @param apiListTypeClass the class of the resource list type
109+
* @param resource the resource to apply
110+
* @param fieldManager the field manager name
111+
* @param forceConflicts whether to force ownership of conflicting fields
112+
* @return the applied resource
113+
* @throws ApiException if an API error occurs
114+
*/
115+
public static <ApiType extends KubernetesObject, ApiListType extends KubernetesListObject>
116+
ApiType apply(
117+
ApiClient apiClient,
118+
Class<ApiType> apiTypeClass,
119+
Class<ApiListType> apiListTypeClass,
120+
ApiType resource,
121+
String fieldManager,
122+
boolean forceConflicts)
123+
throws ApiException {
124+
Builder<ApiType, ApiListType> b = new Builder<>(apiClient);
125+
return b.apiTypeClass(apiTypeClass)
126+
.apiListTypeClass(apiListTypeClass)
127+
.resource(resource)
128+
.fieldManager(fieldManager)
129+
.forceConflicts(forceConflicts)
130+
.apply();
131+
}
132+
133+
/**
134+
* Apply a resource using an existing GenericKubernetesApi.
135+
*
136+
* @param <ApiType> the resource type
137+
* @param <ApiListType> the resource list type
138+
* @param genericApi the GenericKubernetesApi to use
139+
* @param resource the resource to apply
140+
* @param fieldManager the field manager name
141+
* @return the applied resource
142+
* @throws ApiException if an API error occurs
143+
*/
144+
public static <ApiType extends KubernetesObject, ApiListType extends KubernetesListObject>
145+
ApiType apply(
146+
GenericKubernetesApi<ApiType, ApiListType> genericApi,
147+
ApiType resource,
148+
String fieldManager)
149+
throws ApiException {
150+
return applyInternal(genericApi, resource, fieldManager, false, null);
151+
}
152+
153+
/**
154+
* Apply a resource using an existing GenericKubernetesApi with force conflicts.
155+
*
156+
* @param <ApiType> the resource type
157+
* @param <ApiListType> the resource list type
158+
* @param genericApi the GenericKubernetesApi to use
159+
* @param resource the resource to apply
160+
* @param fieldManager the field manager name
161+
* @param forceConflicts whether to force ownership of conflicting fields
162+
* @return the applied resource
163+
* @throws ApiException if an API error occurs
164+
*/
165+
public static <ApiType extends KubernetesObject, ApiListType extends KubernetesListObject>
166+
ApiType apply(
167+
GenericKubernetesApi<ApiType, ApiListType> genericApi,
168+
ApiType resource,
169+
String fieldManager,
170+
boolean forceConflicts)
171+
throws ApiException {
172+
return applyInternal(genericApi, resource, fieldManager, forceConflicts, null);
173+
}
174+
175+
/**
176+
* Create a builder for server-side apply operations.
177+
*
178+
* @param <ApiType> the resource type
179+
* @param <ApiListType> the resource list type
180+
* @param apiClient the API client
181+
* @return a new builder
182+
*/
183+
public static <ApiType extends KubernetesObject, ApiListType extends KubernetesListObject>
184+
Builder<ApiType, ApiListType> builder(ApiClient apiClient) {
185+
return new Builder<>(apiClient);
186+
}
187+
188+
/**
189+
* Internal method to perform the server-side apply.
190+
*/
191+
private static <ApiType extends KubernetesObject, ApiListType extends KubernetesListObject>
192+
ApiType applyInternal(
193+
GenericKubernetesApi<ApiType, ApiListType> api,
194+
ApiType resource,
195+
String fieldManager,
196+
boolean forceConflicts,
197+
String dryRun)
198+
throws ApiException {
199+
200+
Objects.requireNonNull(api, "GenericKubernetesApi must not be null");
201+
Objects.requireNonNull(resource, "Resource must not be null");
202+
Objects.requireNonNull(resource.getMetadata(), "Resource metadata must not be null");
203+
Objects.requireNonNull(resource.getMetadata().getName(), "Resource name must not be null");
204+
205+
if (fieldManager == null || fieldManager.isEmpty()) {
206+
fieldManager = DEFAULT_FIELD_MANAGER;
207+
}
208+
209+
PatchOptions patchOptions = new PatchOptions();
210+
patchOptions.setFieldManager(fieldManager);
211+
patchOptions.setForce(forceConflicts);
212+
if (dryRun != null) {
213+
patchOptions.setDryRun(dryRun);
214+
}
215+
216+
// Serialize the resource to YAML for apply-patch+yaml content type
217+
String resourceYaml = Yaml.dump(resource);
218+
V1Patch patch = new V1Patch(resourceYaml);
219+
220+
String namespace = resource.getMetadata().getNamespace();
221+
String name = resource.getMetadata().getName();
222+
223+
KubernetesApiResponse<ApiType> response;
224+
if (namespace != null && !namespace.isEmpty()) {
225+
response = api.patch(namespace, name, V1Patch.PATCH_FORMAT_APPLY_YAML, patch, patchOptions);
226+
} else {
227+
response = api.patch(name, V1Patch.PATCH_FORMAT_APPLY_YAML, patch, patchOptions);
228+
}
229+
230+
return response.throwsApiException().getObject();
231+
}
232+
233+
/**
234+
* Builder for server-side apply operations providing a fluent API.
235+
*
236+
* @param <ApiType> the resource type
237+
* @param <ApiListType> the resource list type
238+
*/
239+
public static class Builder<ApiType extends KubernetesObject, ApiListType extends KubernetesListObject> {
240+
241+
private final ApiClient apiClient;
242+
private Class<ApiType> apiTypeClass;
243+
private Class<ApiListType> apiListTypeClass;
244+
private ApiType resource;
245+
private String fieldManager = DEFAULT_FIELD_MANAGER;
246+
private boolean forceConflicts = false;
247+
private String dryRun = null;
248+
249+
private Builder(ApiClient apiClient) {
250+
this.apiClient = Objects.requireNonNull(apiClient, "ApiClient must not be null");
251+
}
252+
253+
/**
254+
* Set the API type class.
255+
*/
256+
public Builder<ApiType, ApiListType> apiTypeClass(Class<ApiType> apiTypeClass) {
257+
this.apiTypeClass = apiTypeClass;
258+
return this;
259+
}
260+
261+
/**
262+
* Set the API list type class.
263+
*/
264+
public Builder<ApiType, ApiListType> apiListTypeClass(Class<ApiListType> apiListTypeClass) {
265+
this.apiListTypeClass = apiListTypeClass;
266+
return this;
267+
}
268+
269+
/**
270+
* Set the resource to apply.
271+
*/
272+
public Builder<ApiType, ApiListType> resource(ApiType resource) {
273+
this.resource = resource;
274+
return this;
275+
}
276+
277+
/**
278+
* Set the field manager name.
279+
* The field manager identifies the actor or entity making changes.
280+
*/
281+
public Builder<ApiType, ApiListType> fieldManager(String fieldManager) {
282+
this.fieldManager = fieldManager;
283+
return this;
284+
}
285+
286+
/**
287+
* Set whether to force ownership of conflicting fields.
288+
* When true, the apply operation will take ownership of fields
289+
* that were previously managed by other field managers.
290+
*/
291+
public Builder<ApiType, ApiListType> forceConflicts(boolean forceConflicts) {
292+
this.forceConflicts = forceConflicts;
293+
return this;
294+
}
295+
296+
/**
297+
* Enable dry run mode.
298+
* When enabled, the server processes the request without persisting changes.
299+
*/
300+
public Builder<ApiType, ApiListType> dryRun() {
301+
this.dryRun = DRY_RUN_ALL;
302+
return this;
303+
}
304+
305+
/**
306+
* Set dry run mode with a specific value.
307+
* @param dryRun the dry run value (typically "All" or null)
308+
*/
309+
public Builder<ApiType, ApiListType> dryRun(String dryRun) {
310+
this.dryRun = dryRun;
311+
return this;
312+
}
313+
314+
/**
315+
* Execute the server-side apply operation.
316+
*
317+
* @return the applied resource
318+
* @throws ApiException if an API error occurs
319+
*/
320+
public ApiType apply() throws ApiException {
321+
Objects.requireNonNull(apiTypeClass, "apiTypeClass must be set");
322+
Objects.requireNonNull(apiListTypeClass, "apiListTypeClass must be set");
323+
Objects.requireNonNull(resource, "resource must be set");
324+
325+
GenericKubernetesApi<ApiType, ApiListType> api =
326+
new GenericKubernetesApi<>(
327+
apiTypeClass,
328+
apiListTypeClass,
329+
getApiGroupFromResource(),
330+
getApiVersionFromResource(),
331+
getPluralFromResource(),
332+
apiClient);
333+
334+
return applyInternal(api, resource, fieldManager, forceConflicts, dryRun);
335+
}
336+
337+
private String getApiGroupFromResource() {
338+
String apiVersion = resource.getApiVersion();
339+
if (apiVersion == null) {
340+
return "";
341+
}
342+
if (apiVersion.contains("/")) {
343+
return apiVersion.substring(0, apiVersion.indexOf('/'));
344+
}
345+
return "";
346+
}
347+
348+
private String getApiVersionFromResource() {
349+
String apiVersion = resource.getApiVersion();
350+
if (apiVersion == null) {
351+
return "v1";
352+
}
353+
if (apiVersion.contains("/")) {
354+
return apiVersion.substring(apiVersion.indexOf('/') + 1);
355+
}
356+
return apiVersion;
357+
}
358+
359+
private String getPluralFromResource() {
360+
String kind = resource.getKind();
361+
if (kind == null) {
362+
throw new IllegalStateException("Resource kind must not be null");
363+
}
364+
return pluralize(kind);
365+
}
366+
367+
/**
368+
* Simple pluralization for Kubernetes resource kinds.
369+
*/
370+
private String pluralize(String kind) {
371+
String lower = kind.toLowerCase();
372+
// Special cases for Kubernetes kinds
373+
if (lower.endsWith("s") || lower.endsWith("x") || lower.endsWith("z")
374+
|| lower.endsWith("ch") || lower.endsWith("sh")) {
375+
return lower + "es";
376+
}
377+
if (lower.endsWith("y") && lower.length() > 1) {
378+
char beforeY = lower.charAt(lower.length() - 2);
379+
if (beforeY != 'a' && beforeY != 'e' && beforeY != 'i' && beforeY != 'o' && beforeY != 'u') {
380+
return lower.substring(0, lower.length() - 1) + "ies";
381+
}
382+
}
383+
// Handle known Kubernetes kinds
384+
switch (lower) {
385+
case "endpoints":
386+
return "endpoints";
387+
case "ingress":
388+
return "ingresses";
389+
default:
390+
return lower + "s";
391+
}
392+
}
393+
}
394+
}

0 commit comments

Comments
 (0)