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
bitsreef_cli/__init__.py
ADDED
bitsreef_cli/client.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""HTTP client for BitsReef API with automatic token refresh."""
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from . import config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BitsReefClient:
|
|
11
|
+
"""Authenticated HTTP client for the BitsReef API."""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.base_url = config.get_api_url()
|
|
15
|
+
access, refresh = config.get_tokens()
|
|
16
|
+
self.access_token = access
|
|
17
|
+
self.refresh_token = refresh
|
|
18
|
+
|
|
19
|
+
def _headers(self) -> dict:
|
|
20
|
+
h = {"Content-Type": "application/json"}
|
|
21
|
+
if self.access_token:
|
|
22
|
+
h["Authorization"] = f"Bearer {self.access_token}"
|
|
23
|
+
return h
|
|
24
|
+
|
|
25
|
+
def _refresh(self) -> bool:
|
|
26
|
+
"""Attempt to refresh the access token. Returns True on success."""
|
|
27
|
+
if not self.refresh_token:
|
|
28
|
+
return False
|
|
29
|
+
try:
|
|
30
|
+
resp = httpx.post(
|
|
31
|
+
f"{self.base_url}/account/token/refresh/",
|
|
32
|
+
json={"refresh": self.refresh_token},
|
|
33
|
+
timeout=10,
|
|
34
|
+
)
|
|
35
|
+
if resp.status_code == 200:
|
|
36
|
+
data = resp.json()
|
|
37
|
+
self.access_token = data["access"]
|
|
38
|
+
config.save_tokens(self.access_token, self.refresh_token)
|
|
39
|
+
return True
|
|
40
|
+
except httpx.HTTPError:
|
|
41
|
+
pass
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
def request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
|
45
|
+
"""Make an authenticated request. Auto-refreshes token on 401."""
|
|
46
|
+
url = f"{self.base_url}{path}"
|
|
47
|
+
resp = httpx.request(method, url, headers=self._headers(), timeout=30, **kwargs)
|
|
48
|
+
if resp.status_code == 401 and self._refresh():
|
|
49
|
+
resp = httpx.request(method, url, headers=self._headers(), timeout=30, **kwargs)
|
|
50
|
+
if resp.status_code == 401:
|
|
51
|
+
click.secho("Session expired. Please run: bitsreef auth login", fg="red")
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
return resp
|
|
54
|
+
|
|
55
|
+
def get(self, path: str, **kwargs) -> httpx.Response:
|
|
56
|
+
return self.request("GET", path, **kwargs)
|
|
57
|
+
|
|
58
|
+
def post(self, path: str, **kwargs) -> httpx.Response:
|
|
59
|
+
return self.request("POST", path, **kwargs)
|
|
60
|
+
|
|
61
|
+
def patch(self, path: str, **kwargs) -> httpx.Response:
|
|
62
|
+
return self.request("PATCH", path, **kwargs)
|
|
63
|
+
|
|
64
|
+
def delete(self, path: str, **kwargs) -> httpx.Response:
|
|
65
|
+
return self.request("DELETE", path, **kwargs)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_client() -> BitsReefClient:
|
|
69
|
+
"""Return an authenticated client, exiting if not logged in."""
|
|
70
|
+
access, _ = config.get_tokens()
|
|
71
|
+
if not access:
|
|
72
|
+
click.secho("Not logged in. Run: bitsreef auth login", fg="red")
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
return BitsReefClient()
|
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Auth commands: login, logout, whoami."""
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
import httpx
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from .. import config, output
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def auth():
|
|
15
|
+
"""Authenticate with BitsReef."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@auth.command()
|
|
19
|
+
@click.option("--username", "-u", default=None, help="Username (else prompted)")
|
|
20
|
+
@click.option("--password-stdin", "password_stdin", is_flag=True,
|
|
21
|
+
help="Read the password from stdin (non-interactive, for CI)")
|
|
22
|
+
@click.option("--token", default=None,
|
|
23
|
+
help="Store a pre-issued access token instead of logging in")
|
|
24
|
+
def login(username, password_stdin, token):
|
|
25
|
+
"""Log in with username/password, or store a token non-interactively.
|
|
26
|
+
|
|
27
|
+
The CLI always talks to the hosted BitsReef API — there is no server URL to
|
|
28
|
+
set.
|
|
29
|
+
|
|
30
|
+
CI examples:
|
|
31
|
+
echo "$PASS" | bitsreef auth login -u ci --password-stdin
|
|
32
|
+
bitsreef auth login --token "$BITSREEF_TOKEN"
|
|
33
|
+
(Or skip login entirely and set BITSREEF_TOKEN.)
|
|
34
|
+
"""
|
|
35
|
+
api_url = config.get_api_url()
|
|
36
|
+
|
|
37
|
+
# Non-interactive: store a pre-issued access token (no refresh token).
|
|
38
|
+
if token:
|
|
39
|
+
config.save_tokens(token, "")
|
|
40
|
+
console.print(f"[green]Token stored[/green] for {api_url}")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
if not username:
|
|
44
|
+
username = click.prompt("Username")
|
|
45
|
+
if password_stdin:
|
|
46
|
+
password = sys.stdin.readline().rstrip("\n")
|
|
47
|
+
else:
|
|
48
|
+
password = click.prompt("Password", hide_input=True)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
resp = httpx.post(
|
|
52
|
+
f"{api_url}/account/token/",
|
|
53
|
+
json={"username": username, "password": password},
|
|
54
|
+
timeout=15,
|
|
55
|
+
)
|
|
56
|
+
except httpx.ConnectError:
|
|
57
|
+
console.print(f"[red]Cannot connect to {api_url}[/red]")
|
|
58
|
+
raise SystemExit(1)
|
|
59
|
+
|
|
60
|
+
if resp.status_code != 200:
|
|
61
|
+
console.print("[red]Login failed — check username/password.[/red]")
|
|
62
|
+
raise SystemExit(1)
|
|
63
|
+
|
|
64
|
+
data = resp.json()
|
|
65
|
+
config.save_tokens(data["access"], data["refresh"])
|
|
66
|
+
console.print(f"[green]Logged in as [bold]{username}[/bold][/green]")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@auth.command()
|
|
70
|
+
def logout():
|
|
71
|
+
"""Log out and clear stored tokens."""
|
|
72
|
+
config.clear_tokens()
|
|
73
|
+
console.print("[green]Logged out.[/green]")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@auth.command()
|
|
77
|
+
def whoami():
|
|
78
|
+
"""Show the current logged-in user."""
|
|
79
|
+
from ..client import get_client
|
|
80
|
+
|
|
81
|
+
client = get_client()
|
|
82
|
+
resp = client.get("/account/me/")
|
|
83
|
+
if resp.status_code != 200:
|
|
84
|
+
console.print("[red]Could not fetch user info.[/red]")
|
|
85
|
+
raise SystemExit(1)
|
|
86
|
+
|
|
87
|
+
user = resp.json()
|
|
88
|
+
if output.json_enabled():
|
|
89
|
+
output.emit(user)
|
|
90
|
+
return
|
|
91
|
+
console.print(f"[bold]{user['username']}[/bold] ({user['email']})")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Shell-completion command — emit a completion script for bash/zsh/fish."""
|
|
2
|
+
import click
|
|
3
|
+
from click.shell_completion import get_completion_class
|
|
4
|
+
|
|
5
|
+
_INSTALL_HINTS = {
|
|
6
|
+
"bash": 'eval "$(bitsreef completion bash)" # add to ~/.bashrc',
|
|
7
|
+
"zsh": 'eval "$(bitsreef completion zsh)" # add to ~/.zshrc',
|
|
8
|
+
"fish": "bitsreef completion fish > ~/.config/fish/completions/bitsreef.fish",
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command()
|
|
13
|
+
@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
|
|
14
|
+
def completion(shell):
|
|
15
|
+
"""Print a shell-completion script for SHELL (bash, zsh, or fish).
|
|
16
|
+
|
|
17
|
+
Enable it by sourcing the output, e.g.:
|
|
18
|
+
|
|
19
|
+
eval "$(bitsreef completion bash)"
|
|
20
|
+
|
|
21
|
+
Add that line to your shell's rc file to make it permanent.
|
|
22
|
+
"""
|
|
23
|
+
from ..main import cli as root
|
|
24
|
+
|
|
25
|
+
comp_cls = get_completion_class(shell)
|
|
26
|
+
if comp_cls is None:
|
|
27
|
+
raise click.ClickException(f"Completion is not supported for {shell}.")
|
|
28
|
+
comp = comp_cls(root, {}, "bitsreef", "_BITSREEF_COMPLETE")
|
|
29
|
+
click.echo(comp.source())
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Cron-job commands: list, create, trigger, runs, 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 _service(client, service):
|
|
13
|
+
"""Resolve --service (id/name) or the linked service."""
|
|
14
|
+
link = context.read_link()
|
|
15
|
+
return context.resolve_service(client, link.get("project_id"), service, link)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _results(resp):
|
|
19
|
+
data = resp.json()
|
|
20
|
+
return data.get("results", []) if isinstance(data, dict) else data
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _job_id(client, service_id, value):
|
|
24
|
+
"""Resolve a cron-job id from an id or a job name."""
|
|
25
|
+
if str(value).isdigit():
|
|
26
|
+
return int(value)
|
|
27
|
+
resp = client.get(f"/services/{service_id}/cron-jobs/")
|
|
28
|
+
for j in _results(resp):
|
|
29
|
+
if j.get("name") == value:
|
|
30
|
+
return j["id"]
|
|
31
|
+
raise click.ClickException(f"No cron job '{value}' on this service.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@click.group()
|
|
35
|
+
def cron():
|
|
36
|
+
"""Manage scheduled (cron) jobs for a service."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@cron.command("list")
|
|
40
|
+
@click.option("--service", "-s", default=None, help="Service id or name (else linked)")
|
|
41
|
+
def list_jobs(service):
|
|
42
|
+
"""List cron jobs on a service."""
|
|
43
|
+
client = get_client()
|
|
44
|
+
service_id = _service(client, service)
|
|
45
|
+
resp = client.get(f"/services/{service_id}/cron-jobs/")
|
|
46
|
+
if resp.status_code != 200:
|
|
47
|
+
console.print(f"[red]Failed to fetch cron jobs: {resp.text}[/red]")
|
|
48
|
+
raise SystemExit(1)
|
|
49
|
+
|
|
50
|
+
results = _results(resp)
|
|
51
|
+
if output.json_enabled():
|
|
52
|
+
output.emit(results)
|
|
53
|
+
return
|
|
54
|
+
if not results:
|
|
55
|
+
console.print("[dim]No cron jobs.[/dim]")
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
table = Table(title="Cron Jobs")
|
|
59
|
+
table.add_column("ID", style="dim")
|
|
60
|
+
table.add_column("Name", style="bold")
|
|
61
|
+
table.add_column("Schedule")
|
|
62
|
+
table.add_column("Enabled")
|
|
63
|
+
table.add_column("Last run")
|
|
64
|
+
table.add_column("Next run")
|
|
65
|
+
for j in results:
|
|
66
|
+
enabled = "[green]yes[/green]" if j.get("enabled") else "[red]no[/red]"
|
|
67
|
+
status = j.get("last_run_status")
|
|
68
|
+
last = (j.get("last_run") or "—")[:19]
|
|
69
|
+
if status:
|
|
70
|
+
last = f"{last} ({status})"
|
|
71
|
+
table.add_row(
|
|
72
|
+
str(j["id"]), j["name"], j.get("schedule", "—"),
|
|
73
|
+
enabled, last, (j.get("next_run") or "—")[:19],
|
|
74
|
+
)
|
|
75
|
+
console.print(table)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@cron.command("create")
|
|
79
|
+
@click.argument("name")
|
|
80
|
+
@click.option("--schedule", required=True, help="Cron expression, e.g. '0 * * * *'")
|
|
81
|
+
@click.option("--command", required=True, help="Command to run inside the container")
|
|
82
|
+
@click.option("--service", "-s", default=None, help="Service id or name (else linked)")
|
|
83
|
+
@click.option("--disabled", is_flag=True, help="Create the job disabled")
|
|
84
|
+
def create_job(name, schedule, command, service, disabled):
|
|
85
|
+
"""Create a cron job on a service."""
|
|
86
|
+
client = get_client()
|
|
87
|
+
service_id = _service(client, service)
|
|
88
|
+
payload = {
|
|
89
|
+
"name": name, "schedule": schedule,
|
|
90
|
+
"command": command, "enabled": not disabled,
|
|
91
|
+
}
|
|
92
|
+
resp = client.post(f"/services/{service_id}/cron-jobs/", json=payload)
|
|
93
|
+
if resp.status_code == 201:
|
|
94
|
+
j = resp.json()
|
|
95
|
+
console.print(f"[green]Created[/green] [bold]{j['name']}[/bold] (id={j['id']}) next={j.get('next_run') or '—'}")
|
|
96
|
+
else:
|
|
97
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@cron.command("trigger")
|
|
101
|
+
@click.argument("job")
|
|
102
|
+
@click.option("--service", "-s", default=None, help="Service id or name (else linked)")
|
|
103
|
+
def trigger_job(job, service):
|
|
104
|
+
"""Run a cron job now (by name or id)."""
|
|
105
|
+
client = get_client()
|
|
106
|
+
service_id = _service(client, service)
|
|
107
|
+
job_id = _job_id(client, service_id, job)
|
|
108
|
+
resp = client.post(f"/services/{service_id}/cron-jobs/{job_id}/trigger/")
|
|
109
|
+
if resp.status_code == 202:
|
|
110
|
+
console.print("[green]Triggered.[/green]")
|
|
111
|
+
else:
|
|
112
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@cron.command("runs")
|
|
116
|
+
@click.argument("job")
|
|
117
|
+
@click.option("--service", "-s", default=None, help="Service id or name (else linked)")
|
|
118
|
+
def job_runs(job, service):
|
|
119
|
+
"""Show recent run history for a cron job (by name or id)."""
|
|
120
|
+
client = get_client()
|
|
121
|
+
service_id = _service(client, service)
|
|
122
|
+
job_id = _job_id(client, service_id, job)
|
|
123
|
+
resp = client.get(f"/services/{service_id}/cron-jobs/{job_id}/runs/")
|
|
124
|
+
if resp.status_code != 200:
|
|
125
|
+
console.print(f"[red]Failed to fetch runs: {resp.text}[/red]")
|
|
126
|
+
raise SystemExit(1)
|
|
127
|
+
|
|
128
|
+
results = _results(resp)
|
|
129
|
+
if output.json_enabled():
|
|
130
|
+
output.emit(results)
|
|
131
|
+
return
|
|
132
|
+
if not results:
|
|
133
|
+
console.print("[dim]No runs yet.[/dim]")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
table = Table(title="Cron Runs")
|
|
137
|
+
table.add_column("ID", style="dim")
|
|
138
|
+
table.add_column("Status")
|
|
139
|
+
table.add_column("Exit")
|
|
140
|
+
table.add_column("Started")
|
|
141
|
+
table.add_column("Completed")
|
|
142
|
+
for r in results:
|
|
143
|
+
s = r.get("status", "")
|
|
144
|
+
color = "green" if s == "success" else "red" if s == "failed" else "yellow"
|
|
145
|
+
table.add_row(
|
|
146
|
+
str(r["id"]), f"[{color}]{s}[/{color}]",
|
|
147
|
+
str(r.get("exit_code") if r.get("exit_code") is not None else "—"),
|
|
148
|
+
(r.get("started_at") or "—")[:19], (r.get("completed_at") or "—")[:19],
|
|
149
|
+
)
|
|
150
|
+
console.print(table)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@cron.command("delete")
|
|
154
|
+
@click.argument("job")
|
|
155
|
+
@click.option("--service", "-s", default=None, help="Service id or name (else linked)")
|
|
156
|
+
@click.confirmation_option(prompt="Delete this cron job?")
|
|
157
|
+
def delete_job(job, service):
|
|
158
|
+
"""Delete a cron job (by name or id)."""
|
|
159
|
+
client = get_client()
|
|
160
|
+
service_id = _service(client, service)
|
|
161
|
+
job_id = _job_id(client, service_id, job)
|
|
162
|
+
resp = client.delete(f"/services/{service_id}/cron-jobs/{job_id}/")
|
|
163
|
+
if resp.status_code == 204:
|
|
164
|
+
console.print("[green]Deleted.[/green]")
|
|
165
|
+
else:
|
|
166
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Deploy commands: up, status, redeploy."""
|
|
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 deploy():
|
|
14
|
+
"""Deploy and manage service deployments."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@deploy.command("up")
|
|
18
|
+
@click.argument("project_id", required=False, default=None)
|
|
19
|
+
@click.argument("service_id", required=False, default=None)
|
|
20
|
+
@click.option("--follow", "-f", is_flag=True, help="Stream the deploy to completion (non-zero exit on failure)")
|
|
21
|
+
def deploy_up(project_id, service_id, follow):
|
|
22
|
+
"""Deploy (or redeploy) a service (linked project/service if omitted)."""
|
|
23
|
+
client = get_client()
|
|
24
|
+
link = context.read_link()
|
|
25
|
+
project_id = context.resolve_project(client, project_id, link)
|
|
26
|
+
service_id = context.resolve_service(client, project_id, service_id, link)
|
|
27
|
+
resp = client.post(f"/projects/{project_id}/services/{service_id}/deploy/")
|
|
28
|
+
if resp.status_code not in (200, 202):
|
|
29
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
|
30
|
+
raise SystemExit(1)
|
|
31
|
+
|
|
32
|
+
data = resp.json()
|
|
33
|
+
deployment_id = data.get("deployment_id")
|
|
34
|
+
console.print("[green]Deployment queued.[/green]")
|
|
35
|
+
if deployment_id:
|
|
36
|
+
console.print(f" deployment_id={deployment_id}")
|
|
37
|
+
|
|
38
|
+
if follow and deployment_id:
|
|
39
|
+
from ..deploy_watch import follow_deployment
|
|
40
|
+
|
|
41
|
+
success, _ = follow_deployment(client, service_id, deployment_id)
|
|
42
|
+
if not success:
|
|
43
|
+
raise SystemExit(1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@deploy.command("rebuild")
|
|
47
|
+
@click.argument("project_id", required=False, default=None)
|
|
48
|
+
@click.argument("service_id", required=False, default=None)
|
|
49
|
+
def deploy_rebuild(project_id, service_id):
|
|
50
|
+
"""Force rebuild and deploy a repo-linked service (linked if omitted)."""
|
|
51
|
+
client = get_client()
|
|
52
|
+
link = context.read_link()
|
|
53
|
+
project_id = context.resolve_project(client, project_id, link)
|
|
54
|
+
service_id = context.resolve_service(client, project_id, service_id, link)
|
|
55
|
+
resp = client.post(f"/projects/{project_id}/services/{service_id}/rebuild/")
|
|
56
|
+
if resp.status_code in (200, 202):
|
|
57
|
+
console.print("[green]Rebuild queued.[/green]")
|
|
58
|
+
else:
|
|
59
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@deploy.command("status")
|
|
63
|
+
@click.argument("project_id", required=False, default=None)
|
|
64
|
+
@click.option("--limit", "-n", default=10, type=int, help="Number of deployments to show")
|
|
65
|
+
def deploy_status(project_id, limit):
|
|
66
|
+
"""Show recent deployments for a project (linked project if omitted)."""
|
|
67
|
+
client = get_client()
|
|
68
|
+
project_id = context.resolve_project(client, project_id, context.read_link())
|
|
69
|
+
resp = client.get(f"/projects/{project_id}/activity/")
|
|
70
|
+
if resp.status_code != 200:
|
|
71
|
+
console.print("[red]Failed to fetch deployments.[/red]")
|
|
72
|
+
raise SystemExit(1)
|
|
73
|
+
|
|
74
|
+
results = resp.json().get("results", [])[:limit]
|
|
75
|
+
if output.json_enabled():
|
|
76
|
+
output.emit(results)
|
|
77
|
+
return
|
|
78
|
+
if not results:
|
|
79
|
+
console.print("[dim]No deployments.[/dim]")
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
table = Table(title="Recent Deployments")
|
|
83
|
+
table.add_column("ID", style="dim")
|
|
84
|
+
table.add_column("Service")
|
|
85
|
+
table.add_column("Status")
|
|
86
|
+
table.add_column("Trigger")
|
|
87
|
+
table.add_column("Image Tag")
|
|
88
|
+
table.add_column("Created")
|
|
89
|
+
for d in results:
|
|
90
|
+
s = d["status"]
|
|
91
|
+
color = "green" if s == "running" else "red" if s == "failed" else "yellow" if s in ("building", "deploying", "pushing") else "dim"
|
|
92
|
+
table.add_row(
|
|
93
|
+
str(d["id"]),
|
|
94
|
+
d.get("service_name", "—"),
|
|
95
|
+
f"[{color}]{s}[/{color}]",
|
|
96
|
+
d.get("trigger_type", "—"),
|
|
97
|
+
d.get("image_tag", "—")[:40],
|
|
98
|
+
d["created_at"][:19],
|
|
99
|
+
)
|
|
100
|
+
console.print(table)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@deploy.command("rollback")
|
|
104
|
+
@click.argument("project_id", required=False, default=None)
|
|
105
|
+
@click.argument("service_id", required=False, default=None)
|
|
106
|
+
@click.option("--to", "-d", "deployment_id", type=int, required=True, help="Deployment ID to roll back to")
|
|
107
|
+
def deploy_rollback(project_id, service_id, deployment_id):
|
|
108
|
+
"""Roll back a service to a previous deployment (linked if omitted).
|
|
109
|
+
|
|
110
|
+
Example: `bitsreef deploy rollback -d 42` or
|
|
111
|
+
`bitsreef deploy rollback my-proj web -d 42`.
|
|
112
|
+
"""
|
|
113
|
+
client = get_client()
|
|
114
|
+
link = context.read_link()
|
|
115
|
+
project_id = context.resolve_project(client, project_id, link)
|
|
116
|
+
service_id = context.resolve_service(client, project_id, service_id, link)
|
|
117
|
+
resp = client.post(
|
|
118
|
+
f"/projects/{project_id}/services/{service_id}/rollback/",
|
|
119
|
+
json={"deployment_id": deployment_id},
|
|
120
|
+
)
|
|
121
|
+
if resp.status_code in (200, 202):
|
|
122
|
+
console.print("[green]Rollback queued.[/green]")
|
|
123
|
+
else:
|
|
124
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Custom domain commands: list, add, remove, verify."""
|
|
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 _service(client, service):
|
|
13
|
+
"""Resolve --service (id/name) or the linked service."""
|
|
14
|
+
link = context.read_link()
|
|
15
|
+
return context.resolve_service(client, link.get("project_id"), service, link)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _results(resp):
|
|
19
|
+
data = resp.json()
|
|
20
|
+
return data.get("results", []) if isinstance(data, dict) else data
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _domain_id(client, service_id, value):
|
|
24
|
+
"""Resolve a domain id from an id or a domain name."""
|
|
25
|
+
if str(value).isdigit():
|
|
26
|
+
return int(value)
|
|
27
|
+
resp = client.get(f"/services/{service_id}/domains/")
|
|
28
|
+
for d in _results(resp):
|
|
29
|
+
if d.get("domain_name") == value:
|
|
30
|
+
return d["id"]
|
|
31
|
+
raise click.ClickException(f"No domain '{value}' on this service.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@click.group()
|
|
35
|
+
def domains():
|
|
36
|
+
"""Manage custom domains for a service."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@domains.command("list")
|
|
40
|
+
@click.option("--service", "-s", default=None, help="Service id or name (else linked)")
|
|
41
|
+
def list_domains(service):
|
|
42
|
+
"""List custom domains attached to a service."""
|
|
43
|
+
client = get_client()
|
|
44
|
+
service_id = _service(client, service)
|
|
45
|
+
resp = client.get(f"/services/{service_id}/domains/")
|
|
46
|
+
if resp.status_code != 200:
|
|
47
|
+
console.print(f"[red]Failed to fetch domains: {resp.text}[/red]")
|
|
48
|
+
raise SystemExit(1)
|
|
49
|
+
|
|
50
|
+
results = _results(resp)
|
|
51
|
+
if output.json_enabled():
|
|
52
|
+
output.emit(results)
|
|
53
|
+
return
|
|
54
|
+
if not results:
|
|
55
|
+
console.print("[dim]No domains.[/dim]")
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
table = Table(title="Domains")
|
|
59
|
+
table.add_column("ID", style="dim")
|
|
60
|
+
table.add_column("Domain", style="bold")
|
|
61
|
+
table.add_column("SSL")
|
|
62
|
+
table.add_column("DNS verified")
|
|
63
|
+
for d in results:
|
|
64
|
+
verified = "[green]yes[/green]" if d.get("dns_verified") else "[yellow]no[/yellow]"
|
|
65
|
+
table.add_row(str(d["id"]), d["domain_name"], d.get("ssl_status", "—"), verified)
|
|
66
|
+
console.print(table)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@domains.command("add")
|
|
70
|
+
@click.argument("domain")
|
|
71
|
+
@click.option("--service", "-s", default=None, help="Service id or name (else linked)")
|
|
72
|
+
def add_domain(domain, service):
|
|
73
|
+
"""Attach a custom domain to a service."""
|
|
74
|
+
client = get_client()
|
|
75
|
+
service_id = _service(client, service)
|
|
76
|
+
resp = client.post(f"/services/{service_id}/domains/", json={"domain_name": domain})
|
|
77
|
+
if resp.status_code == 201:
|
|
78
|
+
d = resp.json()
|
|
79
|
+
console.print(f"[green]Added[/green] {d['domain_name']} (id={d['id']})")
|
|
80
|
+
if d.get("verification_token"):
|
|
81
|
+
console.print(f" [dim]DNS verification token: {d['verification_token']}[/dim]")
|
|
82
|
+
console.print(" Run [bold]bitsreef domains verify[/bold] once DNS is configured.")
|
|
83
|
+
else:
|
|
84
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@domains.command("remove")
|
|
88
|
+
@click.argument("domain")
|
|
89
|
+
@click.option("--service", "-s", default=None, help="Service id or name (else linked)")
|
|
90
|
+
def remove_domain(domain, service):
|
|
91
|
+
"""Remove a custom domain (by name or id)."""
|
|
92
|
+
client = get_client()
|
|
93
|
+
service_id = _service(client, service)
|
|
94
|
+
domain_id = _domain_id(client, service_id, domain)
|
|
95
|
+
resp = client.delete(f"/services/{service_id}/domains/{domain_id}/")
|
|
96
|
+
if resp.status_code == 204:
|
|
97
|
+
console.print("[green]Removed.[/green]")
|
|
98
|
+
else:
|
|
99
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@domains.command("verify")
|
|
103
|
+
@click.argument("domain")
|
|
104
|
+
@click.option("--service", "-s", default=None, help="Service id or name (else linked)")
|
|
105
|
+
def verify_domain(domain, service):
|
|
106
|
+
"""Trigger DNS verification for a custom domain (by name or id)."""
|
|
107
|
+
client = get_client()
|
|
108
|
+
service_id = _service(client, service)
|
|
109
|
+
domain_id = _domain_id(client, service_id, domain)
|
|
110
|
+
resp = client.post(f"/services/{service_id}/domains/{domain_id}/verify/")
|
|
111
|
+
if resp.status_code in (200, 202):
|
|
112
|
+
d = resp.json() if resp.text else {}
|
|
113
|
+
verified = d.get("dns_verified")
|
|
114
|
+
if verified:
|
|
115
|
+
console.print("[green]Verified.[/green]")
|
|
116
|
+
else:
|
|
117
|
+
console.print("[yellow]Verification pending — DNS may not have propagated yet.[/yellow]")
|
|
118
|
+
else:
|
|
119
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|