diff --git a/chi/container.py b/chi/container.py index 1a00509..b8f8688 100644 --- a/chi/container.py +++ b/chi/container.py @@ -23,8 +23,10 @@ from typing import List, Tuple, Optional from IPython.display import display, HTML +from zunclient.exceptions import NotFound + from .clients import zun -from .exception import ResourceError +from .exception import CHIValueError, ResourceError from .network import bind_floating_ip, get_free_floating_ip, get_network_id if typing.TYPE_CHECKING: @@ -81,7 +83,7 @@ def __init__(self, exposed_ports: List[str], reservation_id: str = None, start: bool = True, - start_timeout: int = None, + start_timeout: int = 0, runtime: str = None): self.name = name self.image_ref = image_ref @@ -133,8 +135,11 @@ def submit(self, wait_for_active: bool = True, wait_timeout: int = None, if idempotent: existing = get_container(self.name) if existing: - self.__dict__.update(existing.__dict__) - return + if wait_for_active: + existing.wait(status="Running", timeout=wait_timeout) + if show: + existing.show(type=show, wait_for_active=wait_for_active) + return existing container = create_container( name=self.name, @@ -152,7 +157,7 @@ def submit(self, wait_for_active: bool = True, wait_timeout: int = None, else: raise ResourceError("could not create container") - if wait_for_active: + if wait_for_active and self.status != "Running": self.wait(status="Running", timeout=wait_timeout) if show: @@ -198,7 +203,7 @@ def show(self, type: str = "text", wait_for_active: bool = False): type (str, optional): The type of display. Can be "text" or "widget". Defaults to "text". wait_for_active (bool, optional): Whether to wait for the container to be in the "Running" state before displaying information. Defaults to False. """ - if wait_for_active: + if wait_for_active and self.status != "Running": self.wait(status="Running") zun_container = get_container(self.id) @@ -403,7 +408,10 @@ def get_container(name: str) -> Optional[Container]: Returns: Optional[Container]: The retrieved container object, or None if the container does not exist. """ - zun_container = zun().containers.get(name) + try: + zun_container = zun().containers.get(name) + except NotFound: + return None return Container.from_zun_container(zun_container) @@ -519,6 +527,7 @@ def wait_for_active(container_ref: "str", timeout: int = (60 * 2)) -> "Container def _wait_for_status( container_ref: "str", status: "str", timeout: int = (60 * 2) ) -> "Container": + print(f"Waiting for container {container_ref} status to turn to Running. This can take a while depending on the image") start_time = time.perf_counter() while True: diff --git a/chi/context.py b/chi/context.py index cf359e9..752cd94 100644 --- a/chi/context.py +++ b/chi/context.py @@ -29,6 +29,7 @@ DEFAULT_IMAGE_NAME = "CC-Ubuntu22.04" DEFAULT_NODE_TYPE = "compute_skylake" DEFAULT_AUTH_TYPE = "v3token" +DEFAULT_NETWORK = "sharednet1" CONF_GROUP = "chi" RESOURCE_API_URL = os.getenv("CHI_RESOURCE_API_URL", "https://api.chameleoncloud.org") @@ -408,11 +409,27 @@ def choose_site() -> None: 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') + + site_dropdown = widgets.Dropdown( + options=_sites.keys(), + description="Select Site" + ) + + output = widgets.Output() + + def on_change(change): + with output: + output.clear_output() + print(f"Selected site: {change['new']}") + use_site(change['new']) + + site_dropdown.observe(on_change, names='value') + + display(widgets.VBox([site_dropdown, output])) else: print("Choose site feature is only available in an ipynb environment.") @@ -479,10 +496,31 @@ def choose_project() -> None: 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') + projects = list_projects() + + project_dropdown = widgets.Dropdown( + options=projects, + description="Select Project" + ) + + output = widgets.Output() + + def on_change(change): + with output: + output.clear_output() + print(f"Selected project: {change['new']}") + use_project(change['new']) + + project_dropdown.observe(on_change, names='value') + + # Use the first project as the default + use_project(projects[0]) + + # Display the initial selection + with output: + print(f"Initial project: {projects[0]}") + + display(widgets.VBox([project_dropdown, output])) else: print("Choose project feature is only available in Jupyter notebook environment.") diff --git a/chi/hardware.py b/chi/hardware.py index 51abea4..e25e57a 100644 --- a/chi/hardware.py +++ b/chi/hardware.py @@ -10,6 +10,8 @@ LOG = logging.getLogger(__name__) +node_types = [] + @dataclass class Node: """ @@ -38,36 +40,41 @@ def next_free_timeslot(self) -> Tuple[datetime, Optional[datetime]]: A tuple containing the start and end datetime of the next available timeslot. If no timeslot is available, returns (end_datetime_of_last_allocation, None). """ - raise NotImplementedError + def get_host_id(items, target_uid): + for item in items: + if item.get('uid') == target_uid: + return item['id'] + return None + blazarclient = blazar() - # Get allocations for this specific host - allocations = blazarclient.allocation.get(resource_id=self.uid) + # Get allocation for this specific host + host_id = get_host_id(blazarclient.host.list(), self.uid) - # Sort allocations by start time - allocations.sort(key=lambda x: x['start_date']) + allocation = blazarclient.host.get_allocation(host_id) now = datetime.now(timezone.utc) - if not allocations: + if not allocation: return (now, None) - # Check if there's a free slot now - if datetime.fromisoformat(allocations[0]['start_date']) > now: - return (now, datetime.fromisoformat(allocations[0]['start_date'])) + reservations = sorted(allocation['reservations'], key=lambda x: x['start_date']) - # Find the next free slot - for i in range(len(allocations) - 1): - current_end = datetime.fromisoformat(allocations[i]['end_date']) - next_start = datetime.fromisoformat(allocations[i+1]['start_date']) + def parse_datetime(dt_str: str) -> datetime: + dt = datetime.fromisoformat(dt_str) + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + if parse_datetime(reservations[0]['start_date']) > now: + return (now, parse_datetime(reservations[0]['start_date'])) + for i in range(len(reservations) - 1): + current_end = parse_datetime(reservations[i]['end_date']) + next_start = parse_datetime(reservations[i+1]['start_date']) if current_end < next_start: return (current_end, next_start) - # If no free slot found, return the end of the last allocation - last_end = datetime.fromisoformat(allocations[-1]['end_date']) + last_end = parse_datetime(reservations[-1]['end_date']) return (last_end, None) @@ -135,6 +142,8 @@ def get_nodes( uid=node_data.get("uid"), version=node_data.get("version"), ) + if node.type not in node_types: + node_types.append(node.type) if isinstance(node.gpu, list): gpu_filter = gpu is None or (node.gpu and gpu == bool(node.gpu[0]['gpu'])) @@ -146,4 +155,15 @@ def get_nodes( if gpu_filter and cpu_filter: nodes.append(node) - return nodes \ No newline at end of file + return nodes + +def get_node_types() -> List[str]: + """ + Retrieve a list of unique node types. + + Returns: + List[str]: A list of unique node types. + """ + if len(node_types) < 1: + get_nodes() + return list(set(node_types)) \ No newline at end of file diff --git a/chi/image.py b/chi/image.py index 7dfcbaa..68c5a7c 100644 --- a/chi/image.py +++ b/chi/image.py @@ -9,24 +9,105 @@ 'list_images', ] +from dataclasses import dataclass +from datetime import datetime +from typing import List, Optional -def get_image(ref): - """Get an image by its ID or name. +from .clients import glance +from .exception import CHIValueError, ResourceError +from glanceclient.exc import NotFound + +__all__ = [ + 'get_image', + 'get_image_id', + 'list_images', +] + +@dataclass +class Image: + uuid: str + created_at: datetime + is_chameleon_supported: bool + name: str + + @staticmethod + def from_glance_image(glance_image) -> 'Image': + """Convert a glance image object to an Image object. + + Args: + glance_image: The glance image object. + + Returns: + Image: The Image object. + """ + if 'build-repo' in glance_image: + return Image( + uuid=glance_image.id, + created_at=glance_image.created_at, + is_chameleon_supported=(glance_image['build-repo'] == 'https://github.com/ChameleonCloud/cc-images'), + name=glance_image.name + ) + else: + return Image( + uuid=glance_image.id, + created_at=glance_image.created_at, + is_chameleon_supported=False, + name=glance_image.name + ) + +def list_images(is_chameleon_supported: Optional[bool] = False) -> List[Image]: + """List all images available at the current site, filtered by support status. Args: - ref (str): The ID or name of the image. + is_chameleon_supported (bool, optional): Filter images by Chameleon support. Defaults to True. Returns: - The image matching the ID or name. + List[Image]: A list of Image objects. + """ + if is_chameleon_supported: + glance_images = glance().images.list(filters={'build-repo': 'https://github.com/ChameleonCloud/cc-images'}) + else: + glance_images = glance().images.list() + return [Image.from_glance_image(image) for image in glance_images] + +def get_image(name: str) -> Image: + """Get an image by its name. + + Args: + name (str): The name of the image. + + Returns: + Image: The Image object matching the name. Raises: - NotFound: If the image could not be found. + CHIValueError: If no image is found with the given name. + ResourceError: If multiple images are found with the same name. + """ + glance_images = list(glance().images.list(filters={'name': name})) + if not glance_images: + raise CHIValueError(f'No images found matching name "{name}"') + elif len(glance_images) > 1: + raise ResourceError(f'Multiple images found matching name "{name}"') + + return Image.from_glance_image(glance_images[0]) + +def get_image_name(id: str) -> str: + """Look up an image's name from its ID. + + Args: + id (str): The ID of the image. + + Returns: + str: The name of the found image. + + Raises: + CHIValueError: If the image could not be found. """ try: - return glance().images.get(ref) + image = glance().images.get(id) + return image.name except NotFound: - return glance().images.get(get_image_id(ref)) - + raise CHIValueError(f'No image found with ID "{id}"') def get_image_id(name): """Look up an image's ID from its name. @@ -44,15 +125,4 @@ def get_image_id(name): images = list(glance().images.list(filters={'name': name})) if not images: raise CHIValueError(f'No images found matching name "{name}"') - elif len(images) > 1: - raise ResourceError(f'Multiple images found matching name "{name}"') return images[0].id - - -def list_images(): - """List all images under the current project. - - Returns: - All images associated with the current project. - """ - return list(glance().images.list()) diff --git a/chi/lease.py b/chi/lease.py index 65ae7b3..f960580 100644 --- a/chi/lease.py +++ b/chi/lease.py @@ -352,7 +352,7 @@ def add_network_reservation(self, def submit(self, wait_for_active: bool = True, wait_timeout: int = 300, - show: List[str] = ["widget", "text"], + show: Optional[str] = None, idempotent: bool = False): """ Submits the lease for creation. @@ -360,7 +360,7 @@ def submit(self, 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"]. + show (Optional[str], optional): The types of lease information to display. Defaults to None, options are "widget", "text". idempotent (bool, optional): Whether to create the lease only if it doesn't already exist. Defaults to False. Raises: @@ -370,9 +370,13 @@ def submit(self, None """ if idempotent: - existing_lease = self._get_existing_lease() + existing_lease = _get_lease_from_blazar(self.name) if existing_lease: self._populate_from_json(existing_lease) + if wait_for_active: + self.wait(status="active", timeout=wait_timeout) + if show: + self.show(type=show, wait_for_active=wait_for_active) return reservations = self.device_reservations + self.node_reservations + self.fip_reservations + self.network_reservations @@ -390,13 +394,8 @@ def submit(self, 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); + if show: + self.show(type=show, wait_for_active=wait_for_active) def wait(self, status="active", timeout=300): print("Waiting for lease to start... This can take up to 60 seconds") @@ -915,21 +914,12 @@ def list_leases() -> List[Lease]: 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: - A Lease object matching the ID or name, or None if not found. - """ +def _get_lease_from_blazar(ref: str): blazar_client = blazar() try: lease_dict = blazar_client.lease.get(ref) - return Lease(lease_json=lease_dict) + return 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 @@ -939,14 +929,30 @@ def get_lease(ref: str) -> Union[Lease, None]: try: lease_id = get_lease_id(ref) lease_dict = blazar_client.lease.get(lease_id) - return Lease(lease_json=lease_dict) - except BlazarClientException: + return lease_dict + except Exception: # If we still can't find the lease, return None return None else: raise +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: + A Lease object matching the ID or name, or None if not found. + """ + blazar_lease = _get_lease_from_blazar(ref) + if blazar_lease == None: + raise CHIValueError(f"Lease not found maching {ref}") + return Lease(lease_json=blazar_lease) + + def get_lease_id(lease_name) -> str: """Look up a lease's ID from its name. @@ -1020,9 +1026,8 @@ def delete_lease(ref): ref (str): The name or ID of the lease. """ lease = get_lease(ref) - lease_id = lease["id"] - blazar().lease.delete(lease_id) - print(f"Deleted lease with id {lease_id}") + lease.delete() + print(f"Deleted lease {ref}") def wait_for_active(ref): diff --git a/chi/magic.py b/chi/magic.py new file mode 100644 index 0000000..ce744dc --- /dev/null +++ b/chi/magic.py @@ -0,0 +1,253 @@ +from typing import Optional, List +from datetime import timedelta +from IPython.display import display + +from .server import Server +from .container import Container +from .exception import ResourceError +from .lease import Lease, delete_lease +from .image import list_images +from .hardware import get_node_types +from .context import DEFAULT_NODE_TYPE, DEFAULT_IMAGE_NAME, DEFAULT_SITE, DEFAULT_NETWORK, get + +import networkx as nx +import matplotlib.pyplot as plt + + +def visualize_resources(leases: List[Lease]): + """ + Displays a visualization of the resources associated with the leases in a graph. + + Parameters: + leases (List[Lease]): A list of Lease objects. + + Returns: + None + """ + + G = nx.Graph() + + # Colors for different resource types + colors = { + 'node': '#ADD8E6', # Light Blue + 'network': '#90EE90', # Light Green + 'fip': '#FFB6C1', # Light Pink + 'device': '#FAFAD2', # Light Goldenrod + 'idle': '#D3D3D3' # Light Gray + } + + node_positions = {} + node_colors = [] + node_labels = {} + + # Counter for positioning + node_count = 0 + network_count = 0 + fip_count = 0 + device_count = 0 + + for lease in leases: + for node_res in lease.node_reservations: + node_name = f"Node_{node_count}" + G.add_node(node_name) + node_positions[node_name] = (node_count, 0) + node_colors.append(colors['node']) + node_labels[node_name] = f"Node\n{node_res.get('min', 'N/A')}-{node_res.get('max', 'N/A')}" + node_count += 1 + + for net_res in lease.network_reservations: + net_name = f"Net_{network_count}" + G.add_node(net_name) + node_positions[net_name] = (network_count, 1) + node_colors.append(colors['network']) + node_labels[net_name] = f"Network\n{net_res.get('network_name', 'N/A')}" + network_count += 1 + + for fip_res in lease.fip_reservations: + fip_name = f"FIP_{fip_count}" + G.add_node(fip_name) + node_positions[fip_name] = (fip_count, 2) + node_colors.append(colors['fip']) + node_labels[fip_name] = f"FIP\n{fip_res.get('amount', 'N/A')}" + fip_count += 1 + + for device_res in lease.device_reservations: + device_name = f"Device_{device_count}" + G.add_node(device_name) + node_positions[device_name] = (device_count, 3) + node_colors.append(colors['device']) + node_labels[device_name] = f"Device\n{device_res.get('min', 'N/A')}-{device_res.get('max', 'N/A')}" + device_count += 1 + + idle_resources = max(node_count, network_count, fip_count, device_count) + for i in range(idle_resources): + idle_name = f"Idle_{i}" + G.add_node(idle_name) + node_positions[idle_name] = (i, 4) + node_colors.append(colors['idle']) + node_labels[idle_name] = "Idle" + + plt.figure(figsize=(12, 8)) + nx.draw(G, pos=node_positions, node_color=node_colors, node_size=3000, alpha=0.8) + nx.draw_networkx_labels(G, pos=node_positions, labels=node_labels, font_size=8) + + legend_elements = [plt.Line2D([0], [0], marker='o', color='w', label=f'{key.capitalize()}', + markerfacecolor=value, markersize=10) + for key, value in colors.items()] + plt.legend(handles=legend_elements, loc='upper right') + + plt.title("Resource Visualization") + plt.axis('off') + plt.tight_layout() + plt.show() + + +def cleanup_resources(lease_name: str): + """ + Cleans up resources associated with the given lease name. + + Args: + laese_name (str): The name of the lease to be cleaned up. + """ + delete_lease(lease_name) + + +def create_container( + container_name: str, + device_type: str, + container_image: str, + device_name: Optional[str] = None, + reserve_fip: bool = True, + exposed_ports: Optional[List[str]] = None, + runtime: Optional[str] = None, + duration: timedelta = timedelta(hours=24), + show: str = "widget", +) -> Container: + """Creates a lease for a device and then creates a container on it. + + Args: + container_name (str): The name of the container. + device_type (str): The type of device (e.g., compute, storage). + container_image (str): The image to use for the container. + device_name (str, optional): The name of the device. Defaults to None. + reserve_fip (bool, optional): Whether to reserve a floating IP for the container. Defaults to True. + duration (timedelta, optional): Duration for the lease. Defaults to 6 hours. + show (str, optional): Determines whether to display information as a widget. Defaults to "widget". + exposed_ports (List[str], optional): List of ports to expose on the container. Defaults to None. + runtime (str, optional): can be set to nvidia to enable GPU support on supported devices. Defaults to None. + + Returns: + Container: The created container object. + """ + if get("region_name") != "CHI@Edge": + raise ResourceError("Launching containers is only supported on CHI@Edge, please launch servers or change site") + + lease = Lease( + name=f"lease-{container_name}", + duration=duration + ) + + lease.add_device_reservation(amount=1, + machine_type=device_type, + device_name=device_name) + lease.submit(idempotent=True) + + if show == "widget": + lease.show(type="widget") + + container_name = container_name.replace('_', '-') + container_name = container_name.lower() + + container = Container( + name=container_name, + reservation_id=lease.device_reservations[0]['id'], + image_ref=container_image, + exposed_ports=exposed_ports, + runtime=runtime + ) + + container = container.submit(wait_for_active=True, show=show, idempotent=True) + + if reserve_fip: + container.associate_floating_ip() + + return container + + +def create_server( + server_name: str, + network_name: Optional[str] = DEFAULT_NETWORK, + node_type: Optional[str] = None, + image_name: Optional[str] = None, + reserve_fip: bool = True, + duration: timedelta = timedelta(hours=24), + show: str = "widget" +) -> Server: + """ + Creates a server with the given parameters. + + Args: + server_name (str): The name of the server. + node_type (str, optional): The type of the server node. If not provided, the user will be prompted to choose from available options. + image_name (str, optional): The name of the server image. If not provided, the user will be prompted to choose from available options. + reserve_fip (bool, optional): Whether to reserve a floating IP for the server. Defaults to True. + duration (timedelta, optional): The duration of the server lease. Defaults to 24 hours. + show (str, optional): The type of output to show. Defaults to "widget". + + Returns: + Server: The created server object. + + """ + if get("region_name") == "CHI@Edge": + raise ResourceError("Launching servers is not supported on CHI@Edge, please launch containers or change site") + + if node_type is None: + node_type = _get_user_input( + options=get_node_types(), + description="Node Type" + ) + + if image_name is None: + image_name = _get_user_input( + options=[image.name for image in list_images()], + description="Image" + ) + + lease = Lease( + name=f"lease-{server_name}", + duration=duration + ) + + lease.add_node_reservation(amount=1, node_type=node_type) + lease.submit(idempotent=True) + + if show == "widget": + lease.show(type="widget") + + server = Server( + name=server_name, + reservation_id=lease.node_reservations[0]['id'], + image_name=image_name, + network_name=network_name # Change this to a DEFAULT var ASAP + ) + + server = server.submit(wait_for_active=True, show=show, idempotent=True) + + if reserve_fip: + server.associate_floating_ip() + + return server + + +def _get_user_input(options: List[str], description: str) -> str: + print(f"Available {description}s:") + for option in options: + print(f"- {option}") + + selected_value = input(f"Please enter the {description} from the list above: ") + + while selected_value not in options: + print(f"Invalid {description}. Please choose from the list.") + selected_value = input(f"Please enter the {description} from the list above: ") + + return selected_value \ No newline at end of file diff --git a/chi/server.py b/chi/server.py index f0b4b73..903c8ec 100644 --- a/chi/server.py +++ b/chi/server.py @@ -16,7 +16,7 @@ from .clients import connection, glance, nova, neutron from .exception import CHIValueError, ResourceError, ServiceError from .context import get as get_from_context, session, DEFAULT_IMAGE_NAME, _is_ipynb -from .image import get_image, get_image_id +from .image import get_image_id, get_image_name from .keypair import Keypair from .network import (get_network, get_network_id, get_or_create_floating_ip, get_floating_ip, get_free_floating_ip) @@ -193,7 +193,8 @@ def submit(self, wait_for_active: bool = True, show: str = "widget", nova_client = nova() if idempotent: - existing_server = nova_client.servers.get(get_server_id(self.name)) + server_id = get_server_id(self.name) + existing_server = nova_client.servers.get(server_id) if server_id else None if existing_server: server = Server._from_nova_server(existing_server) if wait_for_active: @@ -211,14 +212,7 @@ def submit(self, wait_for_active: bool = True, show: str = "widget", 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 + raise ResourceError(e.message) # Re-raise the exception if not handled server = Server._from_nova_server(nova_server) @@ -249,7 +243,7 @@ def _from_nova_server(cls, nova_server): server = cls(name=nova_server.name, reservation_id=None, - image_name=get_image(image_id).name, + image_name=get_image_name(image_id), 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 ) @@ -422,7 +416,7 @@ def _format_addresses(self, addresses): def associate_floating_ip(self, fip: Optional[str] = None) -> None: """ - Associates a floating IP with the server. + Associates a floating IP with the server. (BROKEN) Args: fip (str, optional): The floating IP to associate with the server. If not provided, a new floating IP will be allocated. @@ -430,11 +424,12 @@ def associate_floating_ip(self, fip: Optional[str] = None) -> None: Returns: None """ + raise NotImplementedError("Floating IP association not working yet") associate_floating_ip(self.id, fip) def detach_floating_ip(self, fip: str) -> None: """ - Detaches a floating IP from the server. + Detaches a floating IP from the server. (BROKEN) Args: fip (str): The floating IP to detach. @@ -442,7 +437,8 @@ def detach_floating_ip(self, fip: str) -> None: Returns: None """ - detach_floating_ip(self.id, fip) + raise NotImplementedError("Floating IP dissociation not working yet") + detach_floating_ip(self.name, fip) def check_connectivity(self, wait: bool = True, port: int = 22, timeout: int = 500, type: Optional[str] = "widget") -> bool: @@ -605,7 +601,7 @@ def get_server_id(name) -> str: """ servers = [s for s in nova().servers.list() if s.name == name] if not servers: - raise CHIValueError(f'No matching servers found for name "{name}"') + return None elif len(servers) > 1: raise ResourceError(f'Multiple matching servers found for name "{name}"') return servers[0].id diff --git a/requirements.txt b/requirements.txt index 8d2fde5..3f47b48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,4 +12,6 @@ python-neutronclient python-novaclient python-zunclient ipython -ipywidgets \ No newline at end of file +ipywidgets +networkx +matplotlib \ No newline at end of file