container-hub 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ from .exceptions import InvalidConfiguration
2
+
3
+
4
+ def get_backend(settings, prefix="CONTAINER_HUB"):
5
+ """
6
+ Get a backend based on simple-settings or Django settings object.
7
+
8
+ Uses `CONTAINER_HUB_CARRIER` = ["docker", "kubernetes"] to determine
9
+ the backend.
10
+ """
11
+ if not hasattr(settings, f"{prefix}_CARRIER"):
12
+ raise InvalidConfiguration(f"{prefix}_CARRIER is a mandatory setting")
13
+ opt = str(getattr(settings, f"{prefix}_CARRIER")).lower()
14
+
15
+ if opt == "docker":
16
+ from container_hub.carriers.docker.backend import (
17
+ DockerBackend,
18
+ DockerBackendConfig,
19
+ )
20
+
21
+ return DockerBackend(DockerBackendConfig.from_settings(settings, prefix))
22
+ elif opt == "kubernetes":
23
+ from container_hub.carriers.kubernetes.backend import (
24
+ KubernetesBackend,
25
+ KubernetesBackendConfig,
26
+ )
27
+
28
+ return KubernetesBackend(
29
+ KubernetesBackendConfig.from_settings(settings, prefix)
30
+ )
31
+
32
+ raise InvalidConfiguration(
33
+ f"Unknown carrier {opt} option, should either be 'docker' or 'kubernetes'"
34
+ )
File without changes
File without changes
@@ -0,0 +1,178 @@
1
+ import logging
2
+ from functools import cached_property
3
+ from pathlib import Path
4
+ from typing import Dict, List
5
+
6
+ from docker import DockerClient
7
+ from docker.errors import APIError, ContainerError, DockerException, NotFound
8
+ from docker.types import Mount
9
+
10
+ from container_hub.exceptions import CarrierError
11
+ from container_hub.models import ContainerConfig, DockerBackendConfig
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class DockerBackend:
17
+ """
18
+ Backend for starting Docker instances via Docker
19
+ """
20
+
21
+ def __init__(self, config: DockerBackendConfig):
22
+ self.config = config
23
+
24
+ @cached_property
25
+ def client(self):
26
+ return DockerClient(base_url=self.config.client_url)
27
+
28
+ def container_hosts(self) -> Dict[str, str]:
29
+ """
30
+ Always localhost, so return emtpy dict
31
+ """
32
+ return {}
33
+
34
+ def container_list(self) -> List[str]:
35
+ """
36
+ Returns a list of simulation_ids
37
+ """
38
+ lc = self.client.containers.list(filters={"label": "simulation_id"})
39
+ return [x.name.lstrip("simulation-") for x in lc]
40
+
41
+ def container_ips(self) -> Dict[str, str]:
42
+ """
43
+ Return list of ip addresses
44
+ """
45
+ d = {}
46
+ containers = self.client.containers.list(filters={"label": "simulation_id"})
47
+ for container in containers:
48
+ try:
49
+ ip_address = container.attrs["NetworkSettings"]["Networks"][
50
+ self.config.network_name
51
+ ]["IPAddress"]
52
+ except KeyError:
53
+ continue
54
+ d[container.name] = ip_address
55
+ return d
56
+
57
+ def up(self, container_config: ContainerConfig) -> str:
58
+ """
59
+ Create container based on simulation and threedimodel.
60
+
61
+ :returns the container id
62
+ """
63
+ name = f"simulation-{container_config.sim_uid}"
64
+ result_path = container_config.base_result_path / Path(name)
65
+ labels = dict([(x.name, x.value) for x in container_config.labels])
66
+ labels.update({"simulation_id": f"{container_config.sim_uid}"})
67
+
68
+ _envs = dict([(f"{x.name}", f"{x.value}") for x in container_config.envs])
69
+ _envs.update({"RESULT_PATH": f"{result_path.as_posix()}"})
70
+
71
+ if (
72
+ container_config.container_log_level is not None
73
+ and "LOG_LEVEL" not in _envs
74
+ ):
75
+ _envs.update(
76
+ {f"LOG_LEVEL": f"{container_config.container_log_level.value}"}
77
+ )
78
+
79
+ if container_config.debugpy_port is not None:
80
+ ports = {
81
+ f"{container_config.debugpy_port}/tcp": container_config.debugpy_port
82
+ }
83
+ _envs.update(
84
+ {f"DEBUGPY": "1", "DEBUGPY_PORT": str(container_config.debugpy_port)}
85
+ )
86
+ else:
87
+ ports = {}
88
+
89
+ cmd = (
90
+ f"python service.py {container_config.redis_host} {container_config.model_config} "
91
+ f"{container_config.sim_uid} {container_config.sim_ref_datetime.isoformat()} "
92
+ f"{container_config.end_time} {container_config.duration} {container_config.start_mode} "
93
+ f"{container_config.pause_timeout} {container_config.max_rate} {container_config.clean_up_files}"
94
+ )
95
+
96
+ skip_model_mount: bool = all(
97
+ [
98
+ x
99
+ for x in [
100
+ container_config.gridadmin_download_url,
101
+ container_config.tables_download_url,
102
+ ]
103
+ ]
104
+ )
105
+
106
+ if container_config.gridadmin_download_url is not None:
107
+ cmd += f" {container_config.gridadmin_download_url}"
108
+ if container_config.tables_download_url is not None:
109
+ cmd += f" {container_config.tables_download_url}"
110
+
111
+ logger.debug("cmd %s", cmd)
112
+ logger.debug("Envs %s", _envs)
113
+
114
+ mounts: List[Mount] = []
115
+ for mount in container_config.mount_points:
116
+ if skip_model_mount and mount.mount_path == "/models":
117
+ # Skip mounting models
118
+ continue
119
+ mounts.append(
120
+ Mount(
121
+ mount.mount_path,
122
+ mount.local_path,
123
+ type="bind",
124
+ read_only=mount.read_only,
125
+ )
126
+ )
127
+
128
+ try:
129
+ container = self.client.containers.run(
130
+ image=container_config.image_name,
131
+ command=cmd,
132
+ name=name,
133
+ network=self.config.network_name,
134
+ mounts=mounts,
135
+ environment=_envs,
136
+ detach=True,
137
+ labels=labels,
138
+ ports=ports,
139
+ )
140
+ except (DockerException, APIError, ContainerError) as err:
141
+ logger.error(err)
142
+ raise CarrierError(err)
143
+
144
+ # double check everything went right
145
+ try:
146
+ self.client.containers.get(container.id)
147
+ except (APIError, NotFound) as err:
148
+ logger.error(
149
+ f"simulation container exited prematurely. Could not retrieve "
150
+ f"the container if though it should be running {err}"
151
+ )
152
+ raise CarrierError(err)
153
+
154
+ logger.info(f"Started simulation container {container.name}")
155
+ return container.id
156
+
157
+ def down(self, sim_uid: str):
158
+ try:
159
+ container = self.client.containers.get(f"simulation-{sim_uid}")
160
+ container_id = container.id
161
+ except (APIError, NotFound) as err:
162
+ logger.error(
163
+ f"Could not get the simulation container, error message: {err}"
164
+ )
165
+ raise CarrierError(err)
166
+ try:
167
+ if not self.config.debug:
168
+ container.remove(force=True)
169
+ else:
170
+ container.kill()
171
+ except APIError as err:
172
+ logger.error(
173
+ f"Could not kill/remove the "
174
+ f"simulation container, error message: {err}"
175
+ )
176
+ raise CarrierError(err)
177
+ logger.info(f"Removed container for simulation {sim_uid}")
178
+ return container_id
File without changes
@@ -0,0 +1,122 @@
1
+ from datetime import datetime
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ import pytest
5
+ from docker.types import Mount
6
+
7
+ from container_hub.carriers.docker.backend import DockerBackend
8
+ from container_hub.models import (
9
+ ContainerConfig,
10
+ DockerBackendConfig,
11
+ EnvVar,
12
+ Label,
13
+ LogLevel,
14
+ MountPoint,
15
+ )
16
+
17
+
18
+ @pytest.fixture
19
+ def docker_backend():
20
+ config = DockerBackendConfig(
21
+ "client_url",
22
+ "my_network",
23
+ )
24
+ return DockerBackend(config)
25
+
26
+
27
+ def test_container_list(docker_backend: DockerBackend):
28
+ with patch("container_hub.carriers.docker.backend.DockerClient") as client:
29
+ container = MagicMock()
30
+ container.name = "simulation-112"
31
+ client().containers.list.return_value = [container]
32
+ containers = docker_backend.container_list()
33
+ assert containers == [
34
+ "112",
35
+ ]
36
+
37
+
38
+ def test_container_ips(docker_backend: DockerBackend):
39
+ with patch("container_hub.carriers.docker.backend.DockerClient") as client:
40
+ container = MagicMock()
41
+ container.name = "simulation-112"
42
+ container.attrs = {
43
+ "NetworkSettings": {
44
+ "Networks": {
45
+ docker_backend.config.network_name: {"IPAddress": "127.0.0.1"}
46
+ }
47
+ }
48
+ }
49
+ client().containers.list.return_value = [container]
50
+ ip_addresses = docker_backend.container_ips()
51
+ assert ip_addresses == {"simulation-112": "127.0.0.1"}
52
+
53
+
54
+ def test_up(docker_backend: DockerBackend):
55
+ dt = datetime.now()
56
+ container_config = ContainerConfig(
57
+ "my_image",
58
+ "base_result_path",
59
+ 12,
60
+ dt,
61
+ 3600,
62
+ 3600,
63
+ 0,
64
+ "initialize",
65
+ "/model.ini",
66
+ 2,
67
+ 512,
68
+ [EnvVar("env", "1")],
69
+ [Label("name", "value")],
70
+ 0,
71
+ True,
72
+ "gridadmin_url",
73
+ "tables_download_url",
74
+ [MountPoint("/local", "/mnt", False)],
75
+ "redis1",
76
+ LogLevel.debug,
77
+ 5678,
78
+ )
79
+ with patch("container_hub.carriers.docker.backend.DockerClient") as client:
80
+ container = MagicMock()
81
+ container.id = 10
82
+ client().containers.run.return_value = container
83
+ container_id = docker_backend.up(container_config)
84
+ assert container_id == 10
85
+
86
+ # Check all params for DockerClient().containers.run
87
+ to_check = {
88
+ "image": "my_image",
89
+ "command": f"python service.py redis1 /model.ini 12 {dt.isoformat()} 3600 3600 initialize 0 0 True gridadmin_url tables_download_url",
90
+ "name": "simulation-12",
91
+ "network": "my_network",
92
+ "mounts": [
93
+ Mount(
94
+ **{
95
+ "target": "/mnt",
96
+ "source": "/local",
97
+ "type": "bind",
98
+ "read_only": False,
99
+ }
100
+ )
101
+ ],
102
+ "environment": {
103
+ "env": "1",
104
+ "RESULT_PATH": "base_result_path/simulation-12",
105
+ "LOG_LEVEL": "DEBUG",
106
+ "DEBUGPY": "1",
107
+ "DEBUGPY_PORT": "5678",
108
+ },
109
+ "ports": {"5678/tcp": 5678},
110
+ "detach": True,
111
+ "labels": {"name": "value", "simulation_id": "12"},
112
+ }
113
+ assert client().containers.run.call_args[1] == to_check
114
+
115
+
116
+ def test_down(docker_backend: DockerBackend):
117
+ with patch("container_hub.carriers.docker.backend.DockerClient") as client:
118
+ container = MagicMock()
119
+ container.id = 112
120
+ client().containers.get.return_value = container
121
+ container_id = docker_backend.down("112")
122
+ assert container_id == 112
File without changes
@@ -0,0 +1,278 @@
1
+ from functools import cached_property
2
+ from typing import Any, Dict, List, Union
3
+
4
+ from hikaru.model.rel_1_28 import (
5
+ Affinity,
6
+ ConfigMapVolumeSource,
7
+ Container,
8
+ ContainerPort,
9
+ EmptyDirVolumeSource,
10
+ EnvVar,
11
+ HostAlias,
12
+ HostPathVolumeSource,
13
+ Job,
14
+ JobSpec,
15
+ LocalObjectReference,
16
+ NodeAffinity,
17
+ NodeSelector,
18
+ NodeSelectorRequirement,
19
+ NodeSelectorTerm,
20
+ ObjectMeta,
21
+ PodSpec,
22
+ PodTemplateSpec,
23
+ ResourceRequirements,
24
+ SecretVolumeSource,
25
+ Volume,
26
+ VolumeMount,
27
+ )
28
+ from kubernetes import config as client_config
29
+ from kubernetes.client import (
30
+ ApiClient,
31
+ ApiException,
32
+ BatchV1Api,
33
+ Configuration,
34
+ CoreV1Api,
35
+ )
36
+ from kubernetes.client.models import V1DeleteOptions, V1Job, V1JobList, V1JobStatus
37
+
38
+ from container_hub.exceptions import CarrierError
39
+ from container_hub.models import (
40
+ KubernetesBackendConfig,
41
+ KubernetesContainer,
42
+ KubernetesJobConfig,
43
+ MountPointType,
44
+ NodeAffinityConfig,
45
+ )
46
+
47
+ # Accessing host (laptop) from within k8s cluster
48
+ K3S_HOST_DNS_NAME = "host.k3d.internal"
49
+ MINIKUBE_HOST_DNS_NAME = "host.minikube.internal"
50
+
51
+
52
+ class KubernetesBackend:
53
+ """
54
+ Backend for starting Docker instances via Docker
55
+ """
56
+
57
+ def __init__(self, config: KubernetesBackendConfig, in_cluster=True):
58
+ self.config = config
59
+ self.in_cluster = in_cluster
60
+
61
+ @cached_property
62
+ def configuration(self) -> Configuration:
63
+ configuration = Configuration(host=self.config.client_url)
64
+ if self.in_cluster:
65
+ client_config.load_incluster_config(configuration)
66
+
67
+ return configuration
68
+
69
+ def container_hosts(self) -> Dict[str, str]:
70
+ return {}
71
+
72
+ def get_job_status(self, job_name: str) -> Dict[str, Any]:
73
+ """
74
+ Get a specific pod ip, based on job_name (simulation-xxx)
75
+ """
76
+ job: Union[V1Job, None] = None
77
+ with ApiClient(self.configuration) as api_client:
78
+ api = BatchV1Api(api_client)
79
+ try:
80
+ job: V1Job = api.read_namespaced_job(
81
+ job_name, namespace=self.config.namespace
82
+ )
83
+ except ApiException:
84
+ job = None
85
+
86
+ if job is None:
87
+ raise CarrierError("unknown Job")
88
+
89
+ if job.status is None:
90
+ raise CarrierError("Job has no status")
91
+
92
+ job_status: V1JobStatus = job.status
93
+ return job_status.to_dict()
94
+
95
+ def pod_ip(self, job_name: str) -> str:
96
+ """
97
+ Get a specific pod ip, based on job_name (simulation-xxx)
98
+ """
99
+ with ApiClient(self.configuration) as api_client:
100
+ api = CoreV1Api(api_client)
101
+ results = api.list_namespaced_pod(
102
+ namespace=self.config.namespace, label_selector=f"job-name={job_name}"
103
+ )
104
+ if len(results.items) != 1:
105
+ raise CarrierError(f"Incorrect number of pods returned: {results}")
106
+ return results.items[0].status.pod_ip
107
+
108
+ def container_ips(self) -> Dict[str, str]:
109
+ return {}
110
+
111
+ def container_list(self) -> List[str]:
112
+ with ApiClient(self.configuration) as api_client:
113
+ jobs: List[Job] = []
114
+ api = BatchV1Api(api_client)
115
+ job_list = None
116
+ while job_list is None or job_list.metadata._continue is not None:
117
+ job_list: V1JobList = api.list_namespaced_job(
118
+ namespace=self.config.namespace
119
+ )
120
+ jobs += [x for x in job_list.items]
121
+ return [
122
+ job.metadata.name.lstrip("simulation-")
123
+ for job in jobs
124
+ if job.metadata.name.startswith("simulation-")
125
+ ]
126
+
127
+ def up(self, job_config: KubernetesJobConfig) -> str:
128
+ """
129
+ Create Kubernetes job for simulation
130
+ """
131
+ job = get_simulation_job(job_config)
132
+ with ApiClient(self.configuration) as api_client:
133
+ job = job.create(namespace=self.config.namespace, client=api_client)
134
+ return job_config.name
135
+
136
+ def down(self, sim_uid: str):
137
+ """Remove the given app."""
138
+ name = f"simulation-{sim_uid}"
139
+ with ApiClient(self.configuration) as api_client:
140
+ api = BatchV1Api(api_client)
141
+ api.delete_namespaced_job(
142
+ namespace=self.config.namespace,
143
+ name=name,
144
+ grace_period_seconds=0,
145
+ propagation_policy="Background",
146
+ body=V1DeleteOptions(propagation_policy="Background"),
147
+ )
148
+
149
+
150
+ def get_node_affinity(node_affinity_cfg: NodeAffinityConfig) -> Affinity:
151
+ return Affinity(
152
+ nodeAffinity=NodeAffinity(
153
+ requiredDuringSchedulingIgnoredDuringExecution=NodeSelector(
154
+ nodeSelectorTerms=[
155
+ NodeSelectorTerm(
156
+ matchExpressions=[
157
+ NodeSelectorRequirement(
158
+ key=node_affinity_cfg.key,
159
+ operator=node_affinity_cfg.operator,
160
+ values=node_affinity_cfg.values,
161
+ )
162
+ ]
163
+ )
164
+ ]
165
+ )
166
+ )
167
+ )
168
+
169
+
170
+ def get_simulation_job(cfg: KubernetesJobConfig) -> Job:
171
+ return Job(
172
+ apiVersion="batch/v1",
173
+ kind="Job",
174
+ metadata=ObjectMeta(
175
+ name=cfg.name,
176
+ annotations={x.name: x.value for x in cfg.annotations},
177
+ labels={"app": cfg.name},
178
+ ),
179
+ spec=JobSpec(
180
+ template=PodTemplateSpec(
181
+ metadata=ObjectMeta(
182
+ annotations={x.name: x.value for x in cfg.annotations},
183
+ labels={"app": cfg.name},
184
+ ),
185
+ spec=PodSpec(
186
+ affinity=get_node_affinity(cfg.node_affinity)
187
+ if cfg.node_affinity is not None
188
+ else None,
189
+ serviceAccountName=cfg.service_account_name,
190
+ imagePullSecrets=[
191
+ LocalObjectReference(name=cfg.regcred_secret_name)
192
+ ]
193
+ if cfg.regcred_secret_name is not None
194
+ else None,
195
+ hostAliases=[
196
+ HostAlias(x.ip_address, x.hostnames) for x in cfg.host_aliases
197
+ ],
198
+ containers=[
199
+ get_container(cfg.redis_config),
200
+ get_container(cfg.scheduler_config),
201
+ get_container(cfg.simulation_config),
202
+ ],
203
+ volumes=[
204
+ Volume(
205
+ name=mount.name,
206
+ hostPath=HostPathVolumeSource(
207
+ path=mount.local_path, type="Directory"
208
+ ),
209
+ )
210
+ for mount in cfg.mount_points
211
+ if mount.type == MountPointType.HOSTPATH
212
+ ]
213
+ + [
214
+ Volume(
215
+ name=mount.name,
216
+ emptyDir=EmptyDirVolumeSource(sizeLimit=mount.size_limit),
217
+ )
218
+ for mount in cfg.mount_points
219
+ if mount.type == MountPointType.EMPTYDIR
220
+ ]
221
+ + [
222
+ Volume(
223
+ name=mount.name,
224
+ configMap=ConfigMapVolumeSource(name=mount.local_path),
225
+ )
226
+ for mount in cfg.mount_points
227
+ if mount.type == MountPointType.CONFIGMAP
228
+ ]
229
+ + [
230
+ Volume(
231
+ name=mount.name,
232
+ secret=SecretVolumeSource(secretName=mount.local_path),
233
+ )
234
+ for mount in cfg.mount_points
235
+ if mount.type == MountPointType.SECRET
236
+ ],
237
+ restartPolicy="Never",
238
+ ),
239
+ ),
240
+ backoffLimit=0,
241
+ completions=1,
242
+ ),
243
+ )
244
+
245
+
246
+ def get_container(cfg: KubernetesContainer) -> Container:
247
+ """
248
+ Get k8s container API resource
249
+ """
250
+ # Might ne needed for local dev:
251
+ # hostAliases=[HostAlias(f"{HOST_IP}", ["minio"])],
252
+
253
+ return Container(
254
+ name=cfg.name,
255
+ image=cfg.image,
256
+ imagePullPolicy="IfNotPresent",
257
+ resources=ResourceRequirements(
258
+ limits=cfg.resources.limits.to_dict(),
259
+ requests=cfg.resources.requests.to_dict(),
260
+ )
261
+ if cfg.resources is not None
262
+ else ResourceRequirements(),
263
+ args=cfg.args,
264
+ env=[
265
+ EnvVar(
266
+ name=envvar.name,
267
+ value=envvar.value,
268
+ )
269
+ for envvar in cfg.envs
270
+ ],
271
+ volumeMounts=[
272
+ VolumeMount(
273
+ mountPath=mount.mount_path, name=mount.name, readOnly=mount.read_only
274
+ )
275
+ for mount in cfg.mount_points
276
+ ],
277
+ ports=[ContainerPort(containerPort=port) for port in cfg.ports],
278
+ )
File without changes
@@ -0,0 +1,78 @@
1
+ from unittest.mock import MagicMock, patch
2
+
3
+ import pytest
4
+ from simple_settings import LazySettings
5
+
6
+ from container_hub.carriers.kubernetes.backend import KubernetesBackend
7
+ from container_hub.models import KubernetesBackendConfig, KubernetesJobConfig
8
+
9
+
10
+ @pytest.fixture
11
+ def kubernetes_backend():
12
+ config = KubernetesBackendConfig(
13
+ "http://kubernetes/",
14
+ "threedi",
15
+ )
16
+ return KubernetesBackend(config, in_cluster=False)
17
+
18
+
19
+ @pytest.fixture
20
+ def kubernetes_simple_settings():
21
+ yield LazySettings("tests.test_files.kubernetes_settings")
22
+
23
+
24
+ def test_container_list(kubernetes_backend: KubernetesBackend):
25
+ with patch("container_hub.carriers.kubernetes.backend.ApiClient") as client:
26
+ job = MagicMock()
27
+ job.metadata.name = "simulation-112"
28
+ job_list = MagicMock()
29
+ job_list.items = [job]
30
+ job_list.metadata._continue = None
31
+ client().__enter__().call_api.return_value = job_list
32
+ hosts = kubernetes_backend.container_list()
33
+ assert hosts == ["112"]
34
+
35
+
36
+ def get_job_status(kubernetes_backend: KubernetesBackend):
37
+ with patch("container_hub.carriers.kubernetes.backend.ApiClient") as client:
38
+ job = MagicMock()
39
+ job.metadata.name = "simulation-112"
40
+ job.status = MagicMock()
41
+ job.status.active = 1
42
+ job.status.succeeded = None
43
+ job.status.failed = 1
44
+ client().__enter__().call_api.return_value = job
45
+ job_status = kubernetes_backend.get_job_status()
46
+ assert job_status == {"active": 1, "succeeded": None, "failed": 1}
47
+
48
+
49
+ def test_pod_ip(kubernetes_backend: KubernetesBackend):
50
+ with patch("container_hub.carriers.kubernetes.backend.ApiClient") as client:
51
+ pod = MagicMock()
52
+ pod.status.pod_ip = "127.0.0.1"
53
+ pod_list = MagicMock()
54
+ pod_list.items = [pod]
55
+ client().__enter__().call_api.return_value = pod_list
56
+ pod_ip = kubernetes_backend.pod_ip("simulation-1")
57
+ assert pod_ip == "127.0.0.1"
58
+
59
+
60
+ def test_up(kubernetes_backend: KubernetesBackend, kubernetes_simple_settings):
61
+ job_config = KubernetesJobConfig.from_settings(
62
+ "simulation-1", kubernetes_simple_settings
63
+ )
64
+
65
+ with patch("container_hub.carriers.kubernetes.backend.ApiClient") as client:
66
+
67
+ def call_api(*args, body=None, **kwargs):
68
+ return (body, 200, {})
69
+
70
+ client().__enter__().call_api = call_api
71
+ name = kubernetes_backend.up(job_config)
72
+ assert name == "simulation-1"
73
+
74
+
75
+ def test_down(kubernetes_backend: KubernetesBackend):
76
+ with patch("container_hub.carriers.kubernetes.backend.ApiClient") as client:
77
+ kubernetes_backend.down("simulation-1")
78
+ client().__enter__().call_api.assert_called()