python-chi 1.1.0__tar.gz → 1.2.2__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 (76) hide show
  1. python_chi-1.2.2/.github/workflows/linting.yml +25 -0
  2. python_chi-1.2.2/ChangeLog +7 -0
  3. {python_chi-1.1.0 → python_chi-1.2.2}/PKG-INFO +1 -1
  4. {python_chi-1.1.0 → python_chi-1.2.2}/chi/__init__.py +2 -3
  5. {python_chi-1.1.0 → python_chi-1.2.2}/chi/clients.py +6 -6
  6. {python_chi-1.1.0 → python_chi-1.2.2}/chi/container.py +1 -2
  7. {python_chi-1.1.0 → python_chi-1.2.2}/chi/context.py +14 -10
  8. {python_chi-1.1.0 → python_chi-1.2.2}/chi/hardware.py +4 -5
  9. {python_chi-1.1.0 → python_chi-1.2.2}/chi/image.py +10 -7
  10. {python_chi-1.1.0 → python_chi-1.2.2}/chi/jupyterhub.py +1 -0
  11. {python_chi-1.1.0 → python_chi-1.2.2}/chi/lease.py +55 -8
  12. {python_chi-1.1.0 → python_chi-1.2.2}/chi/magic.py +9 -9
  13. {python_chi-1.1.0 → python_chi-1.2.2}/chi/network.py +72 -15
  14. {python_chi-1.1.0 → python_chi-1.2.2}/chi/server.py +98 -23
  15. {python_chi-1.1.0 → python_chi-1.2.2}/chi/share.py +2 -3
  16. {python_chi-1.1.0 → python_chi-1.2.2}/chi/storage.py +108 -1
  17. {python_chi-1.1.0 → python_chi-1.2.2}/chi/util.py +6 -4
  18. python_chi-1.2.2/docs/_templates/page.html +13 -0
  19. {python_chi-1.1.0 → python_chi-1.2.2}/docs/generate_notebook.py +40 -31
  20. {python_chi-1.1.0 → python_chi-1.2.2}/python_chi.egg-info/PKG-INFO +1 -1
  21. {python_chi-1.1.0 → python_chi-1.2.2}/python_chi.egg-info/SOURCES.txt +3 -0
  22. python_chi-1.2.2/python_chi.egg-info/pbr.json +1 -0
  23. python_chi-1.2.2/ruff.toml +15 -0
  24. python_chi-1.2.2/setup.py +5 -0
  25. {python_chi-1.1.0 → python_chi-1.2.2}/test-requirements.txt +4 -1
  26. python_chi-1.2.2/tests/test_container.py +112 -0
  27. {python_chi-1.1.0 → python_chi-1.2.2}/tests/test_context.py +17 -5
  28. {python_chi-1.1.0 → python_chi-1.2.2}/tests/test_lease.py +49 -91
  29. {python_chi-1.1.0 → python_chi-1.2.2}/tests/test_network.py +18 -12
  30. python_chi-1.2.2/tests/test_server.py +62 -0
  31. {python_chi-1.1.0 → python_chi-1.2.2}/tests/test_share.py +6 -5
  32. {python_chi-1.1.0 → python_chi-1.2.2}/tests/test_ssh.py +3 -5
  33. python_chi-1.1.0/ChangeLog +0 -7
  34. python_chi-1.1.0/python_chi.egg-info/pbr.json +0 -1
  35. python_chi-1.1.0/setup.py +0 -9
  36. python_chi-1.1.0/tests/test_container.py +0 -72
  37. python_chi-1.1.0/tests/test_server.py +0 -123
  38. {python_chi-1.1.0 → python_chi-1.2.2}/.github/CODEOWNERS +0 -0
  39. {python_chi-1.1.0 → python_chi-1.2.2}/.github/workflows/pypi-publish.yml +0 -0
  40. {python_chi-1.1.0 → python_chi-1.2.2}/.github/workflows/test.yml +0 -0
  41. {python_chi-1.1.0 → python_chi-1.2.2}/.mailmap +0 -0
  42. {python_chi-1.1.0 → python_chi-1.2.2}/.readthedocs.yml +0 -0
  43. {python_chi-1.1.0 → python_chi-1.2.2}/AUTHORS +0 -0
  44. {python_chi-1.1.0 → python_chi-1.2.2}/DEVELOPMENT.rst +0 -0
  45. {python_chi-1.1.0 → python_chi-1.2.2}/LICENSE +0 -0
  46. {python_chi-1.1.0 → python_chi-1.2.2}/Makefile +0 -0
  47. {python_chi-1.1.0 → python_chi-1.2.2}/README.rst +0 -0
  48. {python_chi-1.1.0 → python_chi-1.2.2}/chi/exception.py +0 -0
  49. {python_chi-1.1.0 → python_chi-1.2.2}/chi/keypair.py +0 -0
  50. {python_chi-1.1.0 → python_chi-1.2.2}/chi/ssh.py +0 -0
  51. {python_chi-1.1.0 → python_chi-1.2.2}/docs/__init__.py +0 -0
  52. {python_chi-1.1.0 → python_chi-1.2.2}/docs/conf.py +0 -0
  53. {python_chi-1.1.0 → python_chi-1.2.2}/docs/examples.rst +0 -0
  54. {python_chi-1.1.0 → python_chi-1.2.2}/docs/index.rst +0 -0
  55. {python_chi-1.1.0 → python_chi-1.2.2}/docs/modules/clients.rst +0 -0
  56. {python_chi-1.1.0 → python_chi-1.2.2}/docs/modules/container.rst +0 -0
  57. {python_chi-1.1.0 → python_chi-1.2.2}/docs/modules/context.rst +0 -0
  58. {python_chi-1.1.0 → python_chi-1.2.2}/docs/modules/exception.rst +0 -0
  59. {python_chi-1.1.0 → python_chi-1.2.2}/docs/modules/hardware.rst +0 -0
  60. {python_chi-1.1.0 → python_chi-1.2.2}/docs/modules/image.rst +0 -0
  61. {python_chi-1.1.0 → python_chi-1.2.2}/docs/modules/lease.rst +0 -0
  62. {python_chi-1.1.0 → python_chi-1.2.2}/docs/modules/magic.rst +0 -0
  63. {python_chi-1.1.0 → python_chi-1.2.2}/docs/modules/network.rst +0 -0
  64. {python_chi-1.1.0 → python_chi-1.2.2}/docs/modules/server.rst +0 -0
  65. {python_chi-1.1.0 → python_chi-1.2.2}/docs/modules/share.rst +0 -0
  66. {python_chi-1.1.0 → python_chi-1.2.2}/docs/modules/ssh.rst +0 -0
  67. {python_chi-1.1.0 → python_chi-1.2.2}/docs/modules/storage.rst +0 -0
  68. {python_chi-1.1.0 → python_chi-1.2.2}/docs/requirements.txt +0 -0
  69. {python_chi-1.1.0 → python_chi-1.2.2}/python_chi.egg-info/dependency_links.txt +0 -0
  70. {python_chi-1.1.0 → python_chi-1.2.2}/python_chi.egg-info/not-zip-safe +0 -0
  71. {python_chi-1.1.0 → python_chi-1.2.2}/python_chi.egg-info/requires.txt +0 -0
  72. {python_chi-1.1.0 → python_chi-1.2.2}/python_chi.egg-info/top_level.txt +0 -0
  73. {python_chi-1.1.0 → python_chi-1.2.2}/requirements.txt +0 -0
  74. {python_chi-1.1.0 → python_chi-1.2.2}/setup.cfg +0 -0
  75. {python_chi-1.1.0 → python_chi-1.2.2}/tests/__init__.py +0 -0
  76. {python_chi-1.1.0 → python_chi-1.2.2}/tox.ini +0 -0
@@ -0,0 +1,25 @@
1
+ name: Linting
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ lint-with-ruff:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - name: Set up Python
13
+ uses: actions/setup-python@v5
14
+ with:
15
+ python-version: '3.x'
16
+ - name: Install the code linting and formatting tool Ruff
17
+ run: pip install ruff
18
+
19
+ # run pass-fail test for any lint rule violations
20
+ - name: Lint code with Ruff
21
+ run: ruff check --output-format=github
22
+
23
+ # pas-fail test if any formatting would be applied, and output diff
24
+ - name: Check code formatting with Ruff
25
+ run: ruff format --diff
@@ -0,0 +1,7 @@
1
+ CHANGES
2
+ =======
3
+
4
+ v1.2.2
5
+ ------
6
+
7
+ * Fix nova API version
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-chi
3
- Version: 1.1.0
3
+ Version: 1.2.2
4
4
  Summary: Helper library for Chameleon Infrastructure (CHI) testbed
5
5
  Home-page: https://www.chameleoncloud.org
6
6
  Author: University of Chicago
@@ -1,7 +1,7 @@
1
1
  from .clients import (
2
- connection,
3
2
  blazar,
4
3
  cinder,
4
+ connection,
5
5
  glance,
6
6
  ironic,
7
7
  keystone,
@@ -10,8 +10,7 @@ from .clients import (
10
10
  nova,
11
11
  zun,
12
12
  )
13
- from .context import reset, set, get, params, session, use_site
14
-
13
+ from .context import get, params, reset, session, set, use_site
15
14
 
16
15
  __all__ = [
17
16
  "get",
@@ -1,26 +1,26 @@
1
- from .context import session
2
-
3
1
  # Import all of the client classes for type annotations.
4
2
  # We have to do this because we lazy-import the client definitions
5
3
  # inside each function to reduce runtime dependencies.
6
4
  from typing import TYPE_CHECKING
7
5
 
6
+ from .context import session
7
+
8
8
  if TYPE_CHECKING:
9
- from openstack.connection import Connection
10
9
  from blazarclient.client import Client as BlazarClient
11
10
  from cinderclient.client import Client as CinderClient
12
11
  from glanceclient.client import Client as GlanceClient
12
+ from ironicclient import client as IronicClient
13
+ from keystoneclient.v3.client import Client as KeystoneClient
13
14
  from manilaclient.client import Client as ManilaClient
14
15
  from neutronclient.v2_0.client import Client as NeutronClient
15
16
  from novaclient.client import Client as NovaClient
16
- from ironicclient import client as IronicClient
17
- from keystoneclient.v3.client import Client as KeystoneClient
17
+ from openstack.connection import Connection
18
18
  from zunclient.client import Client as ZunClient
19
19
 
20
20
 
21
21
  session_factory = session
22
22
 
23
- NOVA_API_VERSION = "2.10"
23
+ NOVA_API_VERSION = "2.61"
24
24
  ZUN_API_VERSION = "1.41"
25
25
 
26
26
 
@@ -24,13 +24,12 @@ from packaging.version import Version
24
24
  from zunclient.exceptions import NotFound
25
25
 
26
26
  from chi import context, util
27
+ from chi import network as chi_network
27
28
 
28
29
  from .clients import connection, zun
29
30
  from .context import session
30
31
  from .exception import ResourceError, ServiceError
31
32
  from .network import bind_floating_ip, get_free_floating_ip
32
- from chi import network as chi_network
33
-
34
33
 
35
34
  DEFAULT_IMAGE_DRIVER = "docker"
36
35
  DEFAULT_NETWORK = "containernet1"
@@ -11,6 +11,7 @@ from IPython.display import display
11
11
  from keystoneauth1 import loading, session
12
12
  from keystoneauth1.identity.v3 import OidcAccessToken
13
13
  from keystoneauth1.loading.conf import _AUTH_SECTION_OPT, _AUTH_TYPE_OPT
14
+ from keystoneclient import exceptions as keystone_exceptions
14
15
  from keystoneclient.v3.client import Client as KeystoneClient
15
16
  from oslo_config import cfg
16
17
 
@@ -72,8 +73,7 @@ _auth_plugin = None
72
73
  _session = None
73
74
  _sites = {}
74
75
 
75
- # You must manually opt into our 1.0 features.
76
- version = "0.17.12"
76
+ version = "1.1"
77
77
 
78
78
 
79
79
  def printerr(msg):
@@ -144,7 +144,7 @@ def _default_from_env(opts, group=None):
144
144
  getattr(opt, "deprecated_opts", getattr(opt, "deprecated", None)) or []
145
145
  )
146
146
  for o in all_opts:
147
- v = os.environ.get(f'OS_{o.name.replace("-", "_").upper()}')
147
+ v = os.environ.get(f"OS_{o.name.replace('-', '_').upper()}")
148
148
  if v:
149
149
  return v
150
150
 
@@ -384,19 +384,20 @@ def use_site(site_name: str) -> None:
384
384
  if not site:
385
385
  raise CHIValueError(
386
386
  (
387
- f'No site named "{site_name}" exists! Possible values: '
388
- ", ".join(_sites.keys())
387
+ f'No site named "{site_name}" exists! Possible values: , '.join(
388
+ _sites.keys()
389
+ )
389
390
  )
390
391
  )
391
392
 
392
- set("auth_url", f'{site["web"]}:5000/v3')
393
+ set("auth_url", f"{site['web']}:5000/v3")
393
394
  set("region_name", site["name"])
394
395
 
395
396
  output = [
396
397
  f"Now using {site_name}:",
397
- f'URL: {site.get("web")}',
398
- f'Location: {site.get("location")}',
399
- f'Support contact: {site.get("user_support_contact")}',
398
+ f"URL: {site.get('web')}",
399
+ f"Location: {site.get('location')}",
400
+ f"Support contact: {site.get('user_support_contact')}",
400
401
  ]
401
402
  print("\n".join(output))
402
403
 
@@ -465,7 +466,10 @@ def list_projects(show: str = None) -> List[str]:
465
466
  region_name=getattr(keystone_session, "region_name", None),
466
467
  )
467
468
 
468
- projects = keystone_client.projects.list(user=keystone_session.get_user_id())
469
+ try:
470
+ projects = keystone_client.projects.list(user=keystone_session.get_user_id())
471
+ except keystone_exceptions.Unauthorized:
472
+ raise ResourceError("Failed to retrieve projects. Check your credentials.")
469
473
  project_names = [project.name for project in projects]
470
474
 
471
475
  if show == "widget":
@@ -1,17 +1,16 @@
1
+ import logging
1
2
  from collections import defaultdict
2
3
  from concurrent.futures import ThreadPoolExecutor
3
4
  from dataclasses import dataclass
4
5
  from datetime import datetime, timedelta, timezone
5
6
  from typing import List, Optional, Set, Tuple
6
7
 
8
+ import requests
9
+
7
10
  from chi import exception
8
11
 
9
12
  from .clients import blazar, connection
10
- from .context import get, RESOURCE_API_URL, EDGE_RESOURCE_API_URL, session
11
-
12
-
13
- import requests
14
- import logging
13
+ from .context import EDGE_RESOURCE_API_URL, RESOURCE_API_URL, get, session
15
14
 
16
15
  LOG = logging.getLogger(__name__)
17
16
 
@@ -2,7 +2,7 @@ from dataclasses import dataclass
2
2
  from datetime import datetime
3
3
  from typing import List, Optional
4
4
 
5
- from glanceclient.exc import NotFound
5
+ from glanceclient.exc import HTTPBadRequest, NotFound
6
6
  from packaging.version import Version
7
7
 
8
8
  from chi import context
@@ -79,12 +79,15 @@ def get_image(name: str) -> Image:
79
79
  ResourceError: If multiple images are found with the same name.
80
80
  """
81
81
  if Version(context.version) >= Version("1.0"):
82
- glance_images = list(glance().images.list(filters={"name": name}))
83
- if not glance_images:
84
- raise CHIValueError(f'No images found matching name "{name}"')
85
- elif len(glance_images) > 1:
86
- raise ResourceError(f'Multiple images found matching name "{name}"')
87
- return Image.from_glance_image(glance_images[0])
82
+ try:
83
+ glance_images = list(glance().images.list(filters={"name": name}))
84
+ if not glance_images:
85
+ raise CHIValueError(f'No images found matching name "{name}"')
86
+ elif len(glance_images) > 1:
87
+ raise ResourceError(f'Multiple images found matching name "{name}"')
88
+ return Image.from_glance_image(glance_images[0])
89
+ except HTTPBadRequest:
90
+ return Image(None, None, False, name)
88
91
  try:
89
92
  return glance().images.get(name)
90
93
  except NotFound:
@@ -1,4 +1,5 @@
1
1
  import os
2
+
2
3
  import requests
3
4
 
4
5
  ACCESS_TOKEN_ENDPOINT = "tokens"
@@ -11,7 +11,7 @@ from IPython.display import display
11
11
  from ipywidgets import HTML
12
12
  from packaging.version import Version
13
13
 
14
- from chi import context, util
14
+ from chi import context, server, util
15
15
 
16
16
  from .clients import blazar
17
17
  from .context import _is_ipynb
@@ -172,6 +172,7 @@ class Lease:
172
172
  node_reservations (list): List of node reservations associated with the lease.
173
173
  fip_reservations (list): List of floating IP reservations associated with the lease.
174
174
  network_reservations (list): List of network reservations associated with the lease.
175
+ flavor_reservations (list): List of flavor reservations associated with the lease.
175
176
  events (list): List of events associated with the lease.
176
177
  """
177
178
 
@@ -192,6 +193,7 @@ class Lease:
192
193
  self.device_reservations = []
193
194
  self.node_reservations = []
194
195
  self.fip_reservations = []
196
+ self.flavor_reservations = []
195
197
  self.network_reservations = []
196
198
  self._events = []
197
199
 
@@ -239,6 +241,7 @@ class Lease:
239
241
  self.device_reservations.clear()
240
242
  self.node_reservations.clear()
241
243
  self.fip_reservations.clear()
244
+ self.flavor_reservations.clear()
242
245
  self.network_reservations.clear()
243
246
 
244
247
  for reservation in lease_json.get("reservations", []):
@@ -249,6 +252,8 @@ class Lease:
249
252
  self.node_reservations.append(reservation)
250
253
  elif resource_type == "virtual:floatingip":
251
254
  self.fip_reservations.append(reservation)
255
+ elif resource_type == "flavor:instance":
256
+ self.flavor_reservations.append(reservation)
252
257
  elif resource_type == "network":
253
258
  self.network_reservations.append(reservation)
254
259
 
@@ -347,6 +352,23 @@ class Lease:
347
352
  """
348
353
  add_fip_reservation(reservation_list=self.fip_reservations, count=amount)
349
354
 
355
+ def add_flavor_reservation(self, id, amount=1):
356
+ """
357
+ Add a reservation for a KVM flavor to the list of reservations.
358
+
359
+ Args:
360
+ id (str): The ID of the flavor to reserve
361
+ count (int): The number of floating IPs to reserve.
362
+ """
363
+ self.flavor_reservations.append(
364
+ {
365
+ "resource_type": "flavor:instance",
366
+ "flavor_id": id,
367
+ "amount": amount,
368
+ "affinity": None,
369
+ }
370
+ )
371
+
350
372
  def add_network_reservation(
351
373
  self, network_name: str, usage_type: str = None, stitch_provider: str = None
352
374
  ):
@@ -404,6 +426,7 @@ class Lease:
404
426
  self.device_reservations
405
427
  + self.node_reservations
406
428
  + self.fip_reservations
429
+ + self.flavor_reservations
407
430
  + self.network_reservations
408
431
  )
409
432
 
@@ -414,7 +437,6 @@ class Lease:
414
437
  start_date=self.start_date,
415
438
  end_date=self.end_date,
416
439
  )
417
-
418
440
  if response:
419
441
  self._populate_from_json(response)
420
442
  else:
@@ -505,12 +527,12 @@ class Lease:
505
527
  <h2>Lease Details</h2>
506
528
  <table>
507
529
  <tr><th>Name</th><td>{self.name}</td></tr>
508
- <tr><th>ID</th><td>{self.id or 'N/A'}</td></tr>
509
- <tr><th>Status</th><td>{self.status or 'N/A'}</td></tr>
510
- <tr><th>Start Date</th><td>{self.start_date or 'N/A'}</td></tr>
511
- <tr><th>End Date</th><td>{self.end_date or 'N/A'}</td></tr>
512
- <tr><th>User ID</th><td>{self.user_id or 'N/A'}</td></tr>
513
- <tr><th>Project ID</th><td>{self.project_id or 'N/A'}</td></tr>
530
+ <tr><th>ID</th><td>{self.id or "N/A"}</td></tr>
531
+ <tr><th>Status</th><td>{self.status or "N/A"}</td></tr>
532
+ <tr><th>Start Date</th><td>{self.start_date or "N/A"}</td></tr>
533
+ <tr><th>End Date</th><td>{self.end_date or "N/A"}</td></tr>
534
+ <tr><th>User ID</th><td>{self.user_id or "N/A"}</td></tr>
535
+ <tr><th>Project ID</th><td>{self.project_id or "N/A"}</td></tr>
514
536
  </table>
515
537
 
516
538
 
@@ -534,6 +556,11 @@ class Lease:
534
556
  {"".join(f"<li>ID: {r.get('id', 'N/A')}, Status: {r.get('status', 'N/A')}, Resource type: {r.get('resource_type', 'N/A')}, Network Name: {r.get('network_name', 'N/A')}</li>" for r in self.network_reservations)}
535
557
  </ul>
536
558
 
559
+ <h3>Flavor Reservations</h3>
560
+ <ul>
561
+ {"".join(f"<li>ID: {r.get('id', 'N/A')}, Status: {r.get('status', 'N/A')}, Resource type: {r.get('resource_type', 'N/A')}, Flavor: {r.get('flavor_id', 'N/A')}, Amount: {r.get('amount', 'N/A')}</li>" for r in self.flavor_reservations)}
562
+ </ul>
563
+
537
564
  <h3>Events</h3>
538
565
  <ul>
539
566
  {"".join(f"<li>Type: {e.get('event_type', 'N/A')}, Time: {e.get('time', 'N/A')}, Status: {e.get('status', 'N/A')}</li>" for e in self.events)}
@@ -571,6 +598,12 @@ class Lease:
571
598
  f"ID: {r.get('id', 'N/A')}, Status: {r.get('status', 'N/A')}, Network Name: {r.get('network_name', 'N/A')}"
572
599
  )
573
600
 
601
+ print("\nFlavor Reservations:")
602
+ for r in self.flavor_reservations:
603
+ print(
604
+ f"ID: {r.get('id', 'N/A')}, Status: {r.get('status', 'N/A')}, Flavor: {r.get('flavor_id', 'N/A')}, Amount: {r.get('amount', 'N/A')}"
605
+ )
606
+
574
607
  print("\nEvents:")
575
608
  for e in self.events:
576
609
  print(
@@ -609,6 +642,20 @@ class Lease:
609
642
  )
610
643
  ]
611
644
 
645
+ def get_reserved_flavors(self):
646
+ """Get flavors from flavor reservations in this lease. There will be one
647
+ flavor per flavor reservation.
648
+
649
+ Returns:
650
+ List[chi.server.Flavor] of flavor
651
+ """
652
+ flavors = []
653
+ for res in self.flavor_reservations:
654
+ flavors.extend(
655
+ server.list_flavors(reservable=True, reservation_id=res.get("id"))
656
+ )
657
+ return flavors
658
+
612
659
 
613
660
  def _format_resource_properties(user_constraints, extra_constraints):
614
661
  if user_constraints:
@@ -1,19 +1,19 @@
1
- from typing import Optional, List
2
1
  from datetime import timedelta
2
+ from typing import List, Optional
3
+
4
+ import matplotlib.pyplot as plt
5
+ import networkx as nx
3
6
 
4
- from .server import Server
5
7
  from .container import Container
6
- from .exception import ResourceError
7
- from .lease import Lease, delete_lease
8
- from .image import list_images
9
- from .hardware import get_node_types
10
8
  from .context import (
11
9
  DEFAULT_NETWORK,
12
10
  get,
13
11
  )
14
-
15
- import networkx as nx
16
- import matplotlib.pyplot as plt
12
+ from .exception import ResourceError
13
+ from .hardware import get_node_types
14
+ from .image import list_images
15
+ from .lease import Lease, delete_lease
16
+ from .server import Server
17
17
 
18
18
 
19
19
  def visualize_resources(leases: List[Lease]):
@@ -1,9 +1,7 @@
1
- from .clients import neutron
2
- from .exception import CHIValueError, ResourceError
3
-
4
1
  from neutronclient.common.exceptions import NotFound
5
2
 
6
- import json
3
+ from .clients import neutron
4
+ from .exception import CHIValueError, ResourceError
7
5
 
8
6
  __all__ = [
9
7
  "get_network",
@@ -141,7 +139,7 @@ def create_network(
141
139
  desc_parts = []
142
140
  if of_controller_ip and of_controller_port:
143
141
  desc_parts.append(f"OFController={of_controller_ip}:{of_controller_port}")
144
- if vswitch_name != None:
142
+ if vswitch_name is not None:
145
143
  desc_parts.append(f"VSwitchName={vswitch_name}")
146
144
 
147
145
  network = neutron().create_network(
@@ -189,9 +187,7 @@ def list_networks() -> "list[dict]":
189
187
 
190
188
  def set_network_tag(network_id, value):
191
189
  _neutron = neutron()
192
- _neutron.replace_tag('networks', network_id, {
193
- 'tags': [value]
194
- })
190
+ _neutron.replace_tag("networks", network_id, {"tags": [value]})
195
191
 
196
192
 
197
193
  ##########
@@ -645,19 +641,16 @@ def remove_subnet_from_router(router_id, subnet_id):
645
641
  # Floating IPs
646
642
  ###############
647
643
 
644
+
648
645
  def set_floating_ip_tag(address, value):
649
646
  ip_addr = get_floating_ip(address)
650
647
  _neutron = neutron()
651
- _neutron.replace_tag('floatingips', ip_addr['id'], {
652
- 'tags': [value]
653
- })
648
+ _neutron.replace_tag("floatingips", ip_addr["id"], {"tags": [value]})
654
649
 
655
650
 
656
651
  def deallocate_floating_ip(address):
657
652
  _neutron = neutron()
658
- _neutron.delete_floatingip(
659
- get_floating_ip(address)["id"]
660
- )
653
+ _neutron.delete_floatingip(get_floating_ip(address)["id"])
661
654
 
662
655
 
663
656
  def get_free_floating_ip(allocate=True) -> dict:
@@ -711,7 +704,7 @@ def get_or_create_floating_ip() -> "tuple[dict,bool]":
711
704
  {"floatingip": {"floating_network_id": network_id}}
712
705
  )["floatingip"]
713
706
  created = True
714
- print(f'Allocated new floating IP {fip["floating_ip_address"]}')
707
+ print(f"Allocated new floating IP {fip['floating_ip_address']}")
715
708
  return fip, created
716
709
 
717
710
 
@@ -819,6 +812,70 @@ def nuke_network(network_ref: str):
819
812
  delete_network(network_id)
820
813
 
821
814
 
815
+ class SecurityGroup:
816
+ """A wrapper class for Neutron security groups. Only supports creating."""
817
+
818
+ def __init__(self, security_group_data):
819
+ self.id = security_group_data.get("id")
820
+ self.name = security_group_data.get("name")
821
+ self.description = security_group_data.get("description")
822
+ self.rules = security_group_data.get("security_group_rules", [])
823
+
824
+ def submit(self, idempotent=False):
825
+ """Create a new security group."""
826
+ if idempotent:
827
+ existing_groups = neutron().list_security_groups()["security_groups"]
828
+ for sg in existing_groups:
829
+ if sg["name"] == self.name:
830
+ self.id = sg["id"]
831
+ return
832
+ security_group = neutron().create_security_group(
833
+ {"security_group": {"name": self.name, "description": self.description}}
834
+ )["security_group"]
835
+ self.id = security_group["id"]
836
+ for rule in self.rules:
837
+ neutron().create_security_group_rule(
838
+ {
839
+ "security_group_rule": {
840
+ "direction": rule["direction"],
841
+ "port_range_min": rule.get("port_range_min"),
842
+ "port_range_max": rule.get("port_range_max"),
843
+ "protocol": rule["protocol"],
844
+ "security_group_id": self.id,
845
+ }
846
+ }
847
+ )
848
+
849
+ def add_rule(self, direction, protocol, port):
850
+ """Add a rule to the security group."""
851
+ self.rules.append(
852
+ {
853
+ "direction": direction,
854
+ "protocol": protocol,
855
+ "port_range_min": port,
856
+ "port_range_max": port,
857
+ "security_group_id": self.id,
858
+ }
859
+ )
860
+
861
+
862
+ def list_security_groups(name_filter=None) -> "list[SecurityGroup]":
863
+ """List all security groups.
864
+
865
+ Args:
866
+ name_filter (str, optional): Filter security groups containing name.
867
+
868
+ Returns:
869
+ list[SecurityGroup]: A list of SecurityGroup instances.
870
+ """
871
+ security_groups = neutron().list_security_groups()["security_groups"]
872
+ return [
873
+ SecurityGroup(sg)
874
+ for sg in security_groups
875
+ if not name_filter or name_filter.lower() in sg["name"].lower()
876
+ ]
877
+
878
+
822
879
  ###################
823
880
  # Wizard functions
824
881
  ###################