Skip to content

Instantly share code, notes, and snippets.

@outwitevil
Last active August 28, 2025 19:12
Show Gist options
  • Save outwitevil/7db52f7bd5b6ea36820449389d9c84ac to your computer and use it in GitHub Desktop.
Save outwitevil/7db52f7bd5b6ea36820449389d9c84ac to your computer and use it in GitHub Desktop.
#!/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