superwise-sentinel-cli 0.2.0__tar.gz → 0.2.1__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.
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/CLAUDE.md +3 -3
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/PKG-INFO +1 -1
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/README.md +10 -1
- superwise_sentinel_cli-0.2.1/build.env +1 -0
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/pyproject.toml +1 -1
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/src/superwise_sentinel_cli/cli.py +98 -15
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/src/superwise_sentinel_cli/config.py +1 -1
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/src/superwise_sentinel_cli/controller.py +6 -5
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/src/superwise_sentinel_cli/docker_manager.py +33 -23
- superwise_sentinel_cli-0.2.1/src/superwise_sentinel_cli/exceptions.py +21 -0
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/tests/test_controller.py +2 -2
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/tests/test_docker_manager.py +9 -7
- superwise_sentinel_cli-0.2.0/build.env +0 -1
- superwise_sentinel_cli-0.2.0/src/superwise_sentinel_cli/exceptions.py +0 -12
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/.gitignore +0 -0
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/.gitlab-ci.yml +0 -0
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/.pre-commit-config.yaml +0 -0
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/.releaserc.json +0 -0
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/CHANGELOG.md +0 -0
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/poetry.lock +0 -0
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/poetry.toml +0 -0
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/scripts/semantic-release-cli-publish.sh +0 -0
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/src/superwise_sentinel_cli/__init__.py +0 -0
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/src/superwise_sentinel_cli/auth.py +0 -0
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/src/superwise_sentinel_cli/shell_profile.py +0 -0
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/tests/__init__.py +0 -0
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/tests/test_auth.py +0 -0
- {superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/tests/test_shell_profile.py +0 -0
|
@@ -40,11 +40,11 @@ Run manually: `pre-commit run --all-files`
|
|
|
40
40
|
|
|
41
41
|
All source lives under `src/superwise_sentinel_cli/`:
|
|
42
42
|
|
|
43
|
-
- **`cli.py`** — Click command tree: root group `main`, with subgroups `gateway` (start/stop) and `proxy` (on/off), plus top-level commands `auth` and `config`. Imports from `auth`, `
|
|
43
|
+
- **`cli.py`** — Click command tree: root group `main`, with subgroups `gateway` (start/stop/update) and `proxy` (on/off), plus top-level commands `auth` and `config`. Imports from `auth`, `controller`, `shell_profile`, and `config`.
|
|
44
44
|
- **`config.py`** — `AppConfig` (pydantic-settings). Settings priority: env vars (`SENTINEL_*`) > `~/.config/sentinel/config.json` > field defaults. Also owns `_PROVIDER_CONFIG` (provider → env var + URL path mapping) and `VALID_PROVIDERS`. `AppConfig.load()` / `.save()` are the only intended access points.
|
|
45
45
|
- **`auth.py`** — `authenticate()` hits the Superwise auth API and caches the token at `~/.config/sentinel/token`. Skips the network call on subsequent invocations unless `force=True`.
|
|
46
|
-
- **`
|
|
47
|
-
- **`docker_manager.py`** — Manages the `sentinel-local` Docker container (image
|
|
46
|
+
- **`controller.py`** — Business logic layer between `cli.py` and `docker_manager.py`/`shell_profile.py`. `get_or_create_local_endpoint()` starts the Docker container and returns its base URL. `resolve_env_vars()` maps provider names to `(env_var, full_url)` pairs using `_PROVIDER_CONFIG` from `config.py`. `update_gateway_image` is an alias for `docker_manager.pull_latest_image` — it only refreshes the local image, it does not touch a running container.
|
|
47
|
+
- **`docker_manager.py`** — Manages the `sentinel-local` Docker container (image from `AppConfig.CONTAINER_IMAGE`). `ensure_container_running()` pulls (if not cached), starts, and returns a `ContainerEndpoint`; `stop_container()` stops and removes it; `pull_latest_image()` force-pulls the image regardless of local cache, for use by `gateway update`. Falls back to `linux/amd64` when native platform image is unavailable.
|
|
48
48
|
- **`shell_profile.py`** — `ShellContext` abstraction with `_UnixShellContext` (bash/zsh, auto-detected) and `_WindowsShellContext` (PowerShell). `ensure_shell_integration()` / `remove_shell_integration()` patch the shell profile. `write_env_vars()` / `remove_env_vars()` manage `~/.config/sentinel/protected_vars.json` and a sourced env file. On macOS, also calls `launchctl setenv/unsetenv` for GUI app visibility. The module-level `SHELL` constant holds the current platform context.
|
|
49
49
|
|
|
50
50
|
## Config & state files
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: superwise-sentinel-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Sentinel is an LLM proxy that intercepts traffic, enabling you to monitor, secure, and optimize your AI usage. This CLI tool helps you quickly set up traffic interception on this machine
|
|
5
5
|
Requires-Python: <4.0,>=3.11
|
|
6
6
|
Requires-Dist: click>=8.1
|
|
@@ -28,13 +28,22 @@ Authenticate with Superwise (prompts for Client ID and Client Secret). Pass `--a
|
|
|
28
28
|
|---|---|
|
|
29
29
|
| `sentinel gateway start` | Pull and start the local Docker gateway container |
|
|
30
30
|
| `sentinel gateway stop` | Stop and remove the container |
|
|
31
|
+
| `sentinel gateway update` | Pull the latest gateway image, without touching the running container |
|
|
31
32
|
|
|
32
|
-
Pass `--sentinel-id <uuid>` to `start` to use an existing sentinel rather than creating a new one.
|
|
33
|
+
Pass `--sentinel-id <uuid>` to `start` to use an existing sentinel rather than creating a new one. After `gateway update`, run `sentinel gateway start --force` to switch the running gateway over to the newly pulled image.
|
|
33
34
|
|
|
34
35
|
### `sentinel config`
|
|
35
36
|
|
|
36
37
|
Configure the gateway URL and which LLM providers to intercept. Valid providers: `anthropic`, `openai`, `google`. Use `all` to select every provider.
|
|
37
38
|
|
|
39
|
+
Run interactively via `sentinel config url` / `sentinel config providers`, or set both non-interactively in one line:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
sentinel config --url "https://gateway.example.com" --providers "openai anthropic"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Each subcommand also accepts `--set` for setting just that one value non-interactively, e.g. `sentinel config url --set "https://gateway.example.com"` or `sentinel config providers --set all`.
|
|
46
|
+
|
|
38
47
|
### `sentinel proxy`
|
|
39
48
|
|
|
40
49
|
| Command | Description |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
RELEASED_VERSION=0.2.1
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "superwise-sentinel-cli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "Sentinel is an LLM proxy that intercepts traffic, enabling you to monitor, secure, and optimize your AI usage. This CLI tool helps you quickly set up traffic interception on this machine"
|
|
9
9
|
requires-python = ">=3.11, <4.0"
|
|
10
10
|
dependencies = [
|
{superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/src/superwise_sentinel_cli/cli.py
RENAMED
|
@@ -25,7 +25,9 @@ from superwise_sentinel_cli.controller import save_providers
|
|
|
25
25
|
from superwise_sentinel_cli.controller import stop_gateway_container
|
|
26
26
|
from superwise_sentinel_cli.controller import update_client_id_and_secret
|
|
27
27
|
from superwise_sentinel_cli.controller import update_env_vars
|
|
28
|
+
from superwise_sentinel_cli.controller import update_gateway_image
|
|
28
29
|
from superwise_sentinel_cli.exceptions import AuthenticationError
|
|
30
|
+
from superwise_sentinel_cli.exceptions import BaseCLIException
|
|
29
31
|
|
|
30
32
|
_SECRET_PLACEHOLDER = "********"
|
|
31
33
|
|
|
@@ -48,6 +50,20 @@ def echo_error(message: str) -> None:
|
|
|
48
50
|
click.secho(message, fg="red", err=True)
|
|
49
51
|
|
|
50
52
|
|
|
53
|
+
def _apply_url(cfg: AppConfig, url: str) -> None:
|
|
54
|
+
save_gateway_url(cfg, url)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _apply_providers(cfg: AppConfig, spec: str) -> None:
|
|
58
|
+
providers = parse_providers(spec.split())
|
|
59
|
+
invalid = check_invalid_providers(providers)
|
|
60
|
+
if invalid:
|
|
61
|
+
raise click.ClickException(
|
|
62
|
+
f"Unknown provider(s): {', '.join(invalid)}. Valid choices: {', '.join(VALID_PROVIDERS)}"
|
|
63
|
+
)
|
|
64
|
+
save_providers(cfg, providers)
|
|
65
|
+
|
|
66
|
+
|
|
51
67
|
@click.group()
|
|
52
68
|
@click.version_option(prog_name="superwise-sentinel-cli", help="Show the version and exit.")
|
|
53
69
|
def main() -> None:
|
|
@@ -152,8 +168,13 @@ def gateway() -> None:
|
|
|
152
168
|
default=None,
|
|
153
169
|
help='JSON array of custom LLM providers, e.g. \'[{"provider_name":"myllm","base_url":"http://host:4000"}]\'',
|
|
154
170
|
)
|
|
171
|
+
@click.option(
|
|
172
|
+
"--force", is_flag=True, default=False, help="If a local gateway is already running, stop it and start a new one."
|
|
173
|
+
)
|
|
155
174
|
@click.pass_context
|
|
156
|
-
def start_gateway(
|
|
175
|
+
def start_gateway(
|
|
176
|
+
ctx, sentinel_id: uuid.UUID | None, new_sentinel: bool, custom_providers: str | None, force: bool
|
|
177
|
+
) -> None:
|
|
157
178
|
"""Bootstrap & run a local Sentinel gateway container."""
|
|
158
179
|
cfg = AppConfig.load()
|
|
159
180
|
|
|
@@ -163,15 +184,19 @@ def start_gateway(ctx, sentinel_id: uuid.UUID | None, new_sentinel: bool, custom
|
|
|
163
184
|
except Exception as exc:
|
|
164
185
|
raise click.ClickException(f"Invalid --custom-providers: {exc}") from exc
|
|
165
186
|
|
|
166
|
-
|
|
167
|
-
if running is not None:
|
|
168
|
-
base_url, running_sentinel_id = running
|
|
169
|
-
click.echo(
|
|
170
|
-
f"A local gateway is already running at {base_url} " f"(sentinel ID: {running_sentinel_id or 'unknown'})."
|
|
171
|
-
)
|
|
172
|
-
if not click.confirm("Stop it and start a new one?"):
|
|
173
|
-
return
|
|
187
|
+
if force:
|
|
174
188
|
stop_gateway_container()
|
|
189
|
+
else:
|
|
190
|
+
running = get_running_gateway()
|
|
191
|
+
if running is not None:
|
|
192
|
+
base_url, running_sentinel_id = running
|
|
193
|
+
click.echo(
|
|
194
|
+
f"A local gateway is already running at {base_url} "
|
|
195
|
+
f"(sentinel ID: {running_sentinel_id or 'unknown'})."
|
|
196
|
+
)
|
|
197
|
+
if not click.confirm("Stop it and start a new one?"):
|
|
198
|
+
return
|
|
199
|
+
stop_gateway_container()
|
|
175
200
|
|
|
176
201
|
if not sentinel_id and (new_sentinel or not cfg.sentinel_id):
|
|
177
202
|
click.echo("Creating a new sentinel.")
|
|
@@ -211,23 +236,81 @@ def stop_gateway() -> None:
|
|
|
211
236
|
click.echo(f"Gateway removed. Sentinel configuration remains available at {get_sentinel_url(cfg)}.")
|
|
212
237
|
|
|
213
238
|
|
|
214
|
-
@
|
|
215
|
-
def
|
|
216
|
-
"""
|
|
239
|
+
@gateway.command("update")
|
|
240
|
+
def update_gateway() -> None:
|
|
241
|
+
"""Pull the latest local gateway image."""
|
|
242
|
+
try:
|
|
243
|
+
result = update_gateway_image()
|
|
244
|
+
except BaseCLIException:
|
|
245
|
+
raise
|
|
246
|
+
except Exception:
|
|
247
|
+
raise click.ClickException("Failed to pull the latest sentinel image.")
|
|
248
|
+
if result.updated:
|
|
249
|
+
if get_running_gateway() is not None:
|
|
250
|
+
click.echo(
|
|
251
|
+
"Sentinel image updated. Run 'sentinel gateway start --force' to restart the running gateway using the new image."
|
|
252
|
+
)
|
|
253
|
+
else:
|
|
254
|
+
click.echo("Sentinel image updated.")
|
|
255
|
+
else:
|
|
256
|
+
click.echo("Image is up to date.")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@main.group("config", invoke_without_command=True)
|
|
260
|
+
@click.option("--url", "url_value", default=None, help="Set the gateway URL non-interactively.")
|
|
261
|
+
@click.option(
|
|
262
|
+
"--providers",
|
|
263
|
+
"providers_value",
|
|
264
|
+
default=None,
|
|
265
|
+
help="Set providers non-interactively (space-separated, e.g. \"openai anthropic\", or 'all').",
|
|
266
|
+
)
|
|
267
|
+
@click.pass_context
|
|
268
|
+
def config(ctx, url_value: str | None, providers_value: str | None) -> None:
|
|
269
|
+
"""Configure gateway URL and LLM providers.
|
|
270
|
+
|
|
271
|
+
Run with --url/--providers for a non-interactive one-liner, e.g.
|
|
272
|
+
'sentinel config --url https://gateway.example.com --providers "openai anthropic"'.
|
|
273
|
+
"""
|
|
274
|
+
if ctx.invoked_subcommand is not None:
|
|
275
|
+
return
|
|
276
|
+
if url_value is None and providers_value is None:
|
|
277
|
+
click.echo(ctx.get_help())
|
|
278
|
+
return
|
|
279
|
+
cfg = AppConfig.load()
|
|
280
|
+
if url_value is not None:
|
|
281
|
+
_apply_url(cfg, url_value)
|
|
282
|
+
if providers_value is not None:
|
|
283
|
+
_apply_providers(cfg, providers_value)
|
|
284
|
+
click.echo(f"Configuration updated.")
|
|
217
285
|
|
|
218
286
|
|
|
219
287
|
@config.command("url")
|
|
220
|
-
|
|
288
|
+
@click.option("--set", "set_value", default=None, help="Set the gateway URL non-interactively.")
|
|
289
|
+
def config_url(set_value: str | None) -> None:
|
|
221
290
|
"""Set the gateway URL."""
|
|
222
291
|
cfg = AppConfig.load()
|
|
292
|
+
if set_value is not None:
|
|
293
|
+
_apply_url(cfg, set_value)
|
|
294
|
+
click.echo(f"Gateway URL updated.")
|
|
295
|
+
return
|
|
223
296
|
url = click.prompt("Gateway url", default=cfg.gateway_url)
|
|
224
|
-
|
|
297
|
+
_apply_url(cfg, url)
|
|
225
298
|
|
|
226
299
|
|
|
227
300
|
@config.command("providers")
|
|
228
|
-
|
|
301
|
+
@click.option(
|
|
302
|
+
"--set",
|
|
303
|
+
"set_value",
|
|
304
|
+
default=None,
|
|
305
|
+
help="Set providers non-interactively (space-separated, e.g. \"openai anthropic\", or 'all').",
|
|
306
|
+
)
|
|
307
|
+
def config_providers(set_value: str | None) -> None:
|
|
229
308
|
"""Set the LLM providers to intercept. Use 'all' to select all supported providers."""
|
|
230
309
|
cfg = AppConfig.load()
|
|
310
|
+
if set_value is not None:
|
|
311
|
+
_apply_providers(cfg, set_value)
|
|
312
|
+
click.echo(f"Providers updated.")
|
|
313
|
+
return
|
|
231
314
|
while True:
|
|
232
315
|
providers = click.prompt(
|
|
233
316
|
"Choose the providers you want to route to the configured gateway (space separated)",
|
{superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/src/superwise_sentinel_cli/config.py
RENAMED
|
@@ -31,7 +31,7 @@ CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
|
31
31
|
|
|
32
32
|
_PROVIDER_CONFIG: dict[str, tuple[str, str]] = {
|
|
33
33
|
"anthropic": ("ANTHROPIC_BASE_URL", "anthropic"),
|
|
34
|
-
"openai": ("OPENAI_BASE_URL", "openai"),
|
|
34
|
+
"openai": ("OPENAI_BASE_URL", "openai/v1"),
|
|
35
35
|
"google": ("GOOGLE_GEMINI_BASE_URL", "gemini"),
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -9,12 +9,18 @@ from superwise_sentinel_cli.config import AppConfig
|
|
|
9
9
|
from superwise_sentinel_cli.config import VALID_PROVIDERS
|
|
10
10
|
from superwise_sentinel_cli.docker_manager import ensure_container_running
|
|
11
11
|
from superwise_sentinel_cli.docker_manager import get_running_gateway_info
|
|
12
|
+
from superwise_sentinel_cli.docker_manager import pull_latest_image
|
|
12
13
|
from superwise_sentinel_cli.docker_manager import stop_container
|
|
13
14
|
from superwise_sentinel_cli.exceptions import AuthenticationError
|
|
14
15
|
from superwise_sentinel_cli.shell_profile import remove_env_vars
|
|
15
16
|
from superwise_sentinel_cli.shell_profile import write_env_vars
|
|
16
17
|
|
|
17
18
|
|
|
19
|
+
stop_gateway_container = stop_container
|
|
20
|
+
update_gateway_image = pull_latest_image
|
|
21
|
+
clear_env_vars = remove_env_vars
|
|
22
|
+
|
|
23
|
+
|
|
18
24
|
def check_invalid_providers(providers: list[str]) -> list[str]:
|
|
19
25
|
return [p for p in providers if p not in VALID_PROVIDERS]
|
|
20
26
|
|
|
@@ -116,11 +122,6 @@ def get_running_gateway() -> tuple[str, str | None] | None:
|
|
|
116
122
|
return endpoint.base_url, sentinel_id
|
|
117
123
|
|
|
118
124
|
|
|
119
|
-
stop_gateway_container = stop_container
|
|
120
|
-
|
|
121
|
-
clear_env_vars = remove_env_vars
|
|
122
|
-
|
|
123
|
-
|
|
124
125
|
def update_env_vars(cfg: AppConfig) -> list[tuple[str, str]]:
|
|
125
126
|
pairs = resolve_env_vars(cfg.providers, cfg.gateway_url)
|
|
126
127
|
remove_env_vars()
|
|
@@ -7,6 +7,8 @@ import click
|
|
|
7
7
|
import docker.errors
|
|
8
8
|
from docker.models.containers import Container
|
|
9
9
|
from superwise_sentinel_cli.config import AppConfig
|
|
10
|
+
from superwise_sentinel_cli.exceptions import DockerError
|
|
11
|
+
from superwise_sentinel_cli.exceptions import DockerUnavailableError
|
|
10
12
|
|
|
11
13
|
cfg = AppConfig()
|
|
12
14
|
CONTAINER_IMAGE = cfg.CONTAINER_IMAGE
|
|
@@ -30,7 +32,7 @@ def _docker_client() -> docker.DockerClient:
|
|
|
30
32
|
client.ping()
|
|
31
33
|
return client
|
|
32
34
|
except docker.errors.DockerException as exc:
|
|
33
|
-
raise
|
|
35
|
+
raise DockerUnavailableError() from exc
|
|
34
36
|
|
|
35
37
|
|
|
36
38
|
def _running_container(client: docker.DockerClient) -> Container | None:
|
|
@@ -59,7 +61,7 @@ def _endpoint_from_container(container: Container) -> ContainerEndpoint:
|
|
|
59
61
|
def get_running_gateway_info() -> tuple[ContainerEndpoint, str | None] | None:
|
|
60
62
|
try:
|
|
61
63
|
client = _docker_client()
|
|
62
|
-
except
|
|
64
|
+
except DockerUnavailableError:
|
|
63
65
|
return None
|
|
64
66
|
container = _running_container(client)
|
|
65
67
|
if container is None:
|
|
@@ -68,14 +70,11 @@ def get_running_gateway_info() -> tuple[ContainerEndpoint, str | None] | None:
|
|
|
68
70
|
|
|
69
71
|
|
|
70
72
|
def stop_container() -> None:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
container.remove(force=True)
|
|
77
|
-
except RuntimeError as exc:
|
|
78
|
-
raise click.ClickException(str(exc)) from exc
|
|
73
|
+
client = _docker_client()
|
|
74
|
+
matches = client.containers.list(all=True, filters={"name": CONTAINER_NAME})
|
|
75
|
+
for container in matches:
|
|
76
|
+
container.stop(timeout=5)
|
|
77
|
+
container.remove(force=True)
|
|
79
78
|
|
|
80
79
|
|
|
81
80
|
def _docker_pull(platform: str | None = None) -> subprocess.CompletedProcess:
|
|
@@ -83,30 +82,41 @@ def _docker_pull(platform: str | None = None) -> subprocess.CompletedProcess:
|
|
|
83
82
|
if platform:
|
|
84
83
|
cmd += ["--platform", platform]
|
|
85
84
|
cmd.append(CONTAINER_IMAGE)
|
|
86
|
-
return subprocess.run(cmd,
|
|
85
|
+
return subprocess.run(cmd, capture_output=True, text=True)
|
|
87
86
|
|
|
88
87
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class PullResult:
|
|
90
|
+
platform: str | None
|
|
91
|
+
updated: bool
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def pull_latest_image() -> PullResult:
|
|
95
|
+
_docker_client()
|
|
96
96
|
|
|
97
97
|
click.echo(f"Pulling sentinel image...")
|
|
98
98
|
native = _docker_pull()
|
|
99
99
|
if native.returncode == 0:
|
|
100
|
-
return None
|
|
100
|
+
return PullResult(None, "Status: Downloaded newer image" in native.stdout)
|
|
101
101
|
|
|
102
102
|
if "no matching manifest" in native.stderr:
|
|
103
103
|
click.echo("Native platform image not available, falling back to linux/amd64...")
|
|
104
104
|
fallback = _docker_pull("linux/amd64")
|
|
105
105
|
if fallback.returncode == 0:
|
|
106
|
-
return "linux/amd64"
|
|
107
|
-
raise
|
|
106
|
+
return PullResult("linux/amd64", "Status: Downloaded newer image" in fallback.stdout)
|
|
107
|
+
raise DockerError(f"Failed to pull image {CONTAINER_IMAGE}:\n{fallback.stderr}") from None
|
|
108
|
+
|
|
109
|
+
raise DockerError(f"Failed to pull image {CONTAINER_IMAGE}:\n{native.stderr}") from None
|
|
108
110
|
|
|
109
|
-
|
|
111
|
+
|
|
112
|
+
def _pull_image_if_needed() -> str | None:
|
|
113
|
+
client = _docker_client()
|
|
114
|
+
try:
|
|
115
|
+
client.images.get(CONTAINER_IMAGE)
|
|
116
|
+
return None
|
|
117
|
+
except docker.errors.ImageNotFound:
|
|
118
|
+
pass
|
|
119
|
+
return pull_latest_image().platform
|
|
110
120
|
|
|
111
121
|
|
|
112
122
|
def ensure_container_running(environment: dict[str, str] | None = None) -> ContainerEndpoint:
|
|
@@ -138,6 +148,6 @@ def ensure_container_running(environment: dict[str, str] | None = None) -> Conta
|
|
|
138
148
|
remove=False,
|
|
139
149
|
)
|
|
140
150
|
except docker.errors.APIError as exc:
|
|
141
|
-
raise
|
|
151
|
+
raise DockerError(f"Failed to start sentinel container: {exc.explanation}") from exc
|
|
142
152
|
|
|
143
153
|
return _endpoint_from_container(container)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseCLIException(click.ClickException):
|
|
7
|
+
def show(self, file=None):
|
|
8
|
+
click.secho(f"Error: {self.message}", fg="red", file=file or sys.stderr)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthenticationError(BaseCLIException):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DockerError(BaseCLIException):
|
|
16
|
+
"""Base exception for docker-related failures."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DockerUnavailableError(DockerError):
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
super().__init__("Cannot connect to Docker daemon — is Docker running?")
|
|
@@ -264,13 +264,13 @@ def test_create_sentinel_propagates_http_error(mock_auth, mock_post):
|
|
|
264
264
|
|
|
265
265
|
|
|
266
266
|
def test_resolve_env_vars_returns_correct_pair():
|
|
267
|
-
assert resolve_env_vars(["openai"], "http://proxy") == [("OPENAI_BASE_URL", "http://proxy/openai")]
|
|
267
|
+
assert resolve_env_vars(["openai"], "http://proxy") == [("OPENAI_BASE_URL", "http://proxy/openai/v1")]
|
|
268
268
|
|
|
269
269
|
|
|
270
270
|
def test_resolve_env_vars_handles_multiple_providers():
|
|
271
271
|
pairs = resolve_env_vars(["openai", "anthropic"], "http://proxy")
|
|
272
272
|
|
|
273
|
-
assert ("OPENAI_BASE_URL", "http://proxy/openai") in pairs
|
|
273
|
+
assert ("OPENAI_BASE_URL", "http://proxy/openai/v1") in pairs
|
|
274
274
|
assert ("ANTHROPIC_BASE_URL", "http://proxy/anthropic") in pairs
|
|
275
275
|
|
|
276
276
|
|
|
@@ -11,6 +11,8 @@ from superwise_sentinel_cli.docker_manager import CONTAINER_PORT
|
|
|
11
11
|
from superwise_sentinel_cli.docker_manager import ensure_container_running
|
|
12
12
|
from superwise_sentinel_cli.docker_manager import get_running_gateway_info
|
|
13
13
|
from superwise_sentinel_cli.docker_manager import stop_container
|
|
14
|
+
from superwise_sentinel_cli.exceptions import DockerError
|
|
15
|
+
from superwise_sentinel_cli.exceptions import DockerUnavailableError
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
@patch("superwise_sentinel_cli.docker_manager.docker.from_env")
|
|
@@ -25,12 +27,12 @@ def test_docker_client_returns_client_when_docker_running(mock_from_env):
|
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
@patch("superwise_sentinel_cli.docker_manager.docker.from_env", side_effect=docker.errors.DockerException("down"))
|
|
28
|
-
def
|
|
29
|
-
with pytest.raises(
|
|
30
|
+
def test_docker_client_raises_docker_error_when_docker_unavailable(mock_from_env):
|
|
31
|
+
with pytest.raises(DockerError, match="Cannot connect to Docker daemon"):
|
|
30
32
|
_docker_client()
|
|
31
33
|
|
|
32
34
|
|
|
33
|
-
@patch("superwise_sentinel_cli.docker_manager._docker_client", side_effect=
|
|
35
|
+
@patch("superwise_sentinel_cli.docker_manager._docker_client", side_effect=DockerUnavailableError())
|
|
34
36
|
def test_get_running_gateway_info_returns_none_when_docker_unavailable(mock_client):
|
|
35
37
|
assert get_running_gateway_info() is None
|
|
36
38
|
|
|
@@ -90,7 +92,7 @@ def test_stop_container_noop_when_no_containers(mock_client):
|
|
|
90
92
|
stop_container()
|
|
91
93
|
|
|
92
94
|
|
|
93
|
-
@patch("superwise_sentinel_cli.docker_manager._docker_client", side_effect=
|
|
95
|
+
@patch("superwise_sentinel_cli.docker_manager._docker_client", side_effect=DockerUnavailableError())
|
|
94
96
|
def test_stop_container_raises_click_exception_when_docker_unavailable(mock_client):
|
|
95
97
|
with pytest.raises(click.ClickException):
|
|
96
98
|
stop_container()
|
|
@@ -202,7 +204,7 @@ def test_ensure_container_running_raises_on_api_error(mock_client, mock_running,
|
|
|
202
204
|
client.containers.list.return_value = []
|
|
203
205
|
client.containers.run.side_effect = docker.errors.APIError("start failed")
|
|
204
206
|
|
|
205
|
-
with pytest.raises(
|
|
207
|
+
with pytest.raises(DockerError, match="Failed to start sentinel container"):
|
|
206
208
|
ensure_container_running()
|
|
207
209
|
|
|
208
210
|
|
|
@@ -243,7 +245,7 @@ def test_pull_image_if_needed_raises_when_amd64_fallback_fails(mock_client, mock
|
|
|
243
245
|
MagicMock(returncode=1, stderr="amd64 also failed"),
|
|
244
246
|
]
|
|
245
247
|
|
|
246
|
-
with pytest.raises(
|
|
248
|
+
with pytest.raises(DockerError, match="Failed to pull image"):
|
|
247
249
|
_pull_image_if_needed()
|
|
248
250
|
|
|
249
251
|
|
|
@@ -253,5 +255,5 @@ def test_pull_image_if_needed_raises_on_non_manifest_pull_error(mock_client, moc
|
|
|
253
255
|
mock_client.return_value.images.get.side_effect = docker.errors.ImageNotFound("not found")
|
|
254
256
|
mock_pull.return_value = MagicMock(returncode=1, stderr="network timeout")
|
|
255
257
|
|
|
256
|
-
with pytest.raises(
|
|
258
|
+
with pytest.raises(DockerError, match="Failed to pull image"):
|
|
257
259
|
_pull_image_if_needed()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
RELEASED_VERSION=0.2.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/src/superwise_sentinel_cli/__init__.py
RENAMED
|
File without changes
|
{superwise_sentinel_cli-0.2.0 → superwise_sentinel_cli-0.2.1}/src/superwise_sentinel_cli/auth.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|