Skip to content

Commit 46ff397

Browse files
Kevin Alliolikallioli
authored andcommitted
release: v1.1.0 — application credential auth + hypervisor usage
Features - Native v3applicationcredential auth flow in OrcaClient (id+secret or name+user reference, pre-scoped — no project/domain required). - profile add/edit + setup now offer a password vs application-credential choice and round-trip the AC fields through to-clouds / to-openrc and from-clouds / import-openrc. - Cache key for AC tokens keys on credential identity, not user/project. - New OS_AUTH_TYPE / OS_APPLICATION_CREDENTIAL_* (and ORCA_* equivalents) env vars wired through config.py priority resolution. - application-credential create --save-profile NAME persists the freshly minted AC as an orca profile in one shot. - New hypervisor usage command: per-host CPU/RAM/disk fill rate with bars, color thresholds, sort + filter (--top / --threshold). Fixes - application_credential commands: resolve current user id from token correctly (token wrapper was being read twice, always falling back to the literal string "me"). Tests - 2218 passing, coverage 85.67% (gate 85%).
1 parent 75831be commit 46ff397

12 files changed

Lines changed: 1346 additions & 83 deletions

orca_cli/commands/application_credential.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import click
66

7+
from orca_cli.core.config import save_profile
78
from orca_cli.core.context import OrcaContext
89
from orca_cli.core.output import console, output_options, print_detail, print_list
910

@@ -12,6 +13,21 @@ def _iam(client) -> str:
1213
return client.identity_url
1314

1415

16+
def _current_user_id(client) -> str:
17+
"""Resolve the user id from the current token.
18+
19+
``client._token_data`` already holds the inner ``token`` object returned by
20+
Keystone, so ``user`` is at the top level — no extra ``token`` wrapping.
21+
"""
22+
user_id = client._token_data.get("user", {}).get("id")
23+
if not user_id:
24+
raise click.ClickException(
25+
"Cannot determine current user id from token. "
26+
"Pass --user explicitly."
27+
)
28+
return user_id
29+
30+
1531
@click.group(name="application-credential")
1632
@click.pass_context
1733
def application_credential(ctx: click.Context) -> None:
@@ -26,7 +42,7 @@ def application_credential(ctx: click.Context) -> None:
2642
def app_credential_list(ctx, user_id, output_format, columns, fit_width, max_width, noindent):
2743
"""List application credentials."""
2844
client = ctx.find_object(OrcaContext).ensure_client()
29-
uid = user_id or client._token_data.get("token", {}).get("user", {}).get("id", "me")
45+
uid = user_id or _current_user_id(client)
3046
data = client.get(f"{_iam(client)}/v3/users/{uid}/application_credentials")
3147
print_list(
3248
data.get("application_credentials", []),
@@ -53,7 +69,7 @@ def app_credential_show(ctx, credential_id, user_id,
5369
output_format, columns, fit_width, max_width, noindent):
5470
"""Show application credential details."""
5571
client = ctx.find_object(OrcaContext).ensure_client()
56-
uid = user_id or client._token_data.get("token", {}).get("user", {}).get("id", "me")
72+
uid = user_id or _current_user_id(client)
5773
data = client.get(f"{_iam(client)}/v3/users/{uid}/application_credentials/{credential_id}")
5874
a = data.get("application_credential", data)
5975
print_detail(
@@ -77,11 +93,15 @@ def app_credential_show(ctx, credential_id, user_id,
7793
@click.option("--expires", "expires_at", default=None, help="Expiry (ISO 8601, e.g. 2026-12-31T00:00:00).")
7894
@click.option("--unrestricted", is_flag=True, help="Allow creation of other credentials (dangerous).")
7995
@click.option("--user", "user_id", default=None)
96+
@click.option("--save-profile", "save_profile_name", default=None,
97+
metavar="PROFILE",
98+
help="Save the new credential as an orca profile of this name.")
8099
@click.pass_context
81-
def app_credential_create(ctx, name, description, secret, expires_at, unrestricted, user_id):
100+
def app_credential_create(ctx, name, description, secret, expires_at,
101+
unrestricted, user_id, save_profile_name):
82102
"""Create an application credential."""
83103
client = ctx.find_object(OrcaContext).ensure_client()
84-
uid = user_id or client._token_data.get("token", {}).get("user", {}).get("id", "me")
104+
uid = user_id or _current_user_id(client)
85105
body: dict = {"name": name, "unrestricted": unrestricted}
86106
if description:
87107
body["description"] = description
@@ -100,6 +120,27 @@ def app_credential_create(ctx, name, description, secret, expires_at, unrestrict
100120
console.print(f" [cyan]Secret:[/cyan] {a['secret']}")
101121
console.print(" [bold yellow]This secret will NOT be shown again.[/bold yellow]")
102122

123+
if save_profile_name:
124+
if not a.get("secret"):
125+
raise click.ClickException(
126+
"Cannot save profile: Keystone did not return the credential secret."
127+
)
128+
profile_cfg = {
129+
"auth_url": client._auth_url,
130+
"auth_type": "v3applicationcredential",
131+
"application_credential_id": a["id"],
132+
"application_credential_secret": a["secret"],
133+
}
134+
if client._region_name:
135+
profile_cfg["region_name"] = client._region_name
136+
path = save_profile(save_profile_name, profile_cfg)
137+
console.print(
138+
f"[green]Saved as orca profile '{save_profile_name}' → {path}[/green]"
139+
)
140+
console.print(
141+
f"[dim]Activate with:[/dim] orca profile switch {save_profile_name}"
142+
)
143+
103144

104145
@application_credential.command("delete")
105146
@click.argument("credential_id")
@@ -111,6 +152,6 @@ def app_credential_delete(ctx, credential_id, user_id, yes):
111152
if not yes:
112153
click.confirm(f"Delete application credential {credential_id}?", abort=True)
113154
client = ctx.find_object(OrcaContext).ensure_client()
114-
uid = user_id or client._token_data.get("token", {}).get("user", {}).get("id", "me")
155+
uid = user_id or _current_user_id(client)
115156
client.delete(f"{_iam(client)}/v3/users/{uid}/application_credentials/{credential_id}")
116157
console.print(f"[green]Application credential {credential_id} deleted.[/green]")

orca_cli/commands/hypervisor.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,100 @@ def hypervisor_stats(ctx):
9494

9595
from orca_cli.core.output import console as _console
9696
_console.print(table)
97+
98+
99+
def _pct(used: int, total: int) -> float:
100+
if not total:
101+
return 0.0
102+
return round(100.0 * used / total, 1)
103+
104+
105+
def _color_pct(p: float) -> str:
106+
if p >= 90:
107+
return f"[red bold]{p}%[/red bold]"
108+
if p >= 70:
109+
return f"[yellow]{p}%[/yellow]"
110+
return f"[green]{p}%[/green]"
111+
112+
113+
def _bar(p: float, width: int = 8) -> str:
114+
filled = max(0, min(width, int(p * width / 100)))
115+
color = "red" if p >= 90 else ("yellow" if p >= 70 else "green")
116+
return f"[{color}]{'█' * filled}[/{color}]{'░' * (width - filled)}"
117+
118+
119+
@hypervisor.command("usage")
120+
@click.option("--sort-by", type=click.Choice(["cpu", "ram", "disk", "max", "vms"]),
121+
default="max", show_default=True,
122+
help="Metric to sort by. 'max' picks the worst of CPU/RAM/disk per host.")
123+
@click.option("--reverse", is_flag=True,
124+
help="Sort least-loaded first (default: most-loaded first).")
125+
@click.option("--threshold", type=click.IntRange(0, 100), default=0, show_default=True,
126+
help="Only show hypervisors whose 'max' fill rate is ≥ this percentage.")
127+
@click.option("--top", "top_n", type=int, default=None,
128+
help="Show only the top N hypervisors after sorting.")
129+
@output_options
130+
@click.pass_context
131+
def hypervisor_usage(ctx, sort_by, reverse, threshold, top_n,
132+
output_format, columns, fit_width, max_width, noindent):
133+
"""Show fill rate per hypervisor, sorted by load.
134+
135+
Combines vCPU, RAM and disk usage into a per-host 'score' (the worst
136+
dimension) so the most-saturated hypervisors surface at the top.
137+
138+
\b
139+
Color thresholds:
140+
Green < 70% — comfortable headroom
141+
Yellow 70–90% — monitor closely
142+
Red ≥ 90% — critical
143+
"""
144+
client = ctx.find_object(OrcaContext).ensure_client()
145+
data = client.get(f"{_nova(client)}/os-hypervisors/detail")
146+
hypervisors = data.get("hypervisors", [])
147+
148+
enriched = []
149+
for h in hypervisors:
150+
cpu = _pct(h.get("vcpus_used", 0), h.get("vcpus", 0))
151+
ram = _pct(h.get("memory_mb_used", 0), h.get("memory_mb", 0))
152+
disk = _pct(h.get("local_gb_used", 0), h.get("local_gb", 0))
153+
enriched.append({
154+
**h,
155+
"_cpu_pct": cpu,
156+
"_ram_pct": ram,
157+
"_disk_pct": disk,
158+
"_max_pct": max(cpu, ram, disk),
159+
})
160+
161+
if threshold:
162+
enriched = [h for h in enriched if h["_max_pct"] >= threshold]
163+
164+
sort_key = {"cpu": "_cpu_pct", "ram": "_ram_pct", "disk": "_disk_pct",
165+
"max": "_max_pct", "vms": "running_vms"}[sort_by]
166+
enriched.sort(key=lambda h: h.get(sort_key, 0) or 0, reverse=not reverse)
167+
168+
if top_n is not None and top_n > 0:
169+
enriched = enriched[:top_n]
170+
171+
print_list(
172+
enriched,
173+
[
174+
("Hostname", "hypervisor_hostname", {"style": "bold"}),
175+
("State", lambda h: f"[green]{h.get('state')}[/green]" if h.get("state") == "up"
176+
else f"[red]{h.get('state')}[/red]"),
177+
("vCPU", lambda h: f"{h.get('vcpus_used', 0)}/{h.get('vcpus', 0)}",
178+
{"justify": "right"}),
179+
("CPU%", lambda h: f"{_bar(h['_cpu_pct'])} {_color_pct(h['_cpu_pct'])}"),
180+
("RAM (GB)", lambda h: f"{h.get('memory_mb_used', 0)//1024}/{h.get('memory_mb', 0)//1024}",
181+
{"justify": "right"}),
182+
("RAM%", lambda h: f"{_bar(h['_ram_pct'])} {_color_pct(h['_ram_pct'])}"),
183+
("Disk (GB)", lambda h: f"{h.get('local_gb_used', 0)}/{h.get('local_gb', 0)}",
184+
{"justify": "right"}),
185+
("Disk%", lambda h: f"{_bar(h['_disk_pct'])} {_color_pct(h['_disk_pct'])}"),
186+
("VMs", "running_vms", {"justify": "right"}),
187+
("Score", lambda h: _color_pct(h["_max_pct"])),
188+
],
189+
title="Hypervisor Usage (sorted by " + sort_by + ")",
190+
output_format=output_format, columns=columns,
191+
fit_width=fit_width, max_width=max_width, noindent=noindent,
192+
empty_msg="No hypervisors match the filter.",
193+
)

0 commit comments

Comments
 (0)