bitsreef-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,89 @@
1
+ """Project commands: list, info, create."""
2
+ import click
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+
6
+ from ..client import get_client
7
+ from .. import context, output
8
+
9
+ console = Console()
10
+
11
+
12
+ @click.group()
13
+ def projects():
14
+ """Manage projects."""
15
+
16
+
17
+ @projects.command("list")
18
+ @click.option("--org", type=int, default=None, help="Organization ID")
19
+ def list_projects(org):
20
+ """List all projects in an organization."""
21
+ client = get_client()
22
+ org_id = context.resolve_org(client, org, context.read_link())
23
+ resp = client.get(f"/organizations/{org_id}/projects/")
24
+ if resp.status_code != 200:
25
+ console.print("[red]Failed to fetch projects.[/red]")
26
+ raise SystemExit(1)
27
+
28
+ results = resp.json().get("results", [])
29
+ if output.json_enabled():
30
+ output.emit(results)
31
+ return
32
+ if not results:
33
+ console.print("[dim]No projects.[/dim]")
34
+ return
35
+
36
+ table = Table(title="Projects")
37
+ table.add_column("ID", style="dim")
38
+ table.add_column("Name", style="bold")
39
+ table.add_column("Slug")
40
+ table.add_column("Services", justify="right")
41
+ for p in results:
42
+ table.add_row(str(p["id"]), p["name"], p["slug"], str(p.get("service_count", 0)))
43
+ console.print(table)
44
+
45
+
46
+ @projects.command("info")
47
+ @click.argument("project_id", required=False, default=None)
48
+ @click.option("--org", type=int, default=None, help="Organization ID")
49
+ def project_info(project_id, org):
50
+ """Show project details (id or name; linked project if omitted)."""
51
+ client = get_client()
52
+ link = context.read_link()
53
+ org_id = context.resolve_org(client, org, link)
54
+ project_id = context.resolve_project(client, project_id, link, org=org_id)
55
+ resp = client.get(f"/organizations/{org_id}/projects/{project_id}/")
56
+ if resp.status_code == 404:
57
+ console.print("[red]Project not found.[/red]")
58
+ raise SystemExit(1)
59
+
60
+ p = resp.json()
61
+ if output.json_enabled():
62
+ output.emit(p)
63
+ return
64
+ console.print(f"[bold]{p['name']}[/bold] (id={p['id']}, slug={p['slug']})")
65
+ console.print(f" Description: {p.get('description', '—')}")
66
+ console.print(f" Created: {p['created_at']}")
67
+
68
+ svcs = p.get("services", [])
69
+ if svcs:
70
+ console.print(f"\n [bold]Services ({len(svcs)}):[/bold]")
71
+ for s in svcs:
72
+ status_color = "green" if s["status"] == "running" else "yellow" if s["status"] in ("building", "deploying") else "red"
73
+ console.print(f" [{status_color}]{s['status']:10}[/{status_color}] {s['name']} (image={s.get('image', '—')})")
74
+
75
+
76
+ @projects.command("create")
77
+ @click.argument("name")
78
+ @click.option("--org", type=int, default=None, help="Organization ID")
79
+ @click.option("--description", "-d", default="", help="Project description")
80
+ def create_project(name, org, description):
81
+ """Create a new project."""
82
+ client = get_client()
83
+ org_id = context.resolve_org(client, org, context.read_link())
84
+ resp = client.post(f"/organizations/{org_id}/projects/", json={"name": name, "description": description})
85
+ if resp.status_code == 201:
86
+ p = resp.json()
87
+ console.print(f"[green]Created project [bold]{p['name']}[/bold] (id={p['id']})[/green]")
88
+ else:
89
+ console.print(f"[red]Failed: {resp.text}[/red]")
@@ -0,0 +1,149 @@
1
+ """Service commands: list, info, restart, stop, scale."""
2
+ import click
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+
6
+ from ..client import get_client
7
+ from .. import context, output
8
+
9
+ console = Console()
10
+
11
+
12
+ @click.group()
13
+ def services():
14
+ """Manage services within a project."""
15
+
16
+
17
+ @services.command("list")
18
+ @click.argument("project_id", required=False, default=None)
19
+ @click.option("--environment", "-E", default=None,
20
+ help="Filter to an environment id/name (else all environments)")
21
+ def list_services(project_id, environment):
22
+ """List services in a project (linked project if omitted).
23
+
24
+ Pass --environment to show only one environment's services.
25
+ """
26
+ client = get_client()
27
+ project_id = context.resolve_project(client, project_id, context.read_link())
28
+ path = f"/projects/{project_id}/services/"
29
+ if environment is not None:
30
+ env_id = context.resolve_environment(client, project_id, environment)
31
+ path += f"?environment={env_id}"
32
+ resp = client.get(path)
33
+ if resp.status_code != 200:
34
+ console.print("[red]Failed to fetch services.[/red]")
35
+ raise SystemExit(1)
36
+
37
+ results = resp.json().get("results", [])
38
+ if output.json_enabled():
39
+ output.emit(results)
40
+ return
41
+ if not results:
42
+ console.print("[dim]No services.[/dim]")
43
+ return
44
+
45
+ table = Table(title="Services")
46
+ table.add_column("ID", style="dim")
47
+ table.add_column("Name", style="bold")
48
+ table.add_column("Status")
49
+ table.add_column("Image")
50
+ table.add_column("Replicas", justify="right")
51
+ table.add_column("URL")
52
+ for s in results:
53
+ status_color = "green" if s["status"] == "running" else "yellow" if s["status"] in ("building", "deploying") else "red" if s["status"] in ("failed", "stopped") else "dim"
54
+ table.add_row(
55
+ str(s["id"]),
56
+ s["name"],
57
+ f"[{status_color}]{s['status']}[/{status_color}]",
58
+ s.get("image") or s.get("repository", {}).get("repo_full_name", "—") if s.get("repository") else s.get("image", "—"),
59
+ str(s.get("current_replicas", 0)),
60
+ s.get("assigned_subdomain") or "—",
61
+ )
62
+ console.print(table)
63
+
64
+
65
+ @services.command("info")
66
+ @click.argument("project_id", required=False, default=None)
67
+ @click.argument("service_id", required=False, default=None)
68
+ def service_info(project_id, service_id):
69
+ """Show detailed service info (linked project/service if omitted)."""
70
+ client = get_client()
71
+ link = context.read_link()
72
+ project_id = context.resolve_project(client, project_id, link)
73
+ service_id = context.resolve_service(client, project_id, service_id, link)
74
+ resp = client.get(f"/projects/{project_id}/services/{service_id}/")
75
+ if resp.status_code == 404:
76
+ console.print("[red]Service not found.[/red]")
77
+ raise SystemExit(1)
78
+
79
+ s = resp.json()
80
+ if output.json_enabled():
81
+ output.emit(s)
82
+ return
83
+ status_color = "green" if s["status"] == "running" else "yellow" if s["status"] in ("building", "deploying") else "red"
84
+ console.print(f"[bold]{s['name']}[/bold] (id={s['id']})")
85
+ console.print(f" Status: [{status_color}]{s['status']}[/{status_color}]")
86
+ console.print(f" Source: {s.get('source_type', '—')}")
87
+ console.print(f" Image: {s.get('image', '—')}")
88
+ console.print(f" Replicas: {s.get('current_replicas', 0)} (min={s.get('min_replicas', 0)}, max={s.get('max_replicas', 1)})")
89
+ console.print(f" CPU: {s.get('cpu_limit', '—')} Memory: {s.get('memory_limit', '—')} MB")
90
+ if s.get("assigned_subdomain"):
91
+ console.print(f" URL: {s['assigned_subdomain']}")
92
+ if s.get("private_domain"):
93
+ console.print(f" Private: {s['private_domain']}")
94
+
95
+
96
+ @services.command()
97
+ @click.argument("project_id", required=False, default=None)
98
+ @click.argument("service_id", required=False, default=None)
99
+ def restart(project_id, service_id):
100
+ """Restart a service (linked project/service if omitted)."""
101
+ client = get_client()
102
+ link = context.read_link()
103
+ project_id = context.resolve_project(client, project_id, link)
104
+ service_id = context.resolve_service(client, project_id, service_id, link)
105
+ resp = client.post(f"/projects/{project_id}/services/{service_id}/restart/")
106
+ if resp.status_code in (200, 202):
107
+ console.print("[green]Restart queued.[/green]")
108
+ else:
109
+ console.print(f"[red]Failed: {resp.text}[/red]")
110
+
111
+
112
+ @services.command()
113
+ @click.argument("project_id", required=False, default=None)
114
+ @click.argument("service_id", required=False, default=None)
115
+ def stop(project_id, service_id):
116
+ """Stop a service (linked project/service if omitted)."""
117
+ client = get_client()
118
+ link = context.read_link()
119
+ project_id = context.resolve_project(client, project_id, link)
120
+ service_id = context.resolve_service(client, project_id, service_id, link)
121
+ resp = client.post(f"/projects/{project_id}/services/{service_id}/stop/")
122
+ if resp.status_code in (200, 202):
123
+ console.print("[green]Stop queued.[/green]")
124
+ else:
125
+ console.print(f"[red]Failed: {resp.text}[/red]")
126
+
127
+
128
+ @services.command()
129
+ @click.argument("project_id", required=False, default=None)
130
+ @click.argument("service_id", required=False, default=None)
131
+ @click.option("--replicas", "-r", type=int, required=True, help="Target replica count")
132
+ def scale(project_id, service_id, replicas):
133
+ """Scale a service to N replicas (linked project/service if omitted).
134
+
135
+ Examples: `bitsreef services scale -r 3` (linked) or
136
+ `bitsreef services scale my-proj web -r 3`.
137
+ """
138
+ client = get_client()
139
+ link = context.read_link()
140
+ project_id = context.resolve_project(client, project_id, link)
141
+ service_id = context.resolve_service(client, project_id, service_id, link)
142
+ resp = client.patch(
143
+ f"/projects/{project_id}/services/{service_id}/",
144
+ json={"min_replicas": replicas, "max_replicas": max(replicas, 1)},
145
+ )
146
+ if resp.status_code == 200:
147
+ console.print(f"[green]Scaled to {replicas} replicas.[/green]")
148
+ else:
149
+ console.print(f"[red]Failed: {resp.text}[/red]")
@@ -0,0 +1,106 @@
1
+ """Template commands: list, info, deploy (service templates)."""
2
+ import click
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+
6
+ from ..client import get_client
7
+ from .. import context, output
8
+
9
+ console = Console()
10
+
11
+
12
+ def _results(resp):
13
+ data = resp.json()
14
+ return data.get("results", []) if isinstance(data, dict) else data
15
+
16
+
17
+ @click.group()
18
+ def templates():
19
+ """Browse and deploy service templates."""
20
+
21
+
22
+ @templates.command("list")
23
+ def list_templates():
24
+ """List available service templates."""
25
+ client = get_client()
26
+ resp = client.get("/projects/templates/")
27
+ if resp.status_code != 200:
28
+ console.print(f"[red]Failed to fetch templates: {resp.text}[/red]")
29
+ raise SystemExit(1)
30
+
31
+ results = _results(resp)
32
+ if output.json_enabled():
33
+ output.emit(results)
34
+ return
35
+ if not results:
36
+ console.print("[dim]No templates.[/dim]")
37
+ return
38
+
39
+ table = Table(title="Templates")
40
+ table.add_column("Slug", style="bold")
41
+ table.add_column("Name")
42
+ table.add_column("Category")
43
+ table.add_column("Official")
44
+ for t in results:
45
+ official = "[green]yes[/green]" if t.get("is_official") else ""
46
+ table.add_row(t.get("slug", "—"), t.get("name", "—"),
47
+ t.get("category", "—"), official)
48
+ console.print(table)
49
+
50
+
51
+ @templates.command("info")
52
+ @click.argument("slug")
53
+ def template_info(slug):
54
+ """Show a template's details and the services it deploys."""
55
+ client = get_client()
56
+ resp = client.get(f"/projects/templates/{slug}/")
57
+ if resp.status_code != 200:
58
+ console.print(f"[red]Failed to fetch template: {resp.text}[/red]")
59
+ raise SystemExit(1)
60
+
61
+ t = resp.json()
62
+ if output.json_enabled():
63
+ output.emit(t)
64
+ return
65
+
66
+ console.print(f"[bold]{t.get('name')}[/bold] [dim]({t.get('slug')})[/dim]")
67
+ if t.get("description"):
68
+ console.print(t["description"])
69
+ console.print(f"Category: {t.get('category', '—')}")
70
+ services = (t.get("config") or {}).get("services", [])
71
+ if services:
72
+ console.print("\nServices:")
73
+ for svc in services:
74
+ image = svc.get("image", "—")
75
+ console.print(f" • [bold]{svc.get('name')}[/bold] {image}")
76
+
77
+
78
+ @templates.command("deploy")
79
+ @click.argument("slug")
80
+ @click.argument("project", required=False, default=None)
81
+ @click.option("--var", "-v", "variables", multiple=True, metavar="KEY=VALUE",
82
+ help="Override a template variable (repeatable)")
83
+ def deploy_template(slug, project, variables):
84
+ """Deploy a template into a project (linked project if omitted)."""
85
+ client = get_client()
86
+ project_id = context.resolve_project(client, project, context.read_link())
87
+
88
+ overrides = {}
89
+ for item in variables:
90
+ if "=" not in item:
91
+ raise click.ClickException(f"--var must be KEY=VALUE, got '{item}'")
92
+ key, value = item.split("=", 1)
93
+ overrides[key] = value
94
+
95
+ resp = client.post(
96
+ f"/projects/{project_id}/deploy-template/{slug}/",
97
+ json={"variable_overrides": overrides},
98
+ )
99
+ if resp.status_code == 201:
100
+ if output.json_enabled():
101
+ output.emit(resp.json())
102
+ return
103
+ console.print(f"[green]Deployed template[/green] [bold]{slug}[/bold] into project {project_id}.")
104
+ console.print("Run [bold]bitsreef services list[/bold] to see the new services.")
105
+ else:
106
+ console.print(f"[red]Failed: {resp.text}[/red]")
@@ -0,0 +1,195 @@
1
+ """`bitsreef up` — create-or-update a service from an image and deploy it.
2
+
3
+ The flagship one-shot: go from an image to a running service in a single
4
+ command. If a service with the given name already exists in the project its
5
+ image (and any provided resources/env) is updated; otherwise it is created.
6
+ Then a deployment is triggered, and with --follow the command streams the
7
+ build/deploy to completion and exits non-zero if it fails — so it works in CI.
8
+ """
9
+ import json as jsonlib
10
+ import sys
11
+
12
+ import click
13
+ from rich.console import Console
14
+
15
+ from ..client import get_client
16
+ from ..deploy_watch import follow_deployment
17
+ from .. import context, output
18
+
19
+ console = Console()
20
+
21
+
22
+ def _parse_env(pairs: tuple[str, ...]) -> dict[str, str]:
23
+ """Parse `KEY=VALUE` options into a dict; exit on a malformed pair."""
24
+ out: dict[str, str] = {}
25
+ for p in pairs:
26
+ if "=" not in p:
27
+ console.print(f"[red]Invalid --env '{p}'. Use KEY=VALUE.[/red]")
28
+ raise SystemExit(2)
29
+ key, value = p.split("=", 1)
30
+ key = key.strip()
31
+ if not key:
32
+ console.print(f"[red]Invalid --env '{p}'. Empty key.[/red]")
33
+ raise SystemExit(2)
34
+ out[key] = value
35
+ return out
36
+
37
+
38
+ def _find_service_by_name(client, project_id: int, name: str, environment_id: int | None = None) -> dict | None:
39
+ """Return the service dict matching `name` (and env) in the project."""
40
+ # Scope to the environment so the same name in another env isn't matched.
41
+ path = f"/projects/{project_id}/services/"
42
+ if environment_id is not None:
43
+ path += f"?environment={environment_id}"
44
+ resp = client.get(path)
45
+ if resp.status_code != 200:
46
+ console.print(f"[red]Failed to list services: {resp.text}[/red]")
47
+ raise SystemExit(1)
48
+ data = resp.json()
49
+ while True:
50
+ for s in data.get("results", []):
51
+ if s.get("name") == name:
52
+ return s
53
+ next_url = data.get("next")
54
+ if not next_url:
55
+ return None
56
+ # `next` is an absolute URL; go through the client so auth headers apply.
57
+ r = client.get(next_url.split("/v1", 1)[-1] if "/v1" in next_url else next_url)
58
+ if r.status_code != 200:
59
+ return None
60
+ data = r.json()
61
+
62
+
63
+ def _apply_env(client, service_id: int, env: dict[str, str]) -> None:
64
+ """Create or update each env var on the service before deploy."""
65
+ resp = client.get(f"/services/{service_id}/env/")
66
+ existing = {v["key"]: v for v in resp.json().get("results", [])} if resp.status_code == 200 else {}
67
+ for key, value in env.items():
68
+ if key in existing:
69
+ r = client.patch(
70
+ f"/services/{service_id}/env/{existing[key]['id']}/",
71
+ json={"value": value},
72
+ )
73
+ else:
74
+ r = client.post(
75
+ f"/services/{service_id}/env/",
76
+ json={"key": key, "value": value},
77
+ )
78
+ if r.status_code not in (200, 201):
79
+ console.print(f"[yellow]Warning: could not set env {key}: {r.text}[/yellow]")
80
+
81
+
82
+ @click.command()
83
+ @click.option("--project", "-p", default=None, help="Project id or name (else linked)")
84
+ @click.option("--name", "-n", default=None, help="Service name (else linked service)")
85
+ @click.option("--image", "-i", required=True, help="Docker image (e.g. nginx:latest)")
86
+ @click.option("--port", type=int, default=None, help="Container port to expose (exposed_port)")
87
+ @click.option("--env", "-e", "env_pairs", multiple=True, help="Env var KEY=VALUE (repeatable)")
88
+ @click.option("--replicas", type=int, default=None, help="Number of replicas (min)")
89
+ @click.option("--cpu", type=float, default=None, help="CPU limit (cores)")
90
+ @click.option("--memory", type=int, default=None, help="Memory limit (MB)")
91
+ @click.option("--follow", "-f", is_flag=True, help="Stream the deploy to completion (non-zero exit on failure)")
92
+ @click.option("--environment", "-E", default=None,
93
+ help="Target environment id/name (else the project's default/production)")
94
+ @click.option("--json", "json_mode", is_flag=True, help="Machine-readable output")
95
+ def up(project, name, image, port, env_pairs, replicas, cpu, memory, environment, follow, json_mode):
96
+ """Create or update a service from an image and deploy it.
97
+
98
+ Project/name fall back to the linked directory (`bitsreef link`), so a
99
+ linked service can be redeployed with just `bitsreef up -i image:tag`.
100
+ With --environment the service is created in / matched within that
101
+ environment (the same name can exist in several environments).
102
+ """
103
+ json_mode = json_mode or output.json_enabled()
104
+ client = get_client()
105
+ env = _parse_env(env_pairs)
106
+ link = context.read_link()
107
+ project_id = context.resolve_project(client, project, link)
108
+ environment_id = context.resolve_environment(client, project_id, environment)
109
+ if not name:
110
+ name = link.get("service_name")
111
+ if not name:
112
+ _fail("No service name given and none linked. Pass --name or run `bitsreef link -s <service>`.", json_mode)
113
+
114
+ # Optional fields — only send what was provided so backend defaults apply.
115
+ fields: dict = {"image": image}
116
+ if port is not None:
117
+ fields["exposed_port"] = port
118
+ if replicas is not None:
119
+ fields["min_replicas"] = replicas
120
+ fields["max_replicas"] = max(replicas, 1)
121
+ if cpu is not None:
122
+ fields["cpu_limit"] = cpu
123
+ if memory is not None:
124
+ fields["memory_limit"] = memory
125
+
126
+ existing = _find_service_by_name(client, project_id, name, environment_id)
127
+ if existing:
128
+ service_id = existing["id"]
129
+ action = "updated"
130
+ resp = client.patch(f"/projects/{project_id}/services/{service_id}/", json=fields)
131
+ if resp.status_code != 200:
132
+ _fail(f"Failed to update service: {resp.text}", json_mode)
133
+ else:
134
+ action = "created"
135
+ payload = {"name": name, "source_type": "image", "environment": environment_id, **fields}
136
+ resp = client.post(f"/projects/{project_id}/services/", json=payload)
137
+ if resp.status_code != 201:
138
+ _fail(f"Failed to create service: {resp.text}", json_mode)
139
+ service_id = resp.json()["id"]
140
+
141
+ if env:
142
+ _apply_env(client, service_id, env)
143
+
144
+ if not json_mode:
145
+ console.print(f"[green]Service {action}[/green] [bold]{name}[/bold] (id={service_id})")
146
+
147
+ # Trigger the deployment.
148
+ dep = client.post(f"/projects/{project_id}/services/{service_id}/deploy/")
149
+ if dep.status_code not in (200, 202):
150
+ _fail(f"Deploy failed to queue: {dep.text}", json_mode)
151
+ deployment_id = dep.json().get("deployment_id")
152
+
153
+ success = True
154
+ final_status = "queued"
155
+ if follow and deployment_id:
156
+ success, final = follow_deployment(
157
+ client, service_id, deployment_id, json_mode=json_mode
158
+ )
159
+ final_status = (final or {}).get("status", "unknown")
160
+ elif not json_mode:
161
+ console.print("[green]Deployment queued.[/green]"
162
+ f" Follow with: bitsreef deploy status {project_id}")
163
+
164
+ url = _service_url(client, project_id, service_id)
165
+
166
+ if json_mode:
167
+ click.echo(jsonlib.dumps({
168
+ "service_id": service_id,
169
+ "action": action,
170
+ "deployment_id": deployment_id,
171
+ "status": final_status,
172
+ "url": url,
173
+ "success": success,
174
+ }))
175
+ elif url:
176
+ console.print(f" URL: [link]https://{url}[/link]")
177
+
178
+ if not success:
179
+ sys.exit(1)
180
+
181
+
182
+ def _service_url(client, project_id: int, service_id: int) -> str | None:
183
+ """Best-effort fetch of the service's assigned subdomain."""
184
+ r = client.get(f"/projects/{project_id}/services/{service_id}/")
185
+ if r.status_code == 200:
186
+ return r.json().get("assigned_subdomain") or None
187
+ return None
188
+
189
+
190
+ def _fail(msg: str, json_mode: bool):
191
+ if json_mode:
192
+ click.echo(jsonlib.dumps({"success": False, "error": msg}))
193
+ else:
194
+ console.print(f"[red]{msg}[/red]")
195
+ raise SystemExit(1)
@@ -0,0 +1,99 @@
1
+ """Volume commands: list, create, delete."""
2
+ import click
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+
6
+ from ..client import get_client
7
+ from .. import context, output
8
+
9
+ console = Console()
10
+
11
+
12
+ def _results(resp):
13
+ data = resp.json()
14
+ return data.get("results", []) if isinstance(data, dict) else data
15
+
16
+
17
+ def _volume_id(client, project_id, value):
18
+ """Resolve a volume id from an id or a volume name."""
19
+ if str(value).isdigit():
20
+ return int(value)
21
+ resp = client.get(f"/projects/{project_id}/volumes/")
22
+ for v in _results(resp):
23
+ if v.get("name") == value:
24
+ return v["id"]
25
+ raise click.ClickException(f"No volume '{value}' in this project.")
26
+
27
+
28
+ @click.group()
29
+ def volumes():
30
+ """Manage volumes within a project."""
31
+
32
+
33
+ @volumes.command("list")
34
+ @click.argument("project_id", required=False, default=None)
35
+ def list_volumes(project_id):
36
+ """List volumes in a project (linked project if omitted)."""
37
+ client = get_client()
38
+ project_id = context.resolve_project(client, project_id, context.read_link())
39
+ resp = client.get(f"/projects/{project_id}/volumes/")
40
+ if resp.status_code != 200:
41
+ console.print(f"[red]Failed to fetch volumes: {resp.text}[/red]")
42
+ raise SystemExit(1)
43
+
44
+ results = _results(resp)
45
+ if output.json_enabled():
46
+ output.emit(results)
47
+ return
48
+ if not results:
49
+ console.print("[dim]No volumes.[/dim]")
50
+ return
51
+
52
+ table = Table(title="Volumes")
53
+ table.add_column("ID", style="dim")
54
+ table.add_column("Name", style="bold")
55
+ table.add_column("Mount")
56
+ table.add_column("Size (used/limit MB)", justify="right")
57
+ table.add_column("Service")
58
+ for v in results:
59
+ size = f"{v.get('current_size_mb', 0)}/{v.get('size_limit_mb', '—')}"
60
+ table.add_row(str(v["id"]), v["name"], v.get("mount_path", "—"),
61
+ size, str(v.get("service") or "—"))
62
+ console.print(table)
63
+
64
+
65
+ @volumes.command("create")
66
+ @click.argument("name")
67
+ @click.argument("project_id", required=False, default=None)
68
+ @click.option("--size", type=int, required=True, help="Size limit in MB")
69
+ @click.option("--mount", "mount_path", required=True, help="Mount path in the container")
70
+ @click.option("--service", type=int, default=None, help="Attach to this service ID")
71
+ def create_volume(name, project_id, size, mount_path, service):
72
+ """Create a volume in a project (linked project if omitted)."""
73
+ client = get_client()
74
+ project_id = context.resolve_project(client, project_id, context.read_link())
75
+ payload = {"name": name, "size_limit_mb": size, "mount_path": mount_path}
76
+ if service is not None:
77
+ payload["service"] = service
78
+ resp = client.post(f"/projects/{project_id}/volumes/", json=payload)
79
+ if resp.status_code == 201:
80
+ v = resp.json()
81
+ console.print(f"[green]Created volume[/green] [bold]{v['name']}[/bold] (id={v['id']})")
82
+ else:
83
+ console.print(f"[red]Failed: {resp.text}[/red]")
84
+
85
+
86
+ @volumes.command("delete")
87
+ @click.argument("volume")
88
+ @click.argument("project_id", required=False, default=None)
89
+ @click.confirmation_option(prompt="Delete this volume? Data will be lost.")
90
+ def delete_volume(volume, project_id):
91
+ """Delete a volume (by name or id) from a project."""
92
+ client = get_client()
93
+ project_id = context.resolve_project(client, project_id, context.read_link())
94
+ volume_id = _volume_id(client, project_id, volume)
95
+ resp = client.delete(f"/projects/{project_id}/volumes/{volume_id}/")
96
+ if resp.status_code == 204:
97
+ console.print("[green]Deleted.[/green]")
98
+ else:
99
+ console.print(f"[red]Failed: {resp.text}[/red]")