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.
Files changed (70) hide show
  1. {python_chi-1.0.8 → python_chi-1.1.0}/.github/workflows/test.yml +9 -12
  2. python_chi-1.1.0/ChangeLog +7 -0
  3. {python_chi-1.0.8 → python_chi-1.1.0}/PKG-INFO +3 -2
  4. {python_chi-1.0.8 → python_chi-1.1.0}/chi/container.py +74 -10
  5. {python_chi-1.0.8 → python_chi-1.1.0}/chi/context.py +3 -0
  6. {python_chi-1.0.8 → python_chi-1.1.0}/chi/hardware.py +194 -34
  7. {python_chi-1.0.8 → python_chi-1.1.0}/chi/lease.py +54 -24
  8. {python_chi-1.0.8 → python_chi-1.1.0}/chi/server.py +81 -25
  9. {python_chi-1.0.8 → python_chi-1.1.0}/chi/util.py +28 -6
  10. {python_chi-1.0.8 → python_chi-1.1.0}/python_chi.egg-info/PKG-INFO +3 -2
  11. python_chi-1.1.0/python_chi.egg-info/pbr.json +1 -0
  12. {python_chi-1.0.8 → python_chi-1.1.0}/setup.py +1 -1
  13. python_chi-1.0.8/ChangeLog +0 -7
  14. python_chi-1.0.8/python_chi.egg-info/pbr.json +0 -1
  15. {python_chi-1.0.8 → python_chi-1.1.0}/.github/CODEOWNERS +0 -0
  16. {python_chi-1.0.8 → python_chi-1.1.0}/.github/workflows/pypi-publish.yml +0 -0
  17. {python_chi-1.0.8 → python_chi-1.1.0}/.mailmap +0 -0
  18. {python_chi-1.0.8 → python_chi-1.1.0}/.readthedocs.yml +0 -0
  19. {python_chi-1.0.8 → python_chi-1.1.0}/AUTHORS +0 -0
  20. {python_chi-1.0.8 → python_chi-1.1.0}/DEVELOPMENT.rst +0 -0
  21. {python_chi-1.0.8 → python_chi-1.1.0}/LICENSE +0 -0
  22. {python_chi-1.0.8 → python_chi-1.1.0}/Makefile +0 -0
  23. {python_chi-1.0.8 → python_chi-1.1.0}/README.rst +0 -0
  24. {python_chi-1.0.8 → python_chi-1.1.0}/chi/__init__.py +0 -0
  25. {python_chi-1.0.8 → python_chi-1.1.0}/chi/clients.py +0 -0
  26. {python_chi-1.0.8 → python_chi-1.1.0}/chi/exception.py +0 -0
  27. {python_chi-1.0.8 → python_chi-1.1.0}/chi/image.py +0 -0
  28. {python_chi-1.0.8 → python_chi-1.1.0}/chi/jupyterhub.py +0 -0
  29. {python_chi-1.0.8 → python_chi-1.1.0}/chi/keypair.py +0 -0
  30. {python_chi-1.0.8 → python_chi-1.1.0}/chi/magic.py +0 -0
  31. {python_chi-1.0.8 → python_chi-1.1.0}/chi/network.py +0 -0
  32. {python_chi-1.0.8 → python_chi-1.1.0}/chi/share.py +0 -0
  33. {python_chi-1.0.8 → python_chi-1.1.0}/chi/ssh.py +0 -0
  34. {python_chi-1.0.8 → python_chi-1.1.0}/chi/storage.py +0 -0
  35. {python_chi-1.0.8 → python_chi-1.1.0}/docs/__init__.py +0 -0
  36. {python_chi-1.0.8 → python_chi-1.1.0}/docs/conf.py +0 -0
  37. {python_chi-1.0.8 → python_chi-1.1.0}/docs/examples.rst +0 -0
  38. {python_chi-1.0.8 → python_chi-1.1.0}/docs/generate_notebook.py +0 -0
  39. {python_chi-1.0.8 → python_chi-1.1.0}/docs/index.rst +0 -0
  40. {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/clients.rst +0 -0
  41. {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/container.rst +0 -0
  42. {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/context.rst +0 -0
  43. {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/exception.rst +0 -0
  44. {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/hardware.rst +0 -0
  45. {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/image.rst +0 -0
  46. {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/lease.rst +0 -0
  47. {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/magic.rst +0 -0
  48. {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/network.rst +0 -0
  49. {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/server.rst +0 -0
  50. {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/share.rst +0 -0
  51. {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/ssh.rst +0 -0
  52. {python_chi-1.0.8 → python_chi-1.1.0}/docs/modules/storage.rst +0 -0
  53. {python_chi-1.0.8 → python_chi-1.1.0}/docs/requirements.txt +0 -0
  54. {python_chi-1.0.8 → python_chi-1.1.0}/python_chi.egg-info/SOURCES.txt +0 -0
  55. {python_chi-1.0.8 → python_chi-1.1.0}/python_chi.egg-info/dependency_links.txt +0 -0
  56. {python_chi-1.0.8 → python_chi-1.1.0}/python_chi.egg-info/not-zip-safe +0 -0
  57. {python_chi-1.0.8 → python_chi-1.1.0}/python_chi.egg-info/requires.txt +0 -0
  58. {python_chi-1.0.8 → python_chi-1.1.0}/python_chi.egg-info/top_level.txt +0 -0
  59. {python_chi-1.0.8 → python_chi-1.1.0}/requirements.txt +0 -0
  60. {python_chi-1.0.8 → python_chi-1.1.0}/setup.cfg +0 -0
  61. {python_chi-1.0.8 → python_chi-1.1.0}/test-requirements.txt +0 -0
  62. {python_chi-1.0.8 → python_chi-1.1.0}/tests/__init__.py +0 -0
  63. {python_chi-1.0.8 → python_chi-1.1.0}/tests/test_container.py +0 -0
  64. {python_chi-1.0.8 → python_chi-1.1.0}/tests/test_context.py +0 -0
  65. {python_chi-1.0.8 → python_chi-1.1.0}/tests/test_lease.py +0 -0
  66. {python_chi-1.0.8 → python_chi-1.1.0}/tests/test_network.py +0 -0
  67. {python_chi-1.0.8 → python_chi-1.1.0}/tests/test_server.py +0 -0
  68. {python_chi-1.0.8 → python_chi-1.1.0}/tests/test_share.py +0 -0
  69. {python_chi-1.0.8 → python_chi-1.1.0}/tests/test_ssh.py +0 -0
  70. {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-latest
17
-
13
+ runs-on: ubuntu-22.04
14
+ strategy:
15
+ matrix:
16
+ python:
17
+ - 3.8
18
18
  steps:
19
- - uses: actions/checkout@v2
20
-
19
+ - uses: actions/checkout@v4
21
20
  - name: Set up Python 3.x
22
- uses: actions/setup-python@v1
21
+ uses: actions/setup-python@v5
23
22
  with:
24
- python-version: ${{ env.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 }}"
@@ -0,0 +1,7 @@
1
+ CHANGES
2
+ =======
3
+
4
+ v1.1
5
+ ----
6
+
7
+ * Add configurable option for CHI@Edge hardware API
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: python-chi
3
- Version: 1.0.8
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 .exception import ResourceError
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 = None,
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 None.
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(self, status: str = "Running", timeout: int = None):
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
- timeout (int, optional): The maximum time to wait in seconds. Defaults to None.
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
- wait_for_active(self.id, timeout=timeout)
186
- self._status = status
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 = (lambda member, path: member)
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 item.get("uid") == target_uid or item.get("hypervisor_hostname") == target_uid:
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
- now = datetime.now(timezone.utc)
72
-
73
- if not allocation:
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. (Not Currently implemented)
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(node_data.get("uid"), None)
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 (node.gpu and gpu == bool(node.gpu.get("gpu")))
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
- add_device_reservation(
274
- reservation_list=self.device_reservations,
275
- count=amount,
276
- machine_name=machine_type,
277
- device_model=device_model,
278
- device_name=device_name,
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
- response = create_lease(
393
- lease_name=self.name,
394
- reservations=reservations,
395
- start_date=self.start_date,
396
- end_date=self.end_date,
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
- if response:
400
- self._populate_from_json(response)
401
- else:
402
- raise ResourceError("Unable to make lease")
418
+ if response:
419
+ self._populate_from_json(response)
420
+ else:
421
+ raise ResourceError("Unable to make lease")
403
422
 
404
- if wait_for_active:
405
- self.wait(status="active", timeout=wait_timeout)
423
+ if wait_for_active:
424
+ self.wait(status="active", timeout=wait_timeout)
406
425
 
407
- if show:
408
- self.show(type=show, wait_for_active=wait_for_active)
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... This can take up to 60 seconds")
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 Conflict, NotFound
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
- # TODO use nova_server to update self
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
- if wait_for_active:
203
- self.wait()
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
- if show:
206
- self.show(type=show)
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"] if network_id is not None else None
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
- """Deletes the server.
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
- delete_server(self.id)
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(self, status: str = "ACTIVE", show: str = "widget") -> None:
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, 20 * 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(self, fip: Optional[str] = None, port_id: Optional[str] = None) -> None:
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
- return address["addr"]
474
- return None
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 [Flavor(name=f.name, disk=f.disk, ram=f.ram, vcpus=f.vcpus) for f in flavors]
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(f"Port {port_id} not found on server {server_id}")
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 = {'port_id': port['id']}
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(f"None of the ports can route to floating ip {floating_ip_address} on server {server_id}")
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
- 10
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(5)
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.2
1
+ Metadata-Version: 2.4
2
2
  Name: python-chi
3
- Version: 1.0.8
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}
@@ -5,5 +5,5 @@ from setuptools import setup
5
5
  setup(
6
6
  setup_requires=["pbr"],
7
7
  pbr=True,
8
- version='v1.0.8'
8
+ version='v1.1'
9
9
  )
@@ -1,7 +0,0 @@
1
- CHANGES
2
- =======
3
-
4
- v1.0.8
5
- ------
6
-
7
- * Add Node.reservable field
@@ -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