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,138 @@
|
|
|
1
|
+
"""Environment variable commands: list, set, 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
|
+
@click.group()
|
|
19
|
+
def env():
|
|
20
|
+
"""Manage environment variables for a service."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@env.command("list")
|
|
24
|
+
@click.option("--service", "-s", default=None, help="Service id or name (else linked)")
|
|
25
|
+
def list_vars(service):
|
|
26
|
+
"""List environment variables for a service."""
|
|
27
|
+
client = get_client()
|
|
28
|
+
service_id = _service(client, service)
|
|
29
|
+
resp = client.get(f"/services/{service_id}/env/")
|
|
30
|
+
if resp.status_code != 200:
|
|
31
|
+
console.print("[red]Failed to fetch variables.[/red]")
|
|
32
|
+
raise SystemExit(1)
|
|
33
|
+
|
|
34
|
+
results = resp.json().get("results", [])
|
|
35
|
+
if output.json_enabled():
|
|
36
|
+
# Redact secret values in JSON too (reveal has its own command).
|
|
37
|
+
output.emit([{**v, "value": ("********" if v.get("is_secret") else v.get("value"))} for v in results])
|
|
38
|
+
return
|
|
39
|
+
if not results:
|
|
40
|
+
console.print("[dim]No environment variables.[/dim]")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
table = Table(title="Environment Variables")
|
|
44
|
+
table.add_column("ID", style="dim")
|
|
45
|
+
table.add_column("Key", style="bold")
|
|
46
|
+
table.add_column("Value")
|
|
47
|
+
table.add_column("Secret")
|
|
48
|
+
for v in results:
|
|
49
|
+
val = "********" if v["is_secret"] else v["value"]
|
|
50
|
+
table.add_row(str(v["id"]), v["key"], val, "Yes" if v["is_secret"] else "No")
|
|
51
|
+
console.print(table)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@env.command("set")
|
|
55
|
+
@click.argument("key")
|
|
56
|
+
@click.argument("value")
|
|
57
|
+
@click.option("--service", "-s", default=None, help="Service id or name (else linked)")
|
|
58
|
+
@click.option("--secret", is_flag=True, help="Mark as secret")
|
|
59
|
+
def set_var(key, value, service, secret):
|
|
60
|
+
"""Set an environment variable (creates or updates)."""
|
|
61
|
+
client = get_client()
|
|
62
|
+
service_id = _service(client, service)
|
|
63
|
+
# Check if key already exists
|
|
64
|
+
resp = client.get(f"/services/{service_id}/env/")
|
|
65
|
+
existing = None
|
|
66
|
+
for v in resp.json().get("results", []):
|
|
67
|
+
if v["key"] == key:
|
|
68
|
+
existing = v
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
if existing:
|
|
72
|
+
resp = client.patch(
|
|
73
|
+
f"/services/{service_id}/env/{existing['id']}/",
|
|
74
|
+
json={"value": value, "is_secret": secret},
|
|
75
|
+
)
|
|
76
|
+
if resp.status_code == 200:
|
|
77
|
+
console.print(f"[green]Updated {key}[/green]")
|
|
78
|
+
else:
|
|
79
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
|
80
|
+
else:
|
|
81
|
+
resp = client.post(
|
|
82
|
+
f"/services/{service_id}/env/",
|
|
83
|
+
json={"key": key, "value": value, "is_secret": secret},
|
|
84
|
+
)
|
|
85
|
+
if resp.status_code == 201:
|
|
86
|
+
console.print(f"[green]Set {key}[/green]")
|
|
87
|
+
else:
|
|
88
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@env.command("delete")
|
|
92
|
+
@click.argument("key")
|
|
93
|
+
@click.option("--service", "-s", default=None, help="Service id or name (else linked)")
|
|
94
|
+
def delete_var(key, service):
|
|
95
|
+
"""Delete an environment variable by key."""
|
|
96
|
+
client = get_client()
|
|
97
|
+
service_id = _service(client, service)
|
|
98
|
+
resp = client.get(f"/services/{service_id}/env/")
|
|
99
|
+
target = None
|
|
100
|
+
for v in resp.json().get("results", []):
|
|
101
|
+
if v["key"] == key:
|
|
102
|
+
target = v
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
if not target:
|
|
106
|
+
console.print(f"[yellow]Variable '{key}' not found.[/yellow]")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
resp = client.delete(f"/services/{service_id}/env/{target['id']}/")
|
|
110
|
+
if resp.status_code == 204:
|
|
111
|
+
console.print(f"[green]Deleted {key}[/green]")
|
|
112
|
+
else:
|
|
113
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@env.command("reveal")
|
|
117
|
+
@click.argument("key")
|
|
118
|
+
@click.option("--service", "-s", default=None, help="Service id or name (else linked)")
|
|
119
|
+
def reveal_var(key, service):
|
|
120
|
+
"""Reveal a secret variable's value."""
|
|
121
|
+
client = get_client()
|
|
122
|
+
service_id = _service(client, service)
|
|
123
|
+
resp = client.get(f"/services/{service_id}/env/")
|
|
124
|
+
target = None
|
|
125
|
+
for v in resp.json().get("results", []):
|
|
126
|
+
if v["key"] == key:
|
|
127
|
+
target = v
|
|
128
|
+
break
|
|
129
|
+
|
|
130
|
+
if not target:
|
|
131
|
+
console.print(f"[yellow]Variable '{key}' not found.[/yellow]")
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
resp = client.get(f"/services/{service_id}/env/{target['id']}/reveal/")
|
|
135
|
+
if resp.status_code == 200:
|
|
136
|
+
console.print(f"[bold]{key}[/bold]={resp.json()['value']}")
|
|
137
|
+
else:
|
|
138
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Environment commands: list, create (blank or clone), delete.
|
|
2
|
+
|
|
3
|
+
An environment is an independently-running copy of a project's services
|
|
4
|
+
(production, staging, development, …). Creating one can clone an existing
|
|
5
|
+
environment's service definitions in a stopped state for you to configure and
|
|
6
|
+
deploy.
|
|
7
|
+
"""
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from ..client import get_client
|
|
13
|
+
from .. import context, output
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.group()
|
|
19
|
+
def environments():
|
|
20
|
+
"""Manage project environments (production, staging, …)."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@environments.command("list")
|
|
24
|
+
@click.option("--project", "-p", default=None, help="Project id or name (else linked)")
|
|
25
|
+
def list_environments(project):
|
|
26
|
+
"""List a project's environments."""
|
|
27
|
+
client = get_client()
|
|
28
|
+
link = context.read_link()
|
|
29
|
+
project_id = context.resolve_project(client, project, link)
|
|
30
|
+
resp = client.get(f"/projects/{project_id}/environments/")
|
|
31
|
+
if resp.status_code != 200:
|
|
32
|
+
console.print("[red]Failed to fetch environments.[/red]")
|
|
33
|
+
raise SystemExit(1)
|
|
34
|
+
results = resp.json().get("results", [])
|
|
35
|
+
if output.json_enabled():
|
|
36
|
+
output.emit(results)
|
|
37
|
+
return
|
|
38
|
+
if not results:
|
|
39
|
+
console.print("[dim]No environments.[/dim]")
|
|
40
|
+
return
|
|
41
|
+
table = Table(title="Environments")
|
|
42
|
+
table.add_column("ID", style="dim")
|
|
43
|
+
table.add_column("Name", style="bold")
|
|
44
|
+
table.add_column("Default", justify="center")
|
|
45
|
+
table.add_column("Services", justify="right")
|
|
46
|
+
for e in results:
|
|
47
|
+
table.add_row(
|
|
48
|
+
str(e["id"]),
|
|
49
|
+
e["name"],
|
|
50
|
+
"✓" if e.get("is_default") else "",
|
|
51
|
+
str(e.get("service_count", 0)),
|
|
52
|
+
)
|
|
53
|
+
console.print(table)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@environments.command("create")
|
|
57
|
+
@click.argument("name")
|
|
58
|
+
@click.option("--project", "-p", default=None, help="Project id or name (else linked)")
|
|
59
|
+
@click.option(
|
|
60
|
+
"--clone-from",
|
|
61
|
+
default=None,
|
|
62
|
+
help="Source environment id/name to clone services from (stopped).",
|
|
63
|
+
)
|
|
64
|
+
@click.option(
|
|
65
|
+
"--blank",
|
|
66
|
+
is_flag=True,
|
|
67
|
+
help="Create an empty environment (no services). Default clones production.",
|
|
68
|
+
)
|
|
69
|
+
def create_environment(name, project, clone_from, blank):
|
|
70
|
+
"""Create a new environment.
|
|
71
|
+
|
|
72
|
+
By default clones the project's production environment (services copied in a
|
|
73
|
+
stopped state — configure and deploy them). Use --clone-from to pick a
|
|
74
|
+
different source, or --blank for an empty environment.
|
|
75
|
+
"""
|
|
76
|
+
client = get_client()
|
|
77
|
+
link = context.read_link()
|
|
78
|
+
project_id = context.resolve_project(client, project, link)
|
|
79
|
+
|
|
80
|
+
payload = {"name": name}
|
|
81
|
+
if not blank:
|
|
82
|
+
source = clone_from if clone_from is not None else None
|
|
83
|
+
source_id = context.resolve_environment(client, project_id, source)
|
|
84
|
+
payload["clone_from"] = source_id
|
|
85
|
+
|
|
86
|
+
resp = client.post(f"/projects/{project_id}/environments/", json=payload)
|
|
87
|
+
if resp.status_code not in (200, 201):
|
|
88
|
+
detail = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else resp.text
|
|
89
|
+
if output.json_enabled():
|
|
90
|
+
output.emit({"error": detail})
|
|
91
|
+
else:
|
|
92
|
+
console.print(f"[red]Failed to create environment:[/red] {detail}")
|
|
93
|
+
raise SystemExit(1)
|
|
94
|
+
|
|
95
|
+
data = resp.json()
|
|
96
|
+
if output.json_enabled():
|
|
97
|
+
output.emit(data)
|
|
98
|
+
return
|
|
99
|
+
if blank:
|
|
100
|
+
console.print(f"[green]Created blank environment[/green] '{data['name']}' (id {data['id']}).")
|
|
101
|
+
else:
|
|
102
|
+
console.print(
|
|
103
|
+
f"[green]Created environment[/green] '{data['name']}' (id {data['id']}) — "
|
|
104
|
+
"services cloned in a stopped state. Configure and deploy them, e.g. "
|
|
105
|
+
f"`bitsreef services list -p {project_id} --environment {data['id']}`."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@environments.command("delete")
|
|
110
|
+
@click.argument("environment")
|
|
111
|
+
@click.option("--project", "-p", default=None, help="Project id or name (else linked)")
|
|
112
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
|
|
113
|
+
def delete_environment(environment, project, yes):
|
|
114
|
+
"""Delete an environment (and its services). The default env can't be deleted."""
|
|
115
|
+
client = get_client()
|
|
116
|
+
link = context.read_link()
|
|
117
|
+
project_id = context.resolve_project(client, project, link)
|
|
118
|
+
env_id = context.resolve_environment(client, project_id, environment)
|
|
119
|
+
if not yes and not output.json_enabled():
|
|
120
|
+
click.confirm(
|
|
121
|
+
f"Delete environment {environment} and all its services?", abort=True
|
|
122
|
+
)
|
|
123
|
+
resp = client.delete(f"/projects/{project_id}/environments/{env_id}/")
|
|
124
|
+
if resp.status_code not in (200, 204):
|
|
125
|
+
detail = resp.text
|
|
126
|
+
try:
|
|
127
|
+
detail = resp.json()
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
console.print(f"[red]Failed to delete environment:[/red] {detail}")
|
|
131
|
+
raise SystemExit(1)
|
|
132
|
+
console.print(f"[green]Deleted environment[/green] {environment}.")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""`bitsreef exec` — open an interactive shell in a running service container."""
|
|
2
|
+
import click
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
|
|
5
|
+
from ..client import get_client
|
|
6
|
+
from .. import config, context
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command("exec")
|
|
12
|
+
@click.argument("project_id", required=False, default=None)
|
|
13
|
+
@click.argument("service_id", required=False, default=None)
|
|
14
|
+
def exec_(project_id, service_id):
|
|
15
|
+
"""Open an interactive shell (/bin/sh) in the service's running container.
|
|
16
|
+
|
|
17
|
+
Uses the linked project/service if omitted; names/slugs work in place of IDs.
|
|
18
|
+
Type `exit` (or Ctrl-D) to leave the shell.
|
|
19
|
+
"""
|
|
20
|
+
client = get_client()
|
|
21
|
+
link = context.read_link()
|
|
22
|
+
project_id = context.resolve_project(client, project_id, link)
|
|
23
|
+
service_id = context.resolve_service(client, project_id, service_id, link)
|
|
24
|
+
|
|
25
|
+
from ..shell import exec_shell
|
|
26
|
+
token = config.get_tokens()[0]
|
|
27
|
+
raise SystemExit(exec_shell(config.get_api_url(), token, service_id))
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Function commands: list, create, invoke, 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 output
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group()
|
|
13
|
+
def functions():
|
|
14
|
+
"""Manage serverless functions."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@functions.command("list")
|
|
18
|
+
@click.argument("project_id", type=int)
|
|
19
|
+
def list_functions(project_id):
|
|
20
|
+
"""List all functions in a project."""
|
|
21
|
+
client = get_client()
|
|
22
|
+
resp = client.get(f"/projects/{project_id}/functions/")
|
|
23
|
+
if resp.status_code != 200:
|
|
24
|
+
console.print("[red]Failed to fetch functions.[/red]")
|
|
25
|
+
raise SystemExit(1)
|
|
26
|
+
|
|
27
|
+
results = resp.json().get("results", [])
|
|
28
|
+
if output.json_enabled():
|
|
29
|
+
output.emit(results)
|
|
30
|
+
return
|
|
31
|
+
if not results:
|
|
32
|
+
console.print("[dim]No functions.[/dim]")
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
table = Table(title="Functions")
|
|
36
|
+
table.add_column("ID", style="dim")
|
|
37
|
+
table.add_column("Name", style="bold")
|
|
38
|
+
table.add_column("Runtime")
|
|
39
|
+
table.add_column("Trigger")
|
|
40
|
+
table.add_column("Enabled")
|
|
41
|
+
table.add_column("Invocations", justify="right")
|
|
42
|
+
for fn in results:
|
|
43
|
+
enabled = "[green]Yes[/green]" if fn["enabled"] else "[red]No[/red]"
|
|
44
|
+
trigger = fn["cron_schedule"] if fn["trigger_type"] == "cron" else "HTTP"
|
|
45
|
+
table.add_row(
|
|
46
|
+
str(fn["id"]), fn["name"], fn["runtime"],
|
|
47
|
+
trigger, enabled, str(fn["invocation_count"]),
|
|
48
|
+
)
|
|
49
|
+
console.print(table)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@functions.command("create")
|
|
53
|
+
@click.argument("project_id", type=int)
|
|
54
|
+
@click.argument("name")
|
|
55
|
+
@click.option("--runtime", "-r", type=click.Choice(["bun", "deno", "python", "node"]), default="python")
|
|
56
|
+
@click.option("--code", "-c", default=None, help="Inline code string")
|
|
57
|
+
@click.option("--file", "-f", "code_file", type=click.Path(exists=True), help="Read code from file")
|
|
58
|
+
@click.option("--trigger", "-t", type=click.Choice(["http", "cron"]), default="http")
|
|
59
|
+
@click.option("--cron", default="", help="Cron schedule (for cron trigger)")
|
|
60
|
+
@click.option("--timeout", default=30, type=int, help="Timeout in seconds")
|
|
61
|
+
@click.option("--memory", default=128, type=int, help="Memory limit in MB")
|
|
62
|
+
def create_function(project_id, name, runtime, code, code_file, trigger, cron, timeout, memory):
|
|
63
|
+
"""Create a new function."""
|
|
64
|
+
if code_file:
|
|
65
|
+
with open(code_file) as f:
|
|
66
|
+
code = f.read()
|
|
67
|
+
if not code:
|
|
68
|
+
console.print("[red]Provide --code or --file.[/red]")
|
|
69
|
+
raise SystemExit(1)
|
|
70
|
+
|
|
71
|
+
client = get_client()
|
|
72
|
+
payload = {
|
|
73
|
+
"name": name,
|
|
74
|
+
"runtime": runtime,
|
|
75
|
+
"code": code,
|
|
76
|
+
"trigger_type": trigger,
|
|
77
|
+
"cron_schedule": cron if trigger == "cron" else "",
|
|
78
|
+
"timeout_seconds": timeout,
|
|
79
|
+
"memory_limit_mb": memory,
|
|
80
|
+
}
|
|
81
|
+
resp = client.post(f"/projects/{project_id}/functions/", json=payload)
|
|
82
|
+
if resp.status_code == 201:
|
|
83
|
+
fn = resp.json()
|
|
84
|
+
console.print(f"[green]Created [bold]{fn['name']}[/bold] endpoint={fn['endpoint_path']}[/green]")
|
|
85
|
+
else:
|
|
86
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@functions.command("invoke")
|
|
90
|
+
@click.argument("project_id", type=int)
|
|
91
|
+
@click.argument("function_id", type=int)
|
|
92
|
+
def invoke_function(project_id, function_id):
|
|
93
|
+
"""Invoke a function."""
|
|
94
|
+
client = get_client()
|
|
95
|
+
resp = client.post(f"/projects/{project_id}/functions/{function_id}/invoke/")
|
|
96
|
+
if resp.status_code == 202:
|
|
97
|
+
data = resp.json()
|
|
98
|
+
console.print(f"[green]Invoked — invocation_id={data['invocation_id']}[/green]")
|
|
99
|
+
else:
|
|
100
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@functions.command("history")
|
|
104
|
+
@click.argument("project_id", type=int)
|
|
105
|
+
@click.argument("function_id", type=int)
|
|
106
|
+
def invocation_history(project_id, function_id):
|
|
107
|
+
"""Show invocation history for a function."""
|
|
108
|
+
client = get_client()
|
|
109
|
+
resp = client.get(f"/projects/{project_id}/functions/{function_id}/invocations/")
|
|
110
|
+
if resp.status_code != 200:
|
|
111
|
+
console.print("[red]Failed to fetch invocations.[/red]")
|
|
112
|
+
raise SystemExit(1)
|
|
113
|
+
|
|
114
|
+
results = resp.json().get("results", [])
|
|
115
|
+
if output.json_enabled():
|
|
116
|
+
output.emit(results)
|
|
117
|
+
return
|
|
118
|
+
if not results:
|
|
119
|
+
console.print("[dim]No invocations yet.[/dim]")
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
table = Table(title="Invocation History")
|
|
123
|
+
table.add_column("ID", style="dim")
|
|
124
|
+
table.add_column("Status")
|
|
125
|
+
table.add_column("Duration")
|
|
126
|
+
table.add_column("Trigger")
|
|
127
|
+
table.add_column("Started")
|
|
128
|
+
for inv in results:
|
|
129
|
+
s = inv["status"]
|
|
130
|
+
color = "green" if s == "success" else "red" if s == "failed" else "yellow" if s == "timeout" else "blue"
|
|
131
|
+
dur = f"{inv['duration_ms']}ms" if inv.get("duration_ms") else "—"
|
|
132
|
+
table.add_row(
|
|
133
|
+
str(inv["id"]), f"[{color}]{s}[/{color}]",
|
|
134
|
+
dur, inv["trigger"], inv["started_at"][:19],
|
|
135
|
+
)
|
|
136
|
+
console.print(table)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@functions.command("delete")
|
|
140
|
+
@click.argument("project_id", type=int)
|
|
141
|
+
@click.argument("function_id", type=int)
|
|
142
|
+
@click.confirmation_option(prompt="Delete this function?")
|
|
143
|
+
def delete_function(project_id, function_id):
|
|
144
|
+
"""Delete a function."""
|
|
145
|
+
client = get_client()
|
|
146
|
+
resp = client.delete(f"/projects/{project_id}/functions/{function_id}/")
|
|
147
|
+
if resp.status_code == 204:
|
|
148
|
+
console.print("[green]Deleted.[/green]")
|
|
149
|
+
else:
|
|
150
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@functions.command("update")
|
|
154
|
+
@click.argument("project_id", type=int)
|
|
155
|
+
@click.argument("function_id", type=int)
|
|
156
|
+
@click.option("--code", "-c", default=None, help="New code string")
|
|
157
|
+
@click.option("--file", "-f", "code_file", type=click.Path(exists=True), help="Read new code from file")
|
|
158
|
+
@click.option("--enabled/--disabled", default=None, help="Enable or disable")
|
|
159
|
+
@click.option("--timeout", default=None, type=int, help="Timeout in seconds")
|
|
160
|
+
@click.option("--memory", default=None, type=int, help="Memory limit in MB")
|
|
161
|
+
def update_function(project_id, function_id, code, code_file, enabled, timeout, memory):
|
|
162
|
+
"""Update a function's configuration or code."""
|
|
163
|
+
if code_file:
|
|
164
|
+
with open(code_file) as f:
|
|
165
|
+
code = f.read()
|
|
166
|
+
|
|
167
|
+
payload = {}
|
|
168
|
+
if code is not None:
|
|
169
|
+
payload["code"] = code
|
|
170
|
+
if enabled is not None:
|
|
171
|
+
payload["enabled"] = enabled
|
|
172
|
+
if timeout is not None:
|
|
173
|
+
payload["timeout_seconds"] = timeout
|
|
174
|
+
if memory is not None:
|
|
175
|
+
payload["memory_limit_mb"] = memory
|
|
176
|
+
|
|
177
|
+
if not payload:
|
|
178
|
+
console.print("[yellow]Nothing to update.[/yellow]")
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
client = get_client()
|
|
182
|
+
resp = client.patch(f"/projects/{project_id}/functions/{function_id}/", json=payload)
|
|
183
|
+
if resp.status_code == 200:
|
|
184
|
+
console.print("[green]Updated.[/green]")
|
|
185
|
+
else:
|
|
186
|
+
console.print(f"[red]Failed: {resp.text}[/red]")
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""`bitsreef link` — bind the current directory to a project/service.
|
|
2
|
+
|
|
3
|
+
Once linked, commands can be run without repeating IDs: `bitsreef logs`,
|
|
4
|
+
`bitsreef up -i img:tag`, `bitsreef deploy up`, etc. resolve the target from the
|
|
5
|
+
nearest `.bitsreef.json`. Names/slugs work in place of IDs everywhere too.
|
|
6
|
+
"""
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from ..client import get_client
|
|
11
|
+
from .. import context
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _project_name(client, org_id, project_id):
|
|
17
|
+
r = client.get(f"/organizations/{org_id}/projects/{project_id}/")
|
|
18
|
+
return r.json().get("name") if r.status_code == 200 else None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _service_name(client, project_id, service_id):
|
|
22
|
+
r = client.get(f"/projects/{project_id}/services/{service_id}/")
|
|
23
|
+
return r.json().get("name") if r.status_code == 200 else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@click.command()
|
|
27
|
+
@click.option("--project", "-p", default=None, help="Project id or name")
|
|
28
|
+
@click.option("--service", "-s", default=None, help="Service id or name")
|
|
29
|
+
@click.option("--org", type=int, default=None, help="Organization ID")
|
|
30
|
+
@click.option("--show", is_flag=True, help="Show the current link and exit")
|
|
31
|
+
def link(project, service, org, show):
|
|
32
|
+
"""Link this directory to a project (and optionally a service)."""
|
|
33
|
+
if show:
|
|
34
|
+
current = context.read_link()
|
|
35
|
+
if not current:
|
|
36
|
+
console.print("[dim]No link in this directory.[/dim]")
|
|
37
|
+
return
|
|
38
|
+
f = context.find_link_file()
|
|
39
|
+
console.print(f"[bold]Linked[/bold] ({f}):")
|
|
40
|
+
console.print(f" org={current.get('org_id')} "
|
|
41
|
+
f"project={current.get('project_name') or current.get('project_id')} "
|
|
42
|
+
f"service={current.get('service_name') or current.get('service_id') or '—'}")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
client = get_client()
|
|
46
|
+
link_data = context.read_link()
|
|
47
|
+
org_id = context.resolve_org(client, org, link_data)
|
|
48
|
+
|
|
49
|
+
# Project: explicit id/name, else interactive pick.
|
|
50
|
+
if project is not None:
|
|
51
|
+
project_id = context.resolve_project(client, project, {"org_id": org_id}, org=org_id)
|
|
52
|
+
else:
|
|
53
|
+
projects = context._paginate(client, f"/organizations/{org_id}/projects/")
|
|
54
|
+
if not projects:
|
|
55
|
+
raise click.ClickException("No projects in this org — create one first.")
|
|
56
|
+
console.print("[bold]Select a project:[/bold]")
|
|
57
|
+
for i, p in enumerate(projects, 1):
|
|
58
|
+
console.print(f" {i}. {p['name']} (id={p['id']})")
|
|
59
|
+
choice = click.prompt("Enter number", type=int, default=1)
|
|
60
|
+
project_id = int(projects[choice - 1]["id"])
|
|
61
|
+
|
|
62
|
+
data = {
|
|
63
|
+
"org_id": org_id,
|
|
64
|
+
"project_id": project_id,
|
|
65
|
+
"project_name": _project_name(client, org_id, project_id),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Service is optional.
|
|
69
|
+
if service is not None:
|
|
70
|
+
service_id = context.resolve_service(client, project_id, service)
|
|
71
|
+
data["service_id"] = service_id
|
|
72
|
+
data["service_name"] = _service_name(client, project_id, service_id)
|
|
73
|
+
|
|
74
|
+
path = context.write_link(data)
|
|
75
|
+
console.print(f"[green]Linked[/green] → {data['project_name'] or project_id}"
|
|
76
|
+
+ (f" / {data.get('service_name') or data.get('service_id')}" if service is not None else "")
|
|
77
|
+
+ f" [dim]({path})[/dim]")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@click.command()
|
|
81
|
+
def unlink():
|
|
82
|
+
"""Remove this directory's link."""
|
|
83
|
+
removed = context.clear_link()
|
|
84
|
+
if removed:
|
|
85
|
+
console.print(f"[green]Unlinked[/green] [dim]({removed})[/dim]")
|
|
86
|
+
else:
|
|
87
|
+
console.print("[dim]No link to remove.[/dim]")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Logs commands: view service logs."""
|
|
2
|
+
import click
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
|
|
5
|
+
from ..client import get_client
|
|
6
|
+
from .. import config, context, output
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command()
|
|
12
|
+
@click.argument("project_id", required=False, default=None)
|
|
13
|
+
@click.argument("service_id", required=False, default=None)
|
|
14
|
+
@click.option("--tail", "-n", default=100, type=int, help="Number of log lines")
|
|
15
|
+
@click.option("--follow", "-f", is_flag=True, help="Stream new logs live (Ctrl-C to stop)")
|
|
16
|
+
def logs(project_id, service_id, tail, follow):
|
|
17
|
+
"""View logs for a service. Uses the linked project/service if omitted."""
|
|
18
|
+
client = get_client()
|
|
19
|
+
link = context.read_link()
|
|
20
|
+
project_id = context.resolve_project(client, project_id, link)
|
|
21
|
+
service_id = context.resolve_service(client, project_id, service_id, link)
|
|
22
|
+
|
|
23
|
+
if follow:
|
|
24
|
+
from ..log_stream import follow_logs
|
|
25
|
+
token = config.get_tokens()[0]
|
|
26
|
+
raise SystemExit(follow_logs(config.get_api_url(), token, service_id))
|
|
27
|
+
|
|
28
|
+
resp = client.get(f"/projects/{project_id}/services/{service_id}/logs/", params={"tail": tail})
|
|
29
|
+
if resp.status_code != 200:
|
|
30
|
+
console.print("[red]Failed to fetch logs.[/red]")
|
|
31
|
+
raise SystemExit(1)
|
|
32
|
+
|
|
33
|
+
data = resp.json()
|
|
34
|
+
if output.json_enabled():
|
|
35
|
+
output.emit(data)
|
|
36
|
+
return
|
|
37
|
+
log_text = data.get("logs", "")
|
|
38
|
+
if not log_text:
|
|
39
|
+
console.print("[dim]No logs available.[/dim]")
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
# Print logs line by line
|
|
43
|
+
for line in log_text.splitlines():
|
|
44
|
+
console.print(line)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Service metrics command — current CPU / memory / network snapshot."""
|
|
2
|
+
import click
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
|
|
5
|
+
from ..client import get_client
|
|
6
|
+
from .. import context, output
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command()
|
|
12
|
+
@click.option("--service", "-s", default=None, help="Service id or name (else linked)")
|
|
13
|
+
def metrics(service):
|
|
14
|
+
"""Show the latest resource-usage snapshot for a service."""
|
|
15
|
+
client = get_client()
|
|
16
|
+
link = context.read_link()
|
|
17
|
+
service_id = context.resolve_service(client, link.get("project_id"), service, link)
|
|
18
|
+
resp = client.get(f"/services/{service_id}/metrics/current/")
|
|
19
|
+
if resp.status_code != 200:
|
|
20
|
+
console.print(f"[red]Failed to fetch metrics: {resp.text}[/red]")
|
|
21
|
+
raise SystemExit(1)
|
|
22
|
+
|
|
23
|
+
snap = resp.json()
|
|
24
|
+
if output.json_enabled():
|
|
25
|
+
output.emit(snap)
|
|
26
|
+
return
|
|
27
|
+
if not snap:
|
|
28
|
+
console.print("[dim]No metrics recorded yet.[/dim]")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
cpu = snap.get("cpu_percent")
|
|
32
|
+
used = snap.get("memory_used_mb")
|
|
33
|
+
limit = snap.get("memory_limit_mb")
|
|
34
|
+
console.print(f"[bold]Service {service_id}[/bold] [dim]({snap.get('recorded_at', '')})[/dim]")
|
|
35
|
+
console.print(f" CPU: {cpu if cpu is not None else '—'}%")
|
|
36
|
+
console.print(f" Memory: {used if used is not None else '—'} / {limit if limit is not None else '—'} MB")
|
|
37
|
+
console.print(f" Network: ↓ {_h(snap.get('network_rx_bytes'))} ↑ {_h(snap.get('network_tx_bytes'))}")
|
|
38
|
+
console.print(f" Replicas: {snap.get('replica_count', '—')}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _h(n) -> str:
|
|
42
|
+
"""Human-readable bytes."""
|
|
43
|
+
if n is None:
|
|
44
|
+
return "—"
|
|
45
|
+
value = float(n)
|
|
46
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
47
|
+
if value < 1024 or unit == "TB":
|
|
48
|
+
return f"{value:.1f}{unit}"
|
|
49
|
+
value /= 1024
|
|
50
|
+
return f"{value:.1f}TB"
|