python-chi 1.2.6__tar.gz → 1.2.7__tar.gz
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.
- python_chi-1.2.7/ChangeLog +7 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/PKG-INFO +1 -1
- {python_chi-1.2.6 → python_chi-1.2.7}/chi/container.py +62 -45
- {python_chi-1.2.6 → python_chi-1.2.7}/chi/server.py +18 -10
- {python_chi-1.2.6 → python_chi-1.2.7}/chi/storage.py +2 -1
- {python_chi-1.2.6 → python_chi-1.2.7}/chi/util.py +40 -1
- {python_chi-1.2.6 → python_chi-1.2.7}/python_chi.egg-info/PKG-INFO +1 -1
- python_chi-1.2.7/python_chi.egg-info/pbr.json +1 -0
- python_chi-1.2.7/setup.py +5 -0
- python_chi-1.2.7/tests/test_container.py +275 -0
- python_chi-1.2.6/ChangeLog +0 -7
- python_chi-1.2.6/python_chi.egg-info/pbr.json +0 -1
- python_chi-1.2.6/setup.py +0 -5
- python_chi-1.2.6/tests/test_container.py +0 -112
- {python_chi-1.2.6 → python_chi-1.2.7}/.github/CODEOWNERS +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/.github/workflows/linting.yml +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/.github/workflows/pypi-publish.yml +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/.github/workflows/test.yml +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/.mailmap +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/.readthedocs.yml +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/AUTHORS +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/DEVELOPMENT.rst +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/LICENSE +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/Makefile +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/README.rst +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/chi/__init__.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/chi/clients.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/chi/context.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/chi/exception.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/chi/hardware.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/chi/image.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/chi/jupyterhub.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/chi/keypair.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/chi/lease.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/chi/magic.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/chi/network.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/chi/share.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/chi/ssh.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/__init__.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/_templates/page.html +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/conf.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/examples.rst +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/generate_notebook.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/index.rst +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/clients.rst +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/container.rst +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/context.rst +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/exception.rst +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/hardware.rst +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/image.rst +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/lease.rst +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/magic.rst +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/network.rst +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/server.rst +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/share.rst +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/ssh.rst +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/modules/storage.rst +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/docs/requirements.txt +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/python_chi.egg-info/SOURCES.txt +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/python_chi.egg-info/dependency_links.txt +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/python_chi.egg-info/not-zip-safe +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/python_chi.egg-info/requires.txt +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/python_chi.egg-info/top_level.txt +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/requirements.txt +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/ruff.toml +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/setup.cfg +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/test-requirements.txt +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/tests/__init__.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/tests/test_context.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/tests/test_lease.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/tests/test_network.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/tests/test_server.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/tests/test_share.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/tests/test_ssh.py +0 -0
- {python_chi-1.2.6 → python_chi-1.2.7}/tox.ini +0 -0
|
@@ -18,6 +18,7 @@ import os
|
|
|
18
18
|
import tarfile
|
|
19
19
|
import time
|
|
20
20
|
from typing import Dict, List, Optional, Tuple
|
|
21
|
+
from warnings import warn
|
|
21
22
|
|
|
22
23
|
from IPython.display import HTML, display
|
|
23
24
|
from packaging.version import Version
|
|
@@ -28,7 +29,7 @@ from chi import network as chi_network
|
|
|
28
29
|
|
|
29
30
|
from .clients import connection, zun
|
|
30
31
|
from .context import session
|
|
31
|
-
from .exception import
|
|
32
|
+
from .exception import ServiceError
|
|
32
33
|
from .network import bind_floating_ip, get_free_floating_ip
|
|
33
34
|
|
|
34
35
|
DEFAULT_IMAGE_DRIVER = "docker"
|
|
@@ -80,12 +81,17 @@ class Container:
|
|
|
80
81
|
environment: Dict[str, str] = {},
|
|
81
82
|
device_profiles: List[str] = [],
|
|
82
83
|
):
|
|
84
|
+
|
|
85
|
+
# check if values are not the defaults.
|
|
86
|
+
if not start or start_timeout != 0:
|
|
87
|
+
warn(
|
|
88
|
+
"start and start_timeout are deprecated. Containers always start immmediately."
|
|
89
|
+
)
|
|
90
|
+
|
|
83
91
|
self.name = name
|
|
84
92
|
self.image_ref = image_ref
|
|
85
93
|
self.exposed_ports = exposed_ports
|
|
86
94
|
self.reservation_id = reservation_id
|
|
87
|
-
self.start = start
|
|
88
|
-
self.start_timeout = start_timeout
|
|
89
95
|
self.runtime = runtime
|
|
90
96
|
self.id = None
|
|
91
97
|
self.created_at = None
|
|
@@ -101,18 +107,20 @@ class Container:
|
|
|
101
107
|
name=zun_container.name,
|
|
102
108
|
image_ref=zun_container.image,
|
|
103
109
|
exposed_ports=zun_container.ports if zun_container.ports else [],
|
|
104
|
-
start=True, # Assuming the container is already created
|
|
105
110
|
)
|
|
106
111
|
container.id = zun_container.uuid
|
|
107
112
|
container._status = zun_container.status
|
|
108
113
|
return container
|
|
109
114
|
|
|
110
115
|
@property
|
|
111
|
-
def
|
|
116
|
+
def zun_container(self):
|
|
112
117
|
if self.id:
|
|
113
|
-
|
|
114
|
-
self.
|
|
115
|
-
|
|
118
|
+
self._zun_container = zun().containers.get(self.id)
|
|
119
|
+
return self._zun_container
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def status(self):
|
|
123
|
+
return getattr(self.zun_container, "status")
|
|
116
124
|
|
|
117
125
|
def submit(
|
|
118
126
|
self,
|
|
@@ -155,21 +163,15 @@ class Container:
|
|
|
155
163
|
image=self.image_ref,
|
|
156
164
|
exposed_ports=self.exposed_ports,
|
|
157
165
|
reservation_id=self.reservation_id,
|
|
158
|
-
start=self.start,
|
|
159
|
-
start_timeout=self.start_timeout,
|
|
160
166
|
runtime=self.runtime,
|
|
161
167
|
environment=self.environment,
|
|
162
168
|
device_profiles=self.device_profiles,
|
|
163
169
|
**kwargs,
|
|
164
170
|
)
|
|
171
|
+
self.id = container.uuid
|
|
172
|
+
self._status = container.status
|
|
165
173
|
|
|
166
|
-
if
|
|
167
|
-
self.id = zun().containers.get(self.name).uuid
|
|
168
|
-
self._status = zun().containers.get(self.name).status
|
|
169
|
-
else:
|
|
170
|
-
raise ResourceError("could not create container")
|
|
171
|
-
|
|
172
|
-
if wait_for_active and self.status != "Running":
|
|
174
|
+
if wait_for_active and self._status != "Running":
|
|
173
175
|
self.wait(status="Running", timeout=wait_timeout)
|
|
174
176
|
|
|
175
177
|
if show:
|
|
@@ -189,7 +191,7 @@ class Container:
|
|
|
189
191
|
None
|
|
190
192
|
"""
|
|
191
193
|
if self.id:
|
|
192
|
-
destroy_container(self.id)
|
|
194
|
+
destroy_container(self.id, force=True)
|
|
193
195
|
self.id = None
|
|
194
196
|
self._status = None
|
|
195
197
|
|
|
@@ -212,13 +214,47 @@ class Container:
|
|
|
212
214
|
if show == "widget" and context._is_ipynb():
|
|
213
215
|
pb.display()
|
|
214
216
|
|
|
217
|
+
state = {"status": None, "since": time.perf_counter(), "conditions": {}}
|
|
218
|
+
|
|
219
|
+
def _zun_attr(obj, name):
|
|
220
|
+
val = getattr(obj, name, None)
|
|
221
|
+
return val if val and val != "None" else None
|
|
222
|
+
|
|
215
223
|
def _callback():
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
224
|
+
zun_container = self.zun_container
|
|
225
|
+
now = time.perf_counter()
|
|
226
|
+
elapsed = int(now - state["since"])
|
|
227
|
+
|
|
228
|
+
current_status = getattr(zun_container, "status", None)
|
|
229
|
+
detail = _zun_attr(zun_container, "status_detail")
|
|
230
|
+
reason = _zun_attr(zun_container, "status_reason")
|
|
231
|
+
|
|
232
|
+
if current_status != state["status"]:
|
|
233
|
+
if state["status"]:
|
|
234
|
+
pb.log(f"[{elapsed}s] {state['status']} -> {current_status}")
|
|
235
|
+
state["status"] = current_status
|
|
236
|
+
state["since"] = now
|
|
237
|
+
state["conditions"] = {}
|
|
238
|
+
|
|
239
|
+
if detail:
|
|
240
|
+
entry = state["conditions"].setdefault(detail, {"count": 0})
|
|
241
|
+
entry["count"] += 1
|
|
242
|
+
entry["reason"] = reason
|
|
243
|
+
|
|
244
|
+
parts = [f"{current_status} ({elapsed}s)"]
|
|
245
|
+
for name, entry in state["conditions"].items():
|
|
246
|
+
count = entry["count"]
|
|
247
|
+
|
|
248
|
+
if count > 1:
|
|
249
|
+
line = f"{name} (x{count})"
|
|
250
|
+
else:
|
|
251
|
+
line = name
|
|
252
|
+
if entry.get("reason"):
|
|
253
|
+
line = f"{line} {entry['reason']}"
|
|
254
|
+
parts.append(line)
|
|
255
|
+
pb.update_status("\n".join(parts))
|
|
256
|
+
|
|
257
|
+
return current_status == "Error" or current_status.upper() == status.upper()
|
|
222
258
|
|
|
223
259
|
res = pb.wait(_callback, 2 * 60, timeout)
|
|
224
260
|
if not res:
|
|
@@ -369,9 +405,6 @@ def create_container(
|
|
|
369
405
|
image: "str" = None,
|
|
370
406
|
exposed_ports: "list[str]" = None,
|
|
371
407
|
reservation_id: "str" = None,
|
|
372
|
-
start: "bool" = True,
|
|
373
|
-
start_timeout: "int" = None,
|
|
374
|
-
platform_version: "int" = 2,
|
|
375
408
|
**kwargs,
|
|
376
409
|
):
|
|
377
410
|
"""
|
|
@@ -407,8 +440,6 @@ def create_container(
|
|
|
407
440
|
hints = kwargs.setdefault("hints", {})
|
|
408
441
|
if reservation_id:
|
|
409
442
|
hints["reservation"] = reservation_id
|
|
410
|
-
if platform_version:
|
|
411
|
-
hints["platform_version"] = platform_version
|
|
412
443
|
|
|
413
444
|
# Support simpler syntax for exposed_ports
|
|
414
445
|
if exposed_ports and isinstance(exposed_ports, list):
|
|
@@ -429,20 +460,6 @@ def create_container(
|
|
|
429
460
|
**kwargs,
|
|
430
461
|
)
|
|
431
462
|
|
|
432
|
-
# Wait for a while, the image may need to download. 30 minutes is
|
|
433
|
-
# _quite_ a long time, but the user can interrupt or choose a smaller
|
|
434
|
-
# timeout.
|
|
435
|
-
timeout = start_timeout or (60 * 30)
|
|
436
|
-
LOG.info(f"Waiting up to {timeout}s for container creation ...")
|
|
437
|
-
|
|
438
|
-
if platform_version == 2:
|
|
439
|
-
container = _wait_for_status(container.uuid, "Running", timeout=timeout)
|
|
440
|
-
else:
|
|
441
|
-
container = _wait_for_status(container.uuid, "Created", timeout=timeout)
|
|
442
|
-
if start:
|
|
443
|
-
LOG.info("Starting container ...")
|
|
444
|
-
zun().containers.start(container.uuid)
|
|
445
|
-
|
|
446
463
|
return container
|
|
447
464
|
|
|
448
465
|
|
|
@@ -498,7 +515,7 @@ def snapshot_container(
|
|
|
498
515
|
return zun().containers.commit(container_ref, repository, tag=tag)["uuid"]
|
|
499
516
|
|
|
500
517
|
|
|
501
|
-
def destroy_container(container_ref: "str"):
|
|
518
|
+
def destroy_container(container_ref: "str", stop=False, force=False):
|
|
502
519
|
"""
|
|
503
520
|
.. deprecated:: 1.0
|
|
504
521
|
|
|
@@ -509,7 +526,7 @@ def destroy_container(container_ref: "str"):
|
|
|
509
526
|
Args:
|
|
510
527
|
container_ref (str): The name or ID of the container.
|
|
511
528
|
"""
|
|
512
|
-
return zun().containers.delete(container_ref, stop=
|
|
529
|
+
return zun().containers.delete(container_ref, stop=stop, force=force)
|
|
513
530
|
|
|
514
531
|
|
|
515
532
|
def get_logs(container_ref: "str", stdout=True, stderr=True):
|
|
@@ -76,7 +76,7 @@ class Server:
|
|
|
76
76
|
Args:
|
|
77
77
|
name (str): The name of the server.
|
|
78
78
|
reservation_id (Optional[str]): The reservation ID associated with the server. Defaults to None.
|
|
79
|
-
image_name (str): The name of the image to use for the server. Defaults to DEFAULT_IMAGE_NAME.
|
|
79
|
+
image_name (Optional[str]): The name of the image to use for the server. Defaults to DEFAULT_IMAGE_NAME.
|
|
80
80
|
image (Optional[str]): The image ID or name to use for the server. Defaults to None.
|
|
81
81
|
flavor_name (str): The name of the flavor to use for the server. Defaults to BAREMETAL_FLAVOR.
|
|
82
82
|
key_name (str): The name of the keypair to use for the server. Defaults to None.
|
|
@@ -86,7 +86,7 @@ class Server:
|
|
|
86
86
|
Attributes:
|
|
87
87
|
name (str): The name of the server.
|
|
88
88
|
reservation_id (Optional[str]): The reservation ID associated with the server.
|
|
89
|
-
image_name (str): The name of the image used for the server.
|
|
89
|
+
image_name (Optional[str]): The name of the image used for the server.
|
|
90
90
|
flavor_name (str): The name of the flavor used for the server.
|
|
91
91
|
keypair (Optional[Keypair]): The keypair object used for the server.
|
|
92
92
|
network_name (str): The name of the network used for the server.
|
|
@@ -104,7 +104,7 @@ class Server:
|
|
|
104
104
|
self,
|
|
105
105
|
name: str,
|
|
106
106
|
reservation_id: Optional[str] = None,
|
|
107
|
-
image_name: str = DEFAULT_IMAGE_NAME,
|
|
107
|
+
image_name: Optional[str] = DEFAULT_IMAGE_NAME,
|
|
108
108
|
image: Optional[Image] = None,
|
|
109
109
|
flavor_name: str = BAREMETAL_FLAVOR,
|
|
110
110
|
key_name: str = None,
|
|
@@ -114,8 +114,13 @@ class Server:
|
|
|
114
114
|
self.name = name
|
|
115
115
|
self.reservation_id = reservation_id or None
|
|
116
116
|
# Add this once chi.image is implemented
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
if image:
|
|
118
|
+
self.image = image
|
|
119
|
+
elif image_name:
|
|
120
|
+
self.image = chi.image.get_image(image_name)
|
|
121
|
+
else:
|
|
122
|
+
self.image = None
|
|
123
|
+
self.image_name = self.image.name if self.image else None
|
|
119
124
|
self.flavor_name = flavor_name
|
|
120
125
|
|
|
121
126
|
if keypair:
|
|
@@ -216,10 +221,13 @@ class Server:
|
|
|
216
221
|
|
|
217
222
|
@classmethod
|
|
218
223
|
def _from_nova_server(cls, nova_server):
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
224
|
+
if nova_server.image:
|
|
225
|
+
if isinstance(nova_server.image, dict):
|
|
226
|
+
image_id = nova_server.image.get("id")
|
|
227
|
+
else:
|
|
228
|
+
image_id = nova_server.image
|
|
229
|
+
else:
|
|
230
|
+
image_id = None
|
|
223
231
|
flavor_name = nova_server.flavor.get("original_name", "")
|
|
224
232
|
|
|
225
233
|
try:
|
|
@@ -238,7 +246,7 @@ class Server:
|
|
|
238
246
|
server = cls(
|
|
239
247
|
name=nova_server.name,
|
|
240
248
|
reservation_id=None,
|
|
241
|
-
image_name=get_image_name(image_id),
|
|
249
|
+
image_name=get_image_name(image_id) if image_id else None,
|
|
242
250
|
flavor_name=flavor_name,
|
|
243
251
|
key_name=nova_server.key_name,
|
|
244
252
|
network_name=(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
2
|
from typing import List
|
|
3
3
|
|
|
4
|
+
import cinderclient
|
|
4
5
|
import manilaclient
|
|
5
6
|
import swiftclient
|
|
6
7
|
|
|
@@ -295,7 +296,7 @@ def get_volume(ref) -> Volume:
|
|
|
295
296
|
"""
|
|
296
297
|
try:
|
|
297
298
|
volume = cinder().volumes.get(ref)
|
|
298
|
-
except
|
|
299
|
+
except cinderclient.exceptions.NotFound:
|
|
299
300
|
volumes = list(cinder().volumes.list(search_opts={"name": ref}))
|
|
300
301
|
if not volumes:
|
|
301
302
|
raise CHIValueError(f'No volumes found matching name "{ref}"')
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import base64
|
|
2
|
+
import logging
|
|
2
3
|
import os
|
|
3
4
|
import time
|
|
4
5
|
from datetime import datetime, timedelta
|
|
@@ -10,6 +11,8 @@ from IPython.display import display
|
|
|
10
11
|
|
|
11
12
|
from chi.exception import ResourceError
|
|
12
13
|
|
|
14
|
+
LOG = logging.getLogger(__name__)
|
|
15
|
+
|
|
13
16
|
|
|
14
17
|
def random_base32(n_bytes):
|
|
15
18
|
rand_bytes = os.urandom(n_bytes)
|
|
@@ -60,9 +63,45 @@ class TimerProgressBar:
|
|
|
60
63
|
orientation="horizontal",
|
|
61
64
|
)
|
|
62
65
|
self.label = widgets.Label()
|
|
66
|
+
self.status_output = widgets.HTML()
|
|
67
|
+
self._log_lines = []
|
|
68
|
+
self._current_status_text = ""
|
|
69
|
+
self._is_displayed = False
|
|
63
70
|
|
|
64
71
|
def display(self):
|
|
65
|
-
display(
|
|
72
|
+
display(
|
|
73
|
+
widgets.VBox(
|
|
74
|
+
[
|
|
75
|
+
widgets.HBox([self.label, self.progress]),
|
|
76
|
+
self.status_output,
|
|
77
|
+
]
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
self._is_displayed = True
|
|
81
|
+
|
|
82
|
+
def log(self, msg):
|
|
83
|
+
self._log_lines.append(msg)
|
|
84
|
+
if self._is_displayed:
|
|
85
|
+
self._render()
|
|
86
|
+
else:
|
|
87
|
+
LOG.info(msg)
|
|
88
|
+
|
|
89
|
+
def update_status(self, msg):
|
|
90
|
+
self._current_status_text = msg
|
|
91
|
+
if self._is_displayed:
|
|
92
|
+
self._render()
|
|
93
|
+
|
|
94
|
+
def _render(self):
|
|
95
|
+
from html import escape
|
|
96
|
+
|
|
97
|
+
lines = list(self._log_lines)
|
|
98
|
+
if self._current_status_text:
|
|
99
|
+
lines.append(self._current_status_text)
|
|
100
|
+
inner = "\n".join(escape(line) for line in lines)
|
|
101
|
+
self.status_output.value = (
|
|
102
|
+
'<pre style="max-height:200px;overflow:auto;margin:4px 0;font-family:inherit;">'
|
|
103
|
+
f"{inner}</pre>"
|
|
104
|
+
)
|
|
66
105
|
|
|
67
106
|
def wait(self, callback, expected_timeout, timeout, interval=5):
|
|
68
107
|
"""Wait and update the progress bar.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"git_version": "dfbd927", "is_release": false}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# Copyright 2021 University of Chicago
|
|
2
|
+
#
|
|
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
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
import io
|
|
15
|
+
import os
|
|
16
|
+
import tarfile
|
|
17
|
+
import tempfile
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
from zunclient.exceptions import Conflict
|
|
22
|
+
|
|
23
|
+
from chi.container import Container, download, upload
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture()
|
|
27
|
+
def now():
|
|
28
|
+
return datetime(2021, 1, 1, 0, 0, 0, 0)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_container_upload_method(mocker):
|
|
32
|
+
# Arrange
|
|
33
|
+
mock_upload = mocker.patch("chi.container.upload")
|
|
34
|
+
container = Container(
|
|
35
|
+
name="test",
|
|
36
|
+
image_ref="image",
|
|
37
|
+
exposed_ports=[],
|
|
38
|
+
)
|
|
39
|
+
container.id = "fake_id"
|
|
40
|
+
source = "/tmp/sourcefile"
|
|
41
|
+
remote_dest = "/container/path"
|
|
42
|
+
|
|
43
|
+
# Act
|
|
44
|
+
container.upload(source, remote_dest)
|
|
45
|
+
|
|
46
|
+
# Assert
|
|
47
|
+
mock_upload.assert_called_once_with("fake_id", source, remote_dest)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_container_download_method(mocker):
|
|
51
|
+
# Arrange
|
|
52
|
+
mock_download = mocker.patch("chi.container.download")
|
|
53
|
+
container = Container(
|
|
54
|
+
name="test",
|
|
55
|
+
image_ref="image",
|
|
56
|
+
exposed_ports=[],
|
|
57
|
+
)
|
|
58
|
+
container.id = "fake_id"
|
|
59
|
+
remote_source = "/container/path"
|
|
60
|
+
dest = "/tmp/destfile"
|
|
61
|
+
|
|
62
|
+
# Act
|
|
63
|
+
container.download(remote_source, dest)
|
|
64
|
+
|
|
65
|
+
# Assert
|
|
66
|
+
mock_download.assert_called_once_with("fake_id", remote_source, dest)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_upload_creates_tar_and_calls_put_archive(mocker):
|
|
70
|
+
# Patch zun client
|
|
71
|
+
zun_mock = mocker.patch("chi.container.zun")()
|
|
72
|
+
# Create a temporary file to upload
|
|
73
|
+
with tempfile.NamedTemporaryFile() as tmpfile:
|
|
74
|
+
tmpfile.write(b"hello world")
|
|
75
|
+
tmpfile.flush()
|
|
76
|
+
|
|
77
|
+
upload("container_id", tmpfile.name, "/remote/path")
|
|
78
|
+
# Check that put_archive was called
|
|
79
|
+
assert zun_mock.containers.put_archive.call_count == 1
|
|
80
|
+
args = zun_mock.containers.put_archive.call_args[0]
|
|
81
|
+
assert args[0] == "container_id"
|
|
82
|
+
assert args[1] == "/remote/path"
|
|
83
|
+
# The third argument should be a tar archive containing the file
|
|
84
|
+
tar_bytes = args[2]
|
|
85
|
+
tarfileobj = io.BytesIO(tar_bytes)
|
|
86
|
+
with tarfile.open(fileobj=tarfileobj, mode="r") as tar:
|
|
87
|
+
names = tar.getnames()
|
|
88
|
+
assert os.path.basename(tmpfile.name) in names
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_download_extracts_tar_and_writes_file(mocker):
|
|
92
|
+
# Patch zun client
|
|
93
|
+
zun_mock = mocker.patch("chi.container.zun")()
|
|
94
|
+
# Create a tar archive in memory with a test file
|
|
95
|
+
file_content = b"test content"
|
|
96
|
+
file_name = "testfile.txt"
|
|
97
|
+
tar_bytes_io = io.BytesIO()
|
|
98
|
+
with tarfile.open(fileobj=tar_bytes_io, mode="w") as tar:
|
|
99
|
+
info = tarfile.TarInfo(name=file_name)
|
|
100
|
+
info.size = len(file_content)
|
|
101
|
+
tar.addfile(info, io.BytesIO(file_content))
|
|
102
|
+
tar_bytes = tar_bytes_io.getvalue()
|
|
103
|
+
zun_mock.containers.get_archive.return_value = {"data": tar_bytes}
|
|
104
|
+
|
|
105
|
+
# Use a temporary directory for extraction
|
|
106
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
107
|
+
dest_path = os.path.join(tmpdir, file_name)
|
|
108
|
+
|
|
109
|
+
download("container_id", file_name, tmpdir)
|
|
110
|
+
# Check that the file was extracted
|
|
111
|
+
assert os.path.exists(dest_path)
|
|
112
|
+
with open(dest_path, "rb") as f:
|
|
113
|
+
assert f.read() == file_content
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_delete_calls_force(mocker):
|
|
117
|
+
destroy_mock = mocker.patch("chi.container.destroy_container")
|
|
118
|
+
container = Container(name="test", image_ref="img")
|
|
119
|
+
container.id = "fake-id"
|
|
120
|
+
|
|
121
|
+
container.delete()
|
|
122
|
+
|
|
123
|
+
destroy_mock.assert_called_once_with("fake-id", force=True)
|
|
124
|
+
assert container.id is None
|
|
125
|
+
assert container._status is None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_submit_idempotent_returns_existing_without_create(mocker):
|
|
129
|
+
# idempotent=true, wait=true
|
|
130
|
+
chi_container = Container(name="dup-name", image_ref="img")
|
|
131
|
+
existing_zun_container = mocker.Mock(uuid="existing-uuid", status="Running")
|
|
132
|
+
|
|
133
|
+
mocker.patch("chi.container.get_container", return_value=existing_zun_container)
|
|
134
|
+
create_mock = mocker.patch("chi.container.create_container")
|
|
135
|
+
|
|
136
|
+
submit_result = chi_container.submit(
|
|
137
|
+
idempotent=True, wait_for_active=True, wait_timeout=123, show="text"
|
|
138
|
+
)
|
|
139
|
+
create_mock.assert_not_called()
|
|
140
|
+
existing_zun_container.wait.assert_called_once_with(status="Running", timeout=123)
|
|
141
|
+
existing_zun_container.show.assert_called_once_with(
|
|
142
|
+
type="text", wait_for_active=True
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# submit returns only on idempotent=true
|
|
146
|
+
assert submit_result is existing_zun_container
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_submit_idempotent_returns_existing_without_create_no_wait(mocker):
|
|
150
|
+
# idempotent=true, wait=false
|
|
151
|
+
chi_container = Container(name="dup-name", image_ref="img")
|
|
152
|
+
existing_zun_container = mocker.Mock(uuid="existing-uuid", status="Running")
|
|
153
|
+
|
|
154
|
+
mocker.patch("chi.container.get_container", return_value=existing_zun_container)
|
|
155
|
+
create_mock = mocker.patch("chi.container.create_container")
|
|
156
|
+
|
|
157
|
+
submit_result = chi_container.submit(
|
|
158
|
+
idempotent=True, wait_for_active=False, show=None
|
|
159
|
+
)
|
|
160
|
+
create_mock.assert_not_called()
|
|
161
|
+
existing_zun_container.wait.assert_not_called()
|
|
162
|
+
existing_zun_container.show.assert_not_called()
|
|
163
|
+
|
|
164
|
+
# submit returns only on idempotent=true
|
|
165
|
+
assert submit_result is existing_zun_container
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_submit_duplicate_name_tracks_created_uuid(mocker):
|
|
169
|
+
"""Test the case where we re-run submit after a failure.
|
|
170
|
+
|
|
171
|
+
An "old" container alreday already exists, with name = "dup-name".
|
|
172
|
+
If idempotent=false, it should be possible to make a new container with the same name.
|
|
173
|
+
However, name-based lookups will fail with a 409.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
chi_container = Container(name="dup-name", image_ref="img")
|
|
177
|
+
new_zun_container = mocker.Mock(uuid="new-uuid", status="Running")
|
|
178
|
+
|
|
179
|
+
def _get_side_effect(ref):
|
|
180
|
+
if ref == "dup-name":
|
|
181
|
+
raise Conflict(
|
|
182
|
+
"Multiple containers exist with same name. Please use the container uuid instead."
|
|
183
|
+
)
|
|
184
|
+
if ref == "new-uuid":
|
|
185
|
+
return new_zun_container
|
|
186
|
+
raise AssertionError(ref)
|
|
187
|
+
|
|
188
|
+
mocker.patch("chi.container.create_container", return_value=new_zun_container)
|
|
189
|
+
zun_mock = mocker.patch("chi.container.zun")()
|
|
190
|
+
zun_mock.containers.get.side_effect = _get_side_effect
|
|
191
|
+
|
|
192
|
+
# disable optional behavor from wait_for_active and show
|
|
193
|
+
chi_container.submit(wait_for_active=False, show=None)
|
|
194
|
+
assert chi_container.id == "new-uuid"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# --- Container.wait() status display tests ---
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _make_zun_state(mocker, status, detail=None, reason=None):
|
|
201
|
+
return mocker.Mock(status=status, status_detail=detail, status_reason=reason)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _setup_wait(mocker, states):
|
|
205
|
+
"""Common setup for wait tests: mock zun, ipynb check, and progress bar."""
|
|
206
|
+
container = Container(name="t", image_ref="img")
|
|
207
|
+
container.id = "fake"
|
|
208
|
+
|
|
209
|
+
zun_mock = mocker.patch("chi.container.zun")()
|
|
210
|
+
zun_mock.containers.get.side_effect = states
|
|
211
|
+
mocker.patch("chi.container.context._is_ipynb", return_value=False)
|
|
212
|
+
|
|
213
|
+
pb_cls = mocker.patch("chi.container.util.TimerProgressBar")
|
|
214
|
+
pb = pb_cls.return_value
|
|
215
|
+
|
|
216
|
+
def fake_wait(cb, expected, timeout, interval=5):
|
|
217
|
+
for _ in states:
|
|
218
|
+
if cb():
|
|
219
|
+
return True
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
pb.wait.side_effect = fake_wait
|
|
223
|
+
return container, pb
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_wait_logs_status_transitions(mocker):
|
|
227
|
+
"""Status changes produce log lines; same status does not."""
|
|
228
|
+
states = [
|
|
229
|
+
_make_zun_state(mocker, "Creating"),
|
|
230
|
+
_make_zun_state(mocker, "Creating"),
|
|
231
|
+
_make_zun_state(mocker, "Running"),
|
|
232
|
+
]
|
|
233
|
+
container, pb = _setup_wait(mocker, states)
|
|
234
|
+
|
|
235
|
+
container.wait(status="Running")
|
|
236
|
+
|
|
237
|
+
log_calls = [c.args[0] for c in pb.log.call_args_list]
|
|
238
|
+
assert any("Running" in log for log in log_calls)
|
|
239
|
+
assert len(log_calls) == 1 # one transition
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_wait_accumulates_events_without_duplicates(mocker):
|
|
243
|
+
"""Different details accumulate; same detail is not repeated."""
|
|
244
|
+
states = [
|
|
245
|
+
_make_zun_state(mocker, "Creating", detail="Pulling image"),
|
|
246
|
+
_make_zun_state(mocker, "Creating", detail="Pulling image"),
|
|
247
|
+
_make_zun_state(mocker, "Creating", detail="Configuring net"),
|
|
248
|
+
_make_zun_state(mocker, "Running"),
|
|
249
|
+
]
|
|
250
|
+
container, pb = _setup_wait(mocker, states)
|
|
251
|
+
|
|
252
|
+
captured_updates = []
|
|
253
|
+
pb.update_status.side_effect = lambda msg: captured_updates.append(msg)
|
|
254
|
+
|
|
255
|
+
container.wait(status="Running")
|
|
256
|
+
|
|
257
|
+
creating_updates = [u for u in captured_updates if "Creating" in u]
|
|
258
|
+
last_creating = creating_updates[-1]
|
|
259
|
+
assert "Pulling image" in last_creating
|
|
260
|
+
assert "Configuring net" in last_creating
|
|
261
|
+
assert last_creating.count("Pulling image") == 1
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_wait_stops_on_error(mocker):
|
|
265
|
+
"""Error status stops the wait loop and is logged."""
|
|
266
|
+
states = [
|
|
267
|
+
_make_zun_state(mocker, "Creating"),
|
|
268
|
+
_make_zun_state(mocker, "Error", detail="OOM", reason="Out of memory"),
|
|
269
|
+
]
|
|
270
|
+
container, pb = _setup_wait(mocker, states)
|
|
271
|
+
|
|
272
|
+
container.wait(status="Running")
|
|
273
|
+
|
|
274
|
+
log_calls = [c.args[0] for c in pb.log.call_args_list]
|
|
275
|
+
assert any("Error" in log for log in log_calls)
|
python_chi-1.2.6/ChangeLog
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"git_version": "f691d6a", "is_release": false}
|
python_chi-1.2.6/setup.py
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
# Copyright 2021 University of Chicago
|
|
2
|
-
#
|
|
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
|
-
#
|
|
7
|
-
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
-
#
|
|
9
|
-
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
-
# See the License for the specific language governing permissions and
|
|
13
|
-
# limitations under the License.
|
|
14
|
-
import io
|
|
15
|
-
import os
|
|
16
|
-
import tarfile
|
|
17
|
-
import tempfile
|
|
18
|
-
from datetime import datetime
|
|
19
|
-
|
|
20
|
-
import pytest
|
|
21
|
-
|
|
22
|
-
from chi.container import Container, download, upload
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@pytest.fixture()
|
|
26
|
-
def now():
|
|
27
|
-
return datetime(2021, 1, 1, 0, 0, 0, 0)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def test_container_upload_method(mocker):
|
|
31
|
-
# Arrange
|
|
32
|
-
mock_upload = mocker.patch("chi.container.upload")
|
|
33
|
-
container = Container(
|
|
34
|
-
name="test",
|
|
35
|
-
image_ref="image",
|
|
36
|
-
exposed_ports=[],
|
|
37
|
-
)
|
|
38
|
-
container.id = "fake_id"
|
|
39
|
-
source = "/tmp/sourcefile"
|
|
40
|
-
remote_dest = "/container/path"
|
|
41
|
-
|
|
42
|
-
# Act
|
|
43
|
-
container.upload(source, remote_dest)
|
|
44
|
-
|
|
45
|
-
# Assert
|
|
46
|
-
mock_upload.assert_called_once_with("fake_id", source, remote_dest)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def test_container_download_method(mocker):
|
|
50
|
-
# Arrange
|
|
51
|
-
mock_download = mocker.patch("chi.container.download")
|
|
52
|
-
container = Container(
|
|
53
|
-
name="test",
|
|
54
|
-
image_ref="image",
|
|
55
|
-
exposed_ports=[],
|
|
56
|
-
)
|
|
57
|
-
container.id = "fake_id"
|
|
58
|
-
remote_source = "/container/path"
|
|
59
|
-
dest = "/tmp/destfile"
|
|
60
|
-
|
|
61
|
-
# Act
|
|
62
|
-
container.download(remote_source, dest)
|
|
63
|
-
|
|
64
|
-
# Assert
|
|
65
|
-
mock_download.assert_called_once_with("fake_id", remote_source, dest)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def test_upload_creates_tar_and_calls_put_archive(mocker):
|
|
69
|
-
# Patch zun client
|
|
70
|
-
zun_mock = mocker.patch("chi.container.zun")()
|
|
71
|
-
# Create a temporary file to upload
|
|
72
|
-
with tempfile.NamedTemporaryFile() as tmpfile:
|
|
73
|
-
tmpfile.write(b"hello world")
|
|
74
|
-
tmpfile.flush()
|
|
75
|
-
|
|
76
|
-
upload("container_id", tmpfile.name, "/remote/path")
|
|
77
|
-
# Check that put_archive was called
|
|
78
|
-
assert zun_mock.containers.put_archive.call_count == 1
|
|
79
|
-
args = zun_mock.containers.put_archive.call_args[0]
|
|
80
|
-
assert args[0] == "container_id"
|
|
81
|
-
assert args[1] == "/remote/path"
|
|
82
|
-
# The third argument should be a tar archive containing the file
|
|
83
|
-
tar_bytes = args[2]
|
|
84
|
-
tarfileobj = io.BytesIO(tar_bytes)
|
|
85
|
-
with tarfile.open(fileobj=tarfileobj, mode="r") as tar:
|
|
86
|
-
names = tar.getnames()
|
|
87
|
-
assert os.path.basename(tmpfile.name) in names
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def test_download_extracts_tar_and_writes_file(mocker):
|
|
91
|
-
# Patch zun client
|
|
92
|
-
zun_mock = mocker.patch("chi.container.zun")()
|
|
93
|
-
# Create a tar archive in memory with a test file
|
|
94
|
-
file_content = b"test content"
|
|
95
|
-
file_name = "testfile.txt"
|
|
96
|
-
tar_bytes_io = io.BytesIO()
|
|
97
|
-
with tarfile.open(fileobj=tar_bytes_io, mode="w") as tar:
|
|
98
|
-
info = tarfile.TarInfo(name=file_name)
|
|
99
|
-
info.size = len(file_content)
|
|
100
|
-
tar.addfile(info, io.BytesIO(file_content))
|
|
101
|
-
tar_bytes = tar_bytes_io.getvalue()
|
|
102
|
-
zun_mock.containers.get_archive.return_value = {"data": tar_bytes}
|
|
103
|
-
|
|
104
|
-
# Use a temporary directory for extraction
|
|
105
|
-
with tempfile.TemporaryDirectory() as tmpdir:
|
|
106
|
-
dest_path = os.path.join(tmpdir, file_name)
|
|
107
|
-
|
|
108
|
-
download("container_id", file_name, tmpdir)
|
|
109
|
-
# Check that the file was extracted
|
|
110
|
-
assert os.path.exists(dest_path)
|
|
111
|
-
with open(dest_path, "rb") as f:
|
|
112
|
-
assert f.read() == file_content
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|