compshare-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- compshare_cli/__init__.py +3 -0
- compshare_cli/__main__.py +4 -0
- compshare_cli/actions.py +71 -0
- compshare_cli/api.py +127 -0
- compshare_cli/cli.py +219 -0
- compshare_cli/commands/__init__.py +1 -0
- compshare_cli/commands/common.py +57 -0
- compshare_cli/commands/image.py +528 -0
- compshare_cli/commands/instance.py +1493 -0
- compshare_cli/commands/storage.py +276 -0
- compshare_cli/config.py +127 -0
- compshare_cli/errors.py +10 -0
- compshare_cli/i18n.py +506 -0
- compshare_cli/location.py +91 -0
- compshare_cli/output.py +124 -0
- compshare_cli/parsing.py +93 -0
- compshare_cli/runtime.py +34 -0
- compshare_cli/sdk.py +27 -0
- compshare_cli-0.1.0.dist-info/METADATA +283 -0
- compshare_cli-0.1.0.dist-info/RECORD +23 -0
- compshare_cli-0.1.0.dist-info/WHEEL +5 -0
- compshare_cli-0.1.0.dist-info/entry_points.txt +2 -0
- compshare_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Iterable, Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from compshare_cli.api import call, invoke
|
|
8
|
+
from compshare_cli.commands.common import confirm, request, runtime
|
|
9
|
+
from compshare_cli.i18n import tr
|
|
10
|
+
from compshare_cli.location import locate_disk, locate_instance, region_from_zone, supported_regions
|
|
11
|
+
from compshare_cli.output import Renderer
|
|
12
|
+
from compshare_cli.parsing import compact, disk_gib
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Manage disks and cloud storage.", no_args_is_help=True)
|
|
15
|
+
disk_app = typer.Typer(help="Manage instance disks.", no_args_is_help=True)
|
|
16
|
+
us3_app = typer.Typer(help="Manage US3 attachments.", no_args_is_help=True)
|
|
17
|
+
app.add_typer(disk_app, name="disk")
|
|
18
|
+
app.add_typer(us3_app, name="us3")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _disk_rows(response: Dict[str, Any]) -> Iterable[Dict[str, Any]]:
|
|
22
|
+
for instance in response.get("UHostSet", []):
|
|
23
|
+
for disk in instance.get("DiskSet", []):
|
|
24
|
+
row = dict(disk)
|
|
25
|
+
row["UHostId"] = instance.get("UHostId")
|
|
26
|
+
row["Region"] = instance.get("Region")
|
|
27
|
+
size = row.get("Size")
|
|
28
|
+
row["SizeDisplay"] = f"{size}GiB" if isinstance(size, int) else size
|
|
29
|
+
yield row
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@disk_app.command("list")
|
|
33
|
+
def list_disks(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
instance: Optional[str] = typer.Option(None, "--instance", help="Filter by instance ID."),
|
|
36
|
+
region: Optional[str] = typer.Option(None, "--region", help="Filter by region."),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""List disks reported by one or all instances."""
|
|
39
|
+
state = runtime(ctx)
|
|
40
|
+
if instance:
|
|
41
|
+
resolved_region, _, host = locate_instance(
|
|
42
|
+
state,
|
|
43
|
+
instance,
|
|
44
|
+
preferred_region=region,
|
|
45
|
+
)
|
|
46
|
+
response: Dict[str, Any] = {
|
|
47
|
+
"UHostSet": [{**host, "Region": resolved_region}],
|
|
48
|
+
"RegionSet": [resolved_region],
|
|
49
|
+
}
|
|
50
|
+
else:
|
|
51
|
+
regions = [region] if region else supported_regions(state)
|
|
52
|
+
response = {"UHostSet": [], "RegionSet": regions}
|
|
53
|
+
for current_region in regions:
|
|
54
|
+
current = call(
|
|
55
|
+
state,
|
|
56
|
+
"DescribeCompShareInstance",
|
|
57
|
+
{"Region": current_region, "Limit": 100, "Offset": 0},
|
|
58
|
+
)
|
|
59
|
+
response["UHostSet"].extend(
|
|
60
|
+
{**host, "Region": current_region} for host in current.get("UHostSet", [])
|
|
61
|
+
)
|
|
62
|
+
Renderer(state.json_output).data(
|
|
63
|
+
response,
|
|
64
|
+
rows=_disk_rows(response),
|
|
65
|
+
columns=(
|
|
66
|
+
("UDiskId", "DISK ID"),
|
|
67
|
+
("Name", "NAME"),
|
|
68
|
+
("SizeDisplay", "SIZE"),
|
|
69
|
+
("Type", "TYPE"),
|
|
70
|
+
("IsBoot", "BOOT"),
|
|
71
|
+
("Device", "DEVICE"),
|
|
72
|
+
("UHostId", "INSTANCE"),
|
|
73
|
+
("Region", "REGION"),
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@disk_app.command("create")
|
|
79
|
+
def create_disk(
|
|
80
|
+
ctx: typer.Context,
|
|
81
|
+
instance: str = typer.Option(..., "--instance", help="Target instance ID."),
|
|
82
|
+
size: str = typer.Option(..., help="Disk size, for example 100GiB."),
|
|
83
|
+
name: str = typer.Option(..., help="Disk name."),
|
|
84
|
+
disk_type: str = typer.Option("SSDDataDisk", "--type", help="Disk type."),
|
|
85
|
+
charge: str = typer.Option("Month", help="Billing type."),
|
|
86
|
+
quantity: int = typer.Option(1, min=1, help="Billing duration for prepaid modes."),
|
|
87
|
+
coupon: Optional[str] = typer.Option(None, help="Coupon ID."),
|
|
88
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Create a disk and attach it to an instance."""
|
|
91
|
+
confirm(
|
|
92
|
+
tr(
|
|
93
|
+
"Create {size} disk {name} and attach it to {instance}?",
|
|
94
|
+
size=size,
|
|
95
|
+
name=name,
|
|
96
|
+
instance=instance,
|
|
97
|
+
),
|
|
98
|
+
yes,
|
|
99
|
+
)
|
|
100
|
+
state = runtime(ctx)
|
|
101
|
+
region, zone, _ = locate_instance(state, instance)
|
|
102
|
+
params = request(ctx, zone=True, region_value=region, zone_value=zone)
|
|
103
|
+
params.update(
|
|
104
|
+
compact(
|
|
105
|
+
{
|
|
106
|
+
"UHostId": instance,
|
|
107
|
+
"Size": disk_gib(size),
|
|
108
|
+
"DiskType": disk_type,
|
|
109
|
+
"Name": name,
|
|
110
|
+
"ChargeType": charge,
|
|
111
|
+
"Quantity": quantity,
|
|
112
|
+
"CouponId": coupon,
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
invoke(
|
|
117
|
+
state,
|
|
118
|
+
"CreateAndAttachCompshareDisk",
|
|
119
|
+
params,
|
|
120
|
+
success=tr("Created and attached disk {name}", name=name),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@disk_app.command("attach", help="Attach an existing disk to an instance.")
|
|
125
|
+
def attach_disk(
|
|
126
|
+
ctx: typer.Context,
|
|
127
|
+
disk: str,
|
|
128
|
+
instance: str = typer.Option(..., "--instance", help="Target instance ID."),
|
|
129
|
+
disk_type: Optional[str] = typer.Option(None, "--type", help="Data disk type."),
|
|
130
|
+
) -> None:
|
|
131
|
+
state = runtime(ctx)
|
|
132
|
+
region, zone, _ = locate_instance(state, instance)
|
|
133
|
+
params = request(ctx, zone=True, region_value=region, zone_value=zone)
|
|
134
|
+
params.update(compact({"UHostId": instance, "UDiskId": disk, "DataDiskType": disk_type}))
|
|
135
|
+
invoke(
|
|
136
|
+
state,
|
|
137
|
+
"AttachCompshareDisk",
|
|
138
|
+
params,
|
|
139
|
+
success=tr("Attached {disk} to {instance}", disk=disk, instance=instance),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@disk_app.command("detach", help="Detach a disk from an instance.")
|
|
144
|
+
def detach_disk(
|
|
145
|
+
ctx: typer.Context,
|
|
146
|
+
disk: str,
|
|
147
|
+
instance: str = typer.Option(..., "--instance", help="Attached instance ID."),
|
|
148
|
+
device: str = typer.Option(..., help="Device path, for example /dev/vdb."),
|
|
149
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
150
|
+
) -> None:
|
|
151
|
+
confirm(
|
|
152
|
+
tr(
|
|
153
|
+
"Detach disk {disk} from {instance}? Ensure it is unmounted first.",
|
|
154
|
+
disk=disk,
|
|
155
|
+
instance=instance,
|
|
156
|
+
),
|
|
157
|
+
yes,
|
|
158
|
+
)
|
|
159
|
+
state = runtime(ctx)
|
|
160
|
+
region, zone, _ = locate_instance(state, instance)
|
|
161
|
+
params = request(ctx, zone=True, region_value=region, zone_value=zone)
|
|
162
|
+
params.update(
|
|
163
|
+
{
|
|
164
|
+
"UHostId": instance,
|
|
165
|
+
"UDiskId": disk,
|
|
166
|
+
"Device": device,
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
invoke(
|
|
170
|
+
state,
|
|
171
|
+
"DetachCompshareDisk",
|
|
172
|
+
params,
|
|
173
|
+
success=tr("Detached disk {disk}", disk=disk),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@disk_app.command("price", help="Query a disk expansion price.")
|
|
178
|
+
def disk_price(
|
|
179
|
+
ctx: typer.Context,
|
|
180
|
+
disk: str,
|
|
181
|
+
instance: str = typer.Option(..., "--instance", help="Attached instance ID."),
|
|
182
|
+
size: str = typer.Option(..., help="Target disk size, for example 200GiB."),
|
|
183
|
+
backup: Optional[str] = typer.Option(None, help="Backup mode: NONE, DATAARK or SNAPSHOT."),
|
|
184
|
+
) -> None:
|
|
185
|
+
state = runtime(ctx)
|
|
186
|
+
region, zone, _ = locate_instance(state, instance)
|
|
187
|
+
params = request(ctx, zone=True, region_value=region, zone_value=zone)
|
|
188
|
+
params.update(
|
|
189
|
+
compact(
|
|
190
|
+
{
|
|
191
|
+
"UHostId": instance,
|
|
192
|
+
"DiskId": disk,
|
|
193
|
+
"DiskSpace": disk_gib(size),
|
|
194
|
+
"BackupMode": backup,
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
invoke(state, "GetCompShareAttachedDiskUpgradePrice", params)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@disk_app.command("resize", help="Resize a disk.")
|
|
202
|
+
def resize_disk(
|
|
203
|
+
ctx: typer.Context,
|
|
204
|
+
disk: str,
|
|
205
|
+
size: str = typer.Option(..., help="Target disk size, for example 200GiB."),
|
|
206
|
+
instance: Optional[str] = typer.Option(None, "--instance", help="Attached instance ID."),
|
|
207
|
+
zone: Optional[str] = typer.Option(None, "--zone", help="Availability zone."),
|
|
208
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
209
|
+
) -> None:
|
|
210
|
+
confirm(tr("Resize disk {disk} to {size}? Disks cannot be shrunk.", disk=disk, size=size), yes)
|
|
211
|
+
state = runtime(ctx)
|
|
212
|
+
if instance:
|
|
213
|
+
region, resolved_zone, _ = locate_instance(state, instance)
|
|
214
|
+
elif zone:
|
|
215
|
+
region, resolved_zone = region_from_zone(zone), zone
|
|
216
|
+
else:
|
|
217
|
+
region, resolved_zone, host, _ = locate_disk(state, disk)
|
|
218
|
+
instance = str(host.get("UHostId")) if host else None
|
|
219
|
+
params = request(
|
|
220
|
+
ctx,
|
|
221
|
+
zone=True,
|
|
222
|
+
region_value=region,
|
|
223
|
+
zone_value=resolved_zone,
|
|
224
|
+
)
|
|
225
|
+
params.update(compact({"UDiskId": disk, "UHostId": instance, "Size": disk_gib(size)}))
|
|
226
|
+
invoke(
|
|
227
|
+
state,
|
|
228
|
+
"ResizeCompShareDisk",
|
|
229
|
+
params,
|
|
230
|
+
success=tr("Resized disk {disk}", disk=disk),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@disk_app.command("delete", help="Permanently delete a disk.")
|
|
235
|
+
def delete_disk(
|
|
236
|
+
ctx: typer.Context,
|
|
237
|
+
disk: str,
|
|
238
|
+
zone: Optional[str] = typer.Option(None, "--zone", help="Availability zone."),
|
|
239
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
240
|
+
) -> None:
|
|
241
|
+
confirm(tr("Permanently delete disk {disk} and all its data?", disk=disk), yes)
|
|
242
|
+
state = runtime(ctx)
|
|
243
|
+
if zone:
|
|
244
|
+
region, resolved_zone = region_from_zone(zone), zone
|
|
245
|
+
else:
|
|
246
|
+
region, resolved_zone, _, _ = locate_disk(state, disk)
|
|
247
|
+
params = request(
|
|
248
|
+
ctx,
|
|
249
|
+
zone=True,
|
|
250
|
+
region_value=region,
|
|
251
|
+
zone_value=resolved_zone,
|
|
252
|
+
)
|
|
253
|
+
params["UDiskId"] = disk
|
|
254
|
+
invoke(
|
|
255
|
+
state,
|
|
256
|
+
"DeleteCompshareDisk",
|
|
257
|
+
params,
|
|
258
|
+
success=tr("Deleted disk {disk}", disk=disk),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@us3_app.command("attach", help="Attach US3 object storage to an instance.")
|
|
263
|
+
def attach_us3(
|
|
264
|
+
ctx: typer.Context,
|
|
265
|
+
instance: str = typer.Option(..., "--instance", help="Target running instance ID."),
|
|
266
|
+
) -> None:
|
|
267
|
+
state = runtime(ctx)
|
|
268
|
+
region, zone, _ = locate_instance(state, instance)
|
|
269
|
+
params = request(ctx, zone=True, region_value=region, zone_value=zone)
|
|
270
|
+
params["UHostId"] = instance
|
|
271
|
+
invoke(
|
|
272
|
+
state,
|
|
273
|
+
"AttachUS3",
|
|
274
|
+
params,
|
|
275
|
+
success=tr("Attached US3 to {instance}", instance=instance),
|
|
276
|
+
)
|
compshare_cli/config.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from compshare_cli.errors import ConfigError
|
|
11
|
+
|
|
12
|
+
DEFAULT_PROFILE = "default"
|
|
13
|
+
DEFAULT_REGION = "cn-wlcb"
|
|
14
|
+
DEFAULT_ZONE = "cn-wlcb-01"
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.compshare.cn"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def config_path() -> Path:
|
|
19
|
+
override = os.environ.get("COMPSHARE_CONFIG_FILE")
|
|
20
|
+
if override:
|
|
21
|
+
return Path(override).expanduser()
|
|
22
|
+
root = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
23
|
+
return root / "compshare" / "config.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Profile:
|
|
28
|
+
public_key: str
|
|
29
|
+
private_key: str
|
|
30
|
+
|
|
31
|
+
def sdk_config(self, region: str) -> Dict[str, Any]:
|
|
32
|
+
return {
|
|
33
|
+
"public_key": self.public_key,
|
|
34
|
+
"private_key": self.private_key,
|
|
35
|
+
"region": region,
|
|
36
|
+
"base_url": DEFAULT_BASE_URL,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ConfigStore:
|
|
41
|
+
def __init__(self, path: Optional[Path] = None) -> None:
|
|
42
|
+
self.path = path or config_path()
|
|
43
|
+
|
|
44
|
+
def _read(self) -> Dict[str, Any]:
|
|
45
|
+
if not self.path.exists():
|
|
46
|
+
return {"current_profile": DEFAULT_PROFILE, "profiles": {}}
|
|
47
|
+
try:
|
|
48
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
49
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
50
|
+
raise ConfigError(f"无法读取配置文件 {self.path}: {exc}") from exc
|
|
51
|
+
if not isinstance(data, dict) or not isinstance(data.get("profiles", {}), dict):
|
|
52
|
+
raise ConfigError(f"配置文件格式无效: {self.path}")
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
def save_profile(self, name: str, profile: Profile, *, activate: bool = True) -> None:
|
|
56
|
+
data = self._read()
|
|
57
|
+
profiles = data.setdefault("profiles", {})
|
|
58
|
+
profiles[name] = {key: value for key, value in asdict(profile).items() if value is not None}
|
|
59
|
+
if activate:
|
|
60
|
+
data["current_profile"] = name
|
|
61
|
+
self._write(data)
|
|
62
|
+
|
|
63
|
+
def list_profiles(self) -> List[str]:
|
|
64
|
+
return sorted(self._read().get("profiles", {}))
|
|
65
|
+
|
|
66
|
+
def current_profile(self) -> str:
|
|
67
|
+
return str(self._read().get("current_profile", DEFAULT_PROFILE))
|
|
68
|
+
|
|
69
|
+
def use_profile(self, name: str) -> None:
|
|
70
|
+
data = self._read()
|
|
71
|
+
if name not in data.get("profiles", {}):
|
|
72
|
+
raise ConfigError(f"Credential profile does not exist: {name}")
|
|
73
|
+
data["current_profile"] = name
|
|
74
|
+
self._write(data)
|
|
75
|
+
|
|
76
|
+
def delete_profile(self, name: str) -> None:
|
|
77
|
+
data = self._read()
|
|
78
|
+
profiles = data.get("profiles", {})
|
|
79
|
+
if name not in profiles:
|
|
80
|
+
raise ConfigError(f"Credential profile does not exist: {name}")
|
|
81
|
+
del profiles[name]
|
|
82
|
+
if data.get("current_profile") == name:
|
|
83
|
+
data["current_profile"] = next(iter(sorted(profiles)), DEFAULT_PROFILE)
|
|
84
|
+
self._write(data)
|
|
85
|
+
|
|
86
|
+
def load_language(self) -> Optional[str]:
|
|
87
|
+
value = self._read().get("language")
|
|
88
|
+
return str(value) if value else None
|
|
89
|
+
|
|
90
|
+
def save_language(self, language: str) -> None:
|
|
91
|
+
data = self._read()
|
|
92
|
+
data["language"] = language
|
|
93
|
+
self._write(data)
|
|
94
|
+
|
|
95
|
+
def _write(self, data: Dict[str, Any]) -> None:
|
|
96
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
os.chmod(self.path.parent, stat.S_IRWXU)
|
|
98
|
+
temporary = self.path.with_suffix(".tmp")
|
|
99
|
+
temporary.write_text(
|
|
100
|
+
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
|
|
101
|
+
encoding="utf-8",
|
|
102
|
+
)
|
|
103
|
+
os.chmod(temporary, stat.S_IRUSR | stat.S_IWUSR)
|
|
104
|
+
temporary.replace(self.path)
|
|
105
|
+
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
|
|
106
|
+
|
|
107
|
+
def load_profile(self, name: Optional[str] = None) -> Profile:
|
|
108
|
+
data = self._read()
|
|
109
|
+
selected = (
|
|
110
|
+
name
|
|
111
|
+
or os.environ.get("COMPSHARE_PROFILE")
|
|
112
|
+
or data.get("current_profile", DEFAULT_PROFILE)
|
|
113
|
+
)
|
|
114
|
+
raw = data.get("profiles", {}).get(selected, {})
|
|
115
|
+
|
|
116
|
+
public_key = os.environ.get("COMPSHARE_PUBLIC_KEY") or raw.get("public_key")
|
|
117
|
+
private_key = os.environ.get("COMPSHARE_PRIVATE_KEY") or raw.get("private_key")
|
|
118
|
+
if not public_key or not private_key:
|
|
119
|
+
raise ConfigError(
|
|
120
|
+
"尚未配置 API 密钥。请运行 `compshare config --name NAME`,或设置 "
|
|
121
|
+
"COMPSHARE_PUBLIC_KEY 和 COMPSHARE_PRIVATE_KEY。"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return Profile(
|
|
125
|
+
public_key=str(public_key),
|
|
126
|
+
private_key=str(private_key),
|
|
127
|
+
)
|
compshare_cli/errors.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class CLIError(Exception):
|
|
2
|
+
"""A user-facing command-line error."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConfigError(CLIError):
|
|
6
|
+
"""Raised when CLI configuration is missing or invalid."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UsageError(CLIError):
|
|
10
|
+
"""Raised when a combination of command options is invalid."""
|