Last active
August 28, 2025 19:12
-
-
Save outwitevil/7db52f7bd5b6ea36820449389d9c84ac to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
""" | |
list_hypervisor_tenants_parallel.py | |
Show which OpenStack projects (tenants) have instances on one or more | |
hypervisors. Optional columns: | |
* instance UUID (--show-id) | |
* instance status (--show-status) | |
* host‑aggregate(s) (--show-aggregate) | |
Results can be printed as a pretty table (default) or as plain | |
tab‑separated values, and the header row can be omitted. | |
Requirements | |
------------ | |
* Python 3.8+ | |
* openstacksdk (pip install openstacksdk) | |
The script reads credentials from clouds.yaml **or** the usual OS_* | |
environment variables – exactly like the official openstack CLI. | |
Author: ChatGPT (2025) | |
""" | |
import argparse | |
import sys | |
import concurrent.futures | |
from typing import List, Tuple | |
# -------------------------------------------------------------- | |
# 3rd‑party SDK | |
# -------------------------------------------------------------- | |
import openstack | |
from openstack import exceptions as os_exc | |
# -------------------------------------------------------------- | |
# Simple printer helpers | |
# -------------------------------------------------------------- | |
def _print_pretty_table(rows: List[List[str]], headers: List[str]) -> None: | |
"""Print a fixed‑width table with borders.""" | |
col_widths = [len(h) for h in headers] | |
for r in rows: | |
for i, cell in enumerate(r): | |
col_widths[i] = max(col_widths[i], len(str(cell))) | |
fmt = "| " + " | ".join(f"{{:{w}}}" for w in col_widths) + " |" | |
sep = "+-" + "-+-".join("-" * w for w in col_widths) + "-+" | |
print(sep) | |
print(fmt.format(*headers)) | |
print(sep) | |
for r in rows: | |
print(fmt.format(*r)) | |
print(sep) | |
def _print_plain(rows: List[List[str]], headers: List[str], | |
omit_header: bool) -> None: | |
"""Print rows as tab‑separated values, optionally omitting the header.""" | |
if not omit_header: | |
print("\t".join(headers)) | |
for r in rows: | |
print("\t".join(r)) | |
# -------------------------------------------------------------- | |
# OpenStack connection helpers | |
# -------------------------------------------------------------- | |
def build_connection(cloud_name: str | None = None) -> openstack.connection.Connection: | |
"""Create an SDK connection (cloud name is optional).""" | |
try: | |
return openstack.connect(cloud=cloud_name) | |
except os_exc.SDKException as exc: | |
sys.stderr.write(f"[ERROR] Could not create OpenStack connection: {exc}\n") | |
sys.exit(1) | |
def get_all_hypervisors(conn: openstack.connection.Connection) -> List[openstack.compute.v2.hypervisor.Hypervisor]: | |
"""Return *all* hypervisors in the cloud.""" | |
hypervisors = list(conn.compute.hypervisors()) | |
if not hypervisors: | |
sys.stderr.write("[ERROR] No hypervisors found in this cloud.\n") | |
sys.exit(1) | |
return hypervisors | |
def resolve_hypervisor_names(all_hvs: List[openstack.compute.v2.hypervisor.Hypervisor], | |
names: List[str]) -> List[openstack.compute.v2.hypervisor.Hypervisor]: | |
""" | |
Convert a list of user‑supplied names/IDs to Hypervisor objects. | |
Abort with a clear message if any name/ID cannot be resolved. | |
""" | |
if not names: # caller asked for “all” | |
return all_hvs | |
resolved = [] | |
for token in names: | |
# exact ID match first | |
match = next((hv for hv in all_hvs if hv.id == token), None) | |
if match: | |
resolved.append(match) | |
continue | |
# case‑insensitive name match | |
matches = [hv for hv in all_hvs if hv.name.lower() == token.lower()] | |
if len(matches) == 1: | |
resolved.append(matches[0]) | |
elif len(matches) > 1: | |
sys.stderr.write( | |
f"[ERROR] Ambiguous hypervisor name '{token}'. " | |
f"Multiple hypervisors share this name.\n" | |
) | |
sys.exit(1) | |
else: | |
sys.stderr.write(f"[ERROR] No hypervisor matching '{token}'.\n") | |
sys.stderr.write("Available hypervisors:\n") | |
for hv in all_hvs: | |
sys.stderr.write(f" - {hv.name} (id={hv.id})\n") | |
sys.exit(1) | |
# Preserve the order the user supplied (deterministic output) | |
return resolved | |
def map_project_names(conn: openstack.connection.Connection) -> dict: | |
"""Build {project_id: project_name} for fast lookup.""" | |
proj_map = {} | |
for proj in conn.identity.projects(): | |
proj_map[proj.id] = proj.name | |
return proj_map | |
def build_aggregate_map(conn: openstack.connection.Connection) -> dict: | |
""" | |
Build a mapping ``host_name -> [aggregate_name, …]``. | |
If a host belongs to no aggregate the value will be an empty list. | |
""" | |
host_to_aggs: dict = {} | |
try: | |
for agg in conn.compute.aggregates(): | |
agg_name = agg.name or "(unnamed)" | |
for host in agg.hosts: | |
host_to_aggs.setdefault(host, []).append(agg_name) | |
except os_exc.SDKException as exc: | |
sys.stderr.write(f"[WARN] Could not fetch host aggregates: {exc}\n") | |
return host_to_aggs | |
# -------------------------------------------------------------- | |
# Parallel fetch of instances per hypervisor | |
# -------------------------------------------------------------- | |
def fetch_instances_for_hypervisor( | |
conn: openstack.connection.Connection, | |
hypervisor_name: str, | |
) -> List[Tuple[openstack.compute.v2.server.Server, str]]: | |
""" | |
Return a list of (server, project_id) for *this* hypervisor. | |
Nova supports the ``host`` filter on the ``servers/detail`` call, | |
so we ask for exactly the servers that belong to the given compute node. | |
""" | |
instances = [] | |
try: | |
for srv in conn.compute.servers( | |
details=True, | |
all_projects=True, | |
host=hypervisor_name, | |
): | |
instances.append((srv, srv.project_id)) | |
except os_exc.SDKException as exc: | |
sys.stderr.write( | |
f"[WARN] Could not fetch servers for hypervisor " | |
f"'{hypervisor_name}': {exc}\n" | |
) | |
return instances | |
# -------------------------------------------------------------- | |
# Sorting helpers | |
# -------------------------------------------------------------- | |
def _parse_sort_keys(arg: str) -> List[str]: | |
"""Return a list of column names (lower‑cased) from the user string.""" | |
return [token.strip().lower() for token in arg.split(",") if token.strip()] | |
def sort_rows(rows: List[List[str]], sort_cols: List[str], | |
col_index: dict, reverse: bool) -> List[List[str]]: | |
"""Stable multi‑column sort based on the column‑name list.""" | |
for name in reversed(sort_cols): | |
idx = col_index[name] | |
rows.sort(key=lambda r: r[idx].lower(), reverse=reverse) | |
return rows | |
# -------------------------------------------------------------- | |
# Main driver | |
# -------------------------------------------------------------- | |
def main() -> None: | |
parser = argparse.ArgumentParser( | |
description=( | |
"Show which OpenStack projects (tenants) have instances on one or more " | |
"hypervisors. Optional columns: instance UUID (--show-id), " | |
"instance status (--show-status) and host‑aggregate(s) (--show-aggregate). " | |
"Results can be printed as a pretty table (default) or as plain " | |
"tab‑separated values, and the header row can be omitted." | |
) | |
) | |
parser.add_argument( | |
"hypervisors", | |
nargs="*", | |
help=( | |
"One or more hypervisor names or IDs. Separate multiple values with " | |
"spaces or commas. Omit to scan every hypervisor." | |
), | |
) | |
parser.add_argument( | |
"--cloud", | |
help="Name of the cloud entry in clouds.yaml (defaults to the first entry).", | |
) | |
parser.add_argument( | |
"--max-workers", | |
type=int, | |
default=None, | |
help=( | |
"Maximum number of threads for the parallel fetch. " | |
"If omitted, defaults to min(32, number_of_hypervisors)." | |
), | |
) | |
parser.add_argument( | |
"--show-id", | |
action="store_true", | |
help="Add a column with the instance (server) UUID to the output.", | |
) | |
parser.add_argument( | |
"--show-status", | |
action="store_true", | |
help="Add a column with the instance operational status (ACTIVE, SHUTOFF, …).", | |
) | |
parser.add_argument( | |
"--show-aggregate", | |
action="store_true", | |
help="Add a column with the host‑aggregate(s) the hypervisor belongs to.", | |
) | |
parser.add_argument( | |
"--sort-by", | |
dest="sort_by", | |
default=None, | |
help=( | |
"Comma‑separated list of columns to sort by. Valid values are: " | |
"hypervisor, server, project, id, status, aggregate. Example: " | |
"--sort-by hypervisor,project" | |
), | |
) | |
parser.add_argument( | |
"--reverse", | |
action="store_true", | |
help="Reverse the sort order (like the Unix '-r' flag).", | |
) | |
parser.add_argument( | |
"--no-header", | |
action="store_true", | |
help="Omit the header row from the output.", | |
) | |
parser.add_argument( | |
"--plain", | |
action="store_true", | |
help="Emit raw tab‑separated rows (no table borders).", | |
) | |
args = parser.parse_args() | |
# -------------------------------------------------------------- | |
# 1️⃣ Connect | |
# -------------------------------------------------------------- | |
conn = build_connection(cloud_name=args.cloud) | |
# -------------------------------------------------------------- | |
# 2️⃣ Resolve hypervisor list (space‑ or comma‑separated) | |
# -------------------------------------------------------------- | |
all_hvs = get_all_hypervisors(conn) | |
# Accept both space‑ and comma‑separated specifications | |
user_tokens = [] | |
for token in args.hypervisors: | |
user_tokens.extend([t.strip() for t in token.split(",") if t.strip()]) | |
selected_hvs = resolve_hypervisor_names(all_hvs, user_tokens) | |
# -------------------------------------------------------------- | |
# 3️⃣ Project‑id → name map (Keystone) | |
# -------------------------------------------------------------- | |
project_names = map_project_names(conn) | |
# -------------------------------------------------------------- | |
# 4️⃣ Fetch host‑aggregate information (once) | |
# -------------------------------------------------------------- | |
host_to_agg = build_aggregate_map(conn) # host name -> list of aggregate names | |
# -------------------------------------------------------------- | |
# 5️⃣ Parallel fetch of VMs per hypervisor | |
# -------------------------------------------------------------- | |
max_workers = ( | |
args.max_workers | |
if args.max_workers is not None | |
else min(32, len(selected_hvs)) | |
) | |
hv_to_instances: dict[ | |
str, List[Tuple[openstack.compute.v2.server.Server, str]] | |
] = {} | |
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: | |
future_to_hv = { | |
executor.submit(fetch_instances_for_hypervisor, conn, hv.name): hv | |
for hv in selected_hvs | |
} | |
for future in concurrent.futures.as_completed(future_to_hv): | |
hv = future_to_hv[future] | |
try: | |
hv_to_instances[hv.name] = future.result() | |
except Exception as exc: # pragma: no cover | |
sys.stderr.write( | |
f"[ERROR] Unexpected error while processing hypervisor " | |
f"'{hv.name}': {exc}\n" | |
) | |
hv_to_instances[hv.name] = [] | |
# -------------------------------------------------------------- | |
# 6️⃣ Build rows (always contain the core 4 fields + status + aggregate) | |
# -------------------------------------------------------------- | |
# Internal row layout (always present, even if a column is not displayed): | |
# 0 – hypervisor name | |
# 1 – server name | |
# 2 – project name | |
# 3 – server UUID | |
# 4 – server status (e.g. ACTIVE) | |
# 5 – aggregate list (comma‑separated) – may be empty | |
rows: List[List[str]] = [] | |
for hv in selected_hvs: | |
instances = hv_to_instances.get(hv.name, []) | |
if not instances: | |
rows.append([hv.name, "(none)", "(none)", "(none)", "(none)", "(none)"]) | |
continue | |
# Determine the aggregate(s) for this hypervisor once | |
agg_names = host_to_agg.get(hv.name, []) | |
agg_cell = ", ".join(sorted(agg_names)) if agg_names else "(none)" | |
for srv, proj_id in instances: | |
proj_name = project_names.get(proj_id, f"<unknown:{proj_id}>") | |
rows.append([ | |
hv.name, # 0 | |
srv.name, # 1 | |
proj_name, # 2 | |
srv.id, # 3 | |
srv.status or "(unknown)", # 4 | |
agg_cell # 5 | |
]) | |
# -------------------------------------------------------------- | |
# 7️⃣ Optional sorting | |
# -------------------------------------------------------------- | |
# Map every sortable column name to its index in the *internal* row list. | |
col_index = { | |
"hypervisor": 0, | |
"server": 1, | |
"project": 2, | |
"id": 3, | |
"status": 4, | |
"aggregate": 5, | |
} | |
if args.sort_by: | |
sort_cols = _parse_sort_keys(args.sort_by) | |
# Validate column names | |
for name in sort_cols: | |
if name not in col_index: | |
sys.stderr.write( | |
f"[ERROR] Invalid sort column '{name}'. " | |
f"Valid columns are: hypervisor, server, project, id, status, aggregate.\n" | |
) | |
sys.exit(1) | |
rows = sort_rows(rows, sort_cols, col_index, reverse=args.reverse) | |
# -------------------------------------------------------------- | |
# 8️⃣ Build header list & decide which internal columns to keep | |
# -------------------------------------------------------------- | |
# Core columns are always printed. | |
headers = ["Hypervisor", "Server (Instance)", "Project (Tenant)"] | |
optional_columns: List[Tuple[str, bool]] = [ | |
("Instance ID", args.show_id), | |
("Status", args.show_status), | |
("Aggregate", args.show_aggregate), | |
] | |
# Determine which indices from the internal row we will keep. | |
# First three fields (0‑2) are mandatory. | |
keep_indices: List[int] = [0, 1, 2] # always keep the first three | |
for col_name, enabled in optional_columns: | |
if enabled: | |
headers.append(col_name) | |
# Index in the internal row = 3 + position of the optional column | |
keep_indices.append(3 + optional_columns.index((col_name, enabled))) | |
# Slice rows to the selected columns only. | |
rows = [[row[i] for i in keep_indices] for row in rows] | |
# -------------------------------------------------------------- | |
# 9️⃣ Print the result | |
# -------------------------------------------------------------- | |
if args.plain: | |
# Plain mode – raw TSV, optional header suppression. | |
_print_plain(rows, headers, omit_header=args.no_header) | |
else: | |
# Pretty table (default). Header can be omitted with --no-header. | |
if args.no_header: | |
# Trick: give an empty header list – the borders are still printed, | |
# but no header row appears. | |
_print_pretty_table(rows, [""] * len(headers)) | |
else: | |
_print_pretty_table(rows, headers) | |
# -------------------------------------------------------------- | |
# Helper printing functions (unchanged) | |
# -------------------------------------------------------------- | |
def _print_pretty_table(rows: List[List[str]], headers: List[str]) -> None: | |
"""Print a fixed‑width table with borders.""" | |
col_widths = [len(h) for h in headers] | |
for r in rows: | |
for i, cell in enumerate(r): | |
col_widths[i] = max(col_widths[i], len(str(cell))) | |
fmt = "| " + " | ".join(f"{{:{w}}}" for w in col_widths) + " |" | |
sep = "+-" + "-+-".join("-" * w for w in col_widths) + "-+" | |
print(sep) | |
print(fmt.format(*headers)) | |
print(sep) | |
for r in rows: | |
print(fmt.format(*r)) | |
print(sep) | |
def _print_plain(rows: List[List[str]], headers: List[str], | |
omit_header: bool) -> None: | |
"""Print rows as tab‑separated values, optionally omitting the header.""" | |
if not omit_header: | |
print("\t".join(headers)) | |
for r in rows: | |
print("\t".join(r)) | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment