Skip to content

Commit 6eed476

Browse files
committed
feat(share): support Manila shared file systems
Adds the missing parity with ``openstack share`` — the only significant OpenStack service orca did not wrap. Lot 1 covers the day-to-day surface (create/list/show/delete/set/extend/shrink, access rules, snapshots, read-only types); admin-only flows (share networks, servers, type create) are deferred to lot 2 if demand surfaces. - ``orca_cli/services/shared_file_system.FileShareService`` — pins ``X-OpenStack-Manila-API-Version: 2.51`` (Train, supports the unified ``/share-access-rules`` endpoint) and wraps every endpoint orca uses behind a typed method. - ``orca_cli/models/shared_file_system`` — TypedDict views for Share, ShareAccessRule, ShareSnapshot, ShareType. - ``orca_cli/commands/share`` — top-level group with three sub-groups (``access``, ``snapshot``, ``type``). Follows ADR-0008: ``noun [subnoun] verb`` from day one. - ``orca_cli/core/client.share_url`` — catalog property with the same fallback pattern as ``volume_url``: tries ``sharev2`` first, falls back to legacy ``share``. Raises a clear ``APIError`` if neither is in the catalogue (Manila not deployed). - Tests: ``tests/test_services_shared_file_system.py`` (service- level, 28 tests, 99 % coverage on the service) + ``tests/test_share.py`` (command-level, 50+ tests on registration, body shape, parametrize on every leaf). 2643 tests pass total, 87.93 % coverage. No DevStack Manila instance available to smoke-test live; the service tests lock URL/body/headers so the wire shape is covered without one. When a Manila-enabled cloud is reachable, drop a ``tests/devstack/test_live_share_full.py`` mirroring the existing live patterns.
1 parent 9c568ec commit 6eed476

7 files changed

Lines changed: 1238 additions & 0 deletions

File tree

orca_cli/commands/share.py

Lines changed: 421 additions & 0 deletions
Large diffs are not rendered by default.

orca_cli/core/client.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,25 @@ def rating_url(self) -> str:
650650
"""CloudKitty (rating) public endpoint."""
651651
return self._endpoint_for("rating")
652652

653+
@property
654+
def share_url(self) -> str:
655+
"""Manila (shared file systems) public endpoint.
656+
657+
Catalog type was renamed ``share`` → ``sharev2`` when Manila
658+
adopted microversioning (2.0). Try the modern type first and
659+
fall back so orca works against both eras of catalogue.
660+
"""
661+
for service_type in ("sharev2", "share"):
662+
try:
663+
return self._endpoint_for(service_type)
664+
except APIError:
665+
continue
666+
raise APIError(
667+
0,
668+
"Manila/shared-file-system endpoint not found in catalogue. "
669+
"Check that Manila is deployed and the project has access.",
670+
)
671+
653672
# ── Generic HTTP helpers ──────────────────────────────────────────────────
654673

655674
def _headers(self, extra: dict[str, str] | None = None,
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Typed views of Manila (shared file system) resources."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TypedDict
6+
7+
8+
class Share(TypedDict, total=False):
9+
id: str
10+
name: str
11+
description: str
12+
status: str
13+
size: int
14+
share_proto: str
15+
share_type: str
16+
share_type_name: str
17+
share_network_id: str
18+
share_group_id: str
19+
snapshot_id: str
20+
availability_zone: str
21+
host: str
22+
project_id: str
23+
user_id: str
24+
is_public: bool
25+
access_rules_status: str
26+
has_replicas: bool
27+
replication_type: str
28+
export_location: str
29+
export_locations: list
30+
created_at: str
31+
metadata: dict
32+
33+
34+
class ShareAccessRule(TypedDict, total=False):
35+
id: str
36+
access_level: str # "rw" | "ro"
37+
access_type: str # "ip" | "user" | "cert" | "cephx"
38+
access_to: str
39+
access_key: str # only for CephFS
40+
state: str # "active" | "error" | "applying" | "denying"
41+
share_id: str
42+
created_at: str
43+
updated_at: str
44+
metadata: dict
45+
46+
47+
class ShareSnapshot(TypedDict, total=False):
48+
id: str
49+
name: str
50+
description: str
51+
status: str
52+
share_id: str
53+
share_size: int
54+
size: int
55+
project_id: str
56+
user_id: str
57+
provider_location: str
58+
created_at: str
59+
60+
61+
class ShareType(TypedDict, total=False):
62+
id: str
63+
name: str
64+
description: str
65+
is_default: bool
66+
is_public: bool
67+
extra_specs: dict
68+
required_extra_specs: dict
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""High-level operations on Manila (shared file system) resources.
2+
3+
Manila uses a microversion header to opt into newer API features —
4+
orca pins ``X-OpenStack-Manila-API-Version: 2.51`` (OpenStack Train),
5+
which is old enough to be supported everywhere Manila ships, and new
6+
enough to expose the unified ``/share-access-rules`` endpoint instead
7+
of the legacy per-share action API.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from typing import Any
13+
14+
from orca_cli.core.client import OrcaClient
15+
from orca_cli.models.shared_file_system import (
16+
Share,
17+
ShareAccessRule,
18+
ShareSnapshot,
19+
ShareType,
20+
)
21+
22+
# Microversion floor. Bump only with a clear reason: it directly
23+
# affects the response shape that orca's models assume.
24+
MANILA_MICROVERSION = "2.51"
25+
26+
27+
class FileShareService:
28+
"""Typed wrapper around Manila v2 endpoints."""
29+
30+
def __init__(self, client: OrcaClient) -> None:
31+
self._client = client
32+
self._base = client.share_url
33+
34+
# ── header injection ───────────────────────────────────────────────
35+
36+
def _h(self, extra: dict[str, str] | None = None) -> dict[str, str]:
37+
h = {"X-OpenStack-Manila-API-Version": MANILA_MICROVERSION}
38+
if extra:
39+
h.update(extra)
40+
return h
41+
42+
# ── shares ─────────────────────────────────────────────────────────
43+
44+
def find(self, *, detail: bool = True,
45+
params: dict[str, Any] | None = None) -> list[Share]:
46+
suffix = "/shares/detail" if detail else "/shares"
47+
data = self._client.get(f"{self._base}{suffix}",
48+
params=params, headers=self._h())
49+
return data.get("shares", [])
50+
51+
def get(self, share_id: str) -> Share:
52+
data = self._client.get(f"{self._base}/shares/{share_id}",
53+
headers=self._h())
54+
return data.get("share", data)
55+
56+
def create(self, body: dict[str, Any]) -> Share:
57+
# Manila wraps the body under "share".
58+
data = self._client.post(f"{self._base}/shares",
59+
json={"share": body}, headers=self._h())
60+
return data.get("share", data) if data else {}
61+
62+
def update(self, share_id: str, body: dict[str, Any]) -> Share:
63+
data = self._client.put(f"{self._base}/shares/{share_id}",
64+
json={"share": body}, headers=self._h())
65+
return data.get("share", data) if data else {}
66+
67+
def delete(self, share_id: str) -> None:
68+
self._client.delete(f"{self._base}/shares/{share_id}",
69+
headers=self._h())
70+
71+
def extend(self, share_id: str, new_size: int) -> None:
72+
"""Grow a share to *new_size* GB."""
73+
self._client.post(f"{self._base}/shares/{share_id}/action",
74+
json={"extend": {"new_size": new_size}},
75+
headers=self._h())
76+
77+
def shrink(self, share_id: str, new_size: int) -> None:
78+
self._client.post(f"{self._base}/shares/{share_id}/action",
79+
json={"shrink": {"new_size": new_size}},
80+
headers=self._h())
81+
82+
# ── access rules (unified API, microversion ≥ 2.45) ────────────────
83+
84+
def find_access_rules(self, share_id: str) -> list[ShareAccessRule]:
85+
data = self._client.get(
86+
f"{self._base}/share-access-rules",
87+
params={"share_id": share_id}, headers=self._h(),
88+
)
89+
return data.get("access_list", [])
90+
91+
def get_access_rule(self, access_id: str) -> ShareAccessRule:
92+
data = self._client.get(
93+
f"{self._base}/share-access-rules/{access_id}",
94+
headers=self._h(),
95+
)
96+
return data.get("access", data)
97+
98+
def allow_access(self, share_id: str, access_type: str,
99+
access_to: str, *, access_level: str = "rw",
100+
metadata: dict | None = None) -> ShareAccessRule:
101+
body: dict[str, Any] = {
102+
"allow_access": {
103+
"access_type": access_type,
104+
"access_to": access_to,
105+
"access_level": access_level,
106+
}
107+
}
108+
if metadata:
109+
body["allow_access"]["metadata"] = metadata
110+
data = self._client.post(f"{self._base}/shares/{share_id}/action",
111+
json=body, headers=self._h())
112+
return data.get("access", data) if data else {}
113+
114+
def deny_access(self, share_id: str, access_id: str) -> None:
115+
self._client.post(
116+
f"{self._base}/shares/{share_id}/action",
117+
json={"deny_access": {"access_id": access_id}},
118+
headers=self._h(),
119+
)
120+
121+
# ── snapshots ──────────────────────────────────────────────────────
122+
123+
def find_snapshots(self, *, detail: bool = True,
124+
params: dict[str, Any] | None = None) -> list[ShareSnapshot]:
125+
suffix = "/snapshots/detail" if detail else "/snapshots"
126+
data = self._client.get(f"{self._base}{suffix}",
127+
params=params, headers=self._h())
128+
return data.get("snapshots", [])
129+
130+
def get_snapshot(self, snapshot_id: str) -> ShareSnapshot:
131+
data = self._client.get(f"{self._base}/snapshots/{snapshot_id}",
132+
headers=self._h())
133+
return data.get("snapshot", data)
134+
135+
def create_snapshot(self, share_id: str, *,
136+
name: str | None = None,
137+
description: str | None = None) -> ShareSnapshot:
138+
body: dict[str, Any] = {"share_id": share_id}
139+
if name:
140+
body["name"] = name
141+
if description:
142+
body["description"] = description
143+
data = self._client.post(f"{self._base}/snapshots",
144+
json={"snapshot": body}, headers=self._h())
145+
return data.get("snapshot", data) if data else {}
146+
147+
def delete_snapshot(self, snapshot_id: str) -> None:
148+
self._client.delete(f"{self._base}/snapshots/{snapshot_id}",
149+
headers=self._h())
150+
151+
# ── types (read-only here; admin create lives in lot 2) ────────────
152+
153+
def find_types(self) -> list[ShareType]:
154+
data = self._client.get(f"{self._base}/types", headers=self._h())
155+
return data.get("share_types", [])
156+
157+
def get_type(self, type_id: str) -> ShareType:
158+
data = self._client.get(f"{self._base}/types/{type_id}",
159+
headers=self._h())
160+
return data.get("share_type", data)
161+
162+
163+
__all__ = ["FileShareService", "MANILA_MICROVERSION"]

tests/test_cli_registration.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"user", "volume", "watch", "zone", "placement", "alarm", "rating",
1919
"policy", "identity-provider", "federation-protocol", "mapping", "service-provider",
2020
"limit", "registered-limit", "access-rule", "token", "endpoint-group",
21+
"share",
2122
]
2223

2324

0 commit comments

Comments
 (0)