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.
@@ -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
+ )
@@ -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
+ )
@@ -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."""