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,123 @@
|
|
|
1
|
+
"""Webhook commands: list, add, update, remove (project-scoped)."""
|
|
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
|
+
EVENTS = [
|
|
12
|
+
"deployment.started", "deployment.completed", "deployment.failed",
|
|
13
|
+
"service.stopped", "service.crashed", "scaling.event", "alert.triggered",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _project(client, project):
|
|
18
|
+
return context.resolve_project(client, project, context.read_link())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _results(resp):
|
|
22
|
+
data = resp.json()
|
|
23
|
+
return data.get("results", []) if isinstance(data, dict) else data
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@click.group()
|
|
27
|
+
def webhooks():
|
|
28
|
+
"""Manage project webhooks (outbound event notifications)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@webhooks.command("list")
|
|
32
|
+
@click.argument("project", required=False, default=None)
|
|
33
|
+
def list_webhooks(project):
|
|
34
|
+
"""List webhooks in a project (linked project if omitted)."""
|
|
35
|
+
client = get_client()
|
|
36
|
+
project_id = _project(client, project)
|
|
37
|
+
resp = client.get(f"/projects/{project_id}/webhooks/")
|
|
38
|
+
if resp.status_code != 200:
|
|
39
|
+
console.print(f"[red]Failed to fetch webhooks: {resp.text}[/red]")
|
|
40
|
+
raise SystemExit(1)
|
|
41
|
+
|
|
42
|
+
results = _results(resp)
|
|
43
|
+
if output.json_enabled():
|
|
44
|
+
output.emit(results)
|
|
45
|
+
return
|
|
46
|
+
if not results:
|
|
47
|
+
console.print("[dim]No webhooks.[/dim]")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
table = Table(title="Webhooks")
|
|
51
|
+
table.add_column("ID", style="dim")
|
|
52
|
+
table.add_column("URL", style="bold")
|
|
53
|
+
table.add_column("Events")
|
|
54
|
+
table.add_column("Active")
|
|
55
|
+
for w in results:
|
|
56
|
+
active = "[green]yes[/green]" if w.get("is_active") else "[red]no[/red]"
|
|
57
|
+
events = ", ".join(w.get("events") or []) or "[dim]all[/dim]"
|
|
58
|
+
table.add_row(str(w["id"]), w["url"], events, active)
|
|
59
|
+
console.print(table)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@webhooks.command("add")
|
|
63
|
+
@click.argument("url")
|
|
64
|
+
@click.argument("project", required=False, default=None)
|
|
65
|
+
@click.option("--event", "-e", "events", multiple=True,
|
|
66
|
+
type=click.Choice(EVENTS), help="Event to subscribe to (repeatable)")
|
|
67
|
+
@click.option("--secret", default=None, help="HMAC signing secret")
|
|
68
|
+
def add_webhook(url, project, events, secret):
|
|
69
|
+
"""Add a webhook to a project. Subscribes to all events if none given."""
|
|
70
|
+
client = get_client()
|
|
71
|
+
project_id = _project(client, project)
|
|
72
|
+
payload = {"url": url, "events": list(events)}
|
|
73
|
+
if secret:
|
|
74
|
+
payload["secret"] = secret
|
|
75
|
+
resp = client.post(f"/projects/{project_id}/webhooks/", json=payload)
|
|
76
|
+
if resp.status_code == 201:
|
|
77
|
+
w = resp.json()
|
|
78
|
+
console.print(f"[green]Added webhook[/green] [bold]{w['url']}[/bold] (id={w['id']})")
|
|
79
|
+
else:
|
|
80
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@webhooks.command("update")
|
|
84
|
+
@click.argument("webhook_id", type=int)
|
|
85
|
+
@click.argument("project", required=False, default=None)
|
|
86
|
+
@click.option("--event", "-e", "events", multiple=True,
|
|
87
|
+
type=click.Choice(EVENTS), help="Replace subscribed events (repeatable)")
|
|
88
|
+
@click.option("--secret", default=None, help="New HMAC signing secret")
|
|
89
|
+
@click.option("--active/--inactive", default=None, help="Enable or disable the webhook")
|
|
90
|
+
def update_webhook(webhook_id, project, events, secret, active):
|
|
91
|
+
"""Update a webhook's events, secret, or active state."""
|
|
92
|
+
client = get_client()
|
|
93
|
+
project_id = _project(client, project)
|
|
94
|
+
payload = {}
|
|
95
|
+
if events:
|
|
96
|
+
payload["events"] = list(events)
|
|
97
|
+
if secret is not None:
|
|
98
|
+
payload["secret"] = secret
|
|
99
|
+
if active is not None:
|
|
100
|
+
payload["is_active"] = active
|
|
101
|
+
if not payload:
|
|
102
|
+
console.print("[yellow]Nothing to update.[/yellow]")
|
|
103
|
+
return
|
|
104
|
+
resp = client.patch(f"/projects/{project_id}/webhooks/{webhook_id}/", json=payload)
|
|
105
|
+
if resp.status_code == 200:
|
|
106
|
+
console.print("[green]Updated.[/green]")
|
|
107
|
+
else:
|
|
108
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@webhooks.command("remove")
|
|
112
|
+
@click.argument("webhook_id", type=int)
|
|
113
|
+
@click.argument("project", required=False, default=None)
|
|
114
|
+
@click.confirmation_option(prompt="Delete this webhook?")
|
|
115
|
+
def remove_webhook(webhook_id, project):
|
|
116
|
+
"""Delete a webhook from a project."""
|
|
117
|
+
client = get_client()
|
|
118
|
+
project_id = _project(client, project)
|
|
119
|
+
resp = client.delete(f"/projects/{project_id}/webhooks/{webhook_id}/")
|
|
120
|
+
if resp.status_code == 204:
|
|
121
|
+
console.print("[green]Removed.[/green]")
|
|
122
|
+
else:
|
|
123
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
bitsreef_cli/config.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Configuration and token storage for BitsReef CLI."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
CONFIG_DIR = Path.home() / ".bitsreef"
|
|
7
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
8
|
+
|
|
9
|
+
# BitsReef is a hosted, multi-tenant SaaS — it is not self-hostable, so the CLI
|
|
10
|
+
# always talks to the platform API at this fixed endpoint. The URL is baked into
|
|
11
|
+
# the CLI on purpose and is not user-configurable (no flag, command, or public
|
|
12
|
+
# env var sets it).
|
|
13
|
+
API_URL = "https://v1.api.prod.bitsreef.com/v1"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _ensure_dir():
|
|
17
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_config() -> dict:
|
|
21
|
+
"""Load config from disk."""
|
|
22
|
+
if not CONFIG_FILE.exists():
|
|
23
|
+
return {}
|
|
24
|
+
with open(CONFIG_FILE) as f:
|
|
25
|
+
return json.load(f)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def save_config(data: dict):
|
|
29
|
+
"""Save config to disk."""
|
|
30
|
+
_ensure_dir()
|
|
31
|
+
with open(CONFIG_FILE, "w") as f:
|
|
32
|
+
json.dump(data, f, indent=2)
|
|
33
|
+
# Restrict permissions
|
|
34
|
+
os.chmod(CONFIG_FILE, 0o600)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_api_url() -> str:
|
|
38
|
+
"""Return the (fixed) BitsReef API base URL.
|
|
39
|
+
|
|
40
|
+
BitsReef is hosted SaaS, so the endpoint is constant and cannot be changed
|
|
41
|
+
by users — there is no public flag, command, or env var for it.
|
|
42
|
+
|
|
43
|
+
`_BITSREEF_DEV_API_URL` is an internal, undocumented override used only by
|
|
44
|
+
platform developers running the CLI against a local backend. It is not part
|
|
45
|
+
of the public interface and must not be documented.
|
|
46
|
+
"""
|
|
47
|
+
dev = os.environ.get("_BITSREEF_DEV_API_URL")
|
|
48
|
+
if dev:
|
|
49
|
+
return dev.rstrip("/")
|
|
50
|
+
return API_URL
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_tokens() -> tuple[str | None, str | None]:
|
|
54
|
+
"""Return (access_token, refresh_token).
|
|
55
|
+
|
|
56
|
+
`BITSREEF_TOKEN` (env) wins over the config file and is used as the access
|
|
57
|
+
token, so CI/agents authenticate without a `login` step (no refresh token —
|
|
58
|
+
provide a token that outlives the job, or log in interactively for humans).
|
|
59
|
+
"""
|
|
60
|
+
env = os.environ.get("BITSREEF_TOKEN")
|
|
61
|
+
if env:
|
|
62
|
+
return env, None
|
|
63
|
+
cfg = load_config()
|
|
64
|
+
return cfg.get("access_token"), cfg.get("refresh_token")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def save_tokens(access: str, refresh: str):
|
|
68
|
+
"""Store JWT tokens."""
|
|
69
|
+
cfg = load_config()
|
|
70
|
+
cfg["access_token"] = access
|
|
71
|
+
cfg["refresh_token"] = refresh
|
|
72
|
+
save_config(cfg)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def clear_tokens():
|
|
76
|
+
"""Remove stored tokens."""
|
|
77
|
+
cfg = load_config()
|
|
78
|
+
cfg.pop("access_token", None)
|
|
79
|
+
cfg.pop("refresh_token", None)
|
|
80
|
+
save_config(cfg)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_default_org() -> int | None:
|
|
84
|
+
"""Return the saved default organization ID, if any."""
|
|
85
|
+
return load_config().get("default_org")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def set_default_org(org_id: int):
|
|
89
|
+
"""Persist a default organization so commands stop re-prompting."""
|
|
90
|
+
cfg = load_config()
|
|
91
|
+
cfg["default_org"] = org_id
|
|
92
|
+
save_config(cfg)
|
bitsreef_cli/context.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Project/service context resolution — directory linking + name/ID lookup.
|
|
2
|
+
|
|
3
|
+
Lets commands take a project/service as an **id or a name/slug**, and fall back
|
|
4
|
+
to the linked directory when omitted, so you rarely type raw integer IDs. A link
|
|
5
|
+
is a `.bitsreef.json` file written to a directory (and discovered by walking up
|
|
6
|
+
from the cwd, like `.git`). The filename is deliberately distinct from the global
|
|
7
|
+
`~/.bitsreef/config.json` so directory links never collide with global config.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from . import config
|
|
16
|
+
|
|
17
|
+
LINK_FILENAME = ".bitsreef.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── link file ────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
def find_link_file(start: str | None = None) -> Path | None:
|
|
23
|
+
"""Walk up from `start` (or cwd) to find a `.bitsreef.json` link file."""
|
|
24
|
+
d = Path(start or os.getcwd()).resolve()
|
|
25
|
+
for parent in [d, *d.parents]:
|
|
26
|
+
f = parent / LINK_FILENAME
|
|
27
|
+
if f.is_file():
|
|
28
|
+
return f
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_link(start: str | None = None) -> dict:
|
|
33
|
+
"""Return the nearest link's contents, or {} if none/invalid."""
|
|
34
|
+
f = find_link_file(start)
|
|
35
|
+
if not f:
|
|
36
|
+
return {}
|
|
37
|
+
try:
|
|
38
|
+
return json.loads(f.read_text())
|
|
39
|
+
except (json.JSONDecodeError, OSError):
|
|
40
|
+
return {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def write_link(data: dict, directory: str | None = None) -> Path:
|
|
44
|
+
"""Write a link file into `directory` (or cwd). Returns its path."""
|
|
45
|
+
path = Path(directory or os.getcwd()) / LINK_FILENAME
|
|
46
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
47
|
+
return path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def clear_link() -> Path | None:
|
|
51
|
+
"""Remove the nearest link file. Returns its path, or None if absent."""
|
|
52
|
+
f = find_link_file()
|
|
53
|
+
if f:
|
|
54
|
+
f.unlink()
|
|
55
|
+
return f
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── resolution ───────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def _paginate(client, path: str) -> list[dict]:
|
|
62
|
+
"""Fetch all results across DRF pages for a collection endpoint."""
|
|
63
|
+
out: list[dict] = []
|
|
64
|
+
resp = client.get(path)
|
|
65
|
+
if resp.status_code != 200:
|
|
66
|
+
raise click.ClickException(f"Request to {path} failed ({resp.status_code}).")
|
|
67
|
+
data = resp.json()
|
|
68
|
+
if isinstance(data, list):
|
|
69
|
+
return data
|
|
70
|
+
out.extend(data.get("results", []))
|
|
71
|
+
next_url = data.get("next")
|
|
72
|
+
while next_url:
|
|
73
|
+
# `next` is absolute; route it back through the client so auth applies.
|
|
74
|
+
sub = next_url.split("/v1", 1)[-1] if "/v1" in next_url else next_url
|
|
75
|
+
r = client.get(sub)
|
|
76
|
+
if r.status_code != 200:
|
|
77
|
+
break
|
|
78
|
+
d = r.json()
|
|
79
|
+
out.extend(d.get("results", []))
|
|
80
|
+
next_url = d.get("next")
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve_org(client, explicit=None, link: dict | None = None, *, save: bool = True) -> int:
|
|
85
|
+
"""Resolve an org ID: explicit > link > saved default > interactive pick."""
|
|
86
|
+
link = link or {}
|
|
87
|
+
if explicit is not None:
|
|
88
|
+
return int(explicit)
|
|
89
|
+
if link.get("org_id"):
|
|
90
|
+
return int(link["org_id"])
|
|
91
|
+
default = config.get_default_org()
|
|
92
|
+
if default:
|
|
93
|
+
return int(default)
|
|
94
|
+
|
|
95
|
+
orgs = _paginate(client, "/organizations/")
|
|
96
|
+
if not orgs:
|
|
97
|
+
raise click.ClickException("No organizations found.")
|
|
98
|
+
if len(orgs) == 1:
|
|
99
|
+
org_id = int(orgs[0]["id"])
|
|
100
|
+
else:
|
|
101
|
+
click.echo("Select an organization:")
|
|
102
|
+
for i, org in enumerate(orgs, 1):
|
|
103
|
+
click.echo(f" {i}. {org['name']} (id={org['id']})")
|
|
104
|
+
choice = click.prompt("Enter number", type=int, default=1)
|
|
105
|
+
org_id = int(orgs[choice - 1]["id"])
|
|
106
|
+
if save:
|
|
107
|
+
config.set_default_org(org_id)
|
|
108
|
+
return org_id
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def resolve_project(client, value=None, link: dict | None = None, *, org=None) -> int:
|
|
112
|
+
"""Resolve a project ID from an id/name/slug, or the linked project.
|
|
113
|
+
|
|
114
|
+
`value` may be a numeric id (returned as-is), a name/slug (looked up within
|
|
115
|
+
the resolved org), or None (falls back to the link).
|
|
116
|
+
"""
|
|
117
|
+
link = link or {}
|
|
118
|
+
if value is None:
|
|
119
|
+
if link.get("project_id"):
|
|
120
|
+
return int(link["project_id"])
|
|
121
|
+
raise click.ClickException(
|
|
122
|
+
"No project given and none linked. Pass a project or run `bitsreef link`."
|
|
123
|
+
)
|
|
124
|
+
text = str(value)
|
|
125
|
+
if text.isdigit():
|
|
126
|
+
return int(text)
|
|
127
|
+
org_id = resolve_org(client, org, link)
|
|
128
|
+
projects = _paginate(client, f"/organizations/{org_id}/projects/")
|
|
129
|
+
for p in projects:
|
|
130
|
+
if p.get("name") == text or p.get("slug") == text:
|
|
131
|
+
return int(p["id"])
|
|
132
|
+
raise click.ClickException(f"No project named '{text}' in org {org_id}.")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def resolve_service(client, project_id: int, value=None, link: dict | None = None) -> int:
|
|
136
|
+
"""Resolve a service ID from an id/name, or the linked service."""
|
|
137
|
+
link = link or {}
|
|
138
|
+
if value is None:
|
|
139
|
+
if link.get("service_id"):
|
|
140
|
+
return int(link["service_id"])
|
|
141
|
+
raise click.ClickException(
|
|
142
|
+
"No service given and none linked. Pass a service or run `bitsreef link`."
|
|
143
|
+
)
|
|
144
|
+
text = str(value)
|
|
145
|
+
if text.isdigit():
|
|
146
|
+
return int(text)
|
|
147
|
+
services = _paginate(client, f"/projects/{project_id}/services/")
|
|
148
|
+
for s in services:
|
|
149
|
+
if s.get("name") == text:
|
|
150
|
+
return int(s["id"])
|
|
151
|
+
raise click.ClickException(f"No service named '{text}' in project {project_id}.")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def resolve_environment(client, project_id: int, value=None) -> int:
|
|
155
|
+
"""Resolve an environment ID from an id/name within a project.
|
|
156
|
+
|
|
157
|
+
With no value, returns the project's default (production) environment.
|
|
158
|
+
"""
|
|
159
|
+
envs = _paginate(client, f"/projects/{project_id}/environments/")
|
|
160
|
+
if value is None:
|
|
161
|
+
for e in envs:
|
|
162
|
+
if e.get("is_default"):
|
|
163
|
+
return int(e["id"])
|
|
164
|
+
if envs:
|
|
165
|
+
return int(envs[0]["id"])
|
|
166
|
+
raise click.ClickException(f"Project {project_id} has no environments.")
|
|
167
|
+
text = str(value)
|
|
168
|
+
if text.isdigit():
|
|
169
|
+
return int(text)
|
|
170
|
+
for e in envs:
|
|
171
|
+
if e.get("name") == text:
|
|
172
|
+
return int(e["id"])
|
|
173
|
+
raise click.ClickException(
|
|
174
|
+
f"No environment named '{text}' in project {project_id}."
|
|
175
|
+
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Poll a deployment to completion — shared by `bitsreef up` and `deploy up --follow`.
|
|
2
|
+
|
|
3
|
+
Streams new build/deploy log lines as they appear and returns a (success, final)
|
|
4
|
+
tuple so callers can set a correct process exit code. Terminal states come from
|
|
5
|
+
the Deployment model: `running` = success, `failed` = failure; everything else
|
|
6
|
+
(`queued`, `building`, `pushing`, `deploying`) is in-flight.
|
|
7
|
+
"""
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
TERMINAL_OK = {"running"}
|
|
15
|
+
TERMINAL_FAIL = {"failed", "cancelled"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def follow_deployment(
|
|
19
|
+
client,
|
|
20
|
+
service_id: int,
|
|
21
|
+
deployment_id: int,
|
|
22
|
+
*,
|
|
23
|
+
interval: float = 2.0,
|
|
24
|
+
timeout: float = 600.0,
|
|
25
|
+
json_mode: bool = False,
|
|
26
|
+
) -> tuple[bool, dict | None]:
|
|
27
|
+
"""Poll `/services/{id}/deployments/{id}/` until terminal.
|
|
28
|
+
|
|
29
|
+
Returns (success, final_payload). `success` is False on failure, a non-200
|
|
30
|
+
poll, or timeout. Quiet when `json_mode` is set (caller renders the result).
|
|
31
|
+
"""
|
|
32
|
+
seen_status = None
|
|
33
|
+
build_len = 0
|
|
34
|
+
deploy_len = 0
|
|
35
|
+
start = time.monotonic()
|
|
36
|
+
|
|
37
|
+
while True:
|
|
38
|
+
resp = client.get(f"/services/{service_id}/deployments/{deployment_id}/")
|
|
39
|
+
if resp.status_code != 200:
|
|
40
|
+
if not json_mode:
|
|
41
|
+
console.print(
|
|
42
|
+
f"[red]Could not fetch deployment status "
|
|
43
|
+
f"({resp.status_code}).[/red]"
|
|
44
|
+
)
|
|
45
|
+
return False, None
|
|
46
|
+
|
|
47
|
+
d = resp.json()
|
|
48
|
+
status = d.get("status")
|
|
49
|
+
|
|
50
|
+
if status != seen_status:
|
|
51
|
+
if not json_mode:
|
|
52
|
+
console.print(f"[cyan]● {status}[/cyan]")
|
|
53
|
+
seen_status = status
|
|
54
|
+
|
|
55
|
+
if not json_mode:
|
|
56
|
+
build_len = _stream_log(d.get("build_log") or "", build_len)
|
|
57
|
+
deploy_len = _stream_log(d.get("deploy_log") or "", deploy_len)
|
|
58
|
+
|
|
59
|
+
if status in TERMINAL_OK:
|
|
60
|
+
return True, d
|
|
61
|
+
|
|
62
|
+
if status in TERMINAL_FAIL:
|
|
63
|
+
if not json_mode:
|
|
64
|
+
err = d.get("error_message") or "(no error message)"
|
|
65
|
+
console.print(f"[red]Deployment {status}: {err}[/red]")
|
|
66
|
+
return False, d
|
|
67
|
+
|
|
68
|
+
if time.monotonic() - start > timeout:
|
|
69
|
+
if not json_mode:
|
|
70
|
+
console.print(
|
|
71
|
+
f"[yellow]Timed out after {int(timeout)}s "
|
|
72
|
+
f"(last status: {status}). The deploy may still finish — "
|
|
73
|
+
f"check `bitsreef deploy status`.[/yellow]"
|
|
74
|
+
)
|
|
75
|
+
return False, d
|
|
76
|
+
|
|
77
|
+
time.sleep(interval)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _stream_log(text: str, shown: int) -> int:
|
|
81
|
+
"""Print any log content past `shown` chars; return the new length."""
|
|
82
|
+
if len(text) > shown:
|
|
83
|
+
for line in text[shown:].splitlines():
|
|
84
|
+
if line:
|
|
85
|
+
console.print(f"[dim]│[/dim] {line}")
|
|
86
|
+
return len(text)
|
|
87
|
+
return shown
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Live log streaming over WebSocket (`bitsreef logs --follow`).
|
|
2
|
+
|
|
3
|
+
Connects to the platform's ServiceLogConsumer at
|
|
4
|
+
`/ws/services/{id}/logs/?token=<JWT>` (WS auth is the `?token` query param,
|
|
5
|
+
handled by JWTAuthMiddleware). The consumer streams JSON messages:
|
|
6
|
+
{"type": "history", "logs": "<block>"} — initial tail
|
|
7
|
+
{"type": "log", "message": "<line>"} — each new line
|
|
8
|
+
{"type": "error", "message": "..."} — a stream error
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def ws_url(api_url: str, service_id) -> str:
|
|
19
|
+
"""Derive the log-stream WS URL from the REST base URL.
|
|
20
|
+
|
|
21
|
+
The WS route lives at the host root (`/ws/...`), not under the `/v1` API
|
|
22
|
+
prefix, so only scheme+host are taken from `api_url`.
|
|
23
|
+
"""
|
|
24
|
+
parts = urlsplit(api_url)
|
|
25
|
+
scheme = "wss" if parts.scheme == "https" else "ws"
|
|
26
|
+
return urlunsplit((scheme, parts.netloc, f"/ws/services/{service_id}/logs/", "", ""))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def follow_logs(api_url: str, token: str | None, service_id) -> int:
|
|
30
|
+
"""Stream logs until interrupted (Ctrl-C). Returns a process exit code."""
|
|
31
|
+
try:
|
|
32
|
+
import websocket # from websocket-client
|
|
33
|
+
except ImportError:
|
|
34
|
+
console.print("[red]Live follow needs 'websocket-client'. "
|
|
35
|
+
"Install it: pip install websocket-client[/red]")
|
|
36
|
+
return 1
|
|
37
|
+
|
|
38
|
+
if not token:
|
|
39
|
+
console.print("[red]Not logged in. Run: bitsreef auth login[/red]")
|
|
40
|
+
return 1
|
|
41
|
+
|
|
42
|
+
url = f"{ws_url(api_url, service_id)}?token={token}"
|
|
43
|
+
try:
|
|
44
|
+
ws = websocket.create_connection(url, timeout=30)
|
|
45
|
+
except Exception as exc: # noqa: BLE001 — surface any connect failure cleanly
|
|
46
|
+
console.print(f"[red]Could not connect to log stream: {exc}[/red]")
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
while True:
|
|
51
|
+
try:
|
|
52
|
+
raw = ws.recv()
|
|
53
|
+
except websocket.WebSocketTimeoutException:
|
|
54
|
+
continue # idle keepalive tick — keep waiting
|
|
55
|
+
except websocket.WebSocketConnectionClosedException:
|
|
56
|
+
break
|
|
57
|
+
if not raw:
|
|
58
|
+
break
|
|
59
|
+
_print_message(raw)
|
|
60
|
+
except KeyboardInterrupt:
|
|
61
|
+
pass
|
|
62
|
+
finally:
|
|
63
|
+
try:
|
|
64
|
+
ws.close()
|
|
65
|
+
except Exception: # noqa: BLE001
|
|
66
|
+
pass
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _print_message(raw) -> None:
|
|
71
|
+
try:
|
|
72
|
+
msg = json.loads(raw)
|
|
73
|
+
except (json.JSONDecodeError, ValueError):
|
|
74
|
+
console.print(raw)
|
|
75
|
+
return
|
|
76
|
+
msg_type = msg.get("type")
|
|
77
|
+
if msg_type == "history":
|
|
78
|
+
for line in (msg.get("logs") or "").splitlines():
|
|
79
|
+
console.print(line)
|
|
80
|
+
elif msg_type == "log":
|
|
81
|
+
console.print(msg.get("message", ""))
|
|
82
|
+
elif msg_type == "error":
|
|
83
|
+
console.print(f"[red]{msg.get('message', 'stream error')}[/red]")
|
bitsreef_cli/main.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""BitsReef CLI — main entry point."""
|
|
2
|
+
import click
|
|
3
|
+
|
|
4
|
+
from . import __version__, output
|
|
5
|
+
from .commands import (
|
|
6
|
+
auth, projects, services, env, environments, functions, deploy, logs, up,
|
|
7
|
+
link, exec_cmd, domains, volumes, metrics, cron, webhooks, templates,
|
|
8
|
+
completion,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group()
|
|
13
|
+
@click.version_option(__version__, prog_name="bitsreef")
|
|
14
|
+
@click.option("--json", "json_mode", is_flag=True,
|
|
15
|
+
help="Machine-readable JSON output (place before the command: bitsreef --json ...)")
|
|
16
|
+
def cli(json_mode):
|
|
17
|
+
"""BitsReef — Deploy and manage your services from the command line."""
|
|
18
|
+
output.set_json(json_mode)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
cli.add_command(up.up)
|
|
22
|
+
cli.add_command(exec_cmd.exec_)
|
|
23
|
+
cli.add_command(link.link)
|
|
24
|
+
cli.add_command(link.unlink)
|
|
25
|
+
cli.add_command(auth.auth)
|
|
26
|
+
cli.add_command(projects.projects)
|
|
27
|
+
cli.add_command(services.services)
|
|
28
|
+
cli.add_command(env.env)
|
|
29
|
+
cli.add_command(environments.environments)
|
|
30
|
+
cli.add_command(functions.functions)
|
|
31
|
+
cli.add_command(deploy.deploy)
|
|
32
|
+
cli.add_command(logs.logs)
|
|
33
|
+
cli.add_command(domains.domains)
|
|
34
|
+
cli.add_command(volumes.volumes)
|
|
35
|
+
cli.add_command(metrics.metrics)
|
|
36
|
+
cli.add_command(cron.cron)
|
|
37
|
+
cli.add_command(webhooks.webhooks)
|
|
38
|
+
cli.add_command(templates.templates)
|
|
39
|
+
cli.add_command(completion.completion)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
cli()
|
bitsreef_cli/output.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Output mode — the global `--json` flag and a helper to emit JSON.
|
|
2
|
+
|
|
3
|
+
A process-wide singleton set by the root `cli` callback (`bitsreef --json ...`),
|
|
4
|
+
so read commands can render either a Rich table (humans) or machine-readable
|
|
5
|
+
JSON (CI/agents/scripts) without threading a flag through every signature.
|
|
6
|
+
"""
|
|
7
|
+
import json as _json
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
_JSON_MODE = False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def set_json(enabled: bool) -> None:
|
|
15
|
+
global _JSON_MODE
|
|
16
|
+
_JSON_MODE = bool(enabled)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def json_enabled() -> bool:
|
|
20
|
+
return _JSON_MODE
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def emit(data) -> None:
|
|
24
|
+
"""Print `data` as JSON on stdout (used in --json mode)."""
|
|
25
|
+
click.echo(_json.dumps(data, default=str))
|