diff --git a/README.md b/README.md index 7ae3202..2f9fbec 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ and optional details. If no amphoras match the filter criteria, it will indicate $ usage: openstack-lb-info [-h] [-o {plain,rich,json}] -t {lb,amphora} [--name NAME] [--id ID] [--tags TAGS] [--flavor-id FLAVOR_ID] [--vip-address VIP_ADDRESS] [--availability-zone AVAILABILITY_ZONE] [--vip-network-id VIP_NETWORK_ID] - [--vip-subnet-id VIP_SUBNET_ID] [--details] + [--vip-subnet-id VIP_SUBNET_ID] [--details] [--max-workers MAX_WORKERS] A script to show OpenStack load balancers information. @@ -54,19 +54,21 @@ options: -t {lb,amphora}, --type {lb,amphora} Show information about load balancers or amphoras --name NAME Filter load balancers name - --id ID Filter load balancers id + --id ID Filter load balancers id (UUID) --tags TAGS Filter load balancers tags --flavor-id FLAVOR_ID - Filter load balancers flavor id + Filter load balancers flavor id (UUID) --vip-address VIP_ADDRESS Filter load balancers VIP address --availability-zone AVAILABILITY_ZONE Filter load balancers AZ --vip-network-id VIP_NETWORK_ID - Filter load balancers network id + Filter load balancers network id (UUID) --vip-subnet-id VIP_SUBNET_ID - Filter load balancers subnet id + Filter load balancers subnet id (UUID) --details Show all load balancers/amphora details + --max-workers MAX_WORKERS + Max number of concurrent threads to fetch members details (1-32). (default: 4) Example of use: openstack-lb-info diff --git a/src/openstack_lb_info/__init__.py b/src/openstack_lb_info/__init__.py index ddfff52..3f1e72f 100644 --- a/src/openstack_lb_info/__init__.py +++ b/src/openstack_lb_info/__init__.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- """openstack-lb-info module.""" -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/src/openstack_lb_info/formatters.py b/src/openstack_lb_info/formatters.py index c3c7935..828ab2c 100644 --- a/src/openstack_lb_info/formatters.py +++ b/src/openstack_lb_info/formatters.py @@ -15,6 +15,7 @@ from abc import ABC, abstractmethod try: + from rich import progress from rich.console import Console from rich.highlighter import ReprHighlighter from rich.tree import Tree @@ -27,14 +28,6 @@ class OutputFormatter(ABC): """Abstract base class for output formatters.""" - @abstractmethod - def create_tree(self, name): - """Create a tree structure for the output.""" - - @abstractmethod - def add_to_tree(self, tree, content): - """Add content to the tree structure.""" - @abstractmethod def print_tree(self, tree): """Print the tree structure.""" @@ -47,54 +40,124 @@ def print(self, message): def status(self, message): """Display a status message.""" + @abstractmethod + def track_progress(self, sequence, description, total): + """ + Track progress of an iterable. + + Yields: + The items from the sequence. + """ + @abstractmethod def line(self): """Print a line separator.""" @abstractmethod def rule(self, title, align="center"): - """Print a rule with a title.""" + """Print a horizontal rule with a title.""" @abstractmethod def format_status(self, status): - """Format status text.""" + """Format a status string (e.g., 'ACTIVE') for display.""" + + @abstractmethod + def add_details_to_tree(self, tree, details_dict): + """Add a dictionary of detailed attributes to a tree node.""" + + @abstractmethod + def add_empty_node(self, tree, resource_name): + """Add a placeholder node for a resource that was not found.""" + + @abstractmethod + def add_lb_to_tree(self, lb): + """Create and return the root tree for a Load Balancer.""" + + @abstractmethod + def add_listener_to_tree(self, parent_tree, listener): + """Add a formatted listener node to a parent tree.""" + + @abstractmethod + def add_pool_to_tree(self, parent_tree, pool): + """Add a formatted pool node to a parent tree.""" + + @abstractmethod + def add_health_monitor_to_tree(self, parent_tree, hm): + """Add a formatted health monitor node to a parent tree.""" + + @abstractmethod + def add_member_to_tree(self, parent_tree, member): + """Add a formatted member node to a parent tree.""" + + @abstractmethod + def add_amphora_to_tree(self, parent_tree, amphora, server, image_name): + """Add a formatted amphora node to a parent tree.""" class RichOutputFormatter(OutputFormatter): """Formatter using the Rich library.""" def __init__(self): + """Initialize the Rich console and highlighter.""" self.console = Console() self.highlighter = ReprHighlighter() - def create_tree(self, name): + def _create_tree(self, name): + """Create a Rich Tree instance.""" return Tree(name) - def add_to_tree(self, tree, content, highlight=False): + def _add_to_tree(self, tree, content, highlight=False): + """Add a node to a Rich Tree.""" if highlight: content = self.highlighter(content) return tree.add(content) def print_tree(self, tree): + """Print a Rich Tree to the console.""" self.console.print(tree) def print(self, message): + """Print a message using the Rich console.""" self.console.print(message) def status(self, message): + """Display a status indicator using the Rich console.""" return self.console.status(message) + def track_progress(self, sequence, description, total=None): + """Track progress with a customized Rich progress bar.""" + progress_bar = progress.Progress( + progress.TextColumn("[progress.description]{task.description}"), + progress.BarColumn(), + progress.TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + progress.TextColumn("({task.completed} of {task.total})"), + progress.TimeRemainingColumn(), + console=self.console, + transient=True, + ) + + if total is None: + try: + total = len(sequence) + except TypeError: + total = None + + with progress_bar: + task_id = progress_bar.add_task(description, total=total) + for item in sequence: + progress_bar.update(task_id, advance=1) + yield item + def line(self): + """Print a line using the Rich console.""" self.console.line() def rule(self, title, align="center"): + """Print a Rich rule to the console.""" self.console.rule(title, align=align) - def format_message(self, message): - """Return the message as-is, preserving Rich formatting.""" - return message - def format_status(self, status): + """Format a status string with appropriate colors.""" status_colors = { "ACTIVE": "green", "ONLINE": "green", @@ -103,57 +166,218 @@ def format_status(self, status): color = status_colors.get(status, "red") return f"[{color}]{status}[/{color}]" + def add_details_to_tree(self, tree, details_dict): + """Add highlighted key-value pairs to the tree.""" + for attr in sorted(details_dict): + value = details_dict[attr] + content = f"{attr}: {value}" + self._add_to_tree(tree, content, highlight=True) + + def add_empty_node(self, tree, resource_name): + """Add a styled placeholder for a missing resource.""" + self._add_to_tree(tree, f"[b green]{resource_name}:[/] None") + + def add_lb_to_tree(self, lb): + """Create a styled root tree node for the Load Balancer.""" + message = ( + f"LB:[bright_yellow] {lb.id}[/] " + f"vip:[bright_cyan]{lb.vip_address}[/] " + f"prov_status:{self.format_status(lb.provisioning_status)} " + f"oper_status:{self.format_status(lb.operating_status)} " + f"tags:[magenta]{lb.tags}[/]" + ) + return self._create_tree(message) + + def add_listener_to_tree(self, parent_tree, listener): + """Add a styled listener node to the tree.""" + message = ( + f"[b green]Listener:[/] [b white]{listener.id}[/] " + f"([blue b]{listener.name}[/]) " + f"port:[cyan]{listener.protocol}/{listener.protocol_port}[/] " + f"prov_status:{self.format_status(listener.provisioning_status)} " + f"oper_status:{self.format_status(listener.operating_status)}" + ) + return self._add_to_tree(parent_tree, message) + + def add_pool_to_tree(self, parent_tree, pool): + """Add a styled pool node to the tree.""" + message = ( + f"[b green]Pool:[/] [b white]{pool.id}[/] " + f"protocol:[magenta]{pool.protocol}[/magenta] " + f"algorithm:[magenta]{pool.lb_algorithm}[/magenta] " + f"prov_status:{self.format_status(pool.provisioning_status)} " + f"oper_status:{self.format_status(pool.operating_status)}" + ) + return self._add_to_tree(parent_tree, message) + + def add_health_monitor_to_tree(self, parent_tree, hm): + """Add a styled health monitor node to the tree.""" + message = ( + f"[b green]Health Monitor:[/] [b white]{hm.id}[/] " + f"type:[magenta]{hm.type}[/magenta] " + f"http_method:[magenta]{hm.http_method}[/magenta] " + f"http_codes:[magenta]{hm.expected_codes}[/magenta] " + f"url_path:[magenta]{hm.url_path}[/magenta] " + f"prov_status:{self.format_status(hm.provisioning_status)} " + f"oper_status:{self.format_status(hm.operating_status)}" + ) + return self._add_to_tree(parent_tree, message) + + def add_member_to_tree(self, parent_tree, member): + """Add a styled member node to the tree.""" + message = ( + f"[b green]Member:[/] [b white]{member.id}[/] " + f"IP:[magenta]{member.address}[/magenta] " + f"port:[magenta]{member.protocol_port}[/magenta] " + f"weight:[magenta]{member.weight}[/magenta] " + f"backup:[magenta]{member.backup}[/magenta] " + f"prov_status:{self.format_status(member.provisioning_status)} " + f"oper_status:{self.format_status(member.operating_status)}" + ) + return self._add_to_tree(parent_tree, message) + + def add_amphora_to_tree(self, parent_tree, amphora, server, image_name): + """Add a styled amphora node to the tree.""" + server_id = server.id if server else "N/A" + server_flavor_name = server.flavor.name if server and server.flavor else "N/A" + server_compute_host = server.compute_host if server else "N/A" + + message = ( + f"[b green]amphora: [/]" + f"[b white]{amphora.id} [/]" + f"{amphora.role} " + f"{amphora.status} " + f"lb_network_ip:[green]{amphora.lb_network_ip} [/]" + f"img:[magenta]{image_name}[/] " + f"server:[magenta]{server_id}[/] " + f"vm_flavor:[magenta]{server_flavor_name}[/] " + f"compute host:([magenta]{server_compute_host}[/])" + ) + return self._add_to_tree(parent_tree, message) + class PlainOutputFormatter(OutputFormatter): """Formatter for plain text output.""" - def create_tree(self, name): + def _create_tree(self, name): return {"name": name, "children": []} - def add_to_tree(self, tree, content, highlight=False): - _ = highlight - child_tree = {"name": self.format_message(content), "children": []} + def _add_to_tree(self, tree, content): + child_tree = {"name": content, "children": []} tree["children"].append(child_tree) return child_tree def print_tree(self, tree, level=0): indent = " " * level - print(f"{indent}{self.format_message(tree['name'])}") + print(f"{indent}{tree['name']}") for child in tree.get("children", []): self.print_tree(child, level + 1) def print(self, message): - print(self.format_message(message)) + print(message) def status(self, message): @contextlib.contextmanager def plain_status(): - # Remove Rich formatting codes from the message - clean_message = self.format_message(message) - print(f"[STATUS] {clean_message}") + print(f"[STATUS] {message}") try: yield finally: - print(f"[STATUS] Completed: {clean_message}") + print(f"[STATUS] Completed: {message}") return plain_status() + def track_progress(self, sequence, description, total=None): + print(f"[STATUS] {description}...") + return sequence + def line(self): print() def rule(self, title, align="center"): - title = self.format_message(title) - print(f"{title}") - print("-" * len(title)) - - def format_message(self, message): - """Remove Rich text formatting tags from a message.""" - clean_message = re.sub(r"\[\/?[^\]]+\]", "", message) - return clean_message + clean_title = re.sub(r"\[\/?[^\]]+\]", "", title) + print(f"{clean_title}") + print("-" * len(clean_title)) def format_status(self, status): return status + def add_details_to_tree(self, tree, details_dict): + for attr in sorted(details_dict): + value = details_dict[attr] + content = f"{attr}: {value}" + self._add_to_tree(tree, content) + + def add_empty_node(self, tree, resource_name): + self._add_to_tree(tree, f"{resource_name}: None") + + def add_lb_to_tree(self, lb): + message = ( + f"LB: {lb.id} " + f"vip:{lb.vip_address} " + f"prov_status:{self.format_status(lb.provisioning_status)} " + f"oper_status:{self.format_status(lb.operating_status)} " + f"tags:{lb.tags}" + ) + return self._create_tree(message) + + def add_listener_to_tree(self, parent_tree, listener): + message = ( + f"Listener: {listener.id} ({listener.name}) " + f"port:{listener.protocol}/{listener.protocol_port} " + f"prov_status:{self.format_status(listener.provisioning_status)} " + f"oper_status:{self.format_status(listener.operating_status)}" + ) + return self._add_to_tree(parent_tree, message) + + def add_pool_to_tree(self, parent_tree, pool): + message = ( + f"Pool: {pool.id} " + f"protocol:{pool.protocol} " + f"algorithm:{pool.lb_algorithm} " + f"prov_status:{self.format_status(pool.provisioning_status)} " + f"oper_status:{self.format_status(pool.operating_status)}" + ) + return self._add_to_tree(parent_tree, message) + + def add_health_monitor_to_tree(self, parent_tree, hm): + message = ( + f"Health Monitor: {hm.id} " + f"type:{hm.type} " + f"http_method:{hm.http_method} " + f"http_codes:{hm.expected_codes} " + f"url_path:{hm.url_path} " + f"prov_status:{self.format_status(hm.provisioning_status)} " + f"oper_status:{self.format_status(hm.operating_status)}" + ) + return self._add_to_tree(parent_tree, message) + + def add_member_to_tree(self, parent_tree, member): + message = ( + f"Member: {member.id} " + f"IP:{member.address} " + f"port:{member.protocol_port} " + f"weight:{member.weight} " + f"backup:{member.backup} " + f"prov_status:{self.format_status(member.provisioning_status)} " + f"oper_status:{self.format_status(member.operating_status)}" + ) + return self._add_to_tree(parent_tree, message) + + def add_amphora_to_tree(self, parent_tree, amphora, server, image_name): + server_id = server.id if server else "N/A" + server_flavor_name = server.flavor.name if server and server.flavor else "N/A" + server_compute_host = server.compute_host if server else "N/A" + message = ( + f"amphora: {amphora.id} {amphora.role} {amphora.status} " + f"lb_network_ip:{amphora.lb_network_ip} " + f"img:{image_name} " + f"server:{server_id} " + f"vm_flavor:{server_flavor_name} " + f"compute host:({server_compute_host})" + ) + return self._add_to_tree(parent_tree, message) + class JSONOutputFormatter(OutputFormatter): """Formatter for JSON output.""" @@ -161,37 +385,21 @@ class JSONOutputFormatter(OutputFormatter): def __init__(self): self.data = None - def create_tree(self, name): - # Remove Rich codes - clean_name = self.format_message(name) - self.data = {"name": clean_name, "children": []} - return self.data - - def add_to_tree(self, tree, content, highlight=False): - _ = highlight - # Remove Rich codes - clean_content = self.format_message(content) - # Create a new node and add it to the tree's children - child = {"name": clean_content, "children": []} - tree["children"].append(child) - return child - def print_tree(self, tree): print(json.dumps(tree, indent=4)) def print(self, message): - # Remove Rich codes - clean_message = self.format_message(message) - # Not show empty prints - if not clean_message: + if not message: return - # For consistency, wrap messages in a dict - output = {"message": clean_message} + output = {"message": message} print(json.dumps(output, indent=4)) def status(self, message): return contextlib.nullcontext() + def track_progress(self, sequence, description, total=None): + return sequence + def line(self): pass @@ -201,12 +409,52 @@ def rule(self, title, align="center"): def format_status(self, status): return status - def format_message(self, message): - """Remove Rich text formatting tags from a message.""" - if isinstance(message, str): - clean_message = re.sub(r"\[\/?[^\]]+\]", "", message) - return clean_message - return message + def _add_node_from_obj(self, parent_node, node_type, resource_obj): + node = resource_obj.to_dict() + node["type"] = node_type + if "children" not in node: + node["children"] = [] + parent_node["children"].append(node) + return node + + def add_details_to_tree(self, tree, details_dict): + pass + + def add_empty_node(self, tree, resource_name): + tree["children"].append({f"{resource_name.lower().replace(' ', '_')}": None}) + + def add_lb_to_tree(self, lb): + root_node = lb.to_dict() + root_node["type"] = "loadbalancer" + root_node["children"] = [] + return root_node + + def add_listener_to_tree(self, parent_tree, listener): + return self._add_node_from_obj(parent_tree, "listener", listener) + + def add_pool_to_tree(self, parent_tree, pool): + return self._add_node_from_obj(parent_tree, "pool", pool) + + def add_health_monitor_to_tree(self, parent_tree, hm): + return self._add_node_from_obj(parent_tree, "health_monitor", hm) + + def add_member_to_tree(self, parent_tree, member): + return self._add_node_from_obj(parent_tree, "member", member) + + def add_amphora_to_tree(self, parent_tree, amphora, server, image_name): + node = amphora.to_dict() + node["type"] = "amphora" + node["image_name"] = image_name + if server: + node["server_details"] = { + "id": server.id, + "flavor": server.flavor.name if server.flavor else "N/A", + "compute_host": server.compute_host, + } + else: + node["server_details"] = None + parent_tree["children"].append(node) + return node # vim: ts=4 diff --git a/src/openstack_lb_info/loadbalancer_info.py b/src/openstack_lb_info/loadbalancer_info.py index 807e7fb..e1fc59e 100644 --- a/src/openstack_lb_info/loadbalancer_info.py +++ b/src/openstack_lb_info/loadbalancer_info.py @@ -4,10 +4,9 @@ -------------------------------- This module provides classes for retrieving, organizing, and displaying detailed information -about OpenStack Load Balancers and their associated resources, such as listeners, pools, -health monitors, members, and amphorae. It uses the `OpenStackAPI` class for interacting -with the OpenStack environment and uses `OutputFormatter` instances to present the information -in various output formats (e.g., Rich text, plain text, JSON). +about OpenStack Load Balancers and their associated resources. It uses the `OpenStackAPI` +class for interacting with the OpenStack environment and uses `OutputFormatter` instances +to present the information in various output formats (e.g., Rich text, plain text, JSON). Classes: @@ -17,248 +16,191 @@ - `AmphoraInfo`: Extends `LoadBalancerInfo` to focus on retrieving and displaying information about the amphorae associated with a Load Balancer. """ +import concurrent.futures +from dataclasses import dataclass + +from .formatters import OutputFormatter +from .openstack_api import OpenStackAPI + + +@dataclass +class ProcessingContext: + """ + Holds shared objects and configuration used during load balancer processing. + + Attributes: + openstack_api (OpenStackAPI): An instance of `OpenStackAPI` for OpenStack interactions. + details (bool): If True, displays detailed attributes of the Load Balancer. + formatter (OutputFormatter): An instance of a formatter class for output formatting. + max_workers (int): Max number of concurrent threads to fetch members details. + """ + + openstack_api: OpenStackAPI + details: bool + max_workers: int + formatter: OutputFormatter class LoadBalancerInfo: """ - Provides information and structured display of OpenStack Load Balancers. + Retrieves and displays information for a single OpenStack Load Balancer. """ - def __init__(self, openstack_api, lb, details, formatter): + def __init__(self, lb, context): """ Initialize a LoadBalancerInfo instance. Args: - openstack_api (OpenStackAPI): An instance of `OpenStackAPI` for OpenStack interactions. lb (openstack.load_balancer.v2.load_balancer.LoadBalancer): The Load Balancer object. - details (bool): If True, displays detailed attributes of the Load Balancer. - formatter (OutputFormatter): An instance of a formatter class for output formatting. + context (ProcessingContext): A instance of ProcessingContext class. """ self.lb = lb - self.details = details - self.formatter = formatter + self.details = context.details + self.formatter = context.formatter + self.openstack_api = context.openstack_api + self.max_workers = context.max_workers + # The root of the display tree for the formatter self.lb_tree = None - self.openstack_api = openstack_api - - def _add_all_attr_to_tree(self, obj, tree): - """ - Add all attributes of an object to a tree. - - This function iterates through all the attributes of a given Python object and - adds them to a Rich tree. Each attribute is displayed in the - format "attribute_name: value". - - Args: - obj (object): The object whose attributes are to be added. - tree: The tree to which the attributes will be added. - """ - obj_dict = obj.to_dict() - for attr in sorted(obj_dict): - value = obj_dict[attr] - content = f"{attr}: {value}" - self.formatter.add_to_tree(tree, content, highlight=True) - - # pylint: disable=too-many-arguments - def _retrieve_and_add_to_tree(self, label, resource_id, retrieve_method, tree, format_fn): - """ - Generic helper to retrieve a resource, add its formatted information to a tree. - - This method displays a status message while retrieving a resource via the provided API call. - If the resource is found, its formatted information is added to the specified tree node. In - detailed mode, all resource attributes are appended to the tree node as well. - - Args: - label (str): The resource label (e.g., "Listener, "Health Monitor", ...). - resource_id (str): The ID of the resource to retrieve. - retrieve_method (Callable): The API method used to retrieve the resource. - tree: The tree node to which the resource's info will be added. - format_fn (Callable): A function that takes the resource and returns a formatted string. - - Returns: - The retrieved resource object if found; otherwise, returns None. - """ - with self.formatter.status(f"Getting {label} details id [b]{resource_id}[/b]"): - resource = retrieve_method(resource_id) - - if resource: - resource_tree = self.formatter.add_to_tree(tree, format_fn(resource)) - if self.details: - self._add_all_attr_to_tree(resource, resource_tree) - return resource - - self.formatter.add_to_tree(tree, f"[b green]{label}:[/] None") - - return None def create_lb_tree(self): """ - Create a tree representing Load Balancer information. + Create the root of the display tree for the load balancer. - Returns: - Tree: A tree object representing Load Balancer information. + This method instructs the formatter to create the main tree node + and adds detailed attributes if requested. """ - self.lb_tree = self.formatter.create_tree( - f"LB:[bright_yellow] {self.lb.id}[/] " - f"vip:[bright_cyan]{self.lb.vip_address}[/] " - f"prov_status:{self.formatter.format_status(self.lb.provisioning_status)} " - f"oper_status:{self.formatter.format_status(self.lb.operating_status)} " - f"tags:[magenta]{self.lb.tags}[/]" - ) + self.lb_tree = self.formatter.add_lb_to_tree(self.lb) if self.details: - self._add_all_attr_to_tree(self.lb, self.lb_tree) + self.formatter.add_details_to_tree(self.lb_tree, self.lb.to_dict()) - return self.lb_tree - - def add_listener_info(self, listener_id): + def add_listener_info(self, lb_tree, listener_id): """ - Add information about a Listener to the Load Balancer tree. + Add information about the Listener to the Load Balancer's tree. Args: + lb_tree (object): The root tree node for the load balancer. listener_id (str): The ID of the Listener for which to retrieve and display information. - - Returns: - None """ + with self.formatter.status(f"Getting Listener details id [b]{listener_id}[/b]"): + listener = self.openstack_api.retrieve_listener(listener_id) - def format_listener(listener): - return ( - f"[b green]Listener:[/] [b white]{listener.id}[/] " - f"([blue b]{listener.name}[/]) " - f"port:[cyan]{listener.protocol}/{listener.protocol_port}[/] " - f"prov_status:{self.formatter.format_status(listener.provisioning_status)} " - f"oper_status:{self.formatter.format_status(listener.operating_status)}" - ) - - listener = self._retrieve_and_add_to_tree( - "Listener", - listener_id, - self.openstack_api.retrieve_listener, - self.lb_tree, - format_listener, - ) if listener: + listener_tree = self.formatter.add_listener_to_tree(lb_tree, listener) + if self.details: + self.formatter.add_details_to_tree(listener_tree, listener.to_dict()) + if listener.default_pool_id: - self.add_pool_info(self.lb_tree, listener.default_pool_id) + self.add_pool_info(listener_tree, listener.default_pool_id) else: - self.formatter.add_to_tree(self.lb_tree, "[b green]Pool:[/] None") + self.formatter.add_empty_node(listener_tree, "Pool") + else: + self.formatter.add_empty_node(lb_tree, "Listener") - def add_pool_info(self, tree, pool_id): + def add_pool_info(self, listener_tree, pool_id): """ - Add information about a Pool to the Load Balancer tree. + Add information about the Pool to the listener's tree. Args: - tree: The tree representing the Load Balancer. + listener_tree (object): The tree representing the listener. pool_id (str): The ID of the Pool for which to retrieve and display. - - Returns: - None """ + with self.formatter.status(f"Getting Pool details id [b]{pool_id}[/b]"): + pool = self.openstack_api.retrieve_pool(pool_id) - def format_pool(pool): - return ( - f"[b green]Pool:[/] [b white]{pool.id}[/] " - f"protocol:[magenta]{pool.protocol}[/magenta] " - f"algorithm:[magenta]{pool.lb_algorithm}[/magenta] " - f"prov_status:{self.formatter.format_status(pool.provisioning_status)} " - f"oper_status:{self.formatter.format_status(pool.operating_status)}" - ) - - pool = self._retrieve_and_add_to_tree( - "Pool", pool_id, self.openstack_api.retrieve_pool, tree, format_pool - ) if pool: + pool_tree = self.formatter.add_pool_to_tree(listener_tree, pool) + if self.details: + self.formatter.add_details_to_tree(pool_tree, pool.to_dict()) + if pool.health_monitor_id: - self.add_health_monitor_info(tree, pool.health_monitor_id) + self.add_health_monitor_info(pool_tree, pool.health_monitor_id) else: - self.formatter.add_to_tree(tree, "[b green]Health Monitor:[/] None") + self.formatter.add_empty_node(pool_tree, "Health Monitor") if pool.members: - self.add_pool_members(tree, pool.id, pool.members) + self.add_pool_members(pool_tree, pool.id, pool.members) else: - self.formatter.add_to_tree(tree, "[b green]Member:[/] None") + self.formatter.add_empty_node(pool_tree, "Member") + else: + self.formatter.add_empty_node(listener_tree, "Pool") def add_health_monitor_info(self, pool_tree, health_monitor_id): """ - Add information about a Health Monitor to a Pool tree. + Add information about the Health Monitor to the pool's tree. Args: - pool_tree: The tree representing the Pool. + pool_tree: The tree representing the pool. health_monitor_id (str): The ID of the Health Monitor. - - Returns: - None """ + with self.formatter.status(f"Getting Health Monitor details id [b]{health_monitor_id}[/b]"): + hm = self.openstack_api.retrieve_health_monitor(health_monitor_id) - def format_health_monitor(hm): - return ( - f"[b green]Health Monitor:[/] [b white]{hm.id}[/] " - f"type:[magenta]{hm.type}[/magenta] " - f"http_method:[magenta]{hm.http_method}[/magenta] " - f"http_codes:[magenta]{hm.expected_codes}[/magenta] " - f"url_path:[magenta]{hm.url_path}[/magenta] " - f"prov_status:{self.formatter.format_status(hm.provisioning_status)} " - f"oper_status:{self.formatter.format_status(hm.operating_status)}" - ) - - self._retrieve_and_add_to_tree( - "Health Monitor", - health_monitor_id, - self.openstack_api.retrieve_health_monitor, - pool_tree, - format_health_monitor, - ) + if hm: + hm_tree = self.formatter.add_health_monitor_to_tree(pool_tree, hm) + if self.details: + self.formatter.add_details_to_tree(hm_tree, hm.to_dict()) + else: + self.formatter.add_empty_node(pool_tree, "Health Monitor") def add_pool_members(self, pool_tree, pool_id, pool_members): """ - Add information about Members of a Pool to the Pool tree. + Add information about Members to the pool's tree. Args: pool_tree: The tree representing the Pool. pool_id (str): The ID of the Pool for which to retrieve Member information. pool_members (list): A list of dictionaries containing Member information, where each dictionary includes the Member's ID and additional details. - - Returns: - None """ - for member in pool_members: - with self.formatter.status(f"Getting member details id [b]{member['id']}[/b]"): - os_m = self.openstack_api.retrieve_member(member["id"], pool_id) - - def format_member(m): - return ( - f"[b green]Member:[/] [b white]{m.id}[/] " - f"IP:[magenta]{m.address}[/magenta] " - f"port:[magenta]{m.protocol_port}[/magenta] " - f"weight:[magenta]{m.weight}[/magenta] " - f"backup:[magenta]{m.backup}[/magenta] " - f"prov_status:{self.formatter.format_status(m.provisioning_status)} " - f"oper_status:{self.formatter.format_status(m.operating_status)}" - ) - - def return_member(_, os_m=os_m): - # Simply return the already retrieved member. - return os_m - - self._retrieve_and_add_to_tree( - "Member", member["id"], return_member, pool_tree, format_member + # Avoid spinning up extra idle threads + max_workers = min(self.max_workers, len(pool_members)) + + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + # Create future for each member IDs + futures_to_member_id = { + executor.submit(self.openstack_api.retrieve_member, member["id"], pool_id): member[ + "id" + ] + for member in pool_members + } + + # Use the formatter's progress bar to track completion + description = f"Getting members information for pool id: {pool_id}" + + progress_iterator = self.formatter.track_progress( + sequence=concurrent.futures.as_completed(futures_to_member_id), + description=description, + total=len(futures_to_member_id), ) + for future in progress_iterator: + member_id = futures_to_member_id[future] + try: + member = future.result() + if member: + member_tree = self.formatter.add_member_to_tree(pool_tree, member) + if self.details: + self.formatter.add_details_to_tree(member_tree, member.to_dict()) + else: + self.formatter.add_empty_node(pool_tree, f"Member ({member_id})") + except Exception as exc: # pylint: disable=broad-exception-caught + self.formatter.add_empty_node(pool_tree, f"Member ({member_id} - Error: {exc})") + def display_lb_info(self): """ - Display information about the Load Balancer. + Fetch and display information about the Load Balancer. - Returns: - None + This is the main entry point for this class. It builds the display tree + and prints it. """ self.create_lb_tree() if not self.lb.listeners: - self.formatter.add_to_tree(self.lb_tree, "[b green]Listener:[/] None") + self.formatter.add_empty_node(self.lb_tree, "Listener") else: for listener in self.lb.listeners: - self.add_listener_info(listener["id"]) + self.add_listener_info(self.lb_tree, listener["id"]) self.formatter.rule( f"[b]Loadbalancer ID: {self.lb.id} [bright_blue]({self.lb.name})[/]", @@ -270,31 +212,17 @@ def display_lb_info(self): class AmphoraInfo(LoadBalancerInfo): """ - Provides information about Amphorae associated with an OpenStack Load Balancer. + Retrieves and displays Amphora information for a Load Balancer. - This class extends the LoadBalancerInfo class and adds functionality to retrieve - and display information about Amphorae associated with an OpenStack - Load Balancer. + This class extends LoadBalancerInfo to provide specific functionality for + displaying information about Amphorae associated with a Load Balancer. Class Attributes: - images_name (dict): A dictionary to cache image names for Amphorae. + images_name (dict): A class-level cache for image names. """ images_name = {} - def __init__(self, openstack_api, lb, details, formatter): - """ - Initialize an AmphoraInfo instance. - - Args: - openstack_api (OpenStackAPI): An instance of `OpenStackAPI` for OpenStack interactions. - lb (openstack.load_balancer.v2.load_balancer.LoadBalancer): The Load Balancer object. - details (bool): If True, displays detailed attributes of the Amphorae. - formatter (OutputFormatter): An instance of a formatter class for output formatting. - """ - super().__init__(openstack_api, lb, details, formatter) - self.lb_tree = self.create_lb_tree() - def get_images_name(self, image_ids): """ Retrieve image names for a list of image IDs and cache the results. @@ -305,10 +233,8 @@ def get_images_name(self, image_ids): Note: The retrieved image names are stored in the 'images_name' class attribute for future reference, avoiding redundant queries to the OpenStack. - - Returns: - None """ + # Checks the cache before making an API call new_img_ids = [i for i in image_ids if i not in AmphoraInfo.images_name] if new_img_ids: with self.formatter.status(f"Getting image details [b]{new_img_ids}[/b]"): @@ -325,48 +251,27 @@ def add_amphora_to_tree(self, amphora): Args: amphora (openstack.load_balancer.v2.amphora.Amphora): The amphora for which to display detailed information. - - Returns: - None """ # Get image name for the image ID self.get_images_name([amphora.image_id]) + image_name = AmphoraInfo.images_name.get(amphora.image_id, "N/A") + # Get amphora server (instance) details with self.formatter.status(f"Getting server details [b]{amphora.compute_id}[/b]"): server = self.openstack_api.retrieve_server(amphora.compute_id) - if server: - server_id = server.id - server_flavor_name = server.flavor.name if server.flavor else "N/A" - server_compute_host = server.compute_host - else: - server_id = "N/A" - server_flavor_name = "N/A" - server_compute_host = "N/A" - - # Add amphora to the load balancer tree - amphora_tree = self.formatter.add_to_tree( - self.lb_tree, - f"[b green]amphora: [/]" - f"[b white]{amphora.id} [/]" - f"{amphora.role} " - f"{amphora.status} " - f"lb_network_ip:[green]{amphora.lb_network_ip} [/]" - f"img:[magenta]{AmphoraInfo.images_name.get(amphora.image_id, 'N/A')}[/] " - f"server:[magenta]{server_id}[/] " - f"vm_flavor:[magenta]{server_flavor_name}[/] " - f"compute host:([magenta]{server_compute_host}[/])", - ) + amphora_tree = self.formatter.add_amphora_to_tree(self.lb_tree, amphora, server, image_name) if self.details: - self._add_all_attr_to_tree(amphora, amphora_tree) + self.formatter.add_details_to_tree(amphora_tree, amphora.to_dict()) def display_amp_info(self): """ - Display information about amphorae associated with a load balancer. + Fetch and display information about amphorae associated with a load balancer. - Returns: - None + This is the main entry point for this class. It builds the display tree + and prints it. """ + self.create_lb_tree() with self.formatter.status( f"Getting amphora details for load balancer [b]{self.lb.id}[/b]" diff --git a/src/openstack_lb_info/main.py b/src/openstack_lb_info/main.py index 6fa3976..3e6cc54 100644 --- a/src/openstack_lb_info/main.py +++ b/src/openstack_lb_info/main.py @@ -3,7 +3,7 @@ """ A Python script to display OpenStack Load Balancer details. -This script query an OpenStack environment to present detailed information +This script queries an OpenStack environment to present detailed information about load balancers, including their components such as listeners, pools, health monitors, members, and amphorae. It connects to an OpenStack environment using the OpenStack SDK and presents the information in a @@ -41,9 +41,12 @@ PlainOutputFormatter, RichOutputFormatter, ) -from .loadbalancer_info import AmphoraInfo, LoadBalancerInfo +from .loadbalancer_info import AmphoraInfo, LoadBalancerInfo, ProcessingContext from .openstack_api import OpenStackAPI +# Max allowed threads for --max-workers +MAX_WORKERS_LIMIT = 32 + ########################################################################### # Parses the command line arguments @@ -85,15 +88,20 @@ def parse_parameters(): required=True, ) parser.add_argument("--name", help="Filter load balancers name", type=str, required=False) - parser.add_argument("--id", help="Filter load balancers id", type=str, required=False) + parser.add_argument( + "--id", help="Filter load balancers id (UUID)", type=validate_uuid, required=False + ) parser.add_argument("--tags", help="Filter load balancers tags", type=str, required=False) parser.add_argument( - "--flavor-id", help="Filter load balancers flavor id", type=str, required=False + "--flavor-id", + help="Filter load balancers flavor id (UUID)", + type=validate_uuid, + required=False, ) parser.add_argument( "--vip-address", help="Filter load balancers VIP address", - type=str, + type=validate_ip_address, required=False, ) parser.add_argument( @@ -101,14 +109,14 @@ def parse_parameters(): ) parser.add_argument( "--vip-network-id", - help="Filter load balancers network id", - type=str, + help="Filter load balancers network id (UUID)", + type=validate_uuid, required=False, ) parser.add_argument( "--vip-subnet-id", - help="Filter load balancers subnet id", - type=str, + help="Filter load balancers subnet id (UUID)", + type=validate_uuid, required=False, ) parser.add_argument( @@ -117,72 +125,90 @@ def parse_parameters(): action="store_true", required=False, ) + parser.add_argument( + "--max-workers", + help=( + f"Max number of concurrent threads to fetch members details " + f"(1-{MAX_WORKERS_LIMIT}). (default: %(default)s)" + ), + type=validate_int_range(1, MAX_WORKERS_LIMIT), + default=4, + required=False, + ) if len(sys.argv) < 2: parser.print_help() - sys.exit(1) + sys.exit(0) args = parser.parse_args() - validate_arguments(args) - return args -def validate_arguments(args): +def validate_int_range(min_val, max_val): """ - Validate command-line arguments. + Argparse type function that validates integer input within a given range. Args: - args (argparse.Namespace): Parsed command-line arguments. + min_val (int): Minimum allowed integer value (inclusive). + max_val (int): Maximum allowed integer value (inclusive). Raises: - SystemExit: If any validation fails, the script exits. + argparse.ArgumentTypeError: If the string cannot be converted to an integer + or if the value is out of range. """ - # Validate UUIDs parameters - uuid_args = ["id", "vip_network_id", "vip_subnet_id", "flavor_id"] - for arg_name in uuid_args: - arg_value = getattr(args, arg_name) - if arg_value and not is_valid_uuid(arg_value): - sys.exit(f"Error: Invalid {arg_name.replace('_', '-')} format. Expected a UUID.") - # Validate IP address - if args.vip_address and not is_valid_ip_address(args.vip_address): - sys.exit("Error: Invalid VIP address format. Expected a valid IP address.") + def _check_value(value_str): + try: + value = int(value_str) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"Invalid integer: '{value_str!r}'") from exc + if value < min_val or value > max_val: + raise argparse.ArgumentTypeError(f"Value must be between {min_val} and {max_val}") + return value -def is_valid_uuid(uuid_str): + return _check_value + + +def validate_uuid(value_str): """ - Check if uuid_str parameter is a valid UUID. + Argparse type function that checks whether a string is a valid UUID. Args: - uuid_str (str): The value to check. + value_str (str): The value to validate. Returns: - bool: True if valid UUID, False otherwise. + str: The UUID string if valid. + + Raises: + argparse.ArgumentTypeError: If the value is not a valid UUID. """ try: - uuid.UUID(str(uuid_str)) - return True - except ValueError: - return False + uuid.UUID(str(value_str)) + return value_str + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid UUID: {value_str!r}") from exc -def is_valid_ip_address(address): +def validate_ip_address(value_str): """ - Check if the address parameter is a valid IP address. + Argparse type function that checks whether a string is a valid IP address. Args: - address (str): The IP address to validate. + value_str (str): The IP address to validate. Returns: - bool: True if valid IP address, False otherwise. + str: The string if it is a valid IPv4/IPv6 address. + + Raises: + argparse.ArgumentTypeError: If the string is not valid IP address. """ try: - ipaddress.ip_address(address) - return True - except ValueError: - return False + ipaddress.ip_address(value_str) + return value_str + except ValueError as exc: + raise argparse.ArgumentTypeError(f"Invalid IP address: {value_str!r}") from exc def query_openstack_lbs(openstackapi, args, formatter): @@ -213,15 +239,15 @@ def query_openstack_lbs(openstackapi, args, formatter): if v is not None } - with formatter.status("Quering load balancers..."): + with formatter.status("Querying load balancers and applying filters..."): filtered_lbs_tmp = openstackapi.retrieve_load_balancers(filter_criteria) - # Perform name filtering here rather than adding it to filter_criteria - # because this allows for partial matching of the lb name - if args.name: - filtered_lbs = [lb for lb in filtered_lbs_tmp if args.name in lb.name] - else: - filtered_lbs = list(filtered_lbs_tmp) + # Perform name filtering here rather than adding it to filter_criteria + # because this allows for partial matching of the lb name + if args.name: + filtered_lbs = [lb for lb in filtered_lbs_tmp if args.name in lb.name] + else: + filtered_lbs = list(filtered_lbs_tmp) return filtered_lbs @@ -257,9 +283,6 @@ def main(): args = parse_parameters() - # Create an instance of OpenStackAPI - openstackapi = OpenStackAPI() - if args.output_format == "rich" and not RICH_AVAILABLE: sys.exit( "Error: 'rich' library is not installed. " @@ -269,18 +292,28 @@ def main(): # Initialize the formatter formatter = get_formatter(args.output_format) + # Create an instance of OpenStackAPI + openstackapi = OpenStackAPI() + filtered_lbs = query_openstack_lbs(openstackapi, args, formatter) if not filtered_lbs: formatter.print("No load balancer(s) found.") sys.exit(1) + context = ProcessingContext( + openstack_api=openstackapi, + details=args.details, + max_workers=args.max_workers, + formatter=formatter, + ) + for lb in filtered_lbs: if args.type == "amphora": - amphora_info = AmphoraInfo(openstackapi, lb, args.details, formatter) + amphora_info = AmphoraInfo(lb, context) amphora_info.display_amp_info() else: - lb_info = LoadBalancerInfo(openstackapi, lb, args.details, formatter) + lb_info = LoadBalancerInfo(lb, context) lb_info.display_lb_info()