Skip to content

Commit 2f7d17e

Browse files
committed
migrate to server side-apply
1 parent 2bf57af commit 2f7d17e

File tree

13 files changed

+505
-212
lines changed

13 files changed

+505
-212
lines changed

docker/mongodb-kubernetes-tests/kubeobject/customobject.py

Lines changed: 35 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
from __future__ import annotations
22

3-
import copy
43
from datetime import datetime, timedelta
54
from typing import Dict, Optional
65

76
import yaml
87
from kubernetes import client
8+
from kubernetes.dynamic import DynamicClient
99

1010

1111
class CustomObject:
@@ -73,8 +73,14 @@ def __init__(
7373
# Last time this object was updated
7474
self.last_update: datetime = None
7575

76+
# Store the api_client for later use
77+
self._api_client = api_client if api_client else client.ApiClient()
78+
7679
# Sets the API used for this particular type of object
77-
self.api = client.CustomObjectsApi(api_client=api_client)
80+
self.api = client.CustomObjectsApi(api_client=self._api_client)
81+
82+
# Dynamic client for server-side apply
83+
self._dynamic_client = DynamicClient(self._api_client)
7884

7985
if not hasattr(self, "backing_obj"):
8086
self.backing_obj = {
@@ -112,21 +118,30 @@ def update(self) -> CustomObject:
112118
"""Updates the object in Kubernetes. Deleting keys is done by setting them to None"""
113119
return create_or_update(self)
114120

115-
def patch(self) -> CustomObject:
116-
"""Patch the object in Kubernetes. Deleting keys is done by setting them to None"""
117-
obj = self.api.patch_namespaced_custom_object(
118-
self.group,
119-
self.version,
120-
self.namespace,
121-
self.plural,
122-
self.name,
123-
self.backing_obj,
121+
def patch(self, field_manager: str = "e2e-tests") -> CustomObject:
122+
"""Apply the object in Kubernetes using server-side apply.
123+
124+
Server-side apply (SSA) provides proper field ownership tracking and
125+
conflict resolution, avoiding race conditions when both the operator
126+
and tests modify the same CR.
127+
128+
Args:
129+
field_manager: Identifier for the field manager (owner of the fields being applied)
130+
"""
131+
api_version = self.backing_obj.get("apiVersion", f"{self.group}/{self.version}")
132+
kind = self.backing_obj.get("kind", self.kind)
133+
134+
resource_api = self._dynamic_client.resources.get(api_version=api_version, kind=kind)
135+
result = resource_api.server_side_apply(
136+
namespace=self.namespace,
137+
body=self.backing_obj,
138+
field_manager=field_manager,
124139
)
125-
self.backing_obj = obj
140+
self.backing_obj = result.to_dict()
126141

127142
self._register_updated()
128143

129-
return obj
144+
return self
130145

131146
def _register_updated(self):
132147
"""Register the last time the object was updated from Kubernetes."""
@@ -316,49 +331,21 @@ def get_crd_names(
316331
def create_or_update(resource: CustomObject) -> CustomObject:
317332
"""
318333
Tries to create the resource. If resource already exists (resulting in 409 Conflict),
319-
then it updates it instead. If the resource has been modified externally (operator)
320-
we try to do a client-side merge/override
334+
then it updates it instead using server-side apply.
335+
336+
Server-side apply (SSA) with force=True handles conflicts automatically by taking
337+
ownership of conflicting fields, eliminating the need for client-side merge retries.
321338
"""
322-
tries = 0
323339
if not resource.bound:
324340
try:
325341
resource.create()
326342
except client.ApiException as e:
327343
if e.status != 409:
328344
raise e
345+
# Resource already exists, use SSA to update it
329346
resource.patch()
330347
else:
331-
while tries < 10:
332-
if tries > 0: # The first try we don't need to do client-side merge apply
333-
# do a client-side-apply
334-
new_back_obj_to_apply = copy.deepcopy(resource.backing_obj) # resource and changes we want to apply
335-
336-
resource.load() # resource from the server overwrites resource.backing_obj
337-
338-
# Merge annotations, and labels.
339-
# Client resource takes precedence
340-
# Spec from the given resource is taken,
341-
# since the operator is not supposed to do changes to the spec.
342-
# There can be cases where the obj from the server does not contain annotations/labels, but the object
343-
# we want to apply has them. But that is highly unlikely, and we can add that code in case that happens.
344-
resource["spec"] = new_back_obj_to_apply["spec"]
345-
if "metadata" in resource and "annotations" in resource["metadata"]:
346-
resource["metadata"]["annotations"].update(new_back_obj_to_apply["metadata"]["annotations"])
347-
if "metadata" in resource and "labels" in resource["metadata"]:
348-
resource["metadata"]["labels"].update(new_back_obj_to_apply["metadata"]["labels"])
349-
try:
350-
resource.patch()
351-
break
352-
except client.ApiException as e:
353-
if e.status != 409:
354-
raise e
355-
print(
356-
"detected a resource conflict. That means the operator applied a change "
357-
"to the same resource we are trying to change"
358-
"Applying a client-side merge!"
359-
)
360-
tries += 1
361-
if tries == 10:
362-
raise Exception("Tried client side merge 10 times and did not succeed")
348+
# SSA with force=True handles conflicts automatically
349+
resource.patch()
363350

364351
return resource

docker/mongodb-kubernetes-tests/kubeobject/kubeobject.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from kubeobject.exceptions import ObjectNotBoundException
1212
from kubernetes import client
1313
from kubernetes.client.api import ApiextensionsV1Api, CustomObjectsApi
14+
from kubernetes.dynamic import DynamicClient
1415

1516

1617
class KubeObject(object):
@@ -42,11 +43,14 @@ def init_attributes(self):
4243
# This is the object that will contain the definition of the Custom Object when bound
4344
self.__dict__[KubeObject.BACKING_OBJ] = Box(default_box=True)
4445

46+
# Store the API client for reuse
47+
self.__dict__["_api_client"] = client.ApiClient()
48+
4549
# Set an API to work with.
46-
# TODO: Allow for a better experience; api could be defined from env variables,
47-
# in_cluster or whatever. See if this is needed. Can we run our samples with
48-
# in_cluster, or based on different clusters pointed at by env variables?
49-
self.__dict__["api"] = CustomObjectsApi()
50+
self.__dict__["api"] = CustomObjectsApi(api_client=self.__dict__["_api_client"])
51+
52+
# Dynamic client for server-side apply
53+
self.__dict__["_dynamic_client"] = DynamicClient(self.__dict__["_api_client"])
5054

5155
# Set `auto_reload` to `True` if it needs to be reloaded before every
5256
# read of an attribute. This considers the `auto_reload_period`
@@ -86,19 +90,32 @@ def read(self, name: str, namespace: str):
8690
self._register_update()
8791
return self
8892

89-
def update(self):
93+
def update(self, field_manager: str = "e2e-tests"):
94+
"""Updates the object in Kubernetes using server-side apply.
95+
96+
Server-side apply (SSA) provides proper field ownership tracking and
97+
conflict resolution, avoiding race conditions when both the operator
98+
and tests modify the same CR.
99+
100+
Args:
101+
field_manager: Identifier for the field manager (owner of the fields being applied)
102+
"""
90103
if not self.bound:
91104
# there's no corresponding object in the Kubernetes cluster
92105
raise ObjectNotBoundException
93106

94-
obj = self.api.patch_namespaced_custom_object(
95-
name=self.name,
107+
body = self.__dict__[KubeObject.BACKING_OBJ].to_dict()
108+
api_version = body.get("apiVersion", f"{self.crd['group']}/{self.crd['version']}")
109+
kind = body.get("kind")
110+
111+
resource_api = self.__dict__["_dynamic_client"].resources.get(api_version=api_version, kind=kind)
112+
result = resource_api.server_side_apply(
96113
namespace=self.namespace,
97-
**self.crd,
98-
body=self.__dict__[KubeObject.BACKING_OBJ].to_dict(),
114+
body=body,
115+
field_manager=field_manager,
99116
)
100117

101-
self.__dict__[KubeObject.BACKING_OBJ] = Box(obj, default_box=True)
118+
self.__dict__[KubeObject.BACKING_OBJ] = Box(result.to_dict(), default_box=True)
102119
self._register_update()
103120

104121
return self

docker/mongodb-kubernetes-tests/kubeobject/test_custom_object.py

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -32,27 +32,55 @@
3232
anotherAttrStr: value1
3333
"""
3434

35+
# Shared storage for mock data
36+
_stored_body = []
3537

36-
def mocked_custom_api():
37-
stored_body = []
3838

39+
class MockResourceResult:
40+
"""Mock for DynamicClient resource result."""
41+
42+
def __init__(self, data):
43+
self._data = data
44+
45+
def to_dict(self):
46+
return self._data
47+
48+
49+
class MockResource:
50+
"""Mock for DynamicClient resource API."""
51+
52+
def server_side_apply(self, namespace, body, field_manager):
53+
_stored_body.append(body)
54+
return MockResourceResult(body)
55+
56+
57+
class MockDynamicClient:
58+
"""Mock for DynamicClient."""
59+
60+
class Resources:
61+
def get(self, api_version, kind):
62+
return MockResource()
63+
64+
resources = Resources()
65+
66+
67+
def mocked_dynamic_client():
68+
return MockDynamicClient()
69+
70+
71+
def mocked_custom_api():
3972
def get_namespaced_custom_object(group, version, namespace, plural, name):
40-
if len(stored_body) > 0:
41-
return stored_body[-1]
73+
if len(_stored_body) > 0:
74+
return _stored_body[-1]
4275
return {"name": name}
4376

4477
def create_namespaced_custom_object(group, version, namespace, plural, body: dict):
4578
body.update({"name": body["metadata"]["name"]})
46-
stored_body.append(body)
47-
return body
48-
49-
def patch_namespaced_custom_object(group, version, namespace, plural, name, body: dict):
50-
stored_body.append(body)
79+
_stored_body.append(body)
5180
return body
5281

5382
base = MagicMock()
5483
base.get_namespaced_custom_object = MagicMock(side_effect=get_namespaced_custom_object)
55-
base.patch_namespaced_custom_object = MagicMock(side_effect=patch_namespaced_custom_object)
5684
base.create_namespaced_custom_object = MagicMock(side_effect=create_namespaced_custom_object)
5785

5886
return base
@@ -68,10 +96,13 @@ def mocked_crd_return_value():
6896
)
6997

7098

99+
@mock.patch("kubeobject.customobject.DynamicClient")
71100
@mock.patch("kubeobject.customobject.client.CustomObjectsApi")
72101
@mock.patch("kubeobject.customobject.get_crd_names", return_value=mocked_crd_return_value())
73-
def test_custom_object_creation(mocked_get_crd_names, mocked_client):
102+
def test_custom_object_creation(mocked_get_crd_names, mocked_client, mocked_dynamic):
103+
_stored_body.clear()
74104
mocked_client.return_value = mocked_custom_api()
105+
mocked_dynamic.return_value = mocked_dynamic_client()
75106
custom = CustomObject(
76107
"my-dummy-object",
77108
"my-dummy-namespace",
@@ -84,10 +115,13 @@ def test_custom_object_creation(mocked_get_crd_names, mocked_client):
84115
assert custom["name"] == "my-dummy-object"
85116

86117

118+
@mock.patch("kubeobject.customobject.DynamicClient")
87119
@mock.patch("kubeobject.customobject.client.CustomObjectsApi")
88120
@mock.patch("kubeobject.customobject.get_crd_names", return_value=mocked_crd_return_value())
89-
def test_custom_object_read_from_disk(mocked_get_crd_names, mocked_client):
121+
def test_custom_object_read_from_disk(mocked_get_crd_names, mocked_client, mocked_dynamic):
122+
_stored_body.clear()
90123
mocked_client.return_value = mocked_custom_api()
124+
mocked_dynamic.return_value = mocked_dynamic_client()
91125
with mock.patch(
92126
"kubeobject.customobject.open",
93127
mock.mock_open(read_data=yaml_data0),
@@ -106,10 +140,13 @@ def test_custom_object_read_from_disk(mocked_get_crd_names, mocked_client):
106140
assert custom["spec"]["subDoc"]["anotherAttrStr"] == "value1"
107141

108142

143+
@mock.patch("kubeobject.customobject.DynamicClient")
109144
@mock.patch("kubeobject.customobject.client.CustomObjectsApi")
110145
@mock.patch("kubeobject.customobject.get_crd_names", return_value=mocked_crd_return_value())
111-
def test_custom_object_read_from_disk_with_dat_from_yaml(mocked_get_crd_names, mocked_client):
146+
def test_custom_object_read_from_disk_with_dat_from_yaml(mocked_get_crd_names, mocked_client, mocked_dynamic):
147+
_stored_body.clear()
112148
mocked_client.return_value = mocked_custom_api()
149+
mocked_dynamic.return_value = mocked_dynamic_client()
113150
with mock.patch(
114151
"kubeobject.customobject.open",
115152
mock.mock_open(read_data=yaml_data0),
@@ -137,10 +174,13 @@ def test_custom_object_read_from_disk_with_dat_from_yaml(mocked_get_crd_names, m
137174
assert custom.plural == "dummies"
138175

139176

177+
@mock.patch("kubeobject.customobject.DynamicClient")
140178
@mock.patch("kubeobject.customobject.client.CustomObjectsApi")
141179
@mock.patch("kubeobject.customobject.get_crd_names", return_value=mocked_crd_return_value())
142-
def test_custom_object_can_be_subclassed_create(mocked_crd_return_value, mocked_client):
180+
def test_custom_object_can_be_subclassed_create(mocked_crd_return_value, mocked_client, mocked_dynamic):
181+
_stored_body.clear()
143182
mocked_client.return_value = mocked_custom_api()
183+
mocked_dynamic.return_value = mocked_dynamic_client()
144184

145185
class Subklass(CustomObject):
146186
pass
@@ -156,10 +196,13 @@ class Subklass(CustomObject):
156196
assert a.__class__.__name__ == "Subklass"
157197

158198

199+
@mock.patch("kubeobject.customobject.DynamicClient")
159200
@mock.patch("kubeobject.customobject.client.CustomObjectsApi")
160201
@mock.patch("kubeobject.customobject.get_crd_names", return_value=mocked_crd_return_value())
161-
def test_custom_object_can_be_subclassed_from_yaml(mocked_crd_return_value, mocked_client):
202+
def test_custom_object_can_be_subclassed_from_yaml(mocked_crd_return_value, mocked_client, mocked_dynamic):
203+
_stored_body.clear()
162204
mocked_client.return_value = mocked_custom_api()
205+
mocked_dynamic.return_value = mocked_dynamic_client()
163206

164207
class Subklass(CustomObject):
165208
pass
@@ -175,10 +218,13 @@ class Subklass(CustomObject):
175218
assert a.__class__.__name__ == "Subklass"
176219

177220

221+
@mock.patch("kubeobject.customobject.DynamicClient")
178222
@mock.patch("kubeobject.customobject.client.CustomObjectsApi")
179223
@mock.patch("kubeobject.customobject.get_crd_names", return_value=mocked_crd_return_value())
180-
def test_custom_object_defined(mocked_crd_return_value, mocked_client):
224+
def test_custom_object_defined(mocked_crd_return_value, mocked_client, mocked_dynamic):
225+
_stored_body.clear()
181226
mocked_client.return_value = mocked_custom_api()
227+
mocked_dynamic.return_value = mocked_dynamic_client()
182228
klass = CustomObject.define("Dummy", plural="dummies", group="dummy.com", version="v1")
183229

184230
k = klass("my-dummy", "default").create()
@@ -189,9 +235,12 @@ def test_custom_object_defined(mocked_crd_return_value, mocked_client):
189235
assert repr(k) == "Dummy('my-dummy', 'default')"
190236

191237

238+
@mock.patch("kubeobject.customobject.DynamicClient")
192239
@mock.patch("kubeobject.customobject.client.CustomObjectsApi")
193-
def test_defined_wont_require_api_if_all_parameteres_are_provided(mocked_client):
240+
def test_defined_wont_require_api_if_all_parameteres_are_provided(mocked_client, mocked_dynamic):
241+
_stored_body.clear()
194242
mocked_client.return_value = mocked_custom_api()
243+
mocked_dynamic.return_value = mocked_dynamic_client()
195244
BaseKlass = CustomObject.define("Dummy", kind="Dummy", plural="dummies", group="dummy.com", version="v1")
196245

197246
class SubKlass(BaseKlass):
@@ -207,11 +256,14 @@ def get_spec(self):
207256
assert k.get_spec() == {"testAttr": "value"}
208257

209258

259+
@mock.patch("kubeobject.customobject.DynamicClient")
210260
@mock.patch("kubeobject.customobject.client.CustomObjectsApi")
211261
@mock.patch("kubeobject.customobject.get_crd_names", return_value=mocked_crd_return_value())
212-
def test_custom_object_auto_reload(mocked_get_crd_names, mocked_client):
262+
def test_custom_object_auto_reload(mocked_get_crd_names, mocked_client, mocked_dynamic):
263+
_stored_body.clear()
213264
instance = mocked_custom_api()
214265
mocked_client.return_value = instance
266+
mocked_dynamic.return_value = mocked_dynamic_client()
215267

216268
klass = CustomObject.define("Dummy", plural="dummies", group="dummy.com", version="v1")
217269
k = klass("my-dummy", "default")
@@ -265,9 +317,13 @@ def test_raises_if_no_namespace():
265317
CustomObject.from_yaml("some-other-file.yaml", name="some-name")
266318

267319

320+
@mock.patch("kubeobject.customobject.DynamicClient")
321+
@mock.patch("kubeobject.customobject.client.CustomObjectsApi")
268322
@mock.patch("kubeobject.customobject.get_crd_names", return_value=mocked_crd_return_value())
269-
def test_name_is_set_as_argument(_):
323+
def test_name_is_set_as_argument(_, mocked_client, mocked_dynamic):
270324
# TODO: what is this test supposed to do?
325+
mocked_client.return_value = mocked_custom_api()
326+
mocked_dynamic.return_value = mocked_dynamic_client()
271327
with mock.patch(
272328
"kubeobject.customobject.open",
273329
mock.mock_open(read_data=yaml_data1),

0 commit comments

Comments
 (0)