Skip to content

Commit 5441c43

Browse files
authored
Merge pull request #23 from zalando-incubator/k8s-custom-resources
Support K8s custom resources
2 parents 5ab87dd + a2cc804 commit 5441c43

File tree

9 files changed

+306
-4
lines changed

9 files changed

+306
-4
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.2.14][] - 2019-11-29
9+
10+
### Added
11+
12+
- Support for custom Kubernetes resources (#23)
13+
814
## [1.2.13][] - 2019-10-29
915

1016
### Fixed
@@ -107,6 +113,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
107113
- Implemented `-v`/`--version` option to show Zelt version.
108114
- This changelog.
109115

116+
[1.2.14]: https://github.com/zalando-incubator/zelt/compare/v1.2.13...v1.2.14
110117
[1.2.13]: https://github.com/zalando-incubator/zelt/compare/v1.2.12...v1.2.13
111118
[1.2.12]: https://github.com/zalando-incubator/zelt/compare/v1.2.11...v1.2.12
112119
[1.2.11]: https://github.com/zalando-incubator/zelt/compare/v1.2.10...v1.2.11

docs/Run-a-load-test.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ These consist of:
9292
- ``service.yaml`` that defines the Kubernetes service_ to create
9393
- ``ingress.yaml`` that defines the Kubernetes ingress_ to create
9494

95+
.. note::
96+
Currently Zelt's support is limited to the above Kubernetes resource types, as well as `custom resources`_.
97+
Please note that Zelt will deploy a custom resource only if its `custom resource definition`_ is already deployed in the cluster, and only if
98+
the scope of the resource is limited to a namespace.
99+
95100
.. TODO: Create a page detailing each manifest
96101
.. For more detailed information, please refer to :ref:`manifests`.
97102
@@ -195,3 +200,5 @@ before doing this or they will be deleted!
195200
.. _`Download them`: https://github.com/zalando-incubator/zelt/tree/master/examples/manifests/combined
196201
.. _Transformer: https://github.com/zalando-incubator/Transformer
197202
.. _`Locust's documentation`: https://docs.locust.io/en/stable/what-is-locust.html
203+
.. _`custom resources`: https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/
204+
.. _`custom resource definition`: https://kubernetes.io/docs/tasks/access-kubernetes-api/custom-resources/custom-resource-definitions/

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "zelt"
3-
version = "1.2.13"
3+
version = "1.2.14"
44
description = "Zalando end-to-end load tester"
55
authors = [
66
"Brian Maher <brian.maher@zalando.de>",

tests/kubernetes/test_client.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from typing import List
12
from unittest.mock import MagicMock, patch
23

34
import pytest
@@ -20,6 +21,7 @@
2021
delete_deployments,
2122
await_no_resources_found,
2223
rescale_deployment,
24+
try_creating_custom_objects,
2325
)
2426
from zelt.kubernetes.manifest import Manifest
2527

@@ -360,3 +362,183 @@ def test_it_raises_exception(self, delete, waiting):
360362

361363
delete.assert_called_once()
362364
waiting.assert_not_called()
365+
366+
367+
class TestCreateCustomResources:
368+
@pytest.fixture()
369+
def manifests(self) -> List[Manifest]:
370+
manifest_a = Manifest(
371+
body={
372+
"apiVersion": "zalando.org/v1",
373+
"kind": "ZalandoCustomResource",
374+
"metadata": {
375+
"name": "a_surprise",
376+
"namespace": "a_namespace",
377+
"labels": {"application": "an_application"},
378+
},
379+
}
380+
)
381+
manifest_b = Manifest(
382+
body={
383+
"apiVersion": "example.com/v2",
384+
"kind": "ExampleCustomResource",
385+
"metadata": {
386+
"name": "a_surprise",
387+
"namespace": "a_namespace",
388+
"labels": {"application": "an_application"},
389+
},
390+
}
391+
)
392+
manifest_c = Manifest(
393+
body={
394+
"apiVersion": "example.com/v2",
395+
"kind": "ClusterCustomResource",
396+
"metadata": {
397+
"name": "a_surprise",
398+
"labels": {"application": "an_application"},
399+
},
400+
}
401+
)
402+
return [manifest_a, manifest_b, manifest_c]
403+
404+
@pytest.fixture()
405+
def crds(self) -> List[MagicMock]:
406+
crd_manifest_a = MagicMock()
407+
crd_manifest_a.spec.names.kind = "ZalandoCustomResource"
408+
crd_manifest_a.spec.names.plural = "zalandocustomresources"
409+
crd_manifest_a.spec.scope = "Namespaced"
410+
411+
crd_manifest_b = MagicMock()
412+
crd_manifest_b.spec.names.kind = "ExampleCustomResource"
413+
crd_manifest_b.spec.names.plural = "examplecustomresources"
414+
crd_manifest_b.spec.scope = "Namespaced"
415+
416+
crd_cluster_manifest = MagicMock()
417+
crd_cluster_manifest.spec.names.kind = "ClusterCustomResource"
418+
crd_cluster_manifest.spec.names.plural = "clustercustomresources"
419+
crd_cluster_manifest.spec.scope = "Cluster"
420+
421+
return [crd_manifest_a, crd_manifest_b, crd_cluster_manifest]
422+
423+
@patch("kubernetes.config.load_kube_config")
424+
@patch("zelt.kubernetes.client.CustomObjectsApi.create_namespaced_custom_object")
425+
@patch(
426+
"zelt.kubernetes.client.ApiextensionsV1beta1Api.list_custom_resource_definition"
427+
)
428+
def test_it_fetches_available_crds_once(
429+
self, list_crds, create_custom_objects, config, manifests, crds
430+
):
431+
try_creating_custom_objects(manifests)
432+
list_crds.assert_called_once()
433+
434+
@patch("kubernetes.config.load_kube_config")
435+
@patch("zelt.kubernetes.client.CustomObjectsApi")
436+
@patch(
437+
"zelt.kubernetes.client.ApiextensionsV1beta1Api.list_custom_resource_definition"
438+
)
439+
def test_it_creates_supported_custom_resources(
440+
self,
441+
list_crds,
442+
custom_objects_api,
443+
config,
444+
manifests: List[Manifest],
445+
crds: List[MagicMock],
446+
):
447+
# All 3 CRDs are available in the cluster
448+
list_crds.return_value = MagicMock(items=crds)
449+
# Zelt tries to deploy only the namespaced ones
450+
# (without a CustomClusterResource)
451+
try_creating_custom_objects(manifests[:2])
452+
453+
custom_objects_api().create_namespaced_custom_object.assert_any_call(
454+
namespace=manifests[0].namespace,
455+
body=manifests[0].body,
456+
group="zalando.org",
457+
version="v1",
458+
plural="zalandocustomresources",
459+
)
460+
custom_objects_api().create_namespaced_custom_object.assert_any_call(
461+
namespace=manifests[1].namespace,
462+
body=manifests[1].body,
463+
group="example.com",
464+
version="v2",
465+
plural="examplecustomresources",
466+
)
467+
468+
@patch("kubernetes.config.load_kube_config")
469+
@patch("zelt.kubernetes.client.CustomObjectsApi")
470+
@patch(
471+
"zelt.kubernetes.client.ApiextensionsV1beta1Api.list_custom_resource_definition"
472+
)
473+
def test_it_ignores_unsupported_resources(
474+
self, list_crds, custom_objects_api, config, manifests, crds, caplog
475+
):
476+
# ZalandoCustomResource is the only available CRD in the cluster
477+
list_crds.return_value = MagicMock(items=[crds[0]])
478+
# Zelt tries to deploy ZalandoCustomResource and ExampleCustomResource
479+
try_creating_custom_objects(manifests)
480+
481+
custom_objects_api().create_namespaced_custom_object.assert_called_once()
482+
custom_objects_api().create_namespaced_custom_object.assert_called_with(
483+
namespace=manifests[0].namespace,
484+
body=manifests[0].body,
485+
group="zalando.org",
486+
version="v1",
487+
plural="zalandocustomresources",
488+
)
489+
assert any(
490+
"Unsupported custom manifest" in r.msg for r in caplog.records
491+
), "an error should be logged"
492+
493+
@patch("kubernetes.config.load_kube_config")
494+
@patch("zelt.kubernetes.client.CustomObjectsApi")
495+
@patch(
496+
"zelt.kubernetes.client.ApiextensionsV1beta1Api.list_custom_resource_definition"
497+
)
498+
def test_it_ignores_non_namespaced_crds(
499+
self, list_crds, custom_objects_api, config, manifests, crds, caplog
500+
):
501+
# All 3 CRDs are available in the cluster
502+
list_crds.return_value = MagicMock(items=crds)
503+
# Zelt tries to deploy a ClusterCustomResource
504+
try_creating_custom_objects([manifests[2]])
505+
506+
custom_objects_api().create_namespaced_custom_object.assert_not_called()
507+
assert any(
508+
"Non-namespaced resources are not supported" in r.msg
509+
for r in caplog.records
510+
), "an error should be logged"
511+
512+
@patch("kubernetes.config.load_kube_config")
513+
@patch("zelt.kubernetes.client.CustomObjectsApi")
514+
@patch(
515+
"zelt.kubernetes.client.ApiextensionsV1beta1Api.list_custom_resource_definition"
516+
)
517+
def test_it_raises_exception_on_fetching_crds(
518+
self, list_crds, custom_objects_api, config, manifests
519+
):
520+
list_crds.side_effect = ApiException()
521+
522+
with pytest.raises(ApiException):
523+
try_creating_custom_objects(manifests)
524+
525+
list_crds.assert_called_once()
526+
custom_objects_api().create_namespaced_custom_object.assert_not_called()
527+
528+
@patch("kubernetes.config.load_kube_config")
529+
@patch("zelt.kubernetes.client.CustomObjectsApi")
530+
@patch(
531+
"zelt.kubernetes.client.ApiextensionsV1beta1Api.list_custom_resource_definition"
532+
)
533+
def test_it_raises_exception_on_creating_resources(
534+
self, list_crds, custom_objects_api, config, manifests, crds
535+
):
536+
list_crds.return_value = MagicMock(items=crds)
537+
custom_objects_api().create_namespaced_custom_object.side_effect = (
538+
ApiException()
539+
)
540+
541+
with pytest.raises(ApiException):
542+
try_creating_custom_objects(manifests)
543+
544+
list_crds.assert_called_once()

tests/kubernetes/test_deployer.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def manifest_set(tmp_path: Path) -> ManifestSet:
3737
ingress=Manifest.from_file(manifest_file),
3838
controller=Manifest.from_file(manifest_file),
3939
worker=Manifest.from_file(manifest_file),
40+
others=[],
4041
)
4142

4243

@@ -55,6 +56,7 @@ def configmap_storage() -> ConfigmapStorage:
5556

5657
class TestCreateResources:
5758
@patch("zelt.kubernetes.client.config")
59+
@patch("zelt.kubernetes.client.try_creating_custom_objects")
5860
@patch("zelt.kubernetes.client.CoreV1Api.create_namespace")
5961
@patch("zelt.kubernetes.client.CoreV1Api.create_namespaced_service")
6062
@patch("zelt.kubernetes.client.CoreV1Api.create_namespaced_config_map")
@@ -69,6 +71,7 @@ def test_it_deploys_all_given_manifests_and_configmap(
6971
create_configmap,
7072
create_service,
7173
create_namespace,
74+
create_custom_objects,
7275
config,
7376
configmap_storage: ConfigmapStorage,
7477
locustfile: Path,
@@ -81,8 +84,37 @@ def test_it_deploys_all_given_manifests_and_configmap(
8184
create_service.assert_called_once()
8285
create_configmap.assert_called_once()
8386
create_ingress.assert_called_once()
87+
create_custom_objects.assert_not_called()
8488
assert create_deployment.call_count == 2
8589

90+
@patch("zelt.kubernetes.client.config")
91+
@patch("zelt.kubernetes.client.try_creating_custom_objects")
92+
@patch("zelt.kubernetes.client.CoreV1Api.create_namespace")
93+
@patch("zelt.kubernetes.client.CoreV1Api.create_namespaced_service")
94+
@patch("zelt.kubernetes.client.CoreV1Api.create_namespaced_config_map")
95+
@patch("zelt.kubernetes.client.NetworkingV1beta1Api.create_namespaced_ingress")
96+
@patch("zelt.kubernetes.client.AppsV1Api.create_namespaced_deployment")
97+
@patch("zelt.kubernetes.client.wait_until_pod_ready")
98+
def test_it_deploys_custom_manifests(
99+
self,
100+
wait,
101+
create_deployment,
102+
create_ingress,
103+
create_configmap,
104+
create_service,
105+
create_namespace,
106+
create_custom_objects,
107+
config,
108+
configmap_storage: ConfigmapStorage,
109+
locustfile: Path,
110+
manifest_set: ManifestSet,
111+
):
112+
manifest_set = manifest_set._replace(others=[manifest_set.namespace] * 2)
113+
deployer.create_resources(
114+
ms=manifest_set, storage=configmap_storage, locustfile=locustfile
115+
)
116+
create_custom_objects.assert_called_once()
117+
86118
@patch("zelt.kubernetes.client.config")
87119
@patch("zelt.kubernetes.client.CoreV1Api.create_namespace")
88120
@patch("zelt.kubernetes.client.CoreV1Api.create_namespaced_service")

zelt/kubernetes/client.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
V1ContainerStatus,
1515
NetworkingV1beta1Api,
1616
NetworkingV1beta1Ingress,
17+
CustomObjectsApi,
18+
ApiextensionsV1beta1Api,
1719
)
1820
from kubernetes.client.rest import ApiException
1921
from tenacity import retry, stop_after_delay, wait_fixed, retry_if_exception_type
@@ -179,6 +181,72 @@ def delete_ingress(name: str, namespace: str) -> Optional[V1Status]:
179181
)
180182

181183

184+
def try_creating_custom_objects(manifests: List[Manifest]):
185+
logging.info("Fetching CRDs available in the cluster...")
186+
try:
187+
custom_resources = (
188+
ApiextensionsV1beta1Api().list_custom_resource_definition().items
189+
)
190+
except ApiException as err:
191+
logging.error("Failed to fetch CRDs: %s", err.reason)
192+
raise
193+
194+
available_kinds = {r.spec.names.kind.lower() for r in custom_resources}
195+
196+
for m in manifests:
197+
logging.info("Found a custom manifest: %s %r", m.body["kind"], m.name)
198+
if m.body["kind"].lower() not in available_kinds:
199+
logging.error(
200+
"Unsupported custom manifest %r of kind %r is ignored. "
201+
"Supported custom resource types are: %s",
202+
m.name,
203+
m.body["kind"],
204+
available_kinds,
205+
)
206+
continue
207+
208+
# By supporting only namespaced resources we don't have to manage
209+
# the cleanup - it will be handled by the deletion of the namespace.
210+
matching_resources = [
211+
r
212+
for r in custom_resources
213+
if r.spec.names.kind.lower() == m.body["kind"].lower()
214+
and r.spec.scope.lower() == "namespaced"
215+
]
216+
if not matching_resources:
217+
logging.error(
218+
"Failed to match %r to a namespaced custom resource "
219+
"definition. Non-namespaced resources are not supported!",
220+
m.body["kind"],
221+
)
222+
continue
223+
224+
_create_custom_object_with_plural(
225+
custom_object=m, plural=matching_resources[0].spec.names.plural
226+
)
227+
228+
229+
def _create_custom_object_with_plural(custom_object: Manifest, plural: str):
230+
logging.info("Creating %s %r ", custom_object.body["kind"], custom_object.name)
231+
try:
232+
group, version = custom_object.body.get("apiVersion").rsplit("/", 1)
233+
return CustomObjectsApi().create_namespaced_custom_object(
234+
namespace=custom_object.namespace,
235+
body=custom_object.body,
236+
group=group,
237+
version=version,
238+
plural=plural,
239+
)
240+
except ApiException as err:
241+
logging.error(
242+
"Failed to create %s %r: %s",
243+
custom_object.body["kind"],
244+
custom_object.name,
245+
err.reason,
246+
)
247+
raise
248+
249+
182250
@retry(
183251
stop=stop_after_delay(KUBE_API_DELETE_TIMEOUT),
184252
wait=wait_fixed(KUBE_API_WAIT),

zelt/kubernetes/deployer.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ def create_resources(
2424

2525
kube.create_service(ms.service)
2626
kube.create_ingress(ms.ingress)
27+
28+
if ms.others:
29+
kube.try_creating_custom_objects(ms.others)
30+
2731
except (kube.ApiException, RetryError) as err:
2832
logging.error("Kubernetes operation failed: %s", err.reason)
2933

0 commit comments

Comments
 (0)