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,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)
@@ -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))