Skip to content

Commit a754f34

Browse files
committed
feat: add 40+ new command groups and comprehensive test suite
New command groups (all with full output_options support): - Identity: user, project, domain, role, group, credential, application_credential, access_rule, trust, federation, token, endpoint, endpoint_group, region, service, policy, limit - Compute: aggregate, availability_zone, compute_service, hypervisor, server_group, event, usage - Volume: backup (Freezer), object_store, stack (Heat) - Network: qos_policy, subnet_pool, trunk - DNS: zone, recordset - Placement: resource_class, resource_provider, trait, inventory, usage - Metric: (Gnocchi) resource, measure, archive_policy - Alarm: (Aodh) alarm CRUD - Auth: auth show, token issue New orca-exclusive commands: - overview: real-time project dashboard - watch: live refresh dashboard - doctor: pre-deployment health check - audit: security audit (open SGs, unencrypted volumes, orphaned FIPs) - cleanup: orphaned resource detection - export: infra snapshot to YAML/JSON - ip whois: resolve IP to owning resource - profile: multi-account management (add/edit/remove/switch, from-clouds, to-clouds, from-openrc, to-openrc, set-color, regions) - limits: unified quota view across services - quota: set quotas per project Bug fixes: - stack: resolve via list endpoint to avoid 302; stringify yaml dates - volume set-bootable/set-readonly: positional → flag - volume snapshot-create: accept name or UUID - volume backup-create: GET after POST for full details - object_store container-delete: add --yes guard - loadbalancer delete: catch 409 with helpful message - metric resource-list: default limit=100 - placement: add OpenStack-API-Version header - doctor: use /networks?limit=1 for Neutron reachability check OSC gap closures: - aggregate unset, aggregate cache-image - server migration-abort, migration-force-complete - volume attachment-create/set/complete - volume group-snapshot CRUD, group-type CRUD - network segment CRUD, auto-allocated-topology show/delete
1 parent 9c12a5d commit a754f34

101 files changed

Lines changed: 32578 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ build/
1212
.pytest_cache/
1313
.ruff_cache/
1414
site/
15+
CLAUDE.md

orca_cli/commands/access_rule.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""``orca access-rule`` — manage application credential access rules (Keystone)."""
2+
3+
from __future__ import annotations
4+
5+
import click
6+
7+
from orca_cli.core.context import OrcaContext
8+
from orca_cli.core.output import console, output_options, print_detail, print_list
9+
from orca_cli.core.validators import validate_id
10+
11+
12+
def _iam(client) -> str:
13+
return client.identity_url
14+
15+
16+
@click.group("access-rule")
17+
@click.pass_context
18+
def access_rule(ctx: click.Context) -> None:
19+
"""Manage application credential access rules (Keystone)."""
20+
21+
22+
@access_rule.command("list")
23+
@click.option("--user-id", default=None, callback=validate_id,
24+
help="User ID (defaults to current user).")
25+
@click.option("--service", default=None, help="Filter by service type (e.g. compute).")
26+
@click.option("--method", default=None, help="Filter by HTTP method.")
27+
@click.option("--path", default=None, help="Filter by API path.")
28+
@output_options
29+
@click.pass_context
30+
def ar_list(ctx, user_id, service, method, path,
31+
output_format, columns, fit_width, max_width, noindent):
32+
"""List access rules."""
33+
client = ctx.find_object(OrcaContext).ensure_client()
34+
uid = user_id or client._token_data.get("user", {}).get("id", "") # type: ignore[attr-defined]
35+
params = {}
36+
if service:
37+
params["service"] = service
38+
if method:
39+
params["method"] = method
40+
if path:
41+
params["path"] = path
42+
data = client.get(f"{_iam(client)}/v3/users/{uid}/access_rules", params=params)
43+
items = data.get("access_rules", [])
44+
if not items:
45+
console.print("No access rules found.")
46+
return
47+
col_defs = [
48+
("ID", "id"),
49+
("Service", "service"),
50+
("Method", "method"),
51+
("Path", "path"),
52+
]
53+
print_list(items, col_defs, title="Access Rules",
54+
output_format=output_format, columns=columns,
55+
fit_width=fit_width, max_width=max_width, noindent=noindent)
56+
57+
58+
@access_rule.command("show")
59+
@click.argument("access_rule_id", callback=validate_id)
60+
@click.option("--user-id", default=None, callback=validate_id,
61+
help="User ID (defaults to current user).")
62+
@output_options
63+
@click.pass_context
64+
def ar_show(ctx, access_rule_id, user_id,
65+
output_format, columns, fit_width, max_width, noindent):
66+
"""Show an access rule."""
67+
client = ctx.find_object(OrcaContext).ensure_client()
68+
uid = user_id or client._token_data.get("user", {}).get("id", "") # type: ignore[attr-defined]
69+
data = client.get(f"{_iam(client)}/v3/users/{uid}/access_rules/{access_rule_id}")
70+
ar = data.get("access_rule", data)
71+
fields = [
72+
("ID", ar.get("id", "")),
73+
("Service", ar.get("service", "")),
74+
("Method", ar.get("method", "")),
75+
("Path", ar.get("path", "")),
76+
]
77+
print_detail(fields,
78+
output_format=output_format, columns=columns,
79+
fit_width=fit_width, max_width=max_width, noindent=noindent)
80+
81+
82+
@access_rule.command("delete")
83+
@click.argument("access_rule_id", callback=validate_id)
84+
@click.option("--user-id", default=None, callback=validate_id,
85+
help="User ID (defaults to current user).")
86+
@click.option("--yes", "-y", is_flag=True)
87+
@click.pass_context
88+
def ar_delete(ctx, access_rule_id, user_id, yes):
89+
"""Delete an access rule."""
90+
client = ctx.find_object(OrcaContext).ensure_client()
91+
uid = user_id or client._token_data.get("user", {}).get("id", "") # type: ignore[attr-defined]
92+
if not yes:
93+
click.confirm(f"Delete access rule {access_rule_id}?", abort=True)
94+
client.delete(f"{_iam(client)}/v3/users/{uid}/access_rules/{access_rule_id}")
95+
console.print(f"Access rule [bold]{access_rule_id}[/bold] deleted.")

orca_cli/commands/aggregate.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""``orca aggregate`` — manage host aggregates (Nova)."""
2+
3+
from __future__ import annotations
4+
5+
import click
6+
7+
from orca_cli.core.context import OrcaContext
8+
from orca_cli.core.output import output_options, print_list, print_detail, console
9+
10+
11+
def _nova(client) -> str:
12+
return client.compute_url
13+
14+
15+
@click.group()
16+
@click.pass_context
17+
def aggregate(ctx: click.Context) -> None:
18+
"""Manage host aggregates (Nova)."""
19+
pass
20+
21+
22+
@aggregate.command("list")
23+
@output_options
24+
@click.pass_context
25+
def aggregate_list(ctx, output_format, columns, fit_width, max_width, noindent):
26+
"""List host aggregates."""
27+
client = ctx.find_object(OrcaContext).ensure_client()
28+
data = client.get(f"{_nova(client)}/os-aggregates")
29+
print_list(
30+
data.get("aggregates", []),
31+
[
32+
("ID", "id", {"style": "cyan"}),
33+
("Name", "name", {"style": "bold"}),
34+
("AZ", lambda a: a.get("availability_zone") or "—"),
35+
("Hosts", lambda a: str(len(a.get("hosts", [])))),
36+
("Metadata", lambda a: ", ".join(f"{k}={v}" for k, v in (a.get("metadata") or {}).items()) or "—"),
37+
],
38+
title="Host Aggregates",
39+
output_format=output_format, columns=columns,
40+
fit_width=fit_width, max_width=max_width, noindent=noindent,
41+
empty_msg="No aggregates found.",
42+
)
43+
44+
45+
@aggregate.command("show")
46+
@click.argument("aggregate_id")
47+
@output_options
48+
@click.pass_context
49+
def aggregate_show(ctx, aggregate_id, output_format, columns, fit_width, max_width, noindent):
50+
"""Show aggregate details."""
51+
client = ctx.find_object(OrcaContext).ensure_client()
52+
data = client.get(f"{_nova(client)}/os-aggregates/{aggregate_id}")
53+
a = data.get("aggregate", data)
54+
print_detail(
55+
[
56+
("ID", str(a.get("id", ""))),
57+
("Name", a.get("name", "")),
58+
("Availability Zone", a.get("availability_zone") or "—"),
59+
("Hosts", ", ".join(a.get("hosts", [])) or "—"),
60+
("Metadata", ", ".join(f"{k}={v}" for k, v in (a.get("metadata") or {}).items()) or "—"),
61+
("Created", a.get("created_at", "")),
62+
("Updated", a.get("updated_at") or "—"),
63+
],
64+
output_format=output_format, columns=columns,
65+
fit_width=fit_width, max_width=max_width, noindent=noindent,
66+
)
67+
68+
69+
@aggregate.command("create")
70+
@click.argument("name")
71+
@click.option("--zone", "availability_zone", default=None, help="Availability zone name.")
72+
@click.pass_context
73+
def aggregate_create(ctx, name, availability_zone):
74+
"""Create a host aggregate."""
75+
client = ctx.find_object(OrcaContext).ensure_client()
76+
body: dict = {"name": name}
77+
if availability_zone:
78+
body["availability_zone"] = availability_zone
79+
data = client.post(f"{_nova(client)}/os-aggregates", json={"aggregate": body})
80+
a = data.get("aggregate", data)
81+
console.print(f"[green]Aggregate '{a.get('name')}' ({a.get('id')}) created.[/green]")
82+
83+
84+
@aggregate.command("delete")
85+
@click.argument("aggregate_id")
86+
@click.option("--yes", "-y", is_flag=True)
87+
@click.pass_context
88+
def aggregate_delete(ctx, aggregate_id, yes):
89+
"""Delete a host aggregate."""
90+
if not yes:
91+
click.confirm(f"Delete aggregate {aggregate_id}?", abort=True)
92+
client = ctx.find_object(OrcaContext).ensure_client()
93+
client.delete(f"{_nova(client)}/os-aggregates/{aggregate_id}")
94+
console.print(f"[green]Aggregate {aggregate_id} deleted.[/green]")
95+
96+
97+
@aggregate.command("add-host")
98+
@click.argument("aggregate_id")
99+
@click.argument("host")
100+
@click.pass_context
101+
def aggregate_add_host(ctx, aggregate_id, host):
102+
"""Add a host to an aggregate."""
103+
client = ctx.find_object(OrcaContext).ensure_client()
104+
client.post(f"{_nova(client)}/os-aggregates/{aggregate_id}/action",
105+
json={"add_host": {"host": host}})
106+
console.print(f"[green]Host '{host}' added to aggregate {aggregate_id}.[/green]")
107+
108+
109+
@aggregate.command("remove-host")
110+
@click.argument("aggregate_id")
111+
@click.argument("host")
112+
@click.pass_context
113+
def aggregate_remove_host(ctx, aggregate_id, host):
114+
"""Remove a host from an aggregate."""
115+
client = ctx.find_object(OrcaContext).ensure_client()
116+
client.post(f"{_nova(client)}/os-aggregates/{aggregate_id}/action",
117+
json={"remove_host": {"host": host}})
118+
console.print(f"[green]Host '{host}' removed from aggregate {aggregate_id}.[/green]")
119+
120+
121+
@aggregate.command("set")
122+
@click.argument("aggregate_id")
123+
@click.option("--name", default=None, help="New name.")
124+
@click.option("--zone", "availability_zone", default=None, help="New availability zone.")
125+
@click.option("--property", "properties", multiple=True, metavar="KEY=VALUE",
126+
help="Metadata key=value (repeatable).")
127+
@click.pass_context
128+
def aggregate_set(ctx, aggregate_id, name, availability_zone, properties):
129+
"""Update an aggregate's name, AZ, or metadata."""
130+
client = ctx.find_object(OrcaContext).ensure_client()
131+
body: dict = {}
132+
if name:
133+
body["name"] = name
134+
if availability_zone:
135+
body["availability_zone"] = availability_zone
136+
137+
if body:
138+
client.put(f"{_nova(client)}/os-aggregates/{aggregate_id}",
139+
json={"aggregate": body})
140+
141+
if properties:
142+
meta = {}
143+
for prop in properties:
144+
if "=" not in prop:
145+
raise click.UsageError(f"Invalid format '{prop}', expected KEY=VALUE.")
146+
k, v = prop.split("=", 1)
147+
meta[k] = v
148+
client.post(f"{_nova(client)}/os-aggregates/{aggregate_id}/action",
149+
json={"set_metadata": {"metadata": meta}})
150+
151+
if not body and not properties:
152+
console.print("[yellow]Nothing to update.[/yellow]")
153+
return
154+
console.print(f"[green]Aggregate {aggregate_id} updated.[/green]")
155+
156+
157+
@aggregate.command("unset")
158+
@click.argument("aggregate_id")
159+
@click.option("--property", "properties", multiple=True, metavar="KEY",
160+
help="Metadata key to remove (repeatable).")
161+
@click.pass_context
162+
def aggregate_unset(ctx, aggregate_id, properties):
163+
"""Unset metadata properties on an aggregate."""
164+
if not properties:
165+
console.print("[yellow]Nothing to unset.[/yellow]")
166+
return
167+
client = ctx.find_object(OrcaContext).ensure_client()
168+
# Setting a key to None removes it from the aggregate metadata
169+
meta = {k: None for k in properties}
170+
client.post(f"{_nova(client)}/os-aggregates/{aggregate_id}/action",
171+
json={"set_metadata": {"metadata": meta}})
172+
console.print(f"[green]Aggregate {aggregate_id} properties removed.[/green]")
173+
174+
175+
@aggregate.command("cache-image")
176+
@click.argument("aggregate_id")
177+
@click.argument("image_ids", nargs=-1, required=True)
178+
@click.pass_context
179+
def aggregate_cache_image(ctx, aggregate_id, image_ids):
180+
"""Request that images be cached on hosts in an aggregate.
181+
182+
\b
183+
Examples:
184+
orca aggregate cache-image <agg-id> <image-id>
185+
orca aggregate cache-image <agg-id> <img1> <img2>
186+
"""
187+
client = ctx.find_object(OrcaContext).ensure_client()
188+
client.post(f"{_nova(client)}/os-aggregates/{aggregate_id}/images",
189+
json={"cache": [{"id": iid} for iid in image_ids]})
190+
console.print(f"[green]Image caching requested on aggregate {aggregate_id}.[/green]")

0 commit comments

Comments
 (0)