|
| 1 | +import hashlib |
1 | 2 | import unittest |
2 | 3 | from unittest import mock |
3 | 4 |
|
4 | 5 | from capi_addons import operator |
5 | 6 |
|
6 | 7 |
|
| 8 | +# Helper to create an async iterable |
| 9 | +class AsyncIterList: |
| 10 | + def __init__(self, items): |
| 11 | + self.items = items |
| 12 | + |
| 13 | + def __aiter__(self): |
| 14 | + return self._generator() |
| 15 | + |
| 16 | + async def _generator(self): |
| 17 | + for item in self.items: |
| 18 | + yield item |
| 19 | + |
| 20 | + |
| 21 | +class MockResource: |
| 22 | + def __init__(self, items): |
| 23 | + self._items = items |
| 24 | + self.list_kwargs = None |
| 25 | + self.patch = mock.AsyncMock() |
| 26 | + |
| 27 | + def list(self, **kwargs): |
| 28 | + self.list_kwargs = kwargs |
| 29 | + return AsyncIterList(self._items) |
| 30 | + |
| 31 | + |
7 | 32 | class TestOperator(unittest.IsolatedAsyncioTestCase): |
| 33 | + @mock.patch("capi_addons.operator.HelmClient") |
| 34 | + @mock.patch("capi_addons.operator.Configuration") |
| 35 | + @mock.patch("capi_addons.operator.tempfile.NamedTemporaryFile") |
| 36 | + @mock.patch("capi_addons.operator.base64.b64decode") |
| 37 | + async def test_clients_for_cluster( |
| 38 | + self, mock_b64decode, mock_tempfile, mock_config_class, mock_helm_class |
| 39 | + ): |
| 40 | + # Prepare base64-decoded kubeconfig data |
| 41 | + kubeconfig_data = b"apiVersion: v1\nclusters:\n..." |
| 42 | + encoded_value = hashlib.sha256(kubeconfig_data).hexdigest() # dummy content |
| 43 | + kubeconfig_secret = mock.Mock() |
| 44 | + kubeconfig_secret.data = {"value": encoded_value} |
| 45 | + |
| 46 | + # Mock base64 decoding |
| 47 | + mock_b64decode.return_value = kubeconfig_data |
| 48 | + |
| 49 | + # Mock temp file |
| 50 | + mock_temp = mock.MagicMock() |
| 51 | + mock_temp.name = "/mock/kubeconfig" |
| 52 | + mock_temp.__enter__.return_value = mock_temp |
| 53 | + mock_tempfile.return_value.__enter__.return_value = mock_temp |
| 54 | + |
| 55 | + # Mock EasyKube client |
| 56 | + mock_async_client = mock.AsyncMock() |
| 57 | + mock_config = mock.Mock() |
| 58 | + mock_config.async_client.return_value = mock_async_client |
| 59 | + mock_config_class.from_kubeconfig_data.return_value = mock_config |
| 60 | + |
| 61 | + # Mock Helm client |
| 62 | + mock_helm_instance = mock.Mock() |
| 63 | + mock_helm_class.return_value = mock_helm_instance |
| 64 | + |
| 65 | + # Patch settings |
| 66 | + operator.settings.easykube_field_manager = "test-manager" |
| 67 | + operator.settings.helm_client = mock.Mock( |
| 68 | + default_timeout=60, |
| 69 | + executable="helm", |
| 70 | + history_max_revisions=10, |
| 71 | + insecure_skip_tls_verify=True, |
| 72 | + unpack_directory="/mock/unpack", |
| 73 | + ) |
| 74 | + |
| 75 | + async with operator.clients_for_cluster(kubeconfig_secret) as ( |
| 76 | + ek_client, |
| 77 | + helm_client, |
| 78 | + ): |
| 79 | + mock_b64decode.assert_called_once() |
| 80 | + mock_config_class.from_kubeconfig_data.assert_called_once_with( |
| 81 | + kubeconfig_data, json_encoder=mock.ANY |
| 82 | + ) |
| 83 | + mock_config.async_client.assert_called_once_with( |
| 84 | + default_field_manager="test-manager" |
| 85 | + ) |
| 86 | + mock_helm_class.assert_called_once_with( |
| 87 | + default_timeout=60, |
| 88 | + executable="helm", |
| 89 | + history_max_revisions=10, |
| 90 | + insecure_skip_tls_verify=True, |
| 91 | + kubeconfig="/mock/kubeconfig", |
| 92 | + unpack_directory="/mock/unpack", |
| 93 | + ) |
| 94 | + self.assertEqual(ek_client, mock_async_client) |
| 95 | + self.assertEqual(helm_client, mock_helm_instance) |
| 96 | + |
| 97 | + mock_async_client.__aenter__.assert_called_once() |
| 98 | + mock_async_client.__aexit__.assert_called_once() |
| 99 | + |
| 100 | + async def test_fetch_ref(self): |
| 101 | + ref = {"name": "my-secret"} |
| 102 | + default_namespace = "default" |
| 103 | + |
| 104 | + mock_resource = mock.AsyncMock() |
| 105 | + mock_resource.fetch.return_value = { |
| 106 | + "kind": "Secret", |
| 107 | + "metadata": {"name": "my-secret"}, |
| 108 | + } |
| 109 | + |
| 110 | + mock_api = mock.Mock() |
| 111 | + mock_api.resource = mock.AsyncMock(return_value=mock_resource) |
| 112 | + |
| 113 | + mock_ek_client = mock.Mock() |
| 114 | + mock_ek_client.api.return_value = mock_api |
| 115 | + |
| 116 | + result = await operator.fetch_ref(mock_ek_client, ref, default_namespace) |
| 117 | + |
| 118 | + mock_ek_client.api.assert_called_once_with("v1") |
| 119 | + mock_api.resource.assert_awaited_once_with("Secret") |
| 120 | + mock_resource.fetch.assert_awaited_once_with("my-secret", namespace="default") |
| 121 | + self.assertEqual(result["metadata"]["name"], "my-secret") |
| 122 | + |
| 123 | + @mock.patch("capi_addons.operator.asyncio.sleep", new_callable=mock.AsyncMock) |
| 124 | + @mock.patch("capi_addons.operator.create_ek_client") |
| 125 | + async def test_until_deleted(self, mock_create_ek_client, mock_sleep): |
| 126 | + addon = mock.Mock() |
| 127 | + addon.api_version = "group/v1" |
| 128 | + addon.metadata.name = "my-addon" |
| 129 | + addon.metadata.namespace = "my-namespace" |
| 130 | + addon._meta.plural_name = "addons" |
| 131 | + |
| 132 | + alive_obj = mock.Mock() |
| 133 | + alive_obj.metadata.get.return_value = None |
| 134 | + |
| 135 | + deleting_obj = mock.Mock() |
| 136 | + deleting_obj.metadata.get.return_value = "2024-01-01T00:00:00Z" |
| 137 | + |
| 138 | + mock_resource = mock.AsyncMock() |
| 139 | + mock_resource.fetch = mock.AsyncMock(side_effect=[alive_obj, deleting_obj]) |
| 140 | + |
| 141 | + mock_ekapi = mock.Mock() |
| 142 | + mock_ekapi.resource = mock.AsyncMock(return_value=mock_resource) |
| 143 | + |
| 144 | + mock_ek_client = mock.AsyncMock() |
| 145 | + mock_ek_client.api = mock.Mock(return_value=mock_ekapi) |
| 146 | + |
| 147 | + mock_create_ek_client.return_value.__aenter__.return_value = mock_ek_client |
| 148 | + |
| 149 | + await operator.until_deleted(addon) |
| 150 | + |
| 151 | + self.assertEqual(mock_resource.fetch.await_count, 2) |
| 152 | + mock_resource.fetch.assert_called_with("my-addon", namespace="my-namespace") |
| 153 | + |
| 154 | + async def test_compute_checksum(self): |
| 155 | + data = {"key1": "value1", "key2": "value2"} |
| 156 | + expected_checksum = hashlib.sha256(b"key1=value1;key2=value2").hexdigest() |
| 157 | + result = operator.compute_checksum(data) |
| 158 | + self.assertEqual(result, expected_checksum) |
| 159 | + |
| 160 | + modified_data = {"key1": "value1", "key2": "DIFFERENT"} |
| 161 | + result_modified = operator.compute_checksum(modified_data) |
| 162 | + self.assertNotEqual(result, result_modified) |
| 163 | + |
| 164 | + empty_checksum = operator.compute_checksum({}) |
| 165 | + expected_empty_checksum = hashlib.sha256(b"").hexdigest() |
| 166 | + self.assertEqual(empty_checksum, expected_empty_checksum) |
| 167 | + |
| 168 | + @mock.patch("capi_addons.operator.registry") |
| 169 | + async def test_handle_config_event_deleted(self, mock_registry): |
| 170 | + addon_metadata = mock.Mock() |
| 171 | + addon_metadata.name = "deleted-addon" |
| 172 | + addon_metadata.namespace = "test-namespace" |
| 173 | + addon_metadata.annotations = {} |
| 174 | + |
| 175 | + addon = mock.Mock() |
| 176 | + addon.metadata = addon_metadata |
| 177 | + |
| 178 | + addon_obj = mock.Mock() |
| 179 | + mock_registry.get_model_instance.return_value = addon |
| 180 | + |
| 181 | + # Mock registry to return one CRD |
| 182 | + crd = mock.Mock() |
| 183 | + crd.api_group = "example.group" |
| 184 | + crd.plural_name = "addons" |
| 185 | + crd.versions = {"v1": mock.Mock(storage=True)} |
| 186 | + mock_registry.__iter__.return_value = iter([crd]) |
| 187 | + |
| 188 | + # Use MockResource and AsyncIterList |
| 189 | + mock_resource = MockResource(items=[addon_obj]) |
| 190 | + |
| 191 | + mock_ekapi = mock.Mock() |
| 192 | + mock_ekapi.resource = mock.AsyncMock(return_value=mock_resource) |
| 193 | + |
| 194 | + mock_ek_client = mock.Mock() |
| 195 | + mock_ek_client.api.return_value = mock_ekapi |
| 196 | + |
| 197 | + # Call the method with type="DELETED" |
| 198 | + await operator.handle_config_event( |
| 199 | + ek_client=mock_ek_client, |
| 200 | + type="DELETED", |
| 201 | + name="test-config", |
| 202 | + namespace="test-namespace", |
| 203 | + body={"data": {"foo": "bar"}}, # ignored for DELETED |
| 204 | + annotation_prefix="secret.addons.stackhpc.com", |
| 205 | + ) |
| 206 | + |
| 207 | + self.assertIn( |
| 208 | + "test-config.secret.addons.stackhpc.com/uses", |
| 209 | + mock_resource.list_kwargs["labels"], |
| 210 | + ) |
| 211 | + |
| 212 | + mock_resource.patch.assert_awaited_once() |
| 213 | + |
| 214 | + patched_annotations = mock_resource.patch.call_args[0][1]["metadata"][ |
| 215 | + "annotations" |
| 216 | + ] |
| 217 | + self.assertEqual( |
| 218 | + patched_annotations["secret.addons.stackhpc.com/test-config"], "deleted" |
| 219 | + ) |
| 220 | + |
| 221 | + @mock.patch("capi_addons.operator.registry") |
| 222 | + async def test_handle_config_event_not_deleted(self, mock_registry): |
| 223 | + # Prepare mock addon metadata with no matching annotation initially |
| 224 | + addon_metadata = mock.Mock() |
| 225 | + addon_metadata.name = "addon-1" |
| 226 | + addon_metadata.namespace = "test-namespace" |
| 227 | + addon_metadata.annotations = {} |
| 228 | + |
| 229 | + # Mock addon object returned from registry.get_model_instance |
| 230 | + addon = mock.Mock() |
| 231 | + addon.metadata = addon_metadata |
| 232 | + |
| 233 | + addon_obj = mock.Mock() |
| 234 | + mock_registry.get_model_instance.return_value = addon |
| 235 | + |
| 236 | + # Mock registry to return one CRD with version storage |
| 237 | + crd = mock.Mock() |
| 238 | + crd.api_group = "example.group" |
| 239 | + crd.plural_name = "addons" |
| 240 | + crd.versions = {"v1": mock.Mock(storage=True)} |
| 241 | + mock_registry.__iter__.return_value = iter([crd]) |
| 242 | + |
| 243 | + mock_resource = MockResource(items=[addon_obj]) |
| 244 | + |
| 245 | + mock_ekapi = mock.Mock() |
| 246 | + mock_ekapi.resource = mock.AsyncMock(return_value=mock_resource) |
| 247 | + |
| 248 | + mock_ek_client = mock.Mock() |
| 249 | + mock_ek_client.api.return_value = mock_ekapi |
| 250 | + |
| 251 | + # Define config data and expected checksum |
| 252 | + data = {"foo": "bar", "baz": "qux"} |
| 253 | + data_str = ";".join(sorted(f"{k}={v}" for k, v in data.items())).encode() |
| 254 | + expected_checksum = hashlib.sha256(data_str).hexdigest() |
| 255 | + |
| 256 | + # Call the function with type != "DELETED" |
| 257 | + await operator.handle_config_event( |
| 258 | + ek_client=mock_ek_client, |
| 259 | + type="MODIFIED", |
| 260 | + name="test-config", |
| 261 | + namespace="test-namespace", |
| 262 | + body={"data": data}, |
| 263 | + annotation_prefix="secret.addons.stackhpc.com", |
| 264 | + ) |
| 265 | + |
| 266 | + # Assertions to check resource.list was called with correct labels and namespace |
| 267 | + self.assertEqual( |
| 268 | + mock_resource.list_kwargs["labels"], |
| 269 | + {"test-config.secret.addons.stackhpc.com/uses": operator.PRESENT}, |
| 270 | + ) |
| 271 | + self.assertEqual(mock_resource.list_kwargs["namespace"], "test-namespace") |
| 272 | + |
| 273 | + mock_resource.patch.assert_awaited_once() |
| 274 | + |
| 275 | + # Verify patched annotation value matches expected checksum |
| 276 | + patch_args = mock_resource.patch.call_args[0] |
| 277 | + patch_body = patch_args[1] |
| 278 | + annotation_key = "secret.addons.stackhpc.com/test-config" |
| 279 | + self.assertEqual( |
| 280 | + patch_body["metadata"]["annotations"][annotation_key], |
| 281 | + expected_checksum, |
| 282 | + ) |
| 283 | + |
8 | 284 | @mock.patch.object(operator, "handle_config_event") |
9 | 285 | @mock.patch.object(operator, "create_ek_client") |
10 | 286 | async def test_handle_secret_event( |
|
0 commit comments