From bf43eba8cfbb6b0373d7699f719811fe4526681b Mon Sep 17 00:00:00 2001 From: Soufiane Jounaid Date: Fri, 12 Jul 2024 14:25:05 -0400 Subject: [PATCH 01/14] Python-chi 1.0 context module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added the following new methods: - list_sites(show: [None, “widget”, “text”] = None) -> [str] - use_site(site_name: str = DEFAULT_SITE) -> None - use_project(project_id: str = None) -> None - choose_site() -> None, displays a dropdown widget to choose the site - choose_project() -> None, displays a dropdown widget to choose the project - check_credentials() -> None, prints authentication metadata - set_log_level(debug: Bool), sets openstack debug logging to true, including HTTP request logs - _is_ipynb() -> Bool, checks if the code is running within an ipy notebook. Used to determine whether to execute widgets --- chi/context.py | 125 +++++++++++++++++++++++++++++++++++++++++++++-- requirements.txt | 2 + 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/chi/context.py b/chi/context.py index 3c5fc23..7ae8db6 100644 --- a/chi/context.py +++ b/chi/context.py @@ -1,13 +1,18 @@ -from itertools import chain import os import sys import time +import openstack +from typing import List, Optional from keystoneauth1.identity.v3 import OidcAccessToken from keystoneauth1 import loading from keystoneauth1.loading.conf import _AUTH_SECTION_OPT, _AUTH_TYPE_OPT from keystoneauth1 import session +from keystoneclient.v3.client import Client as KeystoneClient from oslo_config import cfg +from IPython.display import display + +import ipywidgets as widgets import requests from . import jupyterhub @@ -16,11 +21,13 @@ LOG = logging.getLogger(__name__) +DEFAULT_SITE = "CHI@UC" +DEFAULT_IMAGE_NAME = "CC-Ubuntu22.04" +DEFAULT_NODE_TYPE = "compute_skylake" DEFAULT_AUTH_TYPE = "v3token" CONF_GROUP = "chi" RESOURCE_API_URL = os.getenv("CHI_RESOURCE_API_URL", "https://api.chameleoncloud.org") - def default_key_name(): username = os.getenv("USER") return f"{username}-jupyter" if username else None @@ -170,6 +177,15 @@ def _check_deprecated(key): ) return deprecated_extra_opts[key] +def _is_ipynb() -> bool: + try: + from IPython import get_ipython + if 'IPKernelApp' not in get_ipython().config: + return False + except ImportError: + return False + return True + def set(key, value): """Set a context parameter by name. @@ -225,7 +241,6 @@ def get(key): else: return cfg.CONF[_auth_section(_auth_plugin)][key] - def params(): """List all parameters currently set on the context. @@ -237,8 +252,44 @@ def params(): keys.extend(list(cfg.CONF[_auth_section(_auth_plugin)].keys())) return keys +def list_sites(show: Optional[str] = None) -> List[str]: + """ + Retrieve a list of Chameleon sites. + + Args: + show (str, optional): Determines how the list of sites is displayed. + Possible values are "widget" and "text". Defaults to None. + + Returns: + List[str]: A list of site names. + + Raises: + ValueError: If no sites are returned. + + """ + global _sites + if not _sites: + res = requests.get(f"{RESOURCE_API_URL}/sites.json") + try: + res.raise_for_status() + items = res.json().get("items", []) + _sites = {s["name"]: s for s in items} + if not _sites: + raise ValueError("No sites returned.") + except Exception: + print("Failed to fetch list of available Chameleon sites.", file=sys.stderr) + return [] -def use_site(site_name): + site_names = list(_sites.keys()) + + if show == "widget" and _is_ipynb(): + display(widgets.Select(options=site_names, description="Sites")) + elif show == "text": + print("\n".join(site_names)) + + return site_names + +def use_site(site_name: str) -> None: """Configure the global request context to target a particular CHI site. Targeting a site will mean that leases, instance launch requests, and any @@ -321,6 +372,71 @@ def use_site(site_name): ] print("\n".join(output)) +def choose_site() -> None: + """ + Displays a dropdown menu to select a chameleon site. + + Only works if running in a Ipynb notebook environment. + """ + if _is_ipynb(): + site_dropdown = widgets.Dropdown(options=list_sites(), description="Select Site") + display(site_dropdown) + use_site(list_sites()[0]) + site_dropdown.observe(lambda change: use_site(change['new']), names='value') + else: + print("Choose site feature is only available in Jupyter notebook environment.") + +def _get_project_names(): + keystone_session = session() + keystone_client = KeystoneClient( + session=keystone_session, + interface=getattr(keystone_session, "interface", None), + region_name=getattr(keystone_session, "region_name", None), + ) + + projects = keystone_client.projects.list(user=keystone_session.get_user_id()) + + return [project.name for project in projects] + +def choose_project() -> None: + """ + Displays a dropdown menu to select a project. + + Only works if running in a Ipynb notebook environment. + """ + if _is_ipynb(): + project_dropdown = widgets.Dropdown(options=_get_project_names(), description="Select Project") + display(project_dropdown) + _handle_project_change = lambda change: (set('project_name', change['new']), + print(f"Now using project: {change['new']}")) + _handle_project_change({'new': _get_project_names()[0]}) + project_dropdown.observe(_handle_project_change, names='value') + else: + print("Choose project feature is only available in Jupyter notebook environment.") + +def check_credentials() -> None: + """ + Prints authentication metadata (e.g. username, site) and if credentials are currently valid and user is authenticated. + """ + try: + print(f"Username: {os.getenv('USER')}") + print(f"Site: {get('region_name')}" ) + print("Projects:") + for project in _get_project_names(): + print(project) + print("Authentication is valid.") + except Exception as e: + print("Authentication failed: ", str(e)) + +def set_log_level(debug: bool = False) -> None: + """Configures logger for python-chi. By default, only errors are shown. + Set to debug to True, which will show calls to external APIs. + + Args: + debug (bool, optional): shows calls to external openstack APIs if + set to True. Defaults to False. + """ + openstack.enable_logging(debug=debug, http_debug=debug) def session(): """Get a Keystone Session object suitable for authenticating a client. @@ -337,7 +453,6 @@ def session(): ) return _session - def reset(): """Reset the context, removing all overrides and defaults. diff --git a/requirements.txt b/requirements.txt index e936515..8d2fde5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,5 @@ python-manilaclient python-neutronclient python-novaclient python-zunclient +ipython +ipywidgets \ No newline at end of file From 4b002222518a0b30eab77350bb58b63f0165eed9 Mon Sep 17 00:00:00 2001 From: Soufiane Jounaid Date: Mon, 15 Jul 2024 00:55:44 -0400 Subject: [PATCH 02/14] Added custom exceptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raised when argument is not valid. These errors might be fixed by checking hardware catalog or documentation. Examples where this might be seen are: - Site name is not valid - Node type is not valid - e.g. Resource does not exist Raised when a request has valid arguments, but the resources are being used incorrectly, or can’t be used as requested. This type of error might depend on the time the notebook is run, due to the shared nature of the testbed. Examples: - Nodes matching filters (e.g. node_type) are unavailable - Cannot allocate FIP - Allocation expires soon - Allocation has insufficient SUs for request Raised when an error occurs with some Chameleon resource. For example, if your node is having hardware issues, and so fails to provision, this will be raised. Replaced thrown exceptions with their appropriate custom exception accross all modules. --- chi/context.py | 12 ++++++------ chi/exception.py | 27 +++++++++++++++++++++++++++ chi/image.py | 5 +++-- chi/lease.py | 29 +++++++++++++++-------------- chi/network.py | 13 +++++++------ chi/server.py | 15 ++++++++------- chi/share.py | 9 +++++---- 7 files changed, 71 insertions(+), 39 deletions(-) create mode 100644 chi/exception.py diff --git a/chi/context.py b/chi/context.py index 7ae8db6..50bab6f 100644 --- a/chi/context.py +++ b/chi/context.py @@ -1,7 +1,6 @@ import os import sys import time -import openstack from typing import List, Optional from keystoneauth1.identity.v3 import OidcAccessToken @@ -12,11 +11,12 @@ from oslo_config import cfg from IPython.display import display -import ipywidgets as widgets -import requests - from . import jupyterhub +from .exception import CHIValueError, ServiceError +import openstack +import ipywidgets as widgets +import requests import logging LOG = logging.getLogger(__name__) @@ -275,7 +275,7 @@ def list_sites(show: Optional[str] = None) -> List[str]: items = res.json().get("items", []) _sites = {s["name"]: s for s in items} if not _sites: - raise ValueError("No sites returned.") + raise ServiceError("No sites returned.") except Exception: print("Failed to fetch list of available Chameleon sites.", file=sys.stderr) return [] @@ -353,7 +353,7 @@ def use_site(site_name: str) -> None: "user_support_contact": "help@chameleoncloud.org", } else: - raise ValueError( + raise CHIValueError( ( f'No site named "{site_name}" exists! Possible values: ' ", ".join(_sites.keys()) diff --git a/chi/exception.py b/chi/exception.py new file mode 100644 index 0000000..69c2af1 --- /dev/null +++ b/chi/exception.py @@ -0,0 +1,27 @@ +class CHIValueError(Exception): + """Raised when argument is not valid. These errors might be fixed by + checking hardware catalog or documentation. Examples where this might + be seen are: + - Site name is not valid + - Node type is not valid + - Resource does not exist +""" + def __init__(self, message): + super().__init__(message) + + +class ResourceError(Exception): + """Raised when a request has valid arguments, but the resources are + being used incorrectly, or can not be used as requested. This type + of error might depend on the time the request is run, due to the + shared nature of the testbed.""" + def __init__(self, message): + super().__init__(message) + + +class ServiceError(Exception): + """Raised when an error occurs with some Chameleon resource. + For example, if your node is having hardware issues, and so + fails to provision, this will be raised.""" + def __init__(self, message): + super().__init__(message) \ No newline at end of file diff --git a/chi/image.py b/chi/image.py index 16c414d..7dfcbaa 100644 --- a/chi/image.py +++ b/chi/image.py @@ -1,4 +1,5 @@ from .clients import glance +from .exception import CHIValueError, ResourceError from glanceclient.exc import NotFound @@ -42,9 +43,9 @@ def get_image_id(name): """ images = list(glance().images.list(filters={'name': name})) if not images: - raise ValueError(f'No images found matching name "{name}"') + raise CHIValueError(f'No images found matching name "{name}"') elif len(images) > 1: - raise ValueError(f'Multiple images found matching name "{name}"') + raise ResourceError(f'Multiple images found matching name "{name}"') return images[0].id diff --git a/chi/lease.py b/chi/lease.py index 59fdf73..35090a4 100644 --- a/chi/lease.py +++ b/chi/lease.py @@ -9,6 +9,7 @@ from blazarclient.exception import BlazarClientException from .clients import blazar, neutron +from .exception import CHIValueError, ResourceError, ServiceError from .context import get as get_from_context, session from .network import get_network_id, PUBLIC_NETWORK, list_floating_ips from .server import Server, ServerError @@ -85,7 +86,7 @@ def lease_create_args( if length is None and end is None: length = DEFAULT_LEASE_LENGTH elif length is not None and end is not None: - raise ValueError("provide either 'length' or 'end', not both") + raise CHIValueError("provide either 'length' or 'end', not both") if end is None: if isinstance(length, numbers.Number): @@ -150,7 +151,7 @@ def lease_create_nodetype(*args, **kwargs): try: node_type = kwargs.pop("node_type") except KeyError: - raise ValueError("no node_type specified") + raise CHIValueError("no node_type specified") kwargs["node_resource_properties"] = ["==", "$node_type", node_type] return lease_create_args(*args, **kwargs) @@ -234,7 +235,7 @@ def __repr__(self): def __enter__(self): if self.lease is None: # don't support reuse in multiple with's. - raise RuntimeError("Lease context manager not reentrant") + raise ResourceError("Lease context manager not reentrant") self.wait() return self @@ -323,7 +324,7 @@ def wait(self): if self.ready: break else: - raise RuntimeError("timeout, lease failed to start") + raise ServiceError("timeout, lease failed to start") def delete(self): """Deletes the lease""" @@ -538,13 +539,13 @@ def _reservation_matching(lease_ref, match_fn, multiple=False): matches = [r for r in reservations if match_fn(r)] if not matches: - raise ValueError("No matching reservation found") + raise ResourceError("No matching reservation found") if multiple: return matches else: if len(matches) > 1: - raise ValueError("Multiple matching reservations found") + raise ResourceError("Multiple matching reservations found") return matches[0] @@ -622,7 +623,7 @@ def add_fip_reservation(reservation_list, count=1): def add_device_reservation( - reservation_list, count=1, machine_name=None, device_model=None, device_name=None + reservation_list, count=1, machine_name=None, device_model=None, device_name=None ): """Add an IoT/edge device reservation to a reservation list. @@ -652,12 +653,12 @@ def add_device_reservation( resource_properties = [] if device_name: if count > 1: - raise ValueError( + raise ResourceError( "Cannot reserve multiple devices if device_name is a constraint." ) resource_properties.append(["==", "$name", device_name]) if machine_name: - resource_properties.append(["==", "$machine_name", machine_name]) + resource_properties.append(["==", "$machine_name", machine_name]) if device_model: resource_properties.append(["==", "$model", device_model]) @@ -734,9 +735,9 @@ def get_lease_id(lease_name) -> str: """ matching = [l for l in blazar().lease.list() if l["name"] == lease_name] if not matching: - raise ValueError(f"No leases found for name {lease_name}") + raise CHIValueError(f"No leases found for name {lease_name}") elif len(matching) > 1: - raise ValueError(f"Multiple leases found for name {lease_name}") + raise ResourceError(f"Multiple leases found for name {lease_name}") return matching[0]["id"] @@ -761,7 +762,7 @@ def create_lease(lease_name, reservations=[], start_date=None, end_date=None): start_date = utcnow() if not reservations: - raise ValueError("No reservations provided.") + raise CHIValueError("No reservations provided.") try: return blazar().lease.create( @@ -818,6 +819,6 @@ def wait_for_active(ref): if status == "ACTIVE": return lease elif status == "ERROR": - raise RuntimeError("Lease went into ERROR state") + raise ServiceError("Lease went into ERROR state") time.sleep(10) - raise TimeoutError("Lease failed to start") + raise ServiceError("Lease failed to start") diff --git a/chi/network.py b/chi/network.py index b162064..0bef5e1 100644 --- a/chi/network.py +++ b/chi/network.py @@ -1,4 +1,5 @@ from .clients import neutron +from .exception import CHIValueError, ResourceError from neutronclient.common.exceptions import NotFound @@ -59,22 +60,22 @@ def _resolve_id(resource, name) -> str: list_fn = getattr(neutron(), f'list_{resource}', None) if not callable(list_fn): - raise ValueError(f'Invalid resource type "{resource}"') + raise CHIValueError(f'Invalid resource type "{resource}"') resources = [ x for x in list_fn()[resource] if x['name'] == name ] if not resources: - raise RuntimeError(f'No {resource} found with name {name}') + raise CHIValueError(f'No {resource} found with name {name}') elif len(resources) > 1: - raise RuntimeError(f'Found multiple {resource} with name {name}') + raise ResourceError(f'Found multiple {resource} with name {name}') return resources[0]['id'] def _resolve_resource(resource, name_or_id) -> dict: get_fn = getattr(neutron(), f'show_{resource}', None) if not callable(get_fn): - raise ValueError(f'Invalid resource type "{resource}"') + raise CHIValueError(f'Invalid resource type "{resource}"') try: res = get_fn(name_or_id) except NotFound: @@ -651,7 +652,7 @@ def get_free_floating_ip(allocate=True) -> dict: return fip except StopIteration: if not allocate: - raise RuntimeError( + raise ResourceError( "No free floating IPs in project and not allocating a new one") return _neutron.create_floatingip({ "floatingip": { @@ -701,7 +702,7 @@ def get_floating_ip(ip_address) -> dict: for fip in ips: if fip['floating_ip_address'] == ip_address: return fip - raise Exception(f"Floating IP {ip_address} not found") + raise CHIValueError(f"Floating IP {ip_address} not found") def list_floating_ips() -> 'list[dict]': diff --git a/chi/server.py b/chi/server.py index 44fc31a..eae470c 100644 --- a/chi/server.py +++ b/chi/server.py @@ -11,6 +11,7 @@ from openstack.compute.v2.server import Server as OpenStackServer from .clients import connection, glance, nova, neutron +from .exception import CHIValueError, ResourceError, ServiceError from .context import get as get_from_context, session from .image import get_image, get_image_id from .keypair import Keypair @@ -185,7 +186,7 @@ def __init__(self, id=None, lease=None, key=None, image=DEFAULT_IMAGE, ) self.server = self.conn.compute.create_server(**server_kwargs) else: - raise ValueError( + raise CHIValueError( "Missing required argument: 'id' or 'lease' required.") self.id = self.server.id @@ -321,7 +322,7 @@ def get_flavor_id(name) -> str: """ flavor = next((f for f in nova().flavors.list() if f.name == name), None) if not flavor: - raise NotFound(f'No flavors found matching name {name}') + raise CHIValueError(f'No flavors found matching name {name}') return flavor @@ -398,9 +399,9 @@ def get_server_id(name) -> str: """ servers = [s for s in nova().servers.list() if s.name == name] if not servers: - raise ValueError(f'No matching servers found for name "{name}"') + raise CHIValueError(f'No matching servers found for name "{name}"') elif len(servers) > 1: - raise ValueError(f'Multiple matching servers found for name "{name}"') + raise ResourceError(f'Multiple matching servers found for name "{name}"') return servers[0].id @@ -542,7 +543,7 @@ def wait_for_tcp(host, port, timeout=(60 * 20), sleep_time=5): except OSError as ex: time.sleep(sleep_time) if time.perf_counter() - start_time >= timeout: - raise TimeoutError(( + raise ServiceError(( f'Waited too long for the port {port} on host {host} to ' 'start accepting connections.')) from ex @@ -637,7 +638,7 @@ def create_server(server_name, reservation_id=None, key_name=None, network_id=No ValueError: if an invalid count is provided. """ if count < 1: - raise ValueError('Must launch at least one server.') + raise CHIValueError('Must launch at least one server.') if not key_name: key_name = update_keypair().id if not network_id: @@ -652,7 +653,7 @@ def create_server(server_name, reservation_id=None, key_name=None, network_id=No else: flavor_id = next((f.id for f in list_flavors()), None) if not flavor_id: - raise NotFound('Could not auto-select flavor to use') + raise ResourceError('Could not auto-select flavor to use') scheduler_hints = {} if reservation_id: diff --git a/chi/share.py b/chi/share.py index acfafb7..5a302cd 100644 --- a/chi/share.py +++ b/chi/share.py @@ -1,4 +1,5 @@ from .clients import manila +from .exception import CHIValueError, ResourceError from manilaclient.exceptions import NotFound @@ -18,9 +19,9 @@ def _get_default_share_type_id(): # we only support one share type - cephfsnfstype share_types = manila().share_types.list() if not share_types: - raise ValueError("No share types found") + raise CHIValueError("No share types found") elif len(share_types) > 1: - raise ValueError("Multiple share types found") + raise ResourceError("Multiple share types found") return share_types[0].id @@ -113,9 +114,9 @@ def get_share_id(name): """ shares = list(manila().shares.list(search_opts={'name': name})) if not shares: - raise ValueError(f'No shares found matching name "{name}"') + raise CHIValueError(f'No shares found matching name "{name}"') elif len(shares) > 1: - raise ValueError(f'Multiple shares found matching name "{name}"') + raise ResourceError(f'Multiple shares found matching name "{name}"') return shares[0].id From 6b0085c5543dbafdf6f234a4b193db1f3656d3a9 Mon Sep 17 00:00:00 2001 From: Soufiane Jounaid Date: Mon, 15 Jul 2024 10:58:04 -0400 Subject: [PATCH 03/14] Initial implementation of hardware module Incomplete --- chi/hardware.py | 72 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 chi/hardware.py diff --git a/chi/hardware.py b/chi/hardware.py new file mode 100644 index 0000000..549ebf9 --- /dev/null +++ b/chi/hardware.py @@ -0,0 +1,72 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import List, Optional, Tuple + + +from .clients import blazar +from .context import get, RESOURCE_API_URL + +import requests +import json +import logging + +LOG = logging.getLogger(__name__) + +@dataclass +class Node: + site_name: str + node_name: str + node_type: str + architecture: dict + bios: dict + gpu: dict + main_memory: dict + network_adapters: List[dict] + placement: dict + processor: dict + storage_devices: List[dict] + uid: str + version: str + + def next_free_timeslot(self) -> Tuple[datetime, datetime]: + raise NotImplementedError() + + +def get_nodes( + all_sites: bool = False, + filter_reserved: bool = False, + gpu: Optional[bool] = None, + number_cpu: Optional[int] = None, + ) -> List[Node]: + + site = get("region_name") + endpoint = f"sites/{site.split('@')[1].lower()}/clusters/chameleon/nodes" + data = _call_api(endpoint) + nodes = [] + + for node_data in data: + node = Node( + site_name=site, + node_name=node_data.get("name"), + node_type=node_data.get("type"), + architecture=node_data.get("architecture"), + bios=node_data.get("bios"), + gpu=node_data.get("gpu"), + main_memory=node_data.get("main_memory"), + network_adapters=node_data.get("network_adapters"), + placement=node_data.get("placement"), + processor=node_data.get("processor"), + storage_devices=node_data.get("storage_devices"), + uid=node_data.get("uid"), + version=node_data.get("version"), + ) + nodes.append(node) + return nodes + +def _call_api(endpoint): + url = "{0}/{1}.{2}".format(RESOURCE_API_URL, endpoint, "json") + LOG.info("Requesting %s from reference API ...", url) + resp = requests.get(url) + LOG.info("Response received. Parsing to json ...") + data = resp.json() + return data \ No newline at end of file From 4d29fd77f3f0efcc63746d5961bb85a6fbf6edc1 Mon Sep 17 00:00:00 2001 From: Soufiane Jounaid Date: Fri, 12 Jul 2024 14:25:05 -0400 Subject: [PATCH 04/14] Python-chi 1.0 context module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added the following new methods: - list_sites(show: [None, “widget”, “text”] = None) -> [str] - list_projects(show: [None, “widget”, “text”] = None) -> [str] - use_site(site_name: str = DEFAULT_SITE) -> None - use_project(project_id: str = None) -> None - choose_site() -> None, displays a dropdown widget to choose the site - choose_project() -> None, displays a dropdown widget to choose the project - check_credentials() -> None, prints authentication metadata - set_log_level(debug: str), changes logging level to either ERROR or DEBUG, including HTTP request logs if the latter is chosen --- chi/__init__.py | 2 + chi/context.py | 255 ++++++++++++++++++++++++++++++++++++++++------- requirements.txt | 2 + 3 files changed, 225 insertions(+), 34 deletions(-) diff --git a/chi/__init__.py b/chi/__init__.py index 7366bbf..b5c7864 100644 --- a/chi/__init__.py +++ b/chi/__init__.py @@ -33,3 +33,5 @@ "nova", "zun", ] + +context.use_site(context.DEFAULT_SITE) \ No newline at end of file diff --git a/chi/context.py b/chi/context.py index 3c5fc23..1fc2fed 100644 --- a/chi/context.py +++ b/chi/context.py @@ -1,13 +1,19 @@ -from itertools import chain import os import sys import time +import openstack +from enum import Enum +from typing import List, Optional from keystoneauth1.identity.v3 import OidcAccessToken from keystoneauth1 import loading from keystoneauth1.loading.conf import _AUTH_SECTION_OPT, _AUTH_TYPE_OPT from keystoneauth1 import session +from keystoneclient.v3.client import Client as KeystoneClient from oslo_config import cfg +from IPython.display import display + +import ipywidgets as widgets import requests from . import jupyterhub @@ -16,11 +22,13 @@ LOG = logging.getLogger(__name__) +DEFAULT_SITE = "CHI@UC" +DEFAULT_IMAGE_NAME = "CC-Ubuntu22.04" +DEFAULT_NODE_TYPE = "compute_skylake" DEFAULT_AUTH_TYPE = "v3token" CONF_GROUP = "chi" RESOURCE_API_URL = os.getenv("CHI_RESOURCE_API_URL", "https://api.chameleoncloud.org") - def default_key_name(): username = os.getenv("USER") return f"{username}-jupyter" if username else None @@ -170,6 +178,15 @@ def _check_deprecated(key): ) return deprecated_extra_opts[key] +def _is_ipynb() -> bool: + try: + from IPython import get_ipython + if 'IPKernelApp' not in get_ipython().config: + return False + except ImportError: + return False + return True + def set(key, value): """Set a context parameter by name. @@ -225,7 +242,6 @@ def get(key): else: return cfg.CONF[_auth_section(_auth_plugin)][key] - def params(): """List all parameters currently set on the context. @@ -238,7 +254,80 @@ def params(): return keys -def use_site(site_name): +def list_sites(show: Optional[str] = None) -> List[str]: + """ + Retrieves a list of Chameleon sites. + + Args: + show (str, optional): Determines how the site names should be displayed. + Possible values are "widget" to display as a table widget, "text" to print + as plain text,or None (default) to return the List[str] of site names. + + Returns: + If `show` is set to "widget", it displays the site names as a text widget. + If `show` is set to "text", it prints the site names as plain text. + If `show` is set to None, it returns a list of site names. + + Raises: + ValueError: If no sites are returned or if an invalid value is provided for the `show` parameter. + """ + global _sites + + if not _sites: + res = requests.get(f"{RESOURCE_API_URL}/sites.json") + res.raise_for_status() + items = res.json().get("items", []) + _sites = {s["name"]: s for s in items} + _sites = dict(sorted(_sites.items(), + key=lambda x: (x[1]['site_class'], x[0] not in ["CHI@TACC", "CHI@UC"]))) + _sites["KVM@TACC"] = { + "name": "KVM@TACC", + "web": "https://kvm.tacc.chameleoncloud.org", + "location": "Austin, Texas, USA", + "user_support_contact": "help@chameleoncloud.org", + } + if not _sites: + raise ValueError("No sites returned.") + + if show == None: + return _sites + elif show == "widget" and _is_ipynb(): + # Constructing the table HTML + table_html = """ + + + + + + + + """ + + for site_name in _sites.keys(): + table_html += f""" + + + + + + + """ + + table_html += "
NameURLLocationUser Support Contact
{site_name}{_sites[site_name]["web"]}{_sites[site_name]["location"]}{_sites[site_name]["user_support_contact"]}
" + display(widgets.HTML(value=table_html)) + elif show == "text": + print("Chameleon Sites:") + for site_name in _sites.keys(): + site = _sites[site_name] + print(f"- Name: {site_name}") + print(f" URL: {site['web']}") + print(f" Location: {site['location']}") + print(f" User Support Contact: {site['user_support_contact']}") + else: + raise ValueError("Invalid value for 'show' parameter.") + + +def use_site(site_name: str) -> None: """Configure the global request context to target a particular CHI site. Targeting a site will mean that leases, instance launch requests, and any @@ -264,13 +353,8 @@ def use_site(site_name): """ global _sites if not _sites: - res = requests.get(f"{RESOURCE_API_URL}/sites.json") try: - res.raise_for_status() - items = res.json().get("items", []) - _sites = {s["name"]: s for s in items} - if not _sites: - raise ValueError("No sites returned.") + _sites = list_sites() except Exception: printerr( """Failed to fetch list of available Chameleon sites. @@ -285,31 +369,13 @@ def use_site(site_name): site = _sites.get(site_name) if not site: - # TODO(jason): Remove this fallback when CHI@Edge is enrolled into - # the resource discovery API and the resource catalogue has support for it. - if site_name == "CHI@Edge": - site = { - "name": "CHI@Edge", - "web": "https://chi.edge.chameleoncloud.org", - "location": "Distributed", - "user_support_contact": "https://groups.google.com/g/chameleon-edge-users", - } - elif site_name == "KVM@TACC": - site = { - "name": "KVM@TACC", - "web": "https://kvm.tacc.chameleoncloud.org", - "location": "Austin, Texas, USA", - "user_support_contact": "help@chameleoncloud.org", - } - else: - raise ValueError( - ( - f'No site named "{site_name}" exists! Possible values: ' - ", ".join(_sites.keys()) - ) + raise ValueError( + ( + f'No site named "{site_name}" exists! Possible values: ' + ", ".join(_sites.keys()) ) + ) - # Set important parameters set("auth_url", f'{site["web"]}:5000/v3') set("region_name", site["name"]) @@ -321,6 +387,128 @@ def use_site(site_name): ] print("\n".join(output)) +def choose_site() -> None: + """ + Displays a dropdown menu to select a chameleon site. + + Only works if running in a Ipynb notebook environment. + """ + if _is_ipynb(): + global _sites + if not _sites: + _sites = list_sites() + use_site(list(_sites.keys())[0]) + print("Please choose a site in the dropdown below") + site_dropdown = widgets.Dropdown(options=_sites.keys(), description="Select Site") + display(site_dropdown) + site_dropdown.observe(lambda change: use_site(change['new']), names='value') + else: + print("Choose site feature is only available in an ipynb environment.") + + +def list_projects(show: str = None) -> List[str]: + """ + Retrieves a list of projects associated with the current user. + + Args: + show (str, optional): Determines how the project names should be displayed. + Possible values are "widget" to display as a table widget, "text" to print + as plain text, or None (default) to return the list of project names. + + Returns: + If `show` is set to "widget", it displays the project names as a text widget. + If `show` is set to "text", it prints the project names as plain text. + If `show` is set to None, it returns a list of project names. + + Raises: + ValueError: If no projects are returned or an invalid value is provided for the `show` parameter. + + """ + keystone_session = session() + keystone_client = KeystoneClient( + session=keystone_session, + interface=getattr(keystone_session, "interface", None), + region_name=getattr(keystone_session, "region_name", None), + ) + + projects = keystone_client.projects.list(user=keystone_session.get_user_id()) + project_names = [project.name for project in projects] + + if show == "widget": + table_html = "" + for project in project_names: + table_html += f"" + table_html += "
{project}
" + + display(widgets.HTML(table_html)) + elif show == "text": + print("\n".join(project_names)) + elif show == None: + return list(project_names) + else: + raise ValueError("Invalid value for 'show' parameter.") + +def use_project(project: str) -> None: + """ + Sets the current project name. + + Args: + project (str): The name of the project to use. + + Returns: + None + """ + set('project_name', project) + print(f"Now using project: {project}") + +def choose_project() -> None: + """ + Displays a dropdown menu to select a project. + + Only works if running in a Ipynb notebook environment. + """ + if _is_ipynb(): + project_dropdown = widgets.Dropdown(options=list_projects(), description="Select Project") + display(project_dropdown) + use_project(list_projects()[0]) + project_dropdown.observe(lambda change: (use_project(change['new'])), names='value') + else: + print("Choose project feature is only available in Jupyter notebook environment.") + +def check_credentials() -> None: + """ + Prints authentication metadata (e.g. username, site) and if credentials are currently valid and user is authenticated. + """ + try: + print(f"Username: {os.getenv('USER')}") + print(f"Currently site: {get('region_name')}" ) + print(f"Currently project: {get('project_name')}" ) + print("Projects:") + for project in list_projects(): + print(project) + print("Authentication is valid.") + except Exception as e: + print("Authentication failed: ", str(e)) + +class LogLevel(Enum): + ERROR = "ERROR" + DEBUG = "DEBUG" + +def set_log_level(level: str = "ERROR") -> None: + """Configures logger for python-chi. By default, only errors are shown. + Set to "DEBUG" to see debug level logging, which will show calls to external APIs. + + Args: + level (str, optional): The log level. Defaults to "ERROR". + """ + if level == "DEBUG": + openstack.enable_logging(debug=True, http_debug=True) + LOG.setLevel(logging.DEBUG) + elif level == "ERROR": + openstack.enable_logging(debug=False, http_debug=False) + LOG.setLevel(logging.ERROR) + else: + raise ValueError("Invalid log level value, please choose between 'ERROR' and 'DEBUG'") def session(): """Get a Keystone Session object suitable for authenticating a client. @@ -337,7 +525,6 @@ def session(): ) return _session - def reset(): """Reset the context, removing all overrides and defaults. diff --git a/requirements.txt b/requirements.txt index e936515..8d2fde5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,5 @@ python-manilaclient python-neutronclient python-novaclient python-zunclient +ipython +ipywidgets \ No newline at end of file From caaf5ebbe970cb88f655c8e8cbee0d6c92d62acd Mon Sep 17 00:00:00 2001 From: Soufiane Jounaid Date: Wed, 17 Jul 2024 20:10:42 -0400 Subject: [PATCH 05/14] Changed one more exception --- chi/context.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chi/context.py b/chi/context.py index f452971..4d9b86d 100644 --- a/chi/context.py +++ b/chi/context.py @@ -17,7 +17,7 @@ import requests from . import jupyterhub -from .exception import CHIValueError, ServiceError +from .exception import CHIValueError, ResourceError import openstack import ipywidgets as widgets @@ -291,7 +291,7 @@ def list_sites(show: Optional[str] = None) -> List[str]: "user_support_contact": "help@chameleoncloud.org", } if not _sites: - raise ValueError("No sites returned.") + raise ResourceError("No sites returned.") if show == None: return _sites From 0e4cbf18361e975a2b0a2bc321170521ec44cb70 Mon Sep 17 00:00:00 2001 From: Soufiane Jounaid Date: Fri, 12 Jul 2024 14:25:05 -0400 Subject: [PATCH 06/14] Python-chi 1.0 context module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added the following new methods: - list_sites(show: [None, “widget”, “text”] = None) -> [str] - list_projects(show: [None, “widget”, “text”] = None) -> [str] - use_site(site_name: str = DEFAULT_SITE) -> None - use_project(project_id: str = None) -> None - choose_site() -> None, displays a dropdown widget to choose the site - choose_project() -> None, displays a dropdown widget to choose the project - check_credentials() -> None, prints authentication metadata - set_log_level(debug: str), changes logging level to either ERROR or DEBUG, including HTTP request logs if the latter is chosen --- chi/__init__.py | 2 + chi/context.py | 250 ++++++++++++++++++++++++++++++++++++++++------- requirements.txt | 2 + 3 files changed, 220 insertions(+), 34 deletions(-) diff --git a/chi/__init__.py b/chi/__init__.py index 7366bbf..b5c7864 100644 --- a/chi/__init__.py +++ b/chi/__init__.py @@ -33,3 +33,5 @@ "nova", "zun", ] + +context.use_site(context.DEFAULT_SITE) \ No newline at end of file diff --git a/chi/context.py b/chi/context.py index 3c5fc23..045feb0 100644 --- a/chi/context.py +++ b/chi/context.py @@ -1,13 +1,18 @@ -from itertools import chain import os import sys import time +import openstack +from typing import List, Optional from keystoneauth1.identity.v3 import OidcAccessToken from keystoneauth1 import loading from keystoneauth1.loading.conf import _AUTH_SECTION_OPT, _AUTH_TYPE_OPT from keystoneauth1 import session +from keystoneclient.v3.client import Client as KeystoneClient from oslo_config import cfg +from IPython.display import display + +import ipywidgets as widgets import requests from . import jupyterhub @@ -16,11 +21,13 @@ LOG = logging.getLogger(__name__) +DEFAULT_SITE = "CHI@UC" +DEFAULT_IMAGE_NAME = "CC-Ubuntu22.04" +DEFAULT_NODE_TYPE = "compute_skylake" DEFAULT_AUTH_TYPE = "v3token" CONF_GROUP = "chi" RESOURCE_API_URL = os.getenv("CHI_RESOURCE_API_URL", "https://api.chameleoncloud.org") - def default_key_name(): username = os.getenv("USER") return f"{username}-jupyter" if username else None @@ -170,6 +177,15 @@ def _check_deprecated(key): ) return deprecated_extra_opts[key] +def _is_ipynb() -> bool: + try: + from IPython import get_ipython + if 'IPKernelApp' not in get_ipython().config: + return False + except ImportError: + return False + return True + def set(key, value): """Set a context parameter by name. @@ -225,7 +241,6 @@ def get(key): else: return cfg.CONF[_auth_section(_auth_plugin)][key] - def params(): """List all parameters currently set on the context. @@ -238,7 +253,80 @@ def params(): return keys -def use_site(site_name): +def list_sites(show: Optional[str] = None) -> List[str]: + """ + Retrieves a list of Chameleon sites. + + Args: + show (str, optional): Determines how the site names should be displayed. + Possible values are "widget" to display as a table widget, "text" to print + as plain text,or None (default) to return the List[str] of site names. + + Returns: + If `show` is set to "widget", it displays the site names as a text widget. + If `show` is set to "text", it prints the site names as plain text. + If `show` is set to None, it returns a list of site names. + + Raises: + ValueError: If no sites are returned or if an invalid value is provided for the `show` parameter. + """ + global _sites + + if not _sites: + res = requests.get(f"{RESOURCE_API_URL}/sites.json") + res.raise_for_status() + items = res.json().get("items", []) + _sites = {s["name"]: s for s in items} + _sites = dict(sorted(_sites.items(), + key=lambda x: (x[1]['site_class'], x[0] not in ["CHI@TACC", "CHI@UC"]))) + _sites["KVM@TACC"] = { + "name": "KVM@TACC", + "web": "https://kvm.tacc.chameleoncloud.org", + "location": "Austin, Texas, USA", + "user_support_contact": "help@chameleoncloud.org", + } + if not _sites: + raise ValueError("No sites returned.") + + if show == None: + return _sites + elif show == "widget" and _is_ipynb(): + # Constructing the table HTML + table_html = """ + + + + + + + + """ + + for site_name in _sites.keys(): + table_html += f""" + + + + + + + """ + + table_html += "
NameURLLocationUser Support Contact
{site_name}{_sites[site_name]["web"]}{_sites[site_name]["location"]}{_sites[site_name]["user_support_contact"]}
" + display(widgets.HTML(value=table_html)) + elif show == "text": + print("Chameleon Sites:") + for site_name in _sites.keys(): + site = _sites[site_name] + print(f"- Name: {site_name}") + print(f" URL: {site['web']}") + print(f" Location: {site['location']}") + print(f" User Support Contact: {site['user_support_contact']}") + else: + raise ValueError("Invalid value for 'show' parameter.") + + +def use_site(site_name: str) -> None: """Configure the global request context to target a particular CHI site. Targeting a site will mean that leases, instance launch requests, and any @@ -264,13 +352,8 @@ def use_site(site_name): """ global _sites if not _sites: - res = requests.get(f"{RESOURCE_API_URL}/sites.json") try: - res.raise_for_status() - items = res.json().get("items", []) - _sites = {s["name"]: s for s in items} - if not _sites: - raise ValueError("No sites returned.") + _sites = list_sites() except Exception: printerr( """Failed to fetch list of available Chameleon sites. @@ -285,31 +368,13 @@ def use_site(site_name): site = _sites.get(site_name) if not site: - # TODO(jason): Remove this fallback when CHI@Edge is enrolled into - # the resource discovery API and the resource catalogue has support for it. - if site_name == "CHI@Edge": - site = { - "name": "CHI@Edge", - "web": "https://chi.edge.chameleoncloud.org", - "location": "Distributed", - "user_support_contact": "https://groups.google.com/g/chameleon-edge-users", - } - elif site_name == "KVM@TACC": - site = { - "name": "KVM@TACC", - "web": "https://kvm.tacc.chameleoncloud.org", - "location": "Austin, Texas, USA", - "user_support_contact": "help@chameleoncloud.org", - } - else: - raise ValueError( - ( - f'No site named "{site_name}" exists! Possible values: ' - ", ".join(_sites.keys()) - ) + raise ValueError( + ( + f'No site named "{site_name}" exists! Possible values: ' + ", ".join(_sites.keys()) ) + ) - # Set important parameters set("auth_url", f'{site["web"]}:5000/v3') set("region_name", site["name"]) @@ -321,6 +386,124 @@ def use_site(site_name): ] print("\n".join(output)) +def choose_site() -> None: + """ + Displays a dropdown menu to select a chameleon site. + + Only works if running in a Ipynb notebook environment. + """ + if _is_ipynb(): + global _sites + if not _sites: + _sites = list_sites() + use_site(list(_sites.keys())[0]) + print("Please choose a site in the dropdown below") + site_dropdown = widgets.Dropdown(options=_sites.keys(), description="Select Site") + display(site_dropdown) + site_dropdown.observe(lambda change: use_site(change['new']), names='value') + else: + print("Choose site feature is only available in an ipynb environment.") + + +def list_projects(show: str = None) -> List[str]: + """ + Retrieves a list of projects associated with the current user. + + Args: + show (str, optional): Determines how the project names should be displayed. + Possible values are "widget" to display as a table widget, "text" to print + as plain text, or None (default) to return the list of project names. + + Returns: + If `show` is set to "widget", it displays the project names as a text widget. + If `show` is set to "text", it prints the project names as plain text. + If `show` is set to None, it returns a list of project names. + + Raises: + ValueError: If no projects are returned or an invalid value is provided for the `show` parameter. + + """ + keystone_session = session() + keystone_client = KeystoneClient( + session=keystone_session, + interface=getattr(keystone_session, "interface", None), + region_name=getattr(keystone_session, "region_name", None), + ) + + projects = keystone_client.projects.list(user=keystone_session.get_user_id()) + project_names = [project.name for project in projects] + + if show == "widget": + table_html = "" + for project in project_names: + table_html += f"" + table_html += "
{project}
" + + display(widgets.HTML(table_html)) + elif show == "text": + print("\n".join(project_names)) + elif show == None: + return list(project_names) + else: + raise ValueError("Invalid value for 'show' parameter.") + +def use_project(project: str) -> None: + """ + Sets the current project name. + + Args: + project (str): The name of the project to use. + + Returns: + None + """ + set('project_name', project) + print(f"Now using project: {project}") + +def choose_project() -> None: + """ + Displays a dropdown menu to select a project. + + Only works if running in a Ipynb notebook environment. + """ + if _is_ipynb(): + project_dropdown = widgets.Dropdown(options=list_projects(), description="Select Project") + display(project_dropdown) + use_project(list_projects()[0]) + project_dropdown.observe(lambda change: (use_project(change['new'])), names='value') + else: + print("Choose project feature is only available in Jupyter notebook environment.") + +def check_credentials() -> None: + """ + Prints authentication metadata (e.g. username, site) and if credentials are currently valid and user is authenticated. + """ + try: + print(f"Username: {os.getenv('USER')}") + print(f"Currently site: {get('region_name')}" ) + print(f"Currently project: {get('project_name')}" ) + print("Projects:") + for project in list_projects(): + print(project) + print("Authentication is valid.") + except Exception as e: + print("Authentication failed: ", str(e)) + +def set_log_level(level: str = "ERROR") -> None: + """Configures logger for python-chi. By default, only errors are shown. + Set to "DEBUG" to see debug level logging, which will show calls to external APIs. + + Args: + level (str, optional): The log level. Defaults to "ERROR". + """ + if level == "DEBUG": + openstack.enable_logging(debug=True, http_debug=True) + LOG.setLevel(logging.DEBUG) + elif level == "ERROR": + openstack.enable_logging(debug=False, http_debug=False) + LOG.setLevel(logging.ERROR) + else: + raise ValueError("Invalid log level value, please choose between 'ERROR' and 'DEBUG'") def session(): """Get a Keystone Session object suitable for authenticating a client. @@ -337,7 +520,6 @@ def session(): ) return _session - def reset(): """Reset the context, removing all overrides and defaults. diff --git a/requirements.txt b/requirements.txt index e936515..8d2fde5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,5 @@ python-manilaclient python-neutronclient python-novaclient python-zunclient +ipython +ipywidgets \ No newline at end of file From d114355955d80769bee9c8cfcd74750d08611a54 Mon Sep 17 00:00:00 2001 From: Soufiane Jounaid Date: Wed, 17 Jul 2024 20:19:57 -0400 Subject: [PATCH 07/14] typo --- chi/context.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/chi/context.py b/chi/context.py index 385c7f3..3b9e64b 100644 --- a/chi/context.py +++ b/chi/context.py @@ -365,8 +365,6 @@ def use_site(site_name: str) -> None: """ global _sites if not _sites: - try: - _sites = list_sites() try: _sites = list_sites() except Exception: From 035384b2a28e995078221f80d5f987168eb342d4 Mon Sep 17 00:00:00 2001 From: Soufiane Jounaid Date: Fri, 19 Jul 2024 00:01:08 -0400 Subject: [PATCH 08/14] Hardware module 1.0 Used to query hardware on Chameleon. the get_nodes() methods fetches a list of nodes in the currently selected site in form of a list of Node dataclass. --- chi/hardware.py | 157 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 120 insertions(+), 37 deletions(-) diff --git a/chi/hardware.py b/chi/hardware.py index 549ebf9..35b5f37 100644 --- a/chi/hardware.py +++ b/chi/hardware.py @@ -1,67 +1,81 @@ from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from typing import List, Optional, Tuple - from .clients import blazar from .context import get, RESOURCE_API_URL import requests -import json import logging LOG = logging.getLogger(__name__) @dataclass class Node: - site_name: str - node_name: str - node_type: str + """ + Represents the Chameleon hardware that goes into a single node. + A dataclass for node information directly from the hardware browser. + """ + site: str + name: str + type: str architecture: dict bios: dict + cpu: dict gpu: dict main_memory: dict network_adapters: List[dict] placement: dict - processor: dict storage_devices: List[dict] uid: str version: str - def next_free_timeslot(self) -> Tuple[datetime, datetime]: + def next_free_timeslot(self) -> datetime: + """ + (Not implemented yet) Finds the next available timeslot for the hardware. + + Returns: + A tuple containing the start and end datetime of the next available timeslot. + If no timeslot is available, returns the end datetime of the last lease. + + """ raise NotImplementedError() + blazarclient = blazar() -def get_nodes( - all_sites: bool = False, - filter_reserved: bool = False, - gpu: Optional[bool] = None, - number_cpu: Optional[int] = None, - ) -> List[Node]: + # Get all leases + leases = blazarclient.lease.list() - site = get("region_name") - endpoint = f"sites/{site.split('@')[1].lower()}/clusters/chameleon/nodes" - data = _call_api(endpoint) - nodes = [] + # Filter leases for this node + node_leases = [ + lease for lease in leases + if any(r['resource_id'] == self.uid for r in lease.get('reservations', []) + if r['resource_type'] == 'physical:host') + ] - for node_data in data: - node = Node( - site_name=site, - node_name=node_data.get("name"), - node_type=node_data.get("type"), - architecture=node_data.get("architecture"), - bios=node_data.get("bios"), - gpu=node_data.get("gpu"), - main_memory=node_data.get("main_memory"), - network_adapters=node_data.get("network_adapters"), - placement=node_data.get("placement"), - processor=node_data.get("processor"), - storage_devices=node_data.get("storage_devices"), - uid=node_data.get("uid"), - version=node_data.get("version"), - ) - nodes.append(node) - return nodes + # Sort leases by start time + node_leases.sort(key=lambda x: x['start_date']) + + now = datetime.now(timezone.utc) + + print(node_leases) + + # Check if there's a free slot now + if not node_leases or node_leases[0]['start_date'] > now: + return (now, node_leases[0]['start_date'] if node_leases else None) + + # Find the next free slot + for i in range(len(node_leases) - 1): + current_end = datetime.strptime(node_leases[i]['end_date'], "%Y-%m-%d %H:%M:%S") + next_start = datetime.strptime(node_leases[i+1]['start_date'], "%Y-%m-%d %H:%M:%S") + + if current_end < next_start: + return (current_end, next_start) + + # If no free slot found, return the end of the last lease + last_end = datetime.strptime(node_leases[-1]['end_date'], "%Y-%m-%d %H:%M:%S") + + return last_end def _call_api(endpoint): url = "{0}/{1}.{2}".format(RESOURCE_API_URL, endpoint, "json") @@ -69,4 +83,73 @@ def _call_api(endpoint): resp = requests.get(url) LOG.info("Response received. Parsing to json ...") data = resp.json() - return data \ No newline at end of file + return data + +def get_nodes( + all_sites: bool = False, + filter_reserved: bool = False, + gpu: Optional[bool] = None, + min_number_cpu: Optional[int] = None, + ) -> List[Node]: + """ + Retrieve a list of nodes based on the specified criteria. + + Args: + all_sites (bool, optional): Flag to indicate whether to retrieve nodes from all sites. + Defaults to False. + filter_reserved (bool, optional): Flag to indicate whether to filter out reserved nodes. + Defaults to False. (Not Currently implemented) + gpu (bool, optional): Flag to indicate whether to filter nodes based on GPU availability. + Defaults to None. + min_number_cpu (int, optional): Minimum number of CPU logical cores per node. + Defaults to None. + + Returns: + List[Node]: A list of Node objects that match the specified criteria. + """ + + sites = [] + if all_sites: + sites = [site.get("name") for site in _call_api("sites")['items']] + else: + sites.append(get("region_name")) + + nodes = [] + + for site in sites: + # Soufiane: Skipping CHI@EDGE since it is not enrolled in the hardware API, + if site == "CHI@Edge": + print("Please visit the Hardware discovery page for information about CHI@Edge devices") + continue + + endpoint = f"sites/{site.split('@')[1].lower()}/clusters/chameleon/nodes" + data = _call_api(endpoint) + + for node_data in data['items']: + node = Node( + site=site, + name=node_data.get("node_name"), + type=node_data.get("node_type"), + architecture=node_data.get("architecture"), + bios=node_data.get("bios"), + cpu=node_data.get("processor"), + gpu=node_data.get("gpu"), + main_memory=node_data.get("main_memory"), + network_adapters=node_data.get("network_adapters"), + placement=node_data.get("placement"), + storage_devices=node_data.get("storage_devices"), + uid=node_data.get("uid"), + version=node_data.get("version"), + ) + + if isinstance(node.gpu, list): + gpu_filter = gpu is None or (node.gpu and gpu == bool(node.gpu[0]['gpu'])) + else: + gpu_filter = gpu is None or (node.gpu and gpu == bool(node.gpu['gpu'])) + + cpu_filter = min_number_cpu is None or node.architecture['smt_size'] >= min_number_cpu + + if gpu_filter and cpu_filter: + nodes.append(node) + + return nodes \ No newline at end of file From cfc33bd71a6a3ec9fd01b3db78704920a2370182 Mon Sep 17 00:00:00 2001 From: Soufiane Jounaid Date: Sun, 21 Jul 2024 20:33:57 -0400 Subject: [PATCH 09/14] Lease 1.0, incomplete --- chi/lease.py | 431 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 257 insertions(+), 174 deletions(-) diff --git a/chi/lease.py b/chi/lease.py index 35090a4..fceb235 100644 --- a/chi/lease.py +++ b/chi/lease.py @@ -1,16 +1,19 @@ -from datetime import timedelta +from datetime import datetime, timedelta, timezone import json import numbers import re import sys import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, List +from ipywidgets import HTML +from IPython.display import display from blazarclient.exception import BlazarClientException from .clients import blazar, neutron from .exception import CHIValueError, ResourceError, ServiceError -from .context import get as get_from_context, session +from .context import get as get_from_context, session, _is_ipynb +from .hardware import Node from .network import get_network_id, PUBLIC_NETWORK, list_floating_ips from .server import Server, ServerError from .util import random_base32, utcnow @@ -19,6 +22,7 @@ if TYPE_CHECKING: from typing import Pattern +import pprint LOG = logging.getLogger(__name__) @@ -38,6 +42,7 @@ class ErrorParsers: "get_device_reservation", "get_reserved_floating_ips", "lease_duration", + "list_leases", "get_lease", "get_lease_id", "create_lease", @@ -155,192 +160,238 @@ def lease_create_nodetype(*args, **kwargs): kwargs["node_resource_properties"] = ["==", "$node_type", node_type] return lease_create_args(*args, **kwargs) - -class Lease(object): - """ - Creates and manages a lease, optionally with a context manager (``with``). - - .. code-block:: python - - with Lease(session, node_type='compute_skylake') as lease: - instance = lease.create_server() - ... - - When using the context manager, on entering it will wait for the lease - to launch, then on exiting it will delete the lease, which in-turn - also deletes the instances launched with it. - - :param keystone_session: session object - :param bool sequester: If the context manager catches that an instance - failed to start, it will not delete the lease, but rather extend it - and rename it with the ID of the instance that failed. - :param bool _no_clean: Don't delete the lease at the end of a context - manager - :param kwargs: Parameters passed through to - :py:func:`lease_create_nodetype` and in turn - :py:func:`lease_create_args` - """ - - def __init__(self, **kwargs): - kwargs.setdefault("session", session()) - - self.session = kwargs.pop("session") - self.blazar = blazar(session=self.session) - self.neutron = neutron(session=self.session) - - self.lease = None - - self._servers = {} - - self._sequester = kwargs.pop("sequester", False) - - kwargs.setdefault("_preexisting", False) - self._preexisting = kwargs.pop("_preexisting") - - kwargs.setdefault("_no_clean", False) - self._noclean = kwargs.pop("_no_clean") - - prefix = kwargs.pop("prefix", "") - rand = random_base32(6) - self.prefix = f"{prefix}-{rand}" if prefix else rand - - kwargs.setdefault("name", self.prefix) - - if self._preexisting: - self.id = kwargs["_id"] - self.refresh() +class Lease: + def __init__(self, name: str, + start_date: datetime = None, + end_date: datetime = None, + duration: timedelta = None, + lease_json: dict = None): + self.name = name + if start_date: + self.start_date = start_date.strftime(BLAZAR_TIME_FORMAT) + else: + self.start_date = (utcnow() + timedelta(minutes=1)).strftime(BLAZAR_TIME_FORMAT) + + if end_date and duration: + raise CHIValueError("Specify either end_date or duration, not both") + elif end_date: + self.end_date = end_date.strftime(BLAZAR_TIME_FORMAT) + elif duration: + self.start_date, self.end_date = lease_duration(days=duration.days) else: - kwargs.setdefault("node_type", DEFAULT_NODE_TYPE) - self._lease_kwargs = lease_create_nodetype(self.neutron, **kwargs) - self.lease = self.blazar.lease.create(**self._lease_kwargs) - self.id = self.lease["id"] + raise CHIValueError("Either end_date or duration must be specified") + + self.id = None + self.status = None + self.user_id = None + self.project_id = None + self.created_at = None + + self.node_reservations = [] + self.fip_reservations = [] + self.network_reservations = [] + self._events = [] + + if lease_json: + self._populate_from_json(lease_json) + + def _populate_from_json(self, lease_json): + self.id = lease_json.get('id') + self.status = lease_json.get('status') + self.user_id = lease_json.get('user_id') + self.project_id = lease_json.get('project_id') + + self.created_at = datetime.fromisoformat(lease_json.get('created_at')) + self.start_date = datetime.strptime(lease_json.get('start_date'), "%Y-%m-%dT%H:%M:%S.%f") + self.end_date = datetime.strptime(lease_json.get('end_date'), "%Y-%m-%dT%H:%M:%S.%f") + self.created_at = datetime.strptime(lease_json.get('created_at'), "%Y-%m-%d %H:%M:%S") + + self.node_reservations.clear() + self.fip_reservations.clear() + self.network_reservations.clear() + + for reservation in lease_json.get('reservations', []): + resource_type = reservation.get('resource_type') + if resource_type == 'physical:host': + self.node_reservations.append(reservation) + elif resource_type == 'virtual:floatingip': + self.fip_reservations.append(reservation) + elif resource_type == 'network': + self.network_reservations.append(reservation) + + # self.events = lease_json.get('events', []) + + def add_node_reservation(self, + amount: int = None, + node_type: str = None, + node_name: str = None, + nodes: List[Node] = None): + + if nodes: + if any([amount, node_type, node_name]): + raise CHIValueError("When specifying nodes, no other arguments should be included") + for node in nodes: + add_node_reservation(reservation_list=self.node_reservations, + node_name=node.name) + else: + add_node_reservation(reservation_list=self.node_reservations, + count=amount, + node_type=node_type, + node_name=node_name) + + def add_fip_reservation(self, amount: int): + add_fip_reservation(reservation_list=self.fip_reservations, + count=amount) + + def add_network_reservation(self, + network_name: str, + usage_type: str = None, + stitch_provider: str = None): + add_network_reservation(reservation_list=self.network_reservations, + network_name=network_name, + usage_type=usage_type, + stitch_provider=stitch_provider) + + def submit(self, + wait_for_active: bool = True, + wait_timeout: int = 300, + show: List[str] = ["widget", "text"], + idempotent: bool = False): + if idempotent: + existing_lease = self._get_existing_lease() + if existing_lease: + self._populate_from_json(existing_lease) + return + + reservations = self.node_reservations + self.fip_reservations + self.network_reservations + + response = create_lease(lease_name=self.name, + reservations=reservations, + start_date=self.start_date, + end_date=self.end_date) + + self._populate_from_json(response) + + if wait_for_active: + self.wait(status="active", timeout=wait_timeout) + + if "widget" in show: + self.show(type="widget", wait_for_active=wait_for_active) + if "text" in show: + self.show(type="text", wait_for_active=wait_for_active) + + def _get_existing_lease(self): + return get_lease(self.name); + + def wait(self, status="active", timeout=300): + print("Waiting for lease to start... This can take up to 60 seconds") + start_time = time.time() + while time.time() - start_time < timeout: + self.refresh() + if self.status.lower() == status.lower(): + return + time.sleep(15) + raise TimeoutError(f"Lease did not reach '{status}' status within {timeout} seconds") - self.name = self.lease["name"] - self.reservations = self.lease["reservations"] + def refresh(self): + lease_data = blazar().lease.get(self.id) + self._populate_from_json(lease_data) - @classmethod - def from_existing(cls, id): - """ - Attach to an existing lease by ID. When using in conjunction with the - context manager, it will *not* delete the lease at the end. + def delete(self): + if self.id: + blazar().lease.delete(self.id) + self.id = None + self.status = "DELETED" + + def show(self, type=["text", "widget"], wait_for_active=False): + if wait_for_active: + self.wait(status="active") + + if "widget" in type and _is_ipynb(): + self._show_widget() + if "text" in type: + self._show_text() + + def _show_widget(self): + html_content = f""" +

Lease Details

+ + + + + + + + +
Name{self.name}
ID{self.id or 'N/A'}
Status{self.status or 'N/A'}
Start Date{self.start_date or 'N/A'}
End Date{self.end_date or 'N/A'}
User ID{self.user_id or 'N/A'}
Project ID{self.project_id or 'N/A'}
+ +

Node Reservations

+
    + {"".join(f"
  • ID: {r.get('id', 'N/A')}, Status: {r.get('status', 'N/A')}, Resource type: {r.get('resource_type', 'N/A')}, Min: {r.get('min', 'N/A')}, Max: {r.get('max', 'N/A')}
  • " for r in self.node_reservations)} +
+ +

Floating IP Reservations

+
    + {"".join(f"
  • ID: {r.get('id', 'N/A')}, Status: {r.get('status', 'N/A')}, Resource type: {r.get('resource_type', 'N/A')}, Amount: {r.get('amount', 'N/A')}
  • " for r in self.fip_reservations)} +
+ +

Network Reservations

+
    + {"".join(f"
  • 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')}
  • " for r in self.network_reservations)} +
+ +

Events

+
    + {"".join(f"
  • Type: {e.get('event_type', 'N/A')}, Time: {e.get('time', 'N/A')}, Status: {e.get('status', 'N/A')}
  • " for e in self.events)} +
""" - return cls(_preexisting=True, _id=id) - - def __repr__(self): - region = self.session.region_name - return "<{} '{}' on {} ({})>".format( - self.__class__.__name__, self.name, region, self.id - ) - def __enter__(self): - if self.lease is None: - # don't support reuse in multiple with's. - raise ResourceError("Lease context manager not reentrant") - self.wait() - return self - - def __exit__(self, exc_type, exc, exc_tb): - if exc is not None and self._noclean: - print("Lease existing uncleanly (noclean = True).") - return - - if isinstance(exc, ServerError) and self._sequester: - print("Instance failed to start, sequestering lease") - self.blazar.lease.update( - lease_id=self.id, - name="sequester-error-instance-{}".format(exc.server.id), - prolong_for="6d", - ) - return + widget = HTML(html_content) + display(widget) - # if lease exists, delete instances - current_lease = self.blazar.lease.get(self.id) - if current_lease: - for server in self.servers: - server.delete() + def _show_text(self): + print(f"Lease Details:") + print(f"Name: {self.name}") + print(f"ID: {self.id or 'N/A'}") + print(f"Status: {self.status or 'N/A'}") + print(f"Start Date: {self.start_date or 'N/A'}") + print(f"End Date: {self.end_date or 'N/A'}") + print(f"User ID: {self.user_id or 'N/A'}") + print(f"Project ID: {self.project_id or 'N/A'}") - if not self._preexisting: - # don't auto-delete pre-existing leases - self.delete() + print("\nNode Reservations:") + for r in self.node_reservations: + print(f"ID: {r.get('id', 'N/A')}, Status: {r.get('status', 'N/A')}, Min: {r.get('min', 'N/A')}, Max: {r.get('max', 'N/A')}") - def refresh(self): - """Updates the lease data""" - self.lease = self.blazar.lease.get(self.id) + print("\nFloating IP Reservations:") + for r in self.fip_reservations: + print(f"ID: {r.get('id', 'N/A')}, Status: {r.get('status', 'N/A')}, Amount: {r.get('amount', 'N/A')}") - @property - def node_reservation(self): - return next( - iter( - [ - r["id"] - for r in (self.reservations or []) - if r["resource_type"] == "physical:host" - ] - ), - None, - ) + print("\nNetwork Reservations:") + for r in self.network_reservations: + print(f"ID: {r.get('id', 'N/A')}, Status: {r.get('status', 'N/A')}, Network Name: {r.get('network_name', 'N/A')}") - @property - def status(self): - """Refreshes and returns the status of the lease.""" - self.refresh() - # NOTE(priteau): Temporary compatibility with old and new lease status - if self.lease.get("action") is not None: - return self.lease["action"], self.lease["status"] - else: - return self.lease["status"] + print("\nEvents:") + for e in self.events: + print(f"Type: {e.get('event_type', 'N/A')}, Time: {e.get('time', 'N/A')}, Status: {e.get('status', 'N/A')}") - @property - def ready(self): - """Returns True if the lease has started.""" - # NOTE(priteau): Temporary compatibility with old and new lease status - if self.lease.get("action") is not None: - return self.status == ("START", "COMPLETE") - else: - return self.status == "ACTIVE" @property - def servers(self): - return self._servers.values() + def events(self): + if self.id: + # Fetch latest events from Blazar API + pass + return self._events @property - def binding(self): - return { - key: { - "address": value.ip, - "auth": { - "user": "cc", - "private_key": get_from_context("keypair_private_key"), - }, - } - for key, value in self._servers.items() - } + def status(self): + if self.id: + self.refresh() + return self._status - def wait(self): - """Blocks for up to 150 seconds, waiting for the lease to be ready. - Raises a RuntimeError if it times out.""" - for _ in range(15): - time.sleep(10) - if self.ready: - break - else: - raise ServiceError("timeout, lease failed to start") - - def delete(self): - """Deletes the lease""" - self.blazar.lease.delete(self.id) - self.lease = None - - def create_server(self, *server_args, **server_kwargs): - """Generates instances using the resource of the lease. Arguments - are passed to :py:class:`ccmanage.server.Server` and returns same - object.""" - server_kwargs.setdefault("lease", self) - server_name = server_kwargs.pop("name", len(self.servers)) - server_kwargs.setdefault("name", f"{self.prefix}-{server_name}") - server = Server(*server_args, **server_kwargs) - self._servers[server_name] = server - return server + @status.setter + def status(self, value): + self._status = value def _format_resource_properties(user_constraints, extra_constraints): @@ -365,6 +416,7 @@ def add_node_reservation( count=1, resource_properties=None, node_type=None, + node_name=None, architecture=None, ): """Add a node reservation to a reservation list. @@ -381,7 +433,8 @@ def add_node_reservation( nodes with a `node_type` matching "some-node-type". [">", "$architecture.smt_size", 40]: filter to nodes having more than 40 (hyperthread) cores. - + node_name (str): The specific node name to request. If None, the reservation will + target any node of the node_type. node_type (str): The node type to request. If None, the reservation will not target any particular node type. If `resource_properties` is defined, the node type constraint is added to the existing property constraints. @@ -395,6 +448,10 @@ def add_node_reservation( extra_constraints.append(["==", "$node_type", node_type]) if architecture: extra_constraints.append(["==", "$architecture.platform_type", architecture]) + if node_name: + if count != 1 or node_type != None or resource_properties != None or architecture != None: + raise CHIValueError("If node name is specified, no other resource constraint can be specified") + extra_constraints.append(["==", "$node_name", node_name]) resource_properties = _format_resource_properties( user_constraints, extra_constraints @@ -552,9 +609,11 @@ def _reservation_matching(lease_ref, match_fn, multiple=False): def add_network_reservation( reservation_list, network_name, + usage_type=None, of_controller_ip=None, of_controller_port=None, vswitch_name=None, + stitch_provider=None, resource_properties=None, physical_network="physnet1", ): @@ -572,6 +631,7 @@ def add_network_reservation( this network. See `the virtual forwarding context documentation `_ for more details. + stich_provider (str): specify a stitching provider such as fabric. ' resource_properties (list): A list of resource property constraints. These take the form [, , ] physical_network (str): The physical provider network to reserve from. @@ -585,10 +645,24 @@ def add_network_reservation( if vswitch_name: desc_parts.append(f"VSwitchName={vswitch_name}") + + user_constraints = (resource_properties or []).copy() extra_constraints = [] + + + if physical_network: extra_constraints.append(["==", "$physical_network", physical_network]) + if stitch_provider and stitch_provider != 'fabric': + extra_constraints.append(["==", "$stitch_provider", stitch_provider]) + else: + raise CHIValueError("stitch_provider must be 'fabric' or None") + if usage_type and usage_type != 'storage': + extra_constraints.append(["==", "$usage_type", usage_type]) + else: + raise CHIValueError("usage_type must be 'storage' or None") + resource_properties = _format_resource_properties( user_constraints, extra_constraints @@ -697,6 +771,15 @@ def lease_duration(days=1, hours=0): # Leases ######### +def list_leases() -> List[dict]: + """Return a list of user leases. + + Returns: + A list of user leases. + """ + leases = blazar().lease.list() + pprint.pprint(leases) + return leases def get_lease(ref) -> dict: """Get a lease by its ID or name. From af7cd1eaa941e57af153c490f92eeff5bd5ca454 Mon Sep 17 00:00:00 2001 From: Soufiane Jounaid Date: Mon, 22 Jul 2024 11:48:51 -0400 Subject: [PATCH 10/14] more fixes --- chi/lease.py | 60 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/chi/lease.py b/chi/lease.py index fceb235..e9a0e26 100644 --- a/chi/lease.py +++ b/chi/lease.py @@ -161,6 +161,54 @@ def lease_create_nodetype(*args, **kwargs): return lease_create_args(*args, **kwargs) class Lease: + """ + Represents a lease in the CHI system. + + Args: + name (str): The name of the lease. + start_date (datetime, optional): The start date of the lease. Defaults to None. + end_date (datetime, optional): The end date of the lease. Defaults to None. + duration (timedelta, optional): The duration of the lease. Defaults to None. + lease_json (dict, optional): JSON representation of the lease. Defaults to None. + + Attributes: + name (str): The name of the lease. + start_date (str): The start date of the lease in the format specified by BLAZAR_TIME_FORMAT. + end_date (str): The end date of the lease in the format specified by BLAZAR_TIME_FORMAT. + id (str): The ID of the lease. + status (str): The status of the lease. + user_id (str): The ID of the user associated with the lease. + project_id (str): The ID of the project associated with the lease. + created_at (datetime): The creation date of the lease. + node_reservations (list): List of node reservations associated with the lease. + fip_reservations (list): List of floating IP reservations associated with the lease. + network_reservations (list): List of network reservations associated with the lease. + _events (list): List of events associated with the lease. + + Methods: + add_node_reservation: Adds a node reservation to the lease. + add_fip_reservation: Adds a floating IP reservation to the lease. + add_network_reservation: Adds a network reservation to the lease. + submit: Submits the lease for creation. + wait: Waits for the lease to reach a specific status. + refresh: Refreshes the lease data from the Blazar API. + delete: Deletes the lease. + show: Displays the lease details. + + Properties: + events: List of events associated with the lease. + status: The status of the lease. + + """ + + def __init__(self, name: str, + start_date: datetime = None, + end_date: datetime = None, + duration: timedelta = None, + lease_json: dict = None): + # Implementation details... +class Lease: + def __init__(self, name: str, start_date: datetime = None, end_date: datetime = None, @@ -289,19 +337,25 @@ def wait(self, status="active", timeout=300): while time.time() - start_time < timeout: self.refresh() if self.status.lower() == status.lower(): + print(f"Lease {self.name} has reached status {self.status.lower()}") return time.sleep(15) - raise TimeoutError(f"Lease did not reach '{status}' status within {timeout} seconds") + raise ServiceError(f"Lease did not reach '{status}' status within {timeout} seconds") def refresh(self): - lease_data = blazar().lease.get(self.id) - self._populate_from_json(lease_data) + if self.id: + lease_data = blazar().lease.get(self.id) + self._populate_from_json(lease_data) + else: + raise ResourceError("Lease object does not yet have a valid id, please submit the object for creation first") def delete(self): if self.id: blazar().lease.delete(self.id) self.id = None self.status = "DELETED" + else: + raise ResourceError("Lease object does not yet have a valid id, please submit the object for creation first") def show(self, type=["text", "widget"], wait_for_active=False): if wait_for_active: From 16ff7853a8d7f28523c9ee2ad52ea3ca2b345fb7 Mon Sep 17 00:00:00 2001 From: Soufiane Jounaid Date: Mon, 22 Jul 2024 14:42:03 -0400 Subject: [PATCH 11/14] Fix doc formatting --- chi/lease.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/chi/lease.py b/chi/lease.py index e9a0e26..860674c 100644 --- a/chi/lease.py +++ b/chi/lease.py @@ -198,17 +198,7 @@ class Lease: Properties: events: List of events associated with the lease. status: The status of the lease. - """ - - def __init__(self, name: str, - start_date: datetime = None, - end_date: datetime = None, - duration: timedelta = None, - lease_json: dict = None): - # Implementation details... -class Lease: - def __init__(self, name: str, start_date: datetime = None, end_date: datetime = None, From ddaea69975c0439f213b399b31c161f9fc6c7134 Mon Sep 17 00:00:00 2001 From: Soufiane Jounaid Date: Wed, 31 Jul 2024 00:07:19 -0400 Subject: [PATCH 12/14] Adding review comments --- chi/lease.py | 89 +++++++++++++++++++++++++++++++++------------------- 1 file changed, 57 insertions(+), 32 deletions(-) diff --git a/chi/lease.py b/chi/lease.py index 860674c..9855d3a 100644 --- a/chi/lease.py +++ b/chi/lease.py @@ -5,7 +5,7 @@ import sys import time -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING, List, Optional, Union from ipywidgets import HTML from IPython.display import display from blazarclient.exception import BlazarClientException @@ -199,26 +199,12 @@ class Lease: events: List of events associated with the lease. status: The status of the lease. """ - def __init__(self, name: str, - start_date: datetime = None, - end_date: datetime = None, - duration: timedelta = None, - lease_json: dict = None): - self.name = name - if start_date: - self.start_date = start_date.strftime(BLAZAR_TIME_FORMAT) - else: - self.start_date = (utcnow() + timedelta(minutes=1)).strftime(BLAZAR_TIME_FORMAT) - - if end_date and duration: - raise CHIValueError("Specify either end_date or duration, not both") - elif end_date: - self.end_date = end_date.strftime(BLAZAR_TIME_FORMAT) - elif duration: - self.start_date, self.end_date = lease_duration(days=duration.days) - else: - raise CHIValueError("Either end_date or duration must be specified") - + def __init__(self, + name: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + duration: Optional[timedelta] = None, + lease_json: Optional[dict] = None): self.id = None self.status = None self.user_id = None @@ -232,8 +218,27 @@ def __init__(self, name: str, if lease_json: self._populate_from_json(lease_json) + else: + if name is None: + raise CHIValueError("Name must be specified when lease_json is not provided") + + self.name = name + if start_date: + self.start_date = start_date.strftime(BLAZAR_TIME_FORMAT) + else: + self.start_date = 'now' + + if end_date and duration: + raise CHIValueError("Specify either end_date or duration, not both") + elif end_date: + self.end_date = end_date.strftime(BLAZAR_TIME_FORMAT) + elif duration: + self.start_date, self.end_date = lease_duration(days=duration.days) + else: + raise CHIValueError("Either end_date or duration must be specified") def _populate_from_json(self, lease_json): + self.name = lease_json.get('name') self.id = lease_json.get('id') self.status = lease_json.get('status') self.user_id = lease_json.get('user_id') @@ -308,7 +313,10 @@ def submit(self, start_date=self.start_date, end_date=self.end_date) - self._populate_from_json(response) + if response: + self._populate_from_json(response) + else: + raise ResourceError("Unable to make lease") if wait_for_active: self.wait(status="active", timeout=wait_timeout) @@ -815,34 +823,51 @@ def lease_duration(days=1, hours=0): # Leases ######### -def list_leases() -> List[dict]: - """Return a list of user leases. +def list_leases() -> List[Lease]: + """ + Return a list of user leases. Returns: - A list of user leases. + A list of Lease objects representing user leases. """ - leases = blazar().lease.list() - pprint.pprint(leases) + blazar_client = blazar() + lease_dicts = blazar_client.lease.list() + + leases = [] + for lease_dict in lease_dicts: + lease = Lease(lease_json=lease_dict) + leases.append(lease) + return leases -def get_lease(ref) -> dict: - """Get a lease by its ID or name. +def get_lease(ref: str) -> Union[Lease, None]: + """ + Get a lease by its ID or name. Args: ref (str): The ID or name of the lease. Returns: - The lease matching the ID or name. + A Lease object matching the ID or name, or None if not found. """ + blazar_client = blazar() + try: - return blazar().lease.get(ref) + lease_dict = blazar_client.lease.get(ref) + return Lease(lease_json=lease_dict) except BlazarClientException as err: # Blazar's exception class is a bit odd and stores the actual code # in 'kwargs'. The 'code' attribute on the exception is just the default # code. Prefer to use .kwargs['code'] if present, fall back to .code code = getattr(err, "kwargs", {}).get("code", getattr(err, "code", None)) if code == 404: - return blazar().lease.get(get_lease_id(ref)) + try: + lease_id = get_lease_id(ref) + lease_dict = blazar_client.lease.get(lease_id) + return Lease(lease_json=lease_dict) + except BlazarClientException: + # If we still can't find the lease, return None + return None else: raise From 6eed7c8a481f969c9c75718f5432d8f8a4d2bd4c Mon Sep 17 00:00:00 2001 From: Soufiane Jounaid Date: Sun, 21 Jul 2024 20:33:57 -0400 Subject: [PATCH 13/14] Lease 1.0, missing event retrieval Implemented a new OOP interface for lease and added widget representations. list_leases and get_lease methods now return Lease objects instead of dict. --- chi/lease.py | 494 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 323 insertions(+), 171 deletions(-) diff --git a/chi/lease.py b/chi/lease.py index 35090a4..9855d3a 100644 --- a/chi/lease.py +++ b/chi/lease.py @@ -1,16 +1,19 @@ -from datetime import timedelta +from datetime import datetime, timedelta, timezone import json import numbers import re import sys import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, List, Optional, Union +from ipywidgets import HTML +from IPython.display import display from blazarclient.exception import BlazarClientException from .clients import blazar, neutron from .exception import CHIValueError, ResourceError, ServiceError -from .context import get as get_from_context, session +from .context import get as get_from_context, session, _is_ipynb +from .hardware import Node from .network import get_network_id, PUBLIC_NETWORK, list_floating_ips from .server import Server, ServerError from .util import random_base32, utcnow @@ -19,6 +22,7 @@ if TYPE_CHECKING: from typing import Pattern +import pprint LOG = logging.getLogger(__name__) @@ -38,6 +42,7 @@ class ErrorParsers: "get_device_reservation", "get_reserved_floating_ips", "lease_duration", + "list_leases", "get_lease", "get_lease_id", "create_lease", @@ -155,192 +160,290 @@ def lease_create_nodetype(*args, **kwargs): kwargs["node_resource_properties"] = ["==", "$node_type", node_type] return lease_create_args(*args, **kwargs) - -class Lease(object): +class Lease: """ - Creates and manages a lease, optionally with a context manager (``with``). + Represents a lease in the CHI system. - .. code-block:: python + Args: + name (str): The name of the lease. + start_date (datetime, optional): The start date of the lease. Defaults to None. + end_date (datetime, optional): The end date of the lease. Defaults to None. + duration (timedelta, optional): The duration of the lease. Defaults to None. + lease_json (dict, optional): JSON representation of the lease. Defaults to None. - with Lease(session, node_type='compute_skylake') as lease: - instance = lease.create_server() - ... - - When using the context manager, on entering it will wait for the lease - to launch, then on exiting it will delete the lease, which in-turn - also deletes the instances launched with it. - - :param keystone_session: session object - :param bool sequester: If the context manager catches that an instance - failed to start, it will not delete the lease, but rather extend it - and rename it with the ID of the instance that failed. - :param bool _no_clean: Don't delete the lease at the end of a context - manager - :param kwargs: Parameters passed through to - :py:func:`lease_create_nodetype` and in turn - :py:func:`lease_create_args` + Attributes: + name (str): The name of the lease. + start_date (str): The start date of the lease in the format specified by BLAZAR_TIME_FORMAT. + end_date (str): The end date of the lease in the format specified by BLAZAR_TIME_FORMAT. + id (str): The ID of the lease. + status (str): The status of the lease. + user_id (str): The ID of the user associated with the lease. + project_id (str): The ID of the project associated with the lease. + created_at (datetime): The creation date of the lease. + node_reservations (list): List of node reservations associated with the lease. + fip_reservations (list): List of floating IP reservations associated with the lease. + network_reservations (list): List of network reservations associated with the lease. + _events (list): List of events associated with the lease. + + Methods: + add_node_reservation: Adds a node reservation to the lease. + add_fip_reservation: Adds a floating IP reservation to the lease. + add_network_reservation: Adds a network reservation to the lease. + submit: Submits the lease for creation. + wait: Waits for the lease to reach a specific status. + refresh: Refreshes the lease data from the Blazar API. + delete: Deletes the lease. + show: Displays the lease details. + + Properties: + events: List of events associated with the lease. + status: The status of the lease. """ + def __init__(self, + name: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + duration: Optional[timedelta] = None, + lease_json: Optional[dict] = None): + self.id = None + self.status = None + self.user_id = None + self.project_id = None + self.created_at = None + + self.node_reservations = [] + self.fip_reservations = [] + self.network_reservations = [] + self._events = [] + + if lease_json: + self._populate_from_json(lease_json) + else: + if name is None: + raise CHIValueError("Name must be specified when lease_json is not provided") + + self.name = name + if start_date: + self.start_date = start_date.strftime(BLAZAR_TIME_FORMAT) + else: + self.start_date = 'now' + + if end_date and duration: + raise CHIValueError("Specify either end_date or duration, not both") + elif end_date: + self.end_date = end_date.strftime(BLAZAR_TIME_FORMAT) + elif duration: + self.start_date, self.end_date = lease_duration(days=duration.days) + else: + raise CHIValueError("Either end_date or duration must be specified") + + def _populate_from_json(self, lease_json): + self.name = lease_json.get('name') + self.id = lease_json.get('id') + self.status = lease_json.get('status') + self.user_id = lease_json.get('user_id') + self.project_id = lease_json.get('project_id') + + self.created_at = datetime.fromisoformat(lease_json.get('created_at')) + self.start_date = datetime.strptime(lease_json.get('start_date'), "%Y-%m-%dT%H:%M:%S.%f") + self.end_date = datetime.strptime(lease_json.get('end_date'), "%Y-%m-%dT%H:%M:%S.%f") + self.created_at = datetime.strptime(lease_json.get('created_at'), "%Y-%m-%d %H:%M:%S") + + self.node_reservations.clear() + self.fip_reservations.clear() + self.network_reservations.clear() + + for reservation in lease_json.get('reservations', []): + resource_type = reservation.get('resource_type') + if resource_type == 'physical:host': + self.node_reservations.append(reservation) + elif resource_type == 'virtual:floatingip': + self.fip_reservations.append(reservation) + elif resource_type == 'network': + self.network_reservations.append(reservation) + + # self.events = lease_json.get('events', []) + + def add_node_reservation(self, + amount: int = None, + node_type: str = None, + node_name: str = None, + nodes: List[Node] = None): + + if nodes: + if any([amount, node_type, node_name]): + raise CHIValueError("When specifying nodes, no other arguments should be included") + for node in nodes: + add_node_reservation(reservation_list=self.node_reservations, + node_name=node.name) + else: + add_node_reservation(reservation_list=self.node_reservations, + count=amount, + node_type=node_type, + node_name=node_name) + + def add_fip_reservation(self, amount: int): + add_fip_reservation(reservation_list=self.fip_reservations, + count=amount) + + def add_network_reservation(self, + network_name: str, + usage_type: str = None, + stitch_provider: str = None): + add_network_reservation(reservation_list=self.network_reservations, + network_name=network_name, + usage_type=usage_type, + stitch_provider=stitch_provider) + + def submit(self, + wait_for_active: bool = True, + wait_timeout: int = 300, + show: List[str] = ["widget", "text"], + idempotent: bool = False): + if idempotent: + existing_lease = self._get_existing_lease() + if existing_lease: + self._populate_from_json(existing_lease) + return + + reservations = self.node_reservations + self.fip_reservations + self.network_reservations + + response = create_lease(lease_name=self.name, + reservations=reservations, + start_date=self.start_date, + end_date=self.end_date) + + if response: + self._populate_from_json(response) + else: + raise ResourceError("Unable to make lease") - def __init__(self, **kwargs): - kwargs.setdefault("session", session()) - - self.session = kwargs.pop("session") - self.blazar = blazar(session=self.session) - self.neutron = neutron(session=self.session) - - self.lease = None - - self._servers = {} - - self._sequester = kwargs.pop("sequester", False) - - kwargs.setdefault("_preexisting", False) - self._preexisting = kwargs.pop("_preexisting") - - kwargs.setdefault("_no_clean", False) - self._noclean = kwargs.pop("_no_clean") + if wait_for_active: + self.wait(status="active", timeout=wait_timeout) - prefix = kwargs.pop("prefix", "") - rand = random_base32(6) - self.prefix = f"{prefix}-{rand}" if prefix else rand + if "widget" in show: + self.show(type="widget", wait_for_active=wait_for_active) + if "text" in show: + self.show(type="text", wait_for_active=wait_for_active) - kwargs.setdefault("name", self.prefix) + def _get_existing_lease(self): + return get_lease(self.name); - if self._preexisting: - self.id = kwargs["_id"] + def wait(self, status="active", timeout=300): + print("Waiting for lease to start... This can take up to 60 seconds") + start_time = time.time() + while time.time() - start_time < timeout: self.refresh() - else: - kwargs.setdefault("node_type", DEFAULT_NODE_TYPE) - self._lease_kwargs = lease_create_nodetype(self.neutron, **kwargs) - self.lease = self.blazar.lease.create(**self._lease_kwargs) - self.id = self.lease["id"] + if self.status.lower() == status.lower(): + print(f"Lease {self.name} has reached status {self.status.lower()}") + return + time.sleep(15) + raise ServiceError(f"Lease did not reach '{status}' status within {timeout} seconds") - self.name = self.lease["name"] - self.reservations = self.lease["reservations"] + def refresh(self): + if self.id: + lease_data = blazar().lease.get(self.id) + self._populate_from_json(lease_data) + else: + raise ResourceError("Lease object does not yet have a valid id, please submit the object for creation first") - @classmethod - def from_existing(cls, id): - """ - Attach to an existing lease by ID. When using in conjunction with the - context manager, it will *not* delete the lease at the end. + def delete(self): + if self.id: + blazar().lease.delete(self.id) + self.id = None + self.status = "DELETED" + else: + raise ResourceError("Lease object does not yet have a valid id, please submit the object for creation first") + + def show(self, type=["text", "widget"], wait_for_active=False): + if wait_for_active: + self.wait(status="active") + + if "widget" in type and _is_ipynb(): + self._show_widget() + if "text" in type: + self._show_text() + + def _show_widget(self): + html_content = f""" +

Lease Details

+ + + + + + + + +
Name{self.name}
ID{self.id or 'N/A'}
Status{self.status or 'N/A'}
Start Date{self.start_date or 'N/A'}
End Date{self.end_date or 'N/A'}
User ID{self.user_id or 'N/A'}
Project ID{self.project_id or 'N/A'}
+ +

Node Reservations

+
    + {"".join(f"
  • ID: {r.get('id', 'N/A')}, Status: {r.get('status', 'N/A')}, Resource type: {r.get('resource_type', 'N/A')}, Min: {r.get('min', 'N/A')}, Max: {r.get('max', 'N/A')}
  • " for r in self.node_reservations)} +
+ +

Floating IP Reservations

+
    + {"".join(f"
  • ID: {r.get('id', 'N/A')}, Status: {r.get('status', 'N/A')}, Resource type: {r.get('resource_type', 'N/A')}, Amount: {r.get('amount', 'N/A')}
  • " for r in self.fip_reservations)} +
+ +

Network Reservations

+
    + {"".join(f"
  • 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')}
  • " for r in self.network_reservations)} +
+ +

Events

+
    + {"".join(f"
  • Type: {e.get('event_type', 'N/A')}, Time: {e.get('time', 'N/A')}, Status: {e.get('status', 'N/A')}
  • " for e in self.events)} +
""" - return cls(_preexisting=True, _id=id) - - def __repr__(self): - region = self.session.region_name - return "<{} '{}' on {} ({})>".format( - self.__class__.__name__, self.name, region, self.id - ) - def __enter__(self): - if self.lease is None: - # don't support reuse in multiple with's. - raise ResourceError("Lease context manager not reentrant") - self.wait() - return self - - def __exit__(self, exc_type, exc, exc_tb): - if exc is not None and self._noclean: - print("Lease existing uncleanly (noclean = True).") - return - - if isinstance(exc, ServerError) and self._sequester: - print("Instance failed to start, sequestering lease") - self.blazar.lease.update( - lease_id=self.id, - name="sequester-error-instance-{}".format(exc.server.id), - prolong_for="6d", - ) - return + widget = HTML(html_content) + display(widget) - # if lease exists, delete instances - current_lease = self.blazar.lease.get(self.id) - if current_lease: - for server in self.servers: - server.delete() + def _show_text(self): + print(f"Lease Details:") + print(f"Name: {self.name}") + print(f"ID: {self.id or 'N/A'}") + print(f"Status: {self.status or 'N/A'}") + print(f"Start Date: {self.start_date or 'N/A'}") + print(f"End Date: {self.end_date or 'N/A'}") + print(f"User ID: {self.user_id or 'N/A'}") + print(f"Project ID: {self.project_id or 'N/A'}") - if not self._preexisting: - # don't auto-delete pre-existing leases - self.delete() + print("\nNode Reservations:") + for r in self.node_reservations: + print(f"ID: {r.get('id', 'N/A')}, Status: {r.get('status', 'N/A')}, Min: {r.get('min', 'N/A')}, Max: {r.get('max', 'N/A')}") - def refresh(self): - """Updates the lease data""" - self.lease = self.blazar.lease.get(self.id) + print("\nFloating IP Reservations:") + for r in self.fip_reservations: + print(f"ID: {r.get('id', 'N/A')}, Status: {r.get('status', 'N/A')}, Amount: {r.get('amount', 'N/A')}") - @property - def node_reservation(self): - return next( - iter( - [ - r["id"] - for r in (self.reservations or []) - if r["resource_type"] == "physical:host" - ] - ), - None, - ) + print("\nNetwork Reservations:") + for r in self.network_reservations: + print(f"ID: {r.get('id', 'N/A')}, Status: {r.get('status', 'N/A')}, Network Name: {r.get('network_name', 'N/A')}") - @property - def status(self): - """Refreshes and returns the status of the lease.""" - self.refresh() - # NOTE(priteau): Temporary compatibility with old and new lease status - if self.lease.get("action") is not None: - return self.lease["action"], self.lease["status"] - else: - return self.lease["status"] + print("\nEvents:") + for e in self.events: + print(f"Type: {e.get('event_type', 'N/A')}, Time: {e.get('time', 'N/A')}, Status: {e.get('status', 'N/A')}") - @property - def ready(self): - """Returns True if the lease has started.""" - # NOTE(priteau): Temporary compatibility with old and new lease status - if self.lease.get("action") is not None: - return self.status == ("START", "COMPLETE") - else: - return self.status == "ACTIVE" @property - def servers(self): - return self._servers.values() + def events(self): + if self.id: + # Fetch latest events from Blazar API + pass + return self._events @property - def binding(self): - return { - key: { - "address": value.ip, - "auth": { - "user": "cc", - "private_key": get_from_context("keypair_private_key"), - }, - } - for key, value in self._servers.items() - } - - def wait(self): - """Blocks for up to 150 seconds, waiting for the lease to be ready. - Raises a RuntimeError if it times out.""" - for _ in range(15): - time.sleep(10) - if self.ready: - break - else: - raise ServiceError("timeout, lease failed to start") + def status(self): + if self.id: + self.refresh() + return self._status - def delete(self): - """Deletes the lease""" - self.blazar.lease.delete(self.id) - self.lease = None - - def create_server(self, *server_args, **server_kwargs): - """Generates instances using the resource of the lease. Arguments - are passed to :py:class:`ccmanage.server.Server` and returns same - object.""" - server_kwargs.setdefault("lease", self) - server_name = server_kwargs.pop("name", len(self.servers)) - server_kwargs.setdefault("name", f"{self.prefix}-{server_name}") - server = Server(*server_args, **server_kwargs) - self._servers[server_name] = server - return server + @status.setter + def status(self, value): + self._status = value def _format_resource_properties(user_constraints, extra_constraints): @@ -365,6 +468,7 @@ def add_node_reservation( count=1, resource_properties=None, node_type=None, + node_name=None, architecture=None, ): """Add a node reservation to a reservation list. @@ -381,7 +485,8 @@ def add_node_reservation( nodes with a `node_type` matching "some-node-type". [">", "$architecture.smt_size", 40]: filter to nodes having more than 40 (hyperthread) cores. - + node_name (str): The specific node name to request. If None, the reservation will + target any node of the node_type. node_type (str): The node type to request. If None, the reservation will not target any particular node type. If `resource_properties` is defined, the node type constraint is added to the existing property constraints. @@ -395,6 +500,10 @@ def add_node_reservation( extra_constraints.append(["==", "$node_type", node_type]) if architecture: extra_constraints.append(["==", "$architecture.platform_type", architecture]) + if node_name: + if count != 1 or node_type != None or resource_properties != None or architecture != None: + raise CHIValueError("If node name is specified, no other resource constraint can be specified") + extra_constraints.append(["==", "$node_name", node_name]) resource_properties = _format_resource_properties( user_constraints, extra_constraints @@ -552,9 +661,11 @@ def _reservation_matching(lease_ref, match_fn, multiple=False): def add_network_reservation( reservation_list, network_name, + usage_type=None, of_controller_ip=None, of_controller_port=None, vswitch_name=None, + stitch_provider=None, resource_properties=None, physical_network="physnet1", ): @@ -572,6 +683,7 @@ def add_network_reservation( this network. See `the virtual forwarding context documentation `_ for more details. + stich_provider (str): specify a stitching provider such as fabric. ' resource_properties (list): A list of resource property constraints. These take the form [, , ] physical_network (str): The physical provider network to reserve from. @@ -585,10 +697,24 @@ def add_network_reservation( if vswitch_name: desc_parts.append(f"VSwitchName={vswitch_name}") + + user_constraints = (resource_properties or []).copy() extra_constraints = [] + + + if physical_network: extra_constraints.append(["==", "$physical_network", physical_network]) + if stitch_provider and stitch_provider != 'fabric': + extra_constraints.append(["==", "$stitch_provider", stitch_provider]) + else: + raise CHIValueError("stitch_provider must be 'fabric' or None") + if usage_type and usage_type != 'storage': + extra_constraints.append(["==", "$usage_type", usage_type]) + else: + raise CHIValueError("usage_type must be 'storage' or None") + resource_properties = _format_resource_properties( user_constraints, extra_constraints @@ -697,25 +823,51 @@ def lease_duration(days=1, hours=0): # Leases ######### +def list_leases() -> List[Lease]: + """ + Return a list of user leases. + + Returns: + A list of Lease objects representing user leases. + """ + blazar_client = blazar() + lease_dicts = blazar_client.lease.list() + + leases = [] + for lease_dict in lease_dicts: + lease = Lease(lease_json=lease_dict) + leases.append(lease) -def get_lease(ref) -> dict: - """Get a lease by its ID or name. + return leases + +def get_lease(ref: str) -> Union[Lease, None]: + """ + Get a lease by its ID or name. Args: ref (str): The ID or name of the lease. Returns: - The lease matching the ID or name. + A Lease object matching the ID or name, or None if not found. """ + blazar_client = blazar() + try: - return blazar().lease.get(ref) + lease_dict = blazar_client.lease.get(ref) + return Lease(lease_json=lease_dict) except BlazarClientException as err: # Blazar's exception class is a bit odd and stores the actual code # in 'kwargs'. The 'code' attribute on the exception is just the default # code. Prefer to use .kwargs['code'] if present, fall back to .code code = getattr(err, "kwargs", {}).get("code", getattr(err, "code", None)) if code == 404: - return blazar().lease.get(get_lease_id(ref)) + try: + lease_id = get_lease_id(ref) + lease_dict = blazar_client.lease.get(lease_id) + return Lease(lease_json=lease_dict) + except BlazarClientException: + # If we still can't find the lease, return None + return None else: raise From ccc71f0955e4240811e935c8974162ec984a4db3 Mon Sep 17 00:00:00 2001 From: Soufiane Jounaid Date: Wed, 31 Jul 2024 11:30:00 -0400 Subject: [PATCH 14/14] Server 1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented a redesign of the server module's OOP interfaces class Server Fields reservation_id: str addresses: {network: [ip,]} @property which if an id is set, fetches the latest addresses created_at: datetime flavor: str host_id: str host_status: str name: str hypervisor_hostname: str id: str image: Image is_locked: bool keypair: Keypair status @property which if an id is set, fetches the latest status __init__( name: str, reservation_id: str, image_name=”CC-Ubuntu-22.04” : str, image: Image, flavor_name=”baremetal” : str, key_name=”$USER-jupyterhub”, keypair: Keypair, network_name=”sharednet1”, ) Args name - the name of the instance reservation_id - the uuid of the host reservation to draw this instance on. Can be obtained via my_lease.node_reservations[0][“id”] image_name the name of the image to boot image - Image object optionally given instead of image_name. flavor_name - the name of the flavor key_name - the keypair to use for this instance keypair - Keypair object optionally given instead of key_name network_name - the network to launch this server on. submit( count=1, wait_for_active: bool = True, # wait_timeout: seconds, show=[”widget”, “text”]. idempotent=False, ) -> [Server] Args: count - The number of servers to create with these details. wait_for_active - If true, this method will block until the lease is in an active state. wait_timeout - how many seconds to wait until a timeout. idempotent - If true, first try to get a server with the same name. If one does not exist, run submit as normal. Submits this server object to be created. If count is greater than 1, several servers will be created, and the list of Server objects will be returned, instead of self being modified in place. Implementation: This should cache the details for the server in the fields. delete() -> None wait(status=”active” : str) -> None Waits until the server is a given status show( type=[“text”, “widget”] : str, wait_for_active : bool, ) -> None Displays this server in the format specified by the user. Either a textual representation printed to stdout or as a widget table. If wait_for_active and type=widget, then the widget representation will display immediately, and update once the server status changes. If wait_for_active and type=text, displays once server is active. check_connectivity( wait=True : bool, port=22 : int, timeout=500 : int, type=[“widget”, “text”]: str ) -> bool Args wait - Whether to wait for connectivity, or check once port - the port to check on timeout - how many seconds to wait for type - The type of output to display. Will output a timer showing how long this function has waited for. If set to None, displays nothing. Periodically checks that the given port is open, and returns true if a TCP connection can be made to it. associate_floating_ip(fip=None : str) args: fip the fip to associate to this instance, which must be allocated to the project. If none, will allocate a new IP to the project, and associate that. Can be gotten from a lease via my_lease.fip_reservations[0][“address”] Associates a fip to the server. detach_floating_ip(fip : str) args fip the fip to detach from this server. ssh_connection() Returns a Fabric Connection object that may be used to run SSH commands. upload( file : str, remote_path=”” : str, ) Uploads the file or directory specified to the remote path execute(command: str) Runs a command over SSH on the node. list_servers() -> [Server] Returns all servers in the current project get_server(name) -> Server Gets the a server with the given name. class Flavor Fields disk: int ram: int vcpus: int name: str get_flavors() -> [Flavor] Gets a list of all flavors --- chi/lease.py | 44 ++++ chi/server.py | 635 +++++++++++++++++++++++++++++++++++--------------- 2 files changed, 485 insertions(+), 194 deletions(-) diff --git a/chi/lease.py b/chi/lease.py index 9855d3a..566d874 100644 --- a/chi/lease.py +++ b/chi/lease.py @@ -269,7 +269,19 @@ def add_node_reservation(self, node_type: str = None, node_name: str = None, nodes: List[Node] = None): + """ + Add a node reservation to the lease. + + Parameters: + - amount (int): The number of nodes to reserve. + - node_type (str): The type of nodes to reserve. + - node_name (str): The name of the node to reserve. + - nodes (List[Node]): A list of Node objects to reserve. + Raises: + - CHIValueError: If nodes are specified, no other arguments should be included. + + """ if nodes: if any([amount, node_type, node_name]): raise CHIValueError("When specifying nodes, no other arguments should be included") @@ -283,6 +295,15 @@ def add_node_reservation(self, node_name=node_name) def add_fip_reservation(self, amount: int): + """ + Add a reservation for a floating IP address to the list of FIP reservations. + + Args: + amount (int): The number of reservations to add. + + Returns: + None + """ add_fip_reservation(reservation_list=self.fip_reservations, count=amount) @@ -290,6 +311,14 @@ def add_network_reservation(self, network_name: str, usage_type: str = None, stitch_provider: str = None): + """ + Add a network reservation to the list of network reservations. + + Args: + network_name (str): The name of the network to be reserved. + usage_type (str, optional): The type of usage for the network reservation. Defaults to None. + stitch_provider (str, optional): The stitch provider for the network reservation. Defaults to None. + """ add_network_reservation(reservation_list=self.network_reservations, network_name=network_name, usage_type=usage_type, @@ -300,6 +329,21 @@ def submit(self, wait_timeout: int = 300, show: List[str] = ["widget", "text"], idempotent: bool = False): + """ + Submits the lease for creation. + + Args: + wait_for_active (bool, optional): Whether to wait for the lease to become active. Defaults to True. + wait_timeout (int, optional): The maximum time to wait for the lease to become active, in seconds. Defaults to 300. + show (List[str], optional): The types of lease information to display. Defaults to ["widget", "text"]. + idempotent (bool, optional): Whether to create the lease only if it doesn't already exist. Defaults to False. + + Raises: + ResourceError: If unable to create the lease. + + Returns: + None + """ if idempotent: existing_lease = self._get_existing_lease() if existing_lease: diff --git a/chi/server.py b/chi/server.py index eae470c..f0b4b73 100644 --- a/chi/server.py +++ b/chi/server.py @@ -1,9 +1,12 @@ from datetime import datetime from operator import attrgetter +from typing import Dict, List, Optional +from IPython.display import display, HTML import socket import time +import os -from novaclient.exceptions import NotFound +from novaclient.exceptions import NotFound, Conflict from novaclient.v2.flavor_access import FlavorAccess as NovaFlavor from novaclient.v2.keypairs import Keypair as NovaKeypair from novaclient.v2.servers import Server as NovaServer @@ -12,10 +15,10 @@ from .clients import connection, glance, nova, neutron from .exception import CHIValueError, ResourceError, ServiceError -from .context import get as get_from_context, session +from .context import get as get_from_context, session, DEFAULT_IMAGE_NAME, _is_ipynb from .image import get_image, get_image_id from .keypair import Keypair -from .network import (get_network_id, get_or_create_floating_ip, +from .network import (get_network, get_network_id, get_or_create_floating_ip, get_floating_ip, get_free_floating_ip) from .util import random_base32, sshkey_fingerprint @@ -43,9 +46,12 @@ 'wait_for_tcp', 'update_keypair', + + 'get_keypair', + 'list_keypair', ] -DEFAULT_IMAGE = 'CC-Ubuntu20.04' +DEFAULT_IMAGE = DEFAULT_IMAGE_NAME DEFAULT_NETWORK = 'sharednet1' BAREMETAL_FLAVOR = 'baremetal' @@ -70,10 +76,11 @@ def instance_create_args( server_args = { "name": name, - "flavor": flavor, - "image": image, + "flavorRef": get_flavor_id(flavor), + "imageRef": get_image_id(image), "scheduler_hints": {"reservation": reservation, }, "key_name": key, + "networks":net_ids, } if net_ids is None: @@ -85,211 +92,407 @@ def instance_create_args( # https://github.com/ChameleonCloud/horizon/blob/stable/liberty/openstack_dashboard/dashboards/project/instances/workflows/create_instance.py#L943 # appears to POST a JSON akin to # {"server": {..., "networks": [{"uuid": "e8c33574-5423-436c-a45b-5bab78071b8a"}] ...}, "os:scheduler_hints": ...}, - server_args["nics"] = [ - {"net-id": netid, "v4-fixed-ip": ""} for netid in net_ids + server_args["networks"] = [ + {"uuid": netid} for netid in net_ids ] server_args.update(kwargs) return server_args +class Server: + """ + Represents an instance. -class Server(object): - """A wrapper object referring to a server instance. + Args: + name (str): The name of the server. + reservation_id (Optional[str]): The reservation ID associated with the server. Defaults to None. + image_name (str): The name of the image to use for the server. Defaults to DEFAULT_IMAGE_NAME. + image (Optional[str]): The image ID or name to use for the server. Defaults to None. + flavor_name (str): The name of the flavor to use for the server. Defaults to BAREMETAL_FLAVOR. + key_name (str): The name of the keypair to use for the server. Defaults to None. + keypair (Optional[Keypair]): The keypair object to use for the server. Defaults to None. + network_name (str): The name of the network to use for the server. Defaults to DEFAULT_NETWORK. - This class is helpful if you want to use a more object-oriented programming - approach when building your infrastrucutre. With the Server abstraction, - you can for example do the following: + Attributes: + name (str): The name of the server. + reservation_id (Optional[str]): The reservation ID associated with the server. + image_name (str): The name of the image used for the server. + flavor_name (str): The name of the flavor used for the server. + keypair (Optional[Keypair]): The keypair object used for the server. + network_name (str): The name of the network used for the server. + id (Optional[str]): The ID of the server. + _addresses (Dict[str, List[str]]): The IP addresses associated with the server. + created_at (Optional[datetime]): The timestamp when the server was created. + host_id (Optional[str]): The ID of the host where the server is running. + host_status (Optional[str]): The status of the host where the server is running. + hypervisor_hostname (Optional[str]): The hostname of the hypervisor where the server is running. + is_locked (bool): Indicates whether the server is locked. + _status (Optional[str]): The status of the server. + fault (Optional[dict]): The fault information of the server. + """ - .. code-block:: python + def __init__(self, name: str, reservation_id: Optional[str] = None, image_name: str = DEFAULT_IMAGE_NAME, + image: Optional[str] = None, flavor_name: str = BAREMETAL_FLAVOR, + key_name: str = None, keypair: Optional[Keypair] = None, + network_name: str = DEFAULT_NETWORK): + self.name = name + self.reservation_id = reservation_id or None + # Add this once chi.image is implemented + # self.image = image or get_image(image_name) + self.image_name = image_name + self.flavor_name = flavor_name + + if keypair: + self.keypair = keypair + else: + update_keypair() + self.keypair = get_keypair(key_name or get_from_context("keypair_name")) - with Server(lease=my_lease, image=my_image) as server: - # When entering this block, the server is guaranteed to be - # in the "ACTIVE" state if it launched successfully. - server.associate_floating_ip() - # Interact with the server (via, e.g., SSH), then... - # When the block exits, the server will be terminated and deleted + self.network_name = network_name - The above example uses a context manager. The class can also be used - without a context manager: + self.conn = connection(session=session()) - .. code-block:: python + self.id: Optional[str] = None + self._addresses: Dict[str, List[str]] = {} + self.created_at: Optional[datetime] = None + self.host_id: Optional[str] = None + self.host_status: Optional[str] = None + self.hypervisor_hostname: Optional[str] = None + self.is_locked: bool = False + self._status: Optional[str] = None + self.fault: Optional[dict] = None - # Triggers the launch of the server instance - server = Server(lease=my_lease, image=my_image) - # Wait for server to be active - server.wait() - server.associate_floating_ip() - # Interact with the server, then... - server.delete() + @property + def addresses(self) -> Dict[str, List[str]]: + if self.id: + self.refresh() + return self._addresses - Attributes: - id (str): The ID of an existing server instance. Use this if you have - already launched the instance and just want a convenient wrapper - object for it. - lease (Lease): The Lease the instance will be launched under. - key (str): The name of the key pair to associate with the image. This - is only applicable if launching the image; key pairs cannot be - added to a server that has already been launched and wrapped via - the ``id`` attribute. - image (str): The name or ID of the disk iage to use. - name (str): A name to give the new instance. (Defaults to an - auto-generated name.) - net_ids (list[str]): A list of network IDs to associate the instance - with. The instance will obtain an IP address on each network - during boot. - - .. note:: - For bare metal instances, the number of network IDs cannot - exceed the number of enabled NICs on the bare metal node. - - kwargs: Additional keyword arguments to pass to Nova's server - :meth:`~novaclient.v2.servers.ServerManager.create` function. - """ + @property + def status(self) -> Optional[str]: + if self.id: + self.refresh() + return self._status - def __init__(self, id=None, lease=None, key=None, image=DEFAULT_IMAGE, - **kwargs): - kwargs.setdefault("session", session()) - self.session = kwargs.pop("session") - self.conn = connection(session=self.session) - self.neutron = neutron(session=self.session) - self.nova = nova(session=self.session) - self.glance = glance(session=self.session) - self.image = get_image(image) - self.flavor = show_flavor_by_name(BAREMETAL_FLAVOR) - - self.ip = None - self._fip = None - self._fip_created = False - self._preexisting = False - - kwargs.setdefault("_no_clean", False) - self._noclean = kwargs.pop("_no_clean") - - net_ids = kwargs.pop("net_ids", None) - net_name = kwargs.pop("net_name", DEFAULT_NETWORK) - if net_ids is None and net_name is not None: - net_ids = [get_network_id(net_name)] - - if id is not None: - self._preexisting = True - self.server = self.conn.compute.get_server(id) - elif lease is not None: - if key is None: - key = Keypair().key_name - server_kwargs = instance_create_args( - lease.node_reservation, - image=self.image, - flavor=self.flavor, - key=key, - net_ids=net_ids, - **kwargs - ) - self.server = self.conn.compute.create_server(**server_kwargs) - else: - raise CHIValueError( - "Missing required argument: 'id' or 'lease' required.") + def submit(self, wait_for_active: bool = True, show: str = "widget", + idempotent: bool = False) -> 'Server': + """ + Submits a server creation request to the Nova API. - self.id = self.server.id - self.name = self.server.name + Args: + wait_for_active (bool, optional): Whether to wait for the server to become active before returning. Defaults to True. + show (str, optional): The type of server information to display after creation. Defaults to "widget". + idempotent (bool, optional): Whether to create the server only if it doesn't already exist. Defaults to False. - def __repr__(self): - return "<{} '{}' ({})>".format(self.__class__.__name__, self.name, self.id) + Returns: + Server: The created server object. + + Raises: + Conflict: If the server creation fails due to a conflict and idempotent mode is not enabled. + """ + nova_client = nova() + + if idempotent: + existing_server = nova_client.servers.get(get_server_id(self.name)) + if existing_server: + server = Server._from_nova_server(existing_server) + if wait_for_active: + self.wait() + if show: + server.show(type=show) + return server + + server_args = instance_create_args(reservation=self.reservation_id, + name=self.name, + image=self.image_name, + flavor=self.flavor_name, + key=self.keypair.name, + net_ids=[get_network_id(DEFAULT_NETWORK)]) + try: + nova_server = self.conn.compute.create_server(**server_args) + except Conflict as e: + if idempotent: + # If creation failed due to conflict and we're in idempotent mode, + # try to fetch the existing server + existing_server = nova_client.servers.get(get_server_id(self.name)) + if existing_server: + server = Server._from_nova_server(existing_server) + return server + raise e # Re-raise the exception if not handled + + server = Server._from_nova_server(nova_server) + + if wait_for_active: + self.wait() + + if show: + server.show(type=show) + + return server + + @classmethod + def _from_nova_server(cls, nova_server): + try: + image_id = nova_server.image['id'] + except Exception: + image_id = nova_server.image_id + + try: + flavor_id = nova_server.flavor['id'] + except Exception: + flavor_id = nova_server.flavor_id + + try: + network_id = list(nova_server.networks.keys())[0] if len(nova_server.networks) > 0 else None + except Exception: + network_id = nova_server.networks[0]['uuid'] if len(nova_server.networks) > 0 else None + + server = cls(name=nova_server.name, + reservation_id=None, + image_name=get_image(image_id).name, + flavor_name=get_flavor(flavor_id).name, + key_name=nova_server.key_name, + network_name=get_network(network_id)['name'] if network_id is not None else None ) - def __enter__(self): - self.wait() - return self + try: + created_at = nova_server.created + except Exception: + created_at = nova_server.created_at + + try: + host_id = nova_server.hostId + except Exception: + host_id = nova_server.host_id + + try: + host_status = nova_server.host_status + except Exception: + host_status = None - def __exit__(self, exc_type, exc, exc_tb): - if exc is not None and self._noclean: - print("Instance existing uncleanly (noclean = True).") - return + try: + hypervisor_hostname = nova_server.hypervisor_hostname + except Exception: + hypervisor_hostname = None - self.disassociate_floating_ip() - if not self._preexisting: - self.delete() + try: + is_locked = nova_server.is_locked + except Exception: + is_locked = None + + server.id = nova_server.id + server._status = nova_server.status + server._addresses = nova_server.addresses + server.created_at = created_at + server.host_id = host_id + server.host_status = host_status + server.hypervisor_hostname = hypervisor_hostname + server.is_locked = is_locked + server.fault = connection(session()).compute.get_server(get_server_id(server.name)).fault + + return server + + def delete(self) -> None: + delete_server(self.id) def refresh(self): - """Poll the latest state of the server instance.""" - now = datetime.now() + """ + Refreshes the server's information by retrieving the latest details from the server provider. + + Raises: + ResourceError: If the server refresh fails. + """ try: - lr = self._last_refresh - except AttributeError: - pass # expected failure on first pass - else: - # limit refreshes to once/sec. - if (now - lr).total_seconds() < 1: + nova_server = nova().servers.get(get_server_id(self.name)) + conn_server = self.conn.compute.get_server(get_server_id(self.name)) + + + self.id = nova_server.id + self._status = nova_server.status + self._addresses = nova_server.addresses + self.created_at = nova_server.created + self.host_id = nova_server.hostId + self.host_status = conn_server.host_status + self.hypervisor_hostname = conn_server.hypervisor_hostname + self.is_locked = conn_server.is_locked + self.fault = conn_server.fault + except Exception as e: + raise ResourceError(f"Could not refresh server: {e}") + + def wait(self, status: str = "ACTIVE") -> None: + """ + Waits for the server's status to reach the specified status. + + Args: + status (str): The status to wait for. Defaults to "ACTIVE". + + Raises: + ServiceError: If the server does not reach the specified status within the timeout period. + + Returns: + None + """ + print(f"Waiting for server {self.name}'s status to turn to {status}. This can take up to 18 minutes") + timeout = 1800 # 12 minutes + start_time = time.time() + while time.time() - start_time < timeout: + self.refresh() + if self.status == status.upper() or self.status == "ERROR": + print(f"Server has moved to status {self.status}") return + time.sleep(5) # Wait for 5 seconds before checking again + raise ServiceError(f"Timeout waiting for server to reach {status} status") - self.server = self.conn.compute.get_server(self.server) - self._last_refresh = now + def show(self, type: str = "text", wait_for_active: bool = False) -> None: + """ + Display the content of the server. - @property - def status(self) -> str: - """Get the instance status.""" - self.refresh() - return self.server.status + Args: + type (str, optional): The type of content to display. options are ["text","widget"]. Defaults to "text". + wait_for_active (bool, optional): Whether to wait for the server to be active before displaying the content. Defaults to False. - @property - def ready(self) -> bool: - """Check if the instance is marked as active.""" - return self.status == "ACTIVE" + Raises: + CHIValueError: If an invalid show type is provided. - @property - def error(self) -> bool: - """Check if the instance is in an error state.""" - return self.status == "ERROR" + Returns: + None + """ + if wait_for_active: + self.wait("ACTIVE") + + if type == "text": + self._show_text(self) + elif type == "widget" and _is_ipynb(): + self._show_widget(self) + else: + raise CHIValueError("Invalid show type. Use 'text' or 'widget'.") + + def _show_text(self, server): + print(f"Server: {server.name}") + print(f" ID: {server.id}") + print(f" Status: {server.status}") + print(f" Image Name: {server.image_name}") + print(f" Flavor Name: {server.flavor_name}") + print(f" Network Name: {server.network_name}") + print(f" Addresses: {server.addresses}") + print(f" Created at: {server.created_at}") + print(f" Keypair: {server.keypair.name if server.keypair else 'N/A'}") + print(f" Host ID: {server.host_id}") + print(f" Reservation ID: {server.reservation_id}") + print(f" Host Status: {server.host_status}") + print(f" Hypervisor Hostname: {server.hypervisor_hostname}") + print(f" Is Locked: {server.is_locked}") + print(f" Fault: {server.fault}") + + def _show_widget(self, server): + html = "" + html += "" + html += "" + html += f"" + html += "" + + attributes = [ + 'id', 'status', 'image_name', 'flavor_name', 'addresses', 'network_name', + 'created_at', 'keypair', 'reservation_id', 'host_id', 'host_status', + 'hypervisor_hostname', 'is_locked', 'fault' + ] + + for attr in attributes: + html += "" + html += f"" + value = getattr(server, attr) + if attr == 'addresses': + value = self._format_addresses(value) + elif attr == 'keypair': + value = value.name if value else 'N/A' + html += f"" + html += "" + + html += "
Attribute{server.name}
{attr.replace('_', ' ').title()}{value}
" + display(HTML(html)) + + def _format_addresses(self, addresses): + formatted = "" + for network, address_list in addresses.items(): + formatted += f"{network}:
" + for address in address_list: + formatted += (f"  IP: {address['addr']} (v{address['version']})
" + f"  Type: {address['OS-EXT-IPS:type']}
" + f"  MAC: {address['OS-EXT-IPS-MAC:mac_addr']}
") + return formatted + + def associate_floating_ip(self, fip: Optional[str] = None) -> None: + """ + Associates a floating IP with the server. + + Args: + fip (str, optional): The floating IP to associate with the server. If not provided, a new floating IP will be allocated. - def wait(self): - """Wait for the server instance to finish launching. + Returns: + None + """ + associate_floating_ip(self.id, fip) - If the server goes into an error state, this function will return early. + def detach_floating_ip(self, fip: str) -> None: """ - self.conn.compute.wait_for_server(self.server, wait=(60 * 20)) - - def associate_floating_ip(self): - """Attach a floating IP to this server instance.""" - if self.ip is not None: - return self.ip - - self._fip, self._fip_created = get_or_create_floating_ip() - self.ip = self._fip["floating_ip_address"] - self.conn.compute.add_floating_ip_to_server(self.server.id, self.ip) - return self.ip - - def disassociate_floating_ip(self): - """Detach the floating IP attached to this server instance, if any.""" - if self.ip is None: - return - - self.conn.compute.remove_floating_ip_from_server( - self.server.id, self.ip) - if self._fip_created: - self.neutron.delete_floatingip(self._fip["id"]) - - self.ip = None - self._fip = None - self._fip_created = False - - def delete(self): - """Delete this server instance.""" - self.conn.compute.delete_server(self.server) - self.conn.compute.wait_for_delete(self.server) - - def rebuild(self, image_ref): - """Rebuild this server instance. - - .. note:: - For bare metal instances, this effectively redeploys to the host and - overwrites the local disk. + Detaches a floating IP from the server. + + Args: + fip (str): The floating IP to detach. + + Returns: + None """ - self.image = get_image(image_ref) - self.conn.compute.rebuild_server(self.server, image=self.image.id) + detach_floating_ip(self.id, fip) + + def check_connectivity(self, wait: bool = True, port: int = 22, timeout: int = 500, + type: Optional[str] = "widget") -> bool: + # Implementation for checking server connectivity + pass + def upload(self, file: str, remote_path: str = "") -> None: + # Implementation for uploading files to the server + pass + + def execute(self, command: str): + # Implementation for executing commands on the server + pass ########## # Flavors ########## +class Flavor: + """ + Represents a flavor in the system. + + Attributes: + name (str): The name of the flavor. + disk (int): The disk size in GB. + ram (int): The RAM size in MB. + vcpus (int): The number of virtual CPUs. + """ + + def __init__(self, name: str, disk: int, ram: int, vcpus: int): + self.name = name + self.disk = disk + self.ram = ram + self.vcpus = vcpus + + def __repr__(self): + return f"<{self.__class__.__name__} '{self.name}' (disk={self.disk}) (ram={self.ram}) (vcpus={self.vcpus})>" + + +def list_flavors() -> List[Flavor]: + """Get a list of all available flavors. + + Returns: + A list of all flavors. + """ + nova_client = nova() + flavors = nova_client.flavors.list() + return [Flavor(name=f.name, disk=f.disk, ram=f.ram, vcpus=f.vcpus) for f in flavors] + + def get_flavor(ref) -> NovaFlavor: """Get a flavor by its ID or name. @@ -323,7 +526,7 @@ def get_flavor_id(name) -> str: flavor = next((f for f in nova().flavors.list() if f.name == name), None) if not flavor: raise CHIValueError(f'No flavors found matching name {name}') - return flavor + return flavor.id def show_flavor(flavor_id) -> NovaFlavor: @@ -354,35 +557,38 @@ def show_flavor_by_name(name) -> NovaFlavor: return show_flavor(flavor_id) -def list_flavors() -> 'list[NovaFlavor]': - """Get a list of all available flavors. - - Returns: - A list of all flavors. - """ - return nova().flavors.list() - - ########## # Servers ########## -def get_server(ref) -> NovaServer: - """Get a server by its ID. +from typing import List + +def list_servers() -> List[Server]: + """ + Returns a list of all servers in the current project. + + :return: A list of Server objects representing the servers. + """ + nova_servers = nova().servers.list() + servers = [Server._from_nova_server(server) for server in nova_servers] + return servers + +def get_server(name: str) -> Server: + """ + Retrieves a server object by its name. Args: - ref (str): The ID or name of the server. + name (str): The name of the server to retrieve. Returns: - The server matching the ID. + Server: The server object corresponding to the given name. Raises: - NotFound: If the server could not be found. + Exception: If the server with the given name does not exist. + """ - try: - return show_server(ref) - except NotFound: - return show_server(get_server_id(ref)) + nova_server = nova().servers.get(get_server_id(name)) + return Server._from_nova_server(nova_server) def get_server_id(name) -> str: @@ -551,6 +757,48 @@ def wait_for_tcp(host, port, timeout=(60 * 20), sleep_time=5): # Key pairs ############ +class Keypair: + """ + Represents a keypair object. + + Attributes: + name (str): The name of the keypair. + public_key (str): The public key associated with the keypair. + """ + def __init__(self, name: str, public_key: str): + self.name = name + self.public_key = public_key + def __repr__(self): + return f"<{self.__class__.__name__} '{self.name}' ({self.public_key})>" + +def get_keypair(name=None) -> Keypair: + """ + Retrieves a keypair by name. + + Args: + name (str, optional): The name of the keypair to retrieve. If not provided, + it will use the JupyterHub keypair for the current user. + + Returns: + Keypair: An instance of the Keypair class representing the retrieved keypair. + """ + if name is None: + name = get_from_context("keypair_name") + + nova_client = nova() + keypair = nova_client.keypairs.get(name) + return Keypair(name=keypair.name, public_key=keypair.public_key) + +def list_keypair() -> List[Keypair]: + """ + Retrieve a list of keypairs from the Nova client. + + Returns: + A list of Keypair objects, containing the name and public key of each keypair. + """ + nova_client = nova() + keypairs = nova_client.keypairs.list() + return [Keypair(name=kp.name, public_key=kp.public_key) for kp in keypairs] def update_keypair(key_name=None, public_key=None) -> "NovaKeypair": """Update a key pair's public key. @@ -592,7 +840,6 @@ def update_keypair(key_name=None, public_key=None) -> "NovaKeypair": return _nova.keypairs.create( key_name, public_key=public_key, key_type="ssh") - ########## # Wizards ##########