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.
- bitsreef_cli/__init__.py +2 -0
- bitsreef_cli/client.py +74 -0
- bitsreef_cli/commands/__init__.py +0 -0
- bitsreef_cli/commands/auth.py +91 -0
- bitsreef_cli/commands/completion.py +29 -0
- bitsreef_cli/commands/cron.py +166 -0
- bitsreef_cli/commands/deploy.py +124 -0
- bitsreef_cli/commands/domains.py +119 -0
- bitsreef_cli/commands/env.py +138 -0
- bitsreef_cli/commands/environments.py +132 -0
- bitsreef_cli/commands/exec_cmd.py +27 -0
- bitsreef_cli/commands/functions.py +186 -0
- bitsreef_cli/commands/link.py +87 -0
- bitsreef_cli/commands/logs.py +44 -0
- bitsreef_cli/commands/metrics.py +50 -0
- bitsreef_cli/commands/projects.py +89 -0
- bitsreef_cli/commands/services.py +149 -0
- bitsreef_cli/commands/templates.py +106 -0
- bitsreef_cli/commands/up.py +195 -0
- bitsreef_cli/commands/volumes.py +99 -0
- bitsreef_cli/commands/webhooks.py +123 -0
- bitsreef_cli/config.py +92 -0
- bitsreef_cli/context.py +175 -0
- bitsreef_cli/deploy_watch.py +87 -0
- bitsreef_cli/log_stream.py +83 -0
- bitsreef_cli/main.py +43 -0
- bitsreef_cli/output.py +25 -0
- bitsreef_cli/shell.py +136 -0
- bitsreef_cli-0.1.0.dist-info/METADATA +143 -0
- bitsreef_cli-0.1.0.dist-info/RECORD +33 -0
- bitsreef_cli-0.1.0.dist-info/WHEEL +5 -0
- bitsreef_cli-0.1.0.dist-info/entry_points.txt +2 -0
- bitsreef_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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]")
|