python-chi 1.0.8__tar.gz → 1.1.0__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.0.8 → python_chi-1.1.0}/.github/workflows/test.yml +9 -12
- python_chi-1.1.0/ChangeLog +7 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/PKG-INFO +3 -2
- {python_chi-1.0.8 → python_chi-1.1.0}/chi/container.py +74 -10
- {python_chi-1.0.8 → python_chi-1.1.0}/chi/context.py +3 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/chi/hardware.py +194 -34
- {python_chi-1.0.8 → python_chi-1.1.0}/chi/lease.py +54 -24
- {python_chi-1.0.8 → python_chi-1.1.0}/chi/server.py +81 -25
- {python_chi-1.0.8 → python_chi-1.1.0}/chi/util.py +28 -6
- {python_chi-1.0.8 → python_chi-1.1.0}/python_chi.egg-info/PKG-INFO +3 -2
- python_chi-1.1.0/python_chi.egg-info/pbr.json +1 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/setup.py +1 -1
- python_chi-1.0.8/ChangeLog +0 -7
- python_chi-1.0.8/python_chi.egg-info/pbr.json +0 -1
- {python_chi-1.0.8 → python_chi-1.1.0}/.github/CODEOWNERS +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/.github/workflows/pypi-publish.yml +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/.mailmap +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/.readthedocs.yml +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/AUTHORS +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/DEVELOPMENT.rst +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/LICENSE +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/Makefile +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/README.rst +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/chi/__init__.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/chi/clients.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/chi/exception.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/chi/image.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/chi/jupyterhub.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/chi/keypair.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/chi/magic.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/chi/network.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/chi/share.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/chi/ssh.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/chi/storage.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/__init__.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/conf.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/examples.rst +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/generate_notebook.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/index.rst +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/clients.rst +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/container.rst +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/context.rst +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/exception.rst +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/hardware.rst +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/image.rst +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/lease.rst +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/magic.rst +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/network.rst +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/server.rst +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/share.rst +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/ssh.rst +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/storage.rst +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/docs/requirements.txt +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/python_chi.egg-info/SOURCES.txt +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/python_chi.egg-info/dependency_links.txt +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/python_chi.egg-info/not-zip-safe +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/python_chi.egg-info/requires.txt +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/python_chi.egg-info/top_level.txt +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/requirements.txt +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/setup.cfg +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/test-requirements.txt +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/tests/__init__.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/tests/test_container.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/tests/test_context.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/tests/test_lease.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/tests/test_network.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/tests/test_server.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/tests/test_share.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/tests/test_ssh.py +0 -0
- {python_chi-1.0.8 → python_chi-1.1.0}/tox.ini +0 -0
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
name: Unit tests
|
|
2
2
|
|
|
3
|
-
env:
|
|
4
|
-
PYTHON_VERSION: 3.8
|
|
5
|
-
|
|
6
3
|
on:
|
|
7
4
|
push:
|
|
8
5
|
branches:
|
|
@@ -13,18 +10,18 @@ on:
|
|
|
13
10
|
|
|
14
11
|
jobs:
|
|
15
12
|
test:
|
|
16
|
-
runs-on: ubuntu-
|
|
17
|
-
|
|
13
|
+
runs-on: ubuntu-22.04
|
|
14
|
+
strategy:
|
|
15
|
+
matrix:
|
|
16
|
+
python:
|
|
17
|
+
- 3.8
|
|
18
18
|
steps:
|
|
19
|
-
- uses: actions/checkout@
|
|
20
|
-
|
|
19
|
+
- uses: actions/checkout@v4
|
|
21
20
|
- name: Set up Python 3.x
|
|
22
|
-
uses: actions/setup-python@
|
|
21
|
+
uses: actions/setup-python@v5
|
|
23
22
|
with:
|
|
24
|
-
python-version: ${{
|
|
25
|
-
|
|
23
|
+
python-version: ${{ matrix.python }}
|
|
26
24
|
- name: Install tox
|
|
27
25
|
run: pip install tox
|
|
28
|
-
|
|
29
26
|
- name: Run tests
|
|
30
|
-
run: tox
|
|
27
|
+
run: tox -e "py${{ matrix.python }}"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: python-chi
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Helper library for Chameleon Infrastructure (CHI) testbed
|
|
5
5
|
Home-page: https://www.chameleoncloud.org
|
|
6
6
|
Author: University of Chicago
|
|
@@ -35,6 +35,7 @@ Dynamic: author-email
|
|
|
35
35
|
Dynamic: classifier
|
|
36
36
|
Dynamic: description
|
|
37
37
|
Dynamic: home-page
|
|
38
|
+
Dynamic: license-file
|
|
38
39
|
Dynamic: requires-dist
|
|
39
40
|
Dynamic: summary
|
|
40
41
|
|
|
@@ -23,11 +23,14 @@ from IPython.display import HTML, display
|
|
|
23
23
|
from packaging.version import Version
|
|
24
24
|
from zunclient.exceptions import NotFound
|
|
25
25
|
|
|
26
|
-
from chi import context
|
|
26
|
+
from chi import context, util
|
|
27
27
|
|
|
28
|
-
from .clients import zun
|
|
29
|
-
from .
|
|
28
|
+
from .clients import connection, zun
|
|
29
|
+
from .context import session
|
|
30
|
+
from .exception import ResourceError, ServiceError
|
|
30
31
|
from .network import bind_floating_ip, get_free_floating_ip
|
|
32
|
+
from chi import network as chi_network
|
|
33
|
+
|
|
31
34
|
|
|
32
35
|
DEFAULT_IMAGE_DRIVER = "docker"
|
|
33
36
|
DEFAULT_NETWORK = "containernet1"
|
|
@@ -46,6 +49,8 @@ class Container:
|
|
|
46
49
|
start (bool, optional): Indicates whether to start the container. Defaults to True.
|
|
47
50
|
start_timeout (int, optional): The timeout value for starting the container. Defaults to None.
|
|
48
51
|
runtime (str, optional): The runtime environment for the container. Defaults to None.
|
|
52
|
+
command (List[str], optional): The command to run inside the container.
|
|
53
|
+
workdir (str, optional): The workdir to use in the container.
|
|
49
54
|
|
|
50
55
|
Attributes:
|
|
51
56
|
name (str): The name of the container.
|
|
@@ -69,6 +74,8 @@ class Container:
|
|
|
69
74
|
start: bool = True,
|
|
70
75
|
start_timeout: int = 0,
|
|
71
76
|
runtime: str = None,
|
|
77
|
+
command: List[str] = None,
|
|
78
|
+
workdir: str = None,
|
|
72
79
|
):
|
|
73
80
|
self.name = name
|
|
74
81
|
self.image_ref = image_ref
|
|
@@ -80,6 +87,8 @@ class Container:
|
|
|
80
87
|
self.id = None
|
|
81
88
|
self.created_at = None
|
|
82
89
|
self._status = None
|
|
90
|
+
self.command = command
|
|
91
|
+
self.workdir = workdir
|
|
83
92
|
|
|
84
93
|
@classmethod
|
|
85
94
|
def from_zun_container(cls, zun_container):
|
|
@@ -103,7 +112,7 @@ class Container:
|
|
|
103
112
|
def submit(
|
|
104
113
|
self,
|
|
105
114
|
wait_for_active: bool = True,
|
|
106
|
-
wait_timeout: int =
|
|
115
|
+
wait_timeout: int = 5 * 60,
|
|
107
116
|
show: str = "widget",
|
|
108
117
|
idempotent: bool = False,
|
|
109
118
|
):
|
|
@@ -112,7 +121,7 @@ class Container:
|
|
|
112
121
|
|
|
113
122
|
Args:
|
|
114
123
|
wait_for_active (bool, optional): Whether to wait for the container to become active. Defaults to True.
|
|
115
|
-
wait_timeout (int, optional): The maximum time (in seconds) to wait for the container to become active. Defaults to
|
|
124
|
+
wait_timeout (int, optional): The maximum time (in seconds) to wait for the container to become active. Defaults to 5 minutes.
|
|
116
125
|
show (str, optional): The type of output to display. Defaults to "widget".
|
|
117
126
|
idempotent (bool, optional): Whether to update the existing container if it already exists. Defaults to False.
|
|
118
127
|
|
|
@@ -130,6 +139,11 @@ class Container:
|
|
|
130
139
|
if show:
|
|
131
140
|
existing.show(type=show, wait_for_active=wait_for_active)
|
|
132
141
|
return existing
|
|
142
|
+
kwargs = {}
|
|
143
|
+
if self.command:
|
|
144
|
+
kwargs["command"] = self.command
|
|
145
|
+
if self.workdir:
|
|
146
|
+
kwargs["workdir"] = self.workdir
|
|
133
147
|
|
|
134
148
|
container = create_container(
|
|
135
149
|
name=self.name,
|
|
@@ -139,6 +153,7 @@ class Container:
|
|
|
139
153
|
start=self.start,
|
|
140
154
|
start_timeout=self.start_timeout,
|
|
141
155
|
runtime=self.runtime,
|
|
156
|
+
**kwargs,
|
|
142
157
|
)
|
|
143
158
|
|
|
144
159
|
if container:
|
|
@@ -171,19 +186,38 @@ class Container:
|
|
|
171
186
|
self.id = None
|
|
172
187
|
self._status = None
|
|
173
188
|
|
|
174
|
-
def wait(
|
|
189
|
+
def wait(
|
|
190
|
+
self, status: str = "Running", show: str = "widget", timeout: int = 5 * 60
|
|
191
|
+
):
|
|
175
192
|
"""
|
|
176
193
|
Waits for the container to reach the specified status.
|
|
177
194
|
|
|
178
195
|
Args:
|
|
179
196
|
status (str, optional): The status to wait for. Defaults to "Running".
|
|
180
|
-
|
|
197
|
+
show (str, optional): The type of container information to display after creation. Defaults to "widget".
|
|
198
|
+
timeout (int, optional): The maximum time to wait in seconds. Defaults to 5 minutes.
|
|
181
199
|
|
|
182
200
|
Returns:
|
|
183
201
|
None
|
|
184
202
|
"""
|
|
185
|
-
|
|
186
|
-
|
|
203
|
+
|
|
204
|
+
pb = util.TimerProgressBar()
|
|
205
|
+
if show == "widget" and context._is_ipynb():
|
|
206
|
+
pb.display()
|
|
207
|
+
|
|
208
|
+
def _callback():
|
|
209
|
+
# self.status is a property that refreshes itself
|
|
210
|
+
# NOTE: zun statuses are title case
|
|
211
|
+
if self.status.upper() == status.upper() or self.status == "Error":
|
|
212
|
+
print(f"Container has moved to status {self.status}")
|
|
213
|
+
return True
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
res = pb.wait(_callback, 2 * 60, timeout)
|
|
217
|
+
if not res:
|
|
218
|
+
raise ServiceError(
|
|
219
|
+
f"Timeout waiting for container to reach {status} status"
|
|
220
|
+
)
|
|
187
221
|
|
|
188
222
|
def show(self, type: str = "text", wait_for_active: bool = False):
|
|
189
223
|
"""
|
|
@@ -292,6 +326,36 @@ class Container:
|
|
|
292
326
|
"""
|
|
293
327
|
return associate_floating_ip(self.id, fip)
|
|
294
328
|
|
|
329
|
+
def detach_floating_ip(self, fip: str) -> None:
|
|
330
|
+
"""
|
|
331
|
+
Detaches and deletes a floating IP from the container.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
fip (str): The floating IP to detach.
|
|
335
|
+
delete (Optional[bool], optional): Whether to delete the floating IP after disassociation. Defaults to True.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
None
|
|
339
|
+
"""
|
|
340
|
+
conn = connection(session=session())
|
|
341
|
+
floating_ip_obj = chi_network.get_floating_ip(fip)
|
|
342
|
+
conn.network.delete(floating_ip_obj["id"])
|
|
343
|
+
|
|
344
|
+
def logs(self, stdout: str = True, stderr: str = True) -> str:
|
|
345
|
+
"""
|
|
346
|
+
Print all logs outputted by the container.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
container_ref (str): The name or ID of the container.
|
|
350
|
+
stdout (bool): Whether to include stdout logs. Default True.
|
|
351
|
+
stderr (bool): Whether to include stderr logs. Default True.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
A string containing all log output. Log lines will be delimited by
|
|
355
|
+
newline characters.
|
|
356
|
+
"""
|
|
357
|
+
return get_logs(self.id, stdout=stdout, stderr=stderr)
|
|
358
|
+
|
|
295
359
|
|
|
296
360
|
def create_container(
|
|
297
361
|
name: "str",
|
|
@@ -515,7 +579,7 @@ def download(container_ref: "str", source: "str", dest: "str"):
|
|
|
515
579
|
res = zun().containers.get_archive(container_ref, source)
|
|
516
580
|
fd = io.BytesIO(res["data"])
|
|
517
581
|
with tarfile.open(fileobj=fd, mode="r") as tar:
|
|
518
|
-
tar.extraction_filter =
|
|
582
|
+
tar.extraction_filter = lambda member, path: member
|
|
519
583
|
tar.extractall(dest)
|
|
520
584
|
|
|
521
585
|
|
|
@@ -26,6 +26,9 @@ DEFAULT_AUTH_TYPE = "v3token"
|
|
|
26
26
|
DEFAULT_NETWORK = "sharednet1"
|
|
27
27
|
CONF_GROUP = "chi"
|
|
28
28
|
RESOURCE_API_URL = os.getenv("CHI_RESOURCE_API_URL", "https://api.chameleoncloud.org")
|
|
29
|
+
EDGE_RESOURCE_API_URL = os.getenv(
|
|
30
|
+
"EDGE_RESOURCE_API_URL", "https://chameleoncloud.org/edge-hw-discovery/devices"
|
|
31
|
+
)
|
|
29
32
|
|
|
30
33
|
|
|
31
34
|
def default_key_name():
|
|
@@ -2,12 +2,13 @@ from collections import defaultdict
|
|
|
2
2
|
from concurrent.futures import ThreadPoolExecutor
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
from datetime import datetime, timedelta, timezone
|
|
5
|
-
from typing import List, Optional, Tuple
|
|
5
|
+
from typing import List, Optional, Set, Tuple
|
|
6
6
|
|
|
7
7
|
from chi import exception
|
|
8
8
|
|
|
9
|
-
from .clients import blazar
|
|
10
|
-
from .context import get, RESOURCE_API_URL
|
|
9
|
+
from .clients import blazar, connection
|
|
10
|
+
from .context import get, RESOURCE_API_URL, EDGE_RESOURCE_API_URL, session
|
|
11
|
+
|
|
11
12
|
|
|
12
13
|
import requests
|
|
13
14
|
import logging
|
|
@@ -17,6 +18,31 @@ LOG = logging.getLogger(__name__)
|
|
|
17
18
|
node_types = []
|
|
18
19
|
|
|
19
20
|
|
|
21
|
+
def _get_next_free_timeslot(allocation, minimum_hours):
|
|
22
|
+
now = datetime.now(timezone.utc)
|
|
23
|
+
|
|
24
|
+
if not allocation:
|
|
25
|
+
return (now, None)
|
|
26
|
+
|
|
27
|
+
reservations = sorted(allocation["reservations"], key=lambda x: x["start_date"])
|
|
28
|
+
|
|
29
|
+
buffer = timedelta(hours=minimum_hours)
|
|
30
|
+
# Next time this interval could possibly start
|
|
31
|
+
possible_start = now
|
|
32
|
+
for i in range(len(reservations)):
|
|
33
|
+
# Check we have enough time between last known free period and this reservation
|
|
34
|
+
this_start = _parse_blazar_dt(reservations[i]["start_date"])
|
|
35
|
+
if possible_start + buffer < this_start:
|
|
36
|
+
# We found a gap
|
|
37
|
+
return (possible_start, this_start)
|
|
38
|
+
|
|
39
|
+
# Otherwise, no possible start until end of this reservation
|
|
40
|
+
this_end = _parse_blazar_dt(reservations[i]["end_date"])
|
|
41
|
+
possible_start = this_end
|
|
42
|
+
# If there was no gap, use the last reservation's end time
|
|
43
|
+
return (possible_start, None)
|
|
44
|
+
|
|
45
|
+
|
|
20
46
|
@dataclass
|
|
21
47
|
class Node:
|
|
22
48
|
"""
|
|
@@ -55,7 +81,10 @@ class Node:
|
|
|
55
81
|
|
|
56
82
|
def get_host_id(items, target_uid):
|
|
57
83
|
for item in items:
|
|
58
|
-
if
|
|
84
|
+
if (
|
|
85
|
+
item.get("uid") == target_uid
|
|
86
|
+
or item.get("hypervisor_hostname") == target_uid
|
|
87
|
+
):
|
|
59
88
|
return item["id"]
|
|
60
89
|
return None
|
|
61
90
|
|
|
@@ -63,33 +92,12 @@ class Node:
|
|
|
63
92
|
|
|
64
93
|
# Get allocation for this specific host
|
|
65
94
|
host_id = get_host_id(blazarclient.host.list(), self.uid)
|
|
66
|
-
|
|
67
95
|
if not host_id:
|
|
68
96
|
raise exception.ServiceError(f"Host for {self.uid} not found in Blazar")
|
|
69
|
-
allocation = blazarclient.host.get_allocation(host_id)
|
|
70
97
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return (now, None)
|
|
75
|
-
|
|
76
|
-
reservations = sorted(allocation["reservations"], key=lambda x: x["start_date"])
|
|
77
|
-
|
|
78
|
-
buffer = timedelta(hours=minimum_hours)
|
|
79
|
-
# Next time this interval could possibly start
|
|
80
|
-
possible_start = now
|
|
81
|
-
for i in range(len(reservations)):
|
|
82
|
-
# Check we have enough time between last known free period and this reservation
|
|
83
|
-
this_start = _parse_blazar_dt(reservations[i]["start_date"])
|
|
84
|
-
if possible_start + buffer < this_start:
|
|
85
|
-
# We found a gap
|
|
86
|
-
return (possible_start, this_start)
|
|
87
|
-
|
|
88
|
-
# Otherwise, no possible start until end of this reservation
|
|
89
|
-
this_end = _parse_blazar_dt(reservations[i]["end_date"])
|
|
90
|
-
possible_start = this_end
|
|
91
|
-
# If there was no gap, use the last reservation's end time
|
|
92
|
-
return (possible_start, None)
|
|
98
|
+
return _get_next_free_timeslot(
|
|
99
|
+
blazarclient.host.get_allocation(host_id), minimum_hours
|
|
100
|
+
)
|
|
93
101
|
|
|
94
102
|
|
|
95
103
|
def _call_api(endpoint):
|
|
@@ -115,7 +123,7 @@ def get_nodes(
|
|
|
115
123
|
all_sites (bool, optional): Flag to indicate whether to retrieve nodes from all sites.
|
|
116
124
|
Defaults to False.
|
|
117
125
|
filter_reserved (bool, optional): Flag to indicate whether to filter out reserved nodes.
|
|
118
|
-
Defaults to False.
|
|
126
|
+
Defaults to False.
|
|
119
127
|
gpu (bool, optional): Flag to indicate whether to filter nodes based on GPU availability.
|
|
120
128
|
Defaults to None.
|
|
121
129
|
min_number_cpu (int, optional): Minimum number of CPU logical cores per node.
|
|
@@ -137,9 +145,7 @@ def get_nodes(
|
|
|
137
145
|
for site in sites:
|
|
138
146
|
# Soufiane: Skipping CHI@EDGE since it is not enrolled in the hardware API,
|
|
139
147
|
if site == "CHI@Edge":
|
|
140
|
-
print(
|
|
141
|
-
"Please visit the Hardware discovery page for information about CHI@Edge devices"
|
|
142
|
-
)
|
|
148
|
+
print("See `hardware.get_devices` for information about CHI@Edge devices")
|
|
143
149
|
continue
|
|
144
150
|
|
|
145
151
|
allocations = defaultdict(list)
|
|
@@ -174,7 +180,9 @@ def get_nodes(
|
|
|
174
180
|
reserved_now.add(blazar_host["hypervisor_hostname"])
|
|
175
181
|
|
|
176
182
|
for node_data in data["items"]:
|
|
177
|
-
blazar_host = blazar_hosts_by_hypervisor_hostname.get(
|
|
183
|
+
blazar_host = blazar_hosts_by_hypervisor_hostname.get(
|
|
184
|
+
node_data.get("uid"), {}
|
|
185
|
+
)
|
|
178
186
|
node = Node(
|
|
179
187
|
site=site,
|
|
180
188
|
name=node_data.get("node_name"),
|
|
@@ -199,7 +207,9 @@ def get_nodes(
|
|
|
199
207
|
node.gpu and gpu == bool(node.gpu[0].get("gpu"))
|
|
200
208
|
)
|
|
201
209
|
else:
|
|
202
|
-
gpu_filter = gpu is None or (
|
|
210
|
+
gpu_filter = gpu is None or (
|
|
211
|
+
node.gpu and gpu == bool(node.gpu.get("gpu"))
|
|
212
|
+
)
|
|
203
213
|
|
|
204
214
|
cpu_filter = (
|
|
205
215
|
min_number_cpu is None
|
|
@@ -249,3 +259,153 @@ def get_node_types() -> List[str]:
|
|
|
249
259
|
if len(node_types) < 1:
|
|
250
260
|
get_nodes()
|
|
251
261
|
return list(set(node_types))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@dataclass
|
|
265
|
+
class Device:
|
|
266
|
+
"""
|
|
267
|
+
A dataclass for device information directly from the hardware browser.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
device_name: str
|
|
271
|
+
device_type: str
|
|
272
|
+
supported_device_profiles: List[str]
|
|
273
|
+
authorized_projects: Set[str]
|
|
274
|
+
owning_project: str
|
|
275
|
+
uuid: str
|
|
276
|
+
reservable: bool
|
|
277
|
+
|
|
278
|
+
def next_free_timeslot(
|
|
279
|
+
self, minimum_hours: int = 1
|
|
280
|
+
) -> Tuple[datetime, Optional[datetime]]:
|
|
281
|
+
"""
|
|
282
|
+
Finds the next available timeslot for the device using the Blazar client.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
minimum_hours (int, optional): The minimum number of hours for this timeslot.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
A tuple containing the start and end datetime of the next available timeslot.
|
|
289
|
+
If no timeslot is available, returns (end_datetime_of_last_allocation, None).
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
def get_device_id(items, target_uid):
|
|
293
|
+
for item in items:
|
|
294
|
+
if item.get("uid") == target_uid or item.get("uid") == target_uid:
|
|
295
|
+
return item["id"]
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
blazarclient = blazar()
|
|
299
|
+
|
|
300
|
+
# Get allocation for this specific device
|
|
301
|
+
device_id = get_device_id(blazarclient.device.list(), self.uuid)
|
|
302
|
+
if not device_id:
|
|
303
|
+
raise exception.ServiceError(f"Device for {self.uuid} not found in Blazar")
|
|
304
|
+
|
|
305
|
+
# Bug in Blazar API for devices means `get_alloction` doesn't work. We get around this with `list`
|
|
306
|
+
allocs = blazarclient.device.list_allocations()
|
|
307
|
+
this_alloc = None
|
|
308
|
+
for alloc in allocs:
|
|
309
|
+
if alloc["resource_id"] == device_id:
|
|
310
|
+
this_alloc = alloc
|
|
311
|
+
return _get_next_free_timeslot(this_alloc, minimum_hours)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def get_devices(
|
|
315
|
+
device_type: Optional[str] = None,
|
|
316
|
+
filter_reserved: bool = False,
|
|
317
|
+
filter_unauthorized: bool = True,
|
|
318
|
+
) -> List[Device]:
|
|
319
|
+
"""
|
|
320
|
+
Retrieve a list of devices based on the specified criteria.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
device_type (str, optional): The device type to filter by
|
|
324
|
+
filter_reserved (bool, optional): Flag to indicate whether to filter out reserved devices. Defaults to False.
|
|
325
|
+
filter_unauthorized (bool, optional): Filter devices that the current project is not authorized to use
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
List[Device]: A list of Device objects that match the specified criteria.
|
|
329
|
+
"""
|
|
330
|
+
# Query hardware API
|
|
331
|
+
res = requests.get(EDGE_RESOURCE_API_URL)
|
|
332
|
+
try:
|
|
333
|
+
res.raise_for_status()
|
|
334
|
+
except requests.exceptions.HTTPError:
|
|
335
|
+
raise exception.ServiceError(
|
|
336
|
+
f"Failed to get devices. Status code {res.status_code}"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
blazarclient = blazar()
|
|
340
|
+
# Blazar uid matches doni's uuid, so we need to map blazar id to blazar uid for allocations,
|
|
341
|
+
# and uid to id for reservable status
|
|
342
|
+
blazar_devices_by_id = {}
|
|
343
|
+
blazar_devices_by_uid = {}
|
|
344
|
+
for device in blazarclient.device.list():
|
|
345
|
+
blazar_devices_by_id[device["id"]] = device
|
|
346
|
+
blazar_devices_by_uid[device["uid"]] = device
|
|
347
|
+
|
|
348
|
+
devices = []
|
|
349
|
+
for dev_json in res.json():
|
|
350
|
+
blazar_host = blazar_devices_by_uid.get(dev_json.get("uuid"), {})
|
|
351
|
+
devices.append(
|
|
352
|
+
Device(
|
|
353
|
+
device_name=dev_json["device_name"],
|
|
354
|
+
device_type=dev_json["device_type"],
|
|
355
|
+
supported_device_profiles=dev_json["supported_device_profiles"],
|
|
356
|
+
authorized_projects=set(dev_json["authorized_projects"]),
|
|
357
|
+
owning_project=dev_json["owning_project"],
|
|
358
|
+
uuid=dev_json["uuid"],
|
|
359
|
+
reservable=blazar_host.get(
|
|
360
|
+
"reservable", False
|
|
361
|
+
), # not all devices will appear in blazar if registration failed
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Filter based on authorized projects
|
|
366
|
+
authorized_devices = [] if filter_unauthorized else devices
|
|
367
|
+
if filter_unauthorized:
|
|
368
|
+
conn = connection(session=session())
|
|
369
|
+
current_project_id = conn.current_project_id
|
|
370
|
+
for device in devices:
|
|
371
|
+
if (
|
|
372
|
+
"all" in device.authorized_projects
|
|
373
|
+
or current_project_id in device.authorized_projects
|
|
374
|
+
):
|
|
375
|
+
authorized_devices.append(device)
|
|
376
|
+
|
|
377
|
+
# Filter based on device type
|
|
378
|
+
matching_type_devices = [] if device_type else authorized_devices
|
|
379
|
+
if device_type:
|
|
380
|
+
for device in authorized_devices:
|
|
381
|
+
if device.device_type == device_type:
|
|
382
|
+
matching_type_devices.append(device)
|
|
383
|
+
|
|
384
|
+
# Filter based on reserved status
|
|
385
|
+
unreserved_devices = [] if filter_reserved else matching_type_devices
|
|
386
|
+
if filter_reserved:
|
|
387
|
+
now = datetime.now(timezone.utc)
|
|
388
|
+
|
|
389
|
+
reserved_devices = set()
|
|
390
|
+
for resource in blazarclient.device.list_allocations():
|
|
391
|
+
blazar_device = blazar_devices_by_id.get(resource["resource_id"], None)
|
|
392
|
+
if blazar_device:
|
|
393
|
+
for allocation in resource["reservations"]:
|
|
394
|
+
if _reserved_now(allocation, now):
|
|
395
|
+
reserved_devices.add(blazar_device["uid"])
|
|
396
|
+
for device in matching_type_devices:
|
|
397
|
+
# Ensure the device is free and in `reservable` state
|
|
398
|
+
if device.uuid not in reserved_devices and device.reservable:
|
|
399
|
+
unreserved_devices.append(device)
|
|
400
|
+
|
|
401
|
+
return unreserved_devices
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def get_device_types() -> List[str]:
|
|
405
|
+
"""
|
|
406
|
+
Retrieve a list of unique device types.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
List[str]: A list of unique device types.
|
|
410
|
+
"""
|
|
411
|
+
return list(set(d.device_type for d in get_devices()))
|
|
@@ -16,9 +16,9 @@ from chi import context, util
|
|
|
16
16
|
from .clients import blazar
|
|
17
17
|
from .context import _is_ipynb
|
|
18
18
|
from .exception import CHIValueError, ResourceError, ServiceError
|
|
19
|
-
from .hardware import Node
|
|
19
|
+
from .hardware import Device, Node
|
|
20
20
|
from .network import PUBLIC_NETWORK, get_network_id, list_floating_ips
|
|
21
|
-
from .util import utcnow
|
|
21
|
+
from .util import retry_create, utcnow
|
|
22
22
|
|
|
23
23
|
if TYPE_CHECKING:
|
|
24
24
|
from typing import Pattern
|
|
@@ -260,6 +260,7 @@ class Lease:
|
|
|
260
260
|
machine_type: str = None,
|
|
261
261
|
device_model: str = None,
|
|
262
262
|
device_name: str = None,
|
|
263
|
+
devices: List[Device] = None,
|
|
263
264
|
):
|
|
264
265
|
"""
|
|
265
266
|
Add a IoT device reservation to the list of device reservations.
|
|
@@ -269,14 +270,29 @@ class Lease:
|
|
|
269
270
|
machine_type (str, optional): The type of machine to reserve. Defaults to None.
|
|
270
271
|
device_model (str, optional): The model of the device to reserve. Defaults to None.
|
|
271
272
|
device_name (str, optional): The name of the device to reserve. Defaults to None.
|
|
273
|
+
devices (List[Device]): A list of Device objects to reserve.
|
|
274
|
+
|
|
275
|
+
Raises:
|
|
276
|
+
CHIValueError: If devices are specified, no other arguments should be included.
|
|
272
277
|
"""
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
278
|
+
if devices:
|
|
279
|
+
if any([amount, machine_type, device_model, device_name]):
|
|
280
|
+
raise CHIValueError(
|
|
281
|
+
"When specifying nodes, no other arguments should be included"
|
|
282
|
+
)
|
|
283
|
+
for device in devices:
|
|
284
|
+
add_device_reservation(
|
|
285
|
+
reservation_list=self.device_reservations,
|
|
286
|
+
device_name=device.device_name,
|
|
287
|
+
)
|
|
288
|
+
else:
|
|
289
|
+
add_device_reservation(
|
|
290
|
+
reservation_list=self.device_reservations,
|
|
291
|
+
count=amount,
|
|
292
|
+
machine_name=machine_type,
|
|
293
|
+
device_model=device_model,
|
|
294
|
+
device_name=device_name,
|
|
295
|
+
)
|
|
280
296
|
|
|
281
297
|
def add_node_reservation(
|
|
282
298
|
self,
|
|
@@ -355,6 +371,7 @@ class Lease:
|
|
|
355
371
|
wait_timeout: int = 300,
|
|
356
372
|
show: Optional[str] = None,
|
|
357
373
|
idempotent: bool = False,
|
|
374
|
+
retry_on_error: bool = False,
|
|
358
375
|
):
|
|
359
376
|
"""
|
|
360
377
|
Submits the lease for creation.
|
|
@@ -364,6 +381,7 @@ class Lease:
|
|
|
364
381
|
wait_timeout (int, optional): The maximum time to wait for the lease to become active, in seconds. Defaults to 300.
|
|
365
382
|
show (Optional[str], optional): The types of lease information to display. Defaults to None, options are "widget", "text".
|
|
366
383
|
idempotent (bool, optional): Whether to create the lease only if it doesn't already exist. Defaults to False.
|
|
384
|
+
retry_on_error (bool, optional): Whether to retry the server creation if creation fails. Defaults to False.
|
|
367
385
|
|
|
368
386
|
Raises:
|
|
369
387
|
ResourceError: If unable to create the lease.
|
|
@@ -389,23 +407,35 @@ class Lease:
|
|
|
389
407
|
+ self.network_reservations
|
|
390
408
|
)
|
|
391
409
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
410
|
+
def _lease_create_func():
|
|
411
|
+
response = create_lease(
|
|
412
|
+
lease_name=self.name,
|
|
413
|
+
reservations=reservations,
|
|
414
|
+
start_date=self.start_date,
|
|
415
|
+
end_date=self.end_date,
|
|
416
|
+
)
|
|
398
417
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
418
|
+
if response:
|
|
419
|
+
self._populate_from_json(response)
|
|
420
|
+
else:
|
|
421
|
+
raise ResourceError("Unable to make lease")
|
|
403
422
|
|
|
404
|
-
|
|
405
|
-
|
|
423
|
+
if wait_for_active:
|
|
424
|
+
self.wait(status="active", timeout=wait_timeout)
|
|
406
425
|
|
|
407
|
-
|
|
408
|
-
|
|
426
|
+
if show:
|
|
427
|
+
self.show(type=show, wait_for_active=wait_for_active)
|
|
428
|
+
|
|
429
|
+
def _lease_cleanup_func():
|
|
430
|
+
try:
|
|
431
|
+
self.delete()
|
|
432
|
+
except Exception:
|
|
433
|
+
# Ignore any cleanup errors
|
|
434
|
+
pass
|
|
435
|
+
|
|
436
|
+
retry_create(
|
|
437
|
+
3 if retry_on_error else 1, _lease_create_func, _lease_cleanup_func
|
|
438
|
+
)
|
|
409
439
|
|
|
410
440
|
def wait(self, status="active", show: str = "widget", timeout: int = 500):
|
|
411
441
|
"""
|
|
@@ -423,7 +453,7 @@ class Lease:
|
|
|
423
453
|
None
|
|
424
454
|
"""
|
|
425
455
|
|
|
426
|
-
print("Waiting for lease to start...
|
|
456
|
+
print("Waiting for lease to start...")
|
|
427
457
|
|
|
428
458
|
pb = util.TimerProgressBar()
|
|
429
459
|
if show == "widget" and _is_ipynb():
|
|
@@ -6,7 +6,7 @@ from typing import Dict, List, Optional, Union
|
|
|
6
6
|
|
|
7
7
|
from fabric import Connection
|
|
8
8
|
from IPython.display import HTML, display
|
|
9
|
-
from novaclient.exceptions import
|
|
9
|
+
from novaclient.exceptions import NotFound
|
|
10
10
|
from novaclient.v2.flavor_access import FlavorAccess as NovaFlavor
|
|
11
11
|
from novaclient.v2.keypairs import Keypair as NovaKeypair
|
|
12
12
|
from novaclient.v2.servers import Server as NovaServer
|
|
@@ -25,7 +25,7 @@ from .exception import CHIValueError, ResourceError, ServiceError
|
|
|
25
25
|
from .image import Image, get_image_id, get_image_name
|
|
26
26
|
from .keypair import Keypair
|
|
27
27
|
from chi import network as chi_network
|
|
28
|
-
from .util import random_base32, sshkey_fingerprint
|
|
28
|
+
from .util import random_base32, retry_create, sshkey_fingerprint
|
|
29
29
|
from chi import exception
|
|
30
30
|
|
|
31
31
|
DEFAULT_IMAGE = DEFAULT_IMAGE_NAME
|
|
@@ -157,6 +157,8 @@ class Server:
|
|
|
157
157
|
wait_for_active: bool = True,
|
|
158
158
|
show: str = "widget",
|
|
159
159
|
idempotent: bool = False,
|
|
160
|
+
retry_on_error: bool = False,
|
|
161
|
+
wait_timeout: int = 20 * 60,
|
|
160
162
|
**kwargs,
|
|
161
163
|
) -> "Server":
|
|
162
164
|
"""
|
|
@@ -166,6 +168,8 @@ class Server:
|
|
|
166
168
|
wait_for_active (bool, optional): Whether to wait for the server to become active before returning. Defaults to True.
|
|
167
169
|
show (str, optional): The type of server information to display after creation. Defaults to "widget".
|
|
168
170
|
idempotent (bool, optional): Whether to create the server only if it doesn't already exist. Defaults to False.
|
|
171
|
+
retry_on_error (bool, optional): Whether to retry the server creation if creation fails. Defaults to False.
|
|
172
|
+
wait_timeout (int): How long to wait for server to start in seconds. Default 20 minutes.
|
|
169
173
|
|
|
170
174
|
Raises:
|
|
171
175
|
Conflict: If the server creation fails due to a conflict and idempotent mode is not enabled.
|
|
@@ -192,18 +196,25 @@ class Server:
|
|
|
192
196
|
net_ids=[chi_network.get_network_id(DEFAULT_NETWORK)],
|
|
193
197
|
**kwargs,
|
|
194
198
|
)
|
|
195
|
-
try:
|
|
196
|
-
nova_server = self.conn.compute.create_server(**server_args)
|
|
197
|
-
except Conflict as e:
|
|
198
|
-
raise ResourceError(e.message) # Re-raise the exception if not handled
|
|
199
199
|
|
|
200
|
-
|
|
200
|
+
def _server_create_func():
|
|
201
|
+
self.conn.compute.create_server(**server_args)
|
|
202
|
+
if wait_for_active:
|
|
203
|
+
self.wait(timeout=wait_timeout)
|
|
204
|
+
if show:
|
|
205
|
+
self.show(type=show)
|
|
201
206
|
|
|
202
|
-
|
|
203
|
-
|
|
207
|
+
def _server_cleanup_func():
|
|
208
|
+
try:
|
|
209
|
+
self.delete(idempotent=True, delete_ips=False)
|
|
210
|
+
time.sleep(10)
|
|
211
|
+
except Exception:
|
|
212
|
+
# Ignore any cleanup errors
|
|
213
|
+
pass
|
|
204
214
|
|
|
205
|
-
|
|
206
|
-
|
|
215
|
+
retry_create(
|
|
216
|
+
3 if retry_on_error else 1, _server_create_func, _server_cleanup_func
|
|
217
|
+
)
|
|
207
218
|
|
|
208
219
|
@classmethod
|
|
209
220
|
def _from_nova_server(cls, nova_server):
|
|
@@ -237,7 +248,9 @@ class Server:
|
|
|
237
248
|
flavor_name=get_flavor(flavor_id).name,
|
|
238
249
|
key_name=nova_server.key_name,
|
|
239
250
|
network_name=(
|
|
240
|
-
chi_network.get_network(network_id)["name"]
|
|
251
|
+
chi_network.get_network(network_id)["name"]
|
|
252
|
+
if network_id is not None
|
|
253
|
+
else None
|
|
241
254
|
),
|
|
242
255
|
)
|
|
243
256
|
|
|
@@ -277,10 +290,24 @@ class Server:
|
|
|
277
290
|
|
|
278
291
|
return server
|
|
279
292
|
|
|
280
|
-
def delete(self) -> None:
|
|
281
|
-
"""
|
|
293
|
+
def delete(self, idempotent: bool = False, delete_ips: bool = True) -> None:
|
|
294
|
+
"""
|
|
295
|
+
Deletes the server.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
idempotent (bool, optional): Whether to create the server only if it doesn't already exist. Defaults to False.
|
|
299
|
+
delete_ips (bool, optional): Whether to delete the server IPs from this project. Defauls to False
|
|
282
300
|
"""
|
|
283
|
-
|
|
301
|
+
if delete_ips:
|
|
302
|
+
conn = connection(session=session())
|
|
303
|
+
for addr in self.get_all_floating_ips():
|
|
304
|
+
floating_ip_obj = chi_network.get_floating_ip(addr)
|
|
305
|
+
conn.network.delete(floating_ip_obj["id"])
|
|
306
|
+
try:
|
|
307
|
+
delete_server(self.id)
|
|
308
|
+
except NotFound:
|
|
309
|
+
if not idempotent:
|
|
310
|
+
raise ResourceError(f"Server {self.name} not found")
|
|
284
311
|
|
|
285
312
|
def refresh(self):
|
|
286
313
|
"""
|
|
@@ -304,13 +331,16 @@ class Server:
|
|
|
304
331
|
except Exception as e:
|
|
305
332
|
raise ResourceError(f"Could not refresh server: {e}")
|
|
306
333
|
|
|
307
|
-
def wait(
|
|
334
|
+
def wait(
|
|
335
|
+
self, status: str = "ACTIVE", show: str = "widget", timeout: int = 20 * 60
|
|
336
|
+
) -> None:
|
|
308
337
|
"""
|
|
309
338
|
Waits for the server's status to reach the specified status.
|
|
310
339
|
|
|
311
340
|
Args:
|
|
312
341
|
status (str): The status to wait for. Defaults to "ACTIVE".
|
|
313
342
|
show (str, optional): The type of server information to display after creation. Defaults to "widget".
|
|
343
|
+
timeout (int): How long to wait for server to start in seconds. Default 20 minutes.
|
|
314
344
|
|
|
315
345
|
Raises:
|
|
316
346
|
ServiceError: If the server does not reach the specified status within the timeout period.
|
|
@@ -333,7 +363,7 @@ class Server:
|
|
|
333
363
|
return True
|
|
334
364
|
return False
|
|
335
365
|
|
|
336
|
-
res = pb.wait(_callback, 10 * 60,
|
|
366
|
+
res = pb.wait(_callback, 10 * 60, timeout)
|
|
337
367
|
if not res:
|
|
338
368
|
raise ServiceError(f"Timeout waiting for server to reach {status} status")
|
|
339
369
|
|
|
@@ -426,7 +456,9 @@ class Server:
|
|
|
426
456
|
)
|
|
427
457
|
return formatted
|
|
428
458
|
|
|
429
|
-
def associate_floating_ip(
|
|
459
|
+
def associate_floating_ip(
|
|
460
|
+
self, fip: Optional[str] = None, port_id: Optional[str] = None
|
|
461
|
+
) -> None:
|
|
430
462
|
"""
|
|
431
463
|
Associates a floating IP with the server.
|
|
432
464
|
|
|
@@ -441,17 +473,22 @@ class Server:
|
|
|
441
473
|
associate_floating_ip(self.id, fip, port_id)
|
|
442
474
|
self.refresh()
|
|
443
475
|
|
|
444
|
-
def detach_floating_ip(self, fip: str) -> None:
|
|
476
|
+
def detach_floating_ip(self, fip: str, delete: Optional[bool] = True) -> None:
|
|
445
477
|
"""
|
|
446
478
|
Detaches a floating IP from the server.
|
|
447
479
|
|
|
448
480
|
Args:
|
|
449
481
|
fip (str): The floating IP to detach.
|
|
482
|
+
delete (Optional[bool], optional): Whether to delete the floating IP after disassociation. Defaults to True.
|
|
450
483
|
|
|
451
484
|
Returns:
|
|
452
485
|
None
|
|
453
486
|
"""
|
|
454
487
|
detach_floating_ip(self.id, fip)
|
|
488
|
+
if delete:
|
|
489
|
+
conn = connection(session=session())
|
|
490
|
+
floating_ip_obj = chi_network.get_floating_ip(fip)
|
|
491
|
+
conn.network.delete(floating_ip_obj["id"])
|
|
455
492
|
self.refresh()
|
|
456
493
|
|
|
457
494
|
def _can_connect_to_port(self, host, port, timeout):
|
|
@@ -467,11 +504,23 @@ class Server:
|
|
|
467
504
|
Returns:
|
|
468
505
|
str: Floating IP address of server
|
|
469
506
|
"""
|
|
507
|
+
fips = self.get_all_floating_ips()
|
|
508
|
+
if fips:
|
|
509
|
+
return fips[0]
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
def get_all_floating_ips(self):
|
|
513
|
+
"""Get a list of attached floating ips of this server
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
List[str[]: Floating IP addresses of server
|
|
517
|
+
"""
|
|
518
|
+
fips = []
|
|
470
519
|
for net, addresses in self.addresses.items():
|
|
471
520
|
for address in addresses:
|
|
472
521
|
if address.get("OS-EXT-IPS:type") == "floating":
|
|
473
|
-
|
|
474
|
-
return
|
|
522
|
+
fips.append(address["addr"])
|
|
523
|
+
return fips
|
|
475
524
|
|
|
476
525
|
def check_connectivity(
|
|
477
526
|
self,
|
|
@@ -576,6 +625,7 @@ class Server:
|
|
|
576
625
|
def set_metadata_item(self, key, value):
|
|
577
626
|
return nova().servers.set_meta_item(self.id, key, value)
|
|
578
627
|
|
|
628
|
+
|
|
579
629
|
##########
|
|
580
630
|
# Flavors
|
|
581
631
|
##########
|
|
@@ -611,7 +661,9 @@ def list_flavors() -> List[Flavor]:
|
|
|
611
661
|
if Version(context.version) >= Version("1.0"):
|
|
612
662
|
nova_client = nova()
|
|
613
663
|
flavors = nova_client.flavors.list()
|
|
614
|
-
return [
|
|
664
|
+
return [
|
|
665
|
+
Flavor(name=f.name, disk=f.disk, ram=f.ram, vcpus=f.vcpus) for f in flavors
|
|
666
|
+
]
|
|
615
667
|
return nova().flavors.list()
|
|
616
668
|
|
|
617
669
|
|
|
@@ -816,11 +868,13 @@ def associate_floating_ip(server_id, floating_ip_address=None, port_id=None):
|
|
|
816
868
|
if port_id:
|
|
817
869
|
port_obj = next(port for port in ports if port["id"] == port_id)
|
|
818
870
|
if not port_obj:
|
|
819
|
-
raise exception.ResourceError(
|
|
871
|
+
raise exception.ResourceError(
|
|
872
|
+
f"Port {port_id} not found on server {server_id}"
|
|
873
|
+
)
|
|
820
874
|
ports = [port_obj]
|
|
821
875
|
else:
|
|
822
876
|
for port in ports:
|
|
823
|
-
floating_ip_args = {
|
|
877
|
+
floating_ip_args = {"port_id": port["id"]}
|
|
824
878
|
try:
|
|
825
879
|
return conn.network.update_ip(
|
|
826
880
|
floating_ip_obj["id"], **floating_ip_args
|
|
@@ -829,7 +883,9 @@ def associate_floating_ip(server_id, floating_ip_address=None, port_id=None):
|
|
|
829
883
|
# Ignore errors and try the next port
|
|
830
884
|
pass
|
|
831
885
|
floating_ip_address = floating_ip_obj["floating_ip_address"]
|
|
832
|
-
raise exception.ResourceError(
|
|
886
|
+
raise exception.ResourceError(
|
|
887
|
+
f"None of the ports can route to floating ip {floating_ip_address} on server {server_id}"
|
|
888
|
+
)
|
|
833
889
|
|
|
834
890
|
|
|
835
891
|
def detach_floating_ip(server_id, floating_ip_address):
|
|
@@ -4,6 +4,7 @@ import time
|
|
|
4
4
|
from dateutil import tz
|
|
5
5
|
from hashlib import md5
|
|
6
6
|
import os
|
|
7
|
+
from chi.exception import ResourceError
|
|
7
8
|
import ipywidgets as widgets
|
|
8
9
|
from IPython.display import display
|
|
9
10
|
|
|
@@ -61,17 +62,18 @@ class TimerProgressBar:
|
|
|
61
62
|
def display(self):
|
|
62
63
|
display(widgets.HBox([self.label, self.progress]))
|
|
63
64
|
|
|
64
|
-
def wait(self, callback, expected_timeout, timeout):
|
|
65
|
+
def wait(self, callback, expected_timeout, timeout, interval=5):
|
|
65
66
|
"""Wait and update the progress bar.
|
|
66
67
|
|
|
67
68
|
Args:
|
|
68
69
|
callback (function): bool function for whether to break
|
|
69
70
|
expected_timeout (int): how long the progress bar should expect to wait for in seconds. Will display 90% when reached
|
|
70
71
|
timeout (int): The time to reach 100% of the progress bar
|
|
71
|
-
|
|
72
|
+
interval (int): The time to wait between checking the callback)
|
|
72
73
|
Returns:
|
|
73
74
|
Whether callback returned true before timeout
|
|
74
75
|
"""
|
|
76
|
+
expected_proportion = 0.9
|
|
75
77
|
start_time = time.time()
|
|
76
78
|
while time.time() - start_time < timeout:
|
|
77
79
|
if callback():
|
|
@@ -81,12 +83,32 @@ class TimerProgressBar:
|
|
|
81
83
|
self.label.value = f"{str(elapased).split('.')[0]} elapsed."
|
|
82
84
|
|
|
83
85
|
if elapased.total_seconds() < expected_timeout:
|
|
84
|
-
self.progress.value = 100 * elapased.total_seconds() / expected_timeout
|
|
85
|
-
else:
|
|
86
86
|
self.progress.value = (
|
|
87
|
-
|
|
87
|
+
100
|
|
88
|
+
* expected_proportion
|
|
89
|
+
* elapased.total_seconds()
|
|
90
|
+
/ expected_timeout
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
self.progress.value = 100 * (
|
|
94
|
+
expected_proportion
|
|
95
|
+
+ (1 - expected_proportion)
|
|
88
96
|
* (elapased.total_seconds() - expected_timeout)
|
|
89
97
|
/ (timeout - expected_timeout)
|
|
90
98
|
)
|
|
91
|
-
time.sleep(
|
|
99
|
+
time.sleep(interval)
|
|
92
100
|
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def retry_create(max_attempts, create_func, cleanup_func):
|
|
104
|
+
attempt = 0
|
|
105
|
+
while attempt < 3:
|
|
106
|
+
try:
|
|
107
|
+
create_func()
|
|
108
|
+
break
|
|
109
|
+
except Exception as e:
|
|
110
|
+
attempt += 1
|
|
111
|
+
if attempt == max_attempts:
|
|
112
|
+
raise ResourceError(e)
|
|
113
|
+
print(f"Error creating resource on attempt {attempt}/{max_attempts}.")
|
|
114
|
+
cleanup_func()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: python-chi
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Helper library for Chameleon Infrastructure (CHI) testbed
|
|
5
5
|
Home-page: https://www.chameleoncloud.org
|
|
6
6
|
Author: University of Chicago
|
|
@@ -35,6 +35,7 @@ Dynamic: author-email
|
|
|
35
35
|
Dynamic: classifier
|
|
36
36
|
Dynamic: description
|
|
37
37
|
Dynamic: home-page
|
|
38
|
+
Dynamic: license-file
|
|
38
39
|
Dynamic: requires-dist
|
|
39
40
|
Dynamic: summary
|
|
40
41
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"git_version": "7011a87", "is_release": false}
|
python_chi-1.0.8/ChangeLog
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"git_version": "857913c", "is_release": false}
|
|
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
|