bitsreef-cli 0.1.0__tar.gz
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-0.1.0/PKG-INFO +143 -0
- bitsreef_cli-0.1.0/README.md +132 -0
- bitsreef_cli-0.1.0/bitsreef_cli/__init__.py +2 -0
- bitsreef_cli-0.1.0/bitsreef_cli/client.py +74 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/__init__.py +0 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/auth.py +91 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/completion.py +29 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/cron.py +166 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/deploy.py +124 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/domains.py +119 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/env.py +138 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/environments.py +132 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/exec_cmd.py +27 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/functions.py +186 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/link.py +87 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/logs.py +44 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/metrics.py +50 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/projects.py +89 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/services.py +149 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/templates.py +106 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/up.py +195 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/volumes.py +99 -0
- bitsreef_cli-0.1.0/bitsreef_cli/commands/webhooks.py +123 -0
- bitsreef_cli-0.1.0/bitsreef_cli/config.py +92 -0
- bitsreef_cli-0.1.0/bitsreef_cli/context.py +175 -0
- bitsreef_cli-0.1.0/bitsreef_cli/deploy_watch.py +87 -0
- bitsreef_cli-0.1.0/bitsreef_cli/log_stream.py +83 -0
- bitsreef_cli-0.1.0/bitsreef_cli/main.py +43 -0
- bitsreef_cli-0.1.0/bitsreef_cli/output.py +25 -0
- bitsreef_cli-0.1.0/bitsreef_cli/shell.py +136 -0
- bitsreef_cli-0.1.0/bitsreef_cli.egg-info/PKG-INFO +143 -0
- bitsreef_cli-0.1.0/bitsreef_cli.egg-info/SOURCES.txt +37 -0
- bitsreef_cli-0.1.0/bitsreef_cli.egg-info/dependency_links.txt +1 -0
- bitsreef_cli-0.1.0/bitsreef_cli.egg-info/entry_points.txt +2 -0
- bitsreef_cli-0.1.0/bitsreef_cli.egg-info/requires.txt +4 -0
- bitsreef_cli-0.1.0/bitsreef_cli.egg-info/top_level.txt +1 -0
- bitsreef_cli-0.1.0/pyproject.toml +23 -0
- bitsreef_cli-0.1.0/setup.cfg +4 -0
- bitsreef_cli-0.1.0/tests/test_cli.py +934 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bitsreef-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool for BitsReef PaaS platform
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: click>=8.1
|
|
8
|
+
Requires-Dist: httpx>=0.25
|
|
9
|
+
Requires-Dist: rich>=13.0
|
|
10
|
+
Requires-Dist: websocket-client>=1.6
|
|
11
|
+
|
|
12
|
+
# BitsReef CLI
|
|
13
|
+
|
|
14
|
+
`bitsreef` — deploy and manage BitsReef services from the command line.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# One-liner (installs via pipx, or an isolated venv as fallback):
|
|
20
|
+
curl -fsSL https://raw.githubusercontent.com/cygnaragroup/bitsreef/main/cli/install.sh | sh
|
|
21
|
+
|
|
22
|
+
# ...or with pip / pipx directly:
|
|
23
|
+
pipx install bitsreef-cli # recommended — isolated, on PATH
|
|
24
|
+
pip install bitsreef-cli
|
|
25
|
+
|
|
26
|
+
# ...or from a checkout:
|
|
27
|
+
pip install -e cli/
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Shell completion
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
eval "$(bitsreef completion bash)" # add to ~/.bashrc
|
|
34
|
+
eval "$(bitsreef completion zsh)" # add to ~/.zshrc
|
|
35
|
+
bitsreef completion fish > ~/.config/fish/completions/bitsreef.fish
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Getting started
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Log in to BitsReef (the CLI talks to the hosted BitsReef API)
|
|
42
|
+
bitsreef auth login
|
|
43
|
+
|
|
44
|
+
# Deploy an image to a project in one shot, streaming to completion
|
|
45
|
+
bitsreef up --project 4 --name web --image nginx:latest --port 80 --follow
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`bitsreef up` creates the service if it doesn't exist (or updates its image if
|
|
49
|
+
it does), triggers a deployment, and with `--follow` streams the build/deploy
|
|
50
|
+
logs and exits non-zero if the deploy fails — so it drops straight into CI.
|
|
51
|
+
|
|
52
|
+
## Linking (stop typing IDs)
|
|
53
|
+
|
|
54
|
+
Bind a directory to a project/service once, then omit IDs everywhere:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
bitsreef link --project demo --service web # writes ./.bitsreef.json
|
|
58
|
+
bitsreef logs # → the linked service's logs
|
|
59
|
+
bitsreef up -i nginx:1.27 --follow # redeploy the linked service
|
|
60
|
+
bitsreef deploy status # linked project's deployments
|
|
61
|
+
bitsreef link --show # what's linked here
|
|
62
|
+
bitsreef unlink # remove the link
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The link file (`.bitsreef.json`) is discovered by walking up from the current
|
|
66
|
+
directory, like `.git`. **Names or slugs work in place of numeric IDs**
|
|
67
|
+
everywhere (`bitsreef logs demo web`), and a default org is remembered after the
|
|
68
|
+
first pick so multi-org accounts stop re-prompting.
|
|
69
|
+
|
|
70
|
+
## Commands
|
|
71
|
+
|
|
72
|
+
| Command | Purpose |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `up` | Create-or-update a service from an image and deploy it (`--follow`, `--json`) |
|
|
75
|
+
| `exec` | Open an interactive shell (`/bin/sh`) in a running container |
|
|
76
|
+
| `link` / `unlink` | Bind this directory to a project/service (`--show`) |
|
|
77
|
+
| `auth` | `login`, `logout`, `whoami` |
|
|
78
|
+
| `projects` | `list`, `info`, `create` |
|
|
79
|
+
| `services` | `list`, `info`, `restart`, `stop`, `scale` |
|
|
80
|
+
| `deploy` | `up` (`--follow`), `rebuild`, `status`, `rollback` |
|
|
81
|
+
| `env` | `list`, `set`, `delete`, `reveal` |
|
|
82
|
+
| `domains` | `list`, `add`, `remove`, `verify` (custom domains) |
|
|
83
|
+
| `volumes` | `list`, `create`, `delete` |
|
|
84
|
+
| `metrics` | Current CPU / memory / network snapshot for a service |
|
|
85
|
+
| `cron` | `list`, `create`, `trigger`, `runs`, `delete` (scheduled jobs) |
|
|
86
|
+
| `webhooks` | `list`, `add`, `update`, `remove` (project event notifications) |
|
|
87
|
+
| `templates` | `list`, `info`, `deploy` (service templates) |
|
|
88
|
+
| `functions` | `list`, `create`, `invoke`, `history`, `update`, `delete` |
|
|
89
|
+
| `logs` | View service logs (`--tail`, `--follow` for a live tail) |
|
|
90
|
+
| `completion` | Print a shell-completion script (`bash`, `zsh`, `fish`) |
|
|
91
|
+
|
|
92
|
+
Run `bitsreef <command> --help` for full options.
|
|
93
|
+
|
|
94
|
+
## CI / scripting / agents
|
|
95
|
+
|
|
96
|
+
Everything works non-interactively and can emit JSON:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# Auth without a prompt — a token env var (nothing written to disk):
|
|
100
|
+
export BITSREEF_TOKEN=<access-token>
|
|
101
|
+
|
|
102
|
+
# ...or a piped password / a stored token:
|
|
103
|
+
echo "$PASS" | bitsreef auth login -u ci --password-stdin
|
|
104
|
+
bitsreef auth login --token "$BITSREEF_TOKEN"
|
|
105
|
+
|
|
106
|
+
# Machine-readable output (place --json before the command):
|
|
107
|
+
bitsreef --json services list demo | jq '.[].name'
|
|
108
|
+
bitsreef --json whoami
|
|
109
|
+
bitsreef up -p demo -n web -i img:tag --follow --json # exits non-zero on deploy failure
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`BITSREEF_TOKEN` (env) takes precedence over the config file. `--json` is
|
|
113
|
+
supported on the read commands (`whoami`, `projects/services list|info`,
|
|
114
|
+
`deploy status`, `env list`, `functions list|history`, `logs`) and `up`.
|
|
115
|
+
|
|
116
|
+
## Configuration
|
|
117
|
+
|
|
118
|
+
Config and JWT tokens live in `~/.bitsreef/config.json` (created `0600`). The
|
|
119
|
+
access token is refreshed automatically on expiry. `BITSREEF_TOKEN` (env)
|
|
120
|
+
overrides the file when set. The API endpoint is built into the CLI — BitsReef
|
|
121
|
+
is a hosted service, so there is no server URL to configure.
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
cd cli
|
|
127
|
+
pip install -e . pytest
|
|
128
|
+
pytest
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Releasing
|
|
132
|
+
|
|
133
|
+
Publishing is automated by `.github/workflows/cli-publish.yml` via PyPI
|
|
134
|
+
[trusted publishing](https://docs.pypi.org/trusted-publishers/) (no API token).
|
|
135
|
+
Bump `version` in `pyproject.toml` and `bitsreef_cli/__init__.py`, then tag:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
git tag cli-v0.1.0 && git push origin cli-v0.1.0
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The workflow verifies the tag matches the package version, builds the sdist +
|
|
142
|
+
wheel, `twine check`s them, and uploads to PyPI. It can also be run manually
|
|
143
|
+
from the Actions tab (`workflow_dispatch`).
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# BitsReef CLI
|
|
2
|
+
|
|
3
|
+
`bitsreef` — deploy and manage BitsReef services from the command line.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# One-liner (installs via pipx, or an isolated venv as fallback):
|
|
9
|
+
curl -fsSL https://raw.githubusercontent.com/cygnaragroup/bitsreef/main/cli/install.sh | sh
|
|
10
|
+
|
|
11
|
+
# ...or with pip / pipx directly:
|
|
12
|
+
pipx install bitsreef-cli # recommended — isolated, on PATH
|
|
13
|
+
pip install bitsreef-cli
|
|
14
|
+
|
|
15
|
+
# ...or from a checkout:
|
|
16
|
+
pip install -e cli/
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Shell completion
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
eval "$(bitsreef completion bash)" # add to ~/.bashrc
|
|
23
|
+
eval "$(bitsreef completion zsh)" # add to ~/.zshrc
|
|
24
|
+
bitsreef completion fish > ~/.config/fish/completions/bitsreef.fish
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Getting started
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# Log in to BitsReef (the CLI talks to the hosted BitsReef API)
|
|
31
|
+
bitsreef auth login
|
|
32
|
+
|
|
33
|
+
# Deploy an image to a project in one shot, streaming to completion
|
|
34
|
+
bitsreef up --project 4 --name web --image nginx:latest --port 80 --follow
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`bitsreef up` creates the service if it doesn't exist (or updates its image if
|
|
38
|
+
it does), triggers a deployment, and with `--follow` streams the build/deploy
|
|
39
|
+
logs and exits non-zero if the deploy fails — so it drops straight into CI.
|
|
40
|
+
|
|
41
|
+
## Linking (stop typing IDs)
|
|
42
|
+
|
|
43
|
+
Bind a directory to a project/service once, then omit IDs everywhere:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
bitsreef link --project demo --service web # writes ./.bitsreef.json
|
|
47
|
+
bitsreef logs # → the linked service's logs
|
|
48
|
+
bitsreef up -i nginx:1.27 --follow # redeploy the linked service
|
|
49
|
+
bitsreef deploy status # linked project's deployments
|
|
50
|
+
bitsreef link --show # what's linked here
|
|
51
|
+
bitsreef unlink # remove the link
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The link file (`.bitsreef.json`) is discovered by walking up from the current
|
|
55
|
+
directory, like `.git`. **Names or slugs work in place of numeric IDs**
|
|
56
|
+
everywhere (`bitsreef logs demo web`), and a default org is remembered after the
|
|
57
|
+
first pick so multi-org accounts stop re-prompting.
|
|
58
|
+
|
|
59
|
+
## Commands
|
|
60
|
+
|
|
61
|
+
| Command | Purpose |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `up` | Create-or-update a service from an image and deploy it (`--follow`, `--json`) |
|
|
64
|
+
| `exec` | Open an interactive shell (`/bin/sh`) in a running container |
|
|
65
|
+
| `link` / `unlink` | Bind this directory to a project/service (`--show`) |
|
|
66
|
+
| `auth` | `login`, `logout`, `whoami` |
|
|
67
|
+
| `projects` | `list`, `info`, `create` |
|
|
68
|
+
| `services` | `list`, `info`, `restart`, `stop`, `scale` |
|
|
69
|
+
| `deploy` | `up` (`--follow`), `rebuild`, `status`, `rollback` |
|
|
70
|
+
| `env` | `list`, `set`, `delete`, `reveal` |
|
|
71
|
+
| `domains` | `list`, `add`, `remove`, `verify` (custom domains) |
|
|
72
|
+
| `volumes` | `list`, `create`, `delete` |
|
|
73
|
+
| `metrics` | Current CPU / memory / network snapshot for a service |
|
|
74
|
+
| `cron` | `list`, `create`, `trigger`, `runs`, `delete` (scheduled jobs) |
|
|
75
|
+
| `webhooks` | `list`, `add`, `update`, `remove` (project event notifications) |
|
|
76
|
+
| `templates` | `list`, `info`, `deploy` (service templates) |
|
|
77
|
+
| `functions` | `list`, `create`, `invoke`, `history`, `update`, `delete` |
|
|
78
|
+
| `logs` | View service logs (`--tail`, `--follow` for a live tail) |
|
|
79
|
+
| `completion` | Print a shell-completion script (`bash`, `zsh`, `fish`) |
|
|
80
|
+
|
|
81
|
+
Run `bitsreef <command> --help` for full options.
|
|
82
|
+
|
|
83
|
+
## CI / scripting / agents
|
|
84
|
+
|
|
85
|
+
Everything works non-interactively and can emit JSON:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# Auth without a prompt — a token env var (nothing written to disk):
|
|
89
|
+
export BITSREEF_TOKEN=<access-token>
|
|
90
|
+
|
|
91
|
+
# ...or a piped password / a stored token:
|
|
92
|
+
echo "$PASS" | bitsreef auth login -u ci --password-stdin
|
|
93
|
+
bitsreef auth login --token "$BITSREEF_TOKEN"
|
|
94
|
+
|
|
95
|
+
# Machine-readable output (place --json before the command):
|
|
96
|
+
bitsreef --json services list demo | jq '.[].name'
|
|
97
|
+
bitsreef --json whoami
|
|
98
|
+
bitsreef up -p demo -n web -i img:tag --follow --json # exits non-zero on deploy failure
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`BITSREEF_TOKEN` (env) takes precedence over the config file. `--json` is
|
|
102
|
+
supported on the read commands (`whoami`, `projects/services list|info`,
|
|
103
|
+
`deploy status`, `env list`, `functions list|history`, `logs`) and `up`.
|
|
104
|
+
|
|
105
|
+
## Configuration
|
|
106
|
+
|
|
107
|
+
Config and JWT tokens live in `~/.bitsreef/config.json` (created `0600`). The
|
|
108
|
+
access token is refreshed automatically on expiry. `BITSREEF_TOKEN` (env)
|
|
109
|
+
overrides the file when set. The API endpoint is built into the CLI — BitsReef
|
|
110
|
+
is a hosted service, so there is no server URL to configure.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
cd cli
|
|
116
|
+
pip install -e . pytest
|
|
117
|
+
pytest
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Releasing
|
|
121
|
+
|
|
122
|
+
Publishing is automated by `.github/workflows/cli-publish.yml` via PyPI
|
|
123
|
+
[trusted publishing](https://docs.pypi.org/trusted-publishers/) (no API token).
|
|
124
|
+
Bump `version` in `pyproject.toml` and `bitsreef_cli/__init__.py`, then tag:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
git tag cli-v0.1.0 && git push origin cli-v0.1.0
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The workflow verifies the tag matches the package version, builds the sdist +
|
|
131
|
+
wheel, `twine check`s them, and uploads to PyPI. It can also be run manually
|
|
132
|
+
from the Actions tab (`workflow_dispatch`).
|
|
@@ -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]")
|