sovaria-cli 0.2.9__tar.gz → 0.3.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.
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/.gitignore +2 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/PKG-INFO +18 -3
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/README.md +15 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/__init__.py +1 -1
- sovaria_cli-0.3.0/apps/cli/src/commands/ai_data.py +341 -0
- sovaria_cli-0.3.0/apps/cli/src/commands/alerts.py +142 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/api.py +10 -3
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/audit.py +9 -13
- sovaria_cli-0.3.0/apps/cli/src/commands/auth_enterprise.py +158 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/auth_product.py +108 -4
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/billing.py +102 -0
- sovaria_cli-0.3.0/apps/cli/src/commands/branches.py +240 -0
- sovaria_cli-0.3.0/apps/cli/src/commands/builds.py +128 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/chrono.py +50 -0
- sovaria_cli-0.3.0/apps/cli/src/commands/collections.py +289 -0
- sovaria_cli-0.3.0/apps/cli/src/commands/crashes.py +92 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/db.py +603 -41
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/deploy.py +210 -120
- sovaria_cli-0.3.0/apps/cli/src/commands/env.py +176 -0
- sovaria_cli-0.3.0/apps/cli/src/commands/fn_platform.py +783 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/gen.py +26 -5
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/governance.py +86 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/iam.py +9 -3
- sovaria_cli-0.3.0/apps/cli/src/commands/import_cmd.py +840 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/keys.py +24 -3
- sovaria_cli-0.3.0/apps/cli/src/commands/knowledge.py +214 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/login.py +19 -10
- sovaria_cli-0.3.0/apps/cli/src/commands/logs.py +197 -0
- sovaria_cli-0.3.0/apps/cli/src/commands/mcp.py +208 -0
- sovaria_cli-0.3.0/apps/cli/src/commands/messaging.py +200 -0
- sovaria_cli-0.3.0/apps/cli/src/commands/metrics.py +85 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/org.py +8 -4
- sovaria_cli-0.3.0/apps/cli/src/commands/org_key.py +96 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/policy.py +42 -9
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/realtime.py +34 -0
- sovaria_cli-0.3.0/apps/cli/src/commands/rules.py +226 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/secrets.py +17 -10
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/shell.py +49 -23
- sovaria_cli-0.3.0/apps/cli/src/commands/sim.py +511 -0
- sovaria_cli-0.3.0/apps/cli/src/commands/simulate.py +150 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/storage.py +196 -18
- sovaria_cli-0.3.0/apps/cli/src/commands/templates.py +162 -0
- sovaria_cli-0.3.0/apps/cli/src/commands/vector.py +395 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/main.py +92 -4
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/api_client.py +174 -8
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/auth_store.py +27 -4
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/config.py +6 -5
- sovaria_cli-0.3.0/apps/cli/src/utils/forge_local.py +664 -0
- sovaria_cli-0.3.0/apps/cli/src/utils/importers.py +573 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/migrations.py +14 -2
- sovaria_cli-0.3.0/apps/cli/src/utils/mobile_types.py +322 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/premium_display.py +8 -3
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/project_context.py +37 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/schema_introspect.py +1 -1
- sovaria_cli-0.3.0/apps/cli/src/utils/sim_offline.py +469 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/pyproject.toml +8 -3
- sovaria_cli-0.2.9/.github/dependabot.yml +0 -12
- sovaria_cli-0.2.9/.github/workflows/ci.yml +0 -174
- sovaria_cli-0.2.9/.github/workflows/publish.yml +0 -37
- sovaria_cli-0.2.9/.gitleaks.toml +0 -17
- sovaria_cli-0.2.9/.python-version +0 -1
- sovaria_cli-0.2.9/Makefile +0 -44
- sovaria_cli-0.2.9/apps/cli/src/commands/collections.py +0 -38
- sovaria_cli-0.2.9/apps/cli/src/commands/logs.py +0 -103
- sovaria_cli-0.2.9/apps/cli/src/commands/mcp.py +0 -79
- sovaria_cli-0.2.9/apps/cli/src/commands/sim.py +0 -216
- sovaria_cli-0.2.9/apps/cli/src/commands/vector.py +0 -207
- sovaria_cli-0.2.9/apps/cli/tests/test_aug18_cli_tickets.py +0 -450
- sovaria_cli-0.2.9/apps/cli/tests/test_auth_store_profiles.py +0 -103
- sovaria_cli-0.2.9/apps/cli/tests/test_cli_security.py +0 -110
- sovaria_cli-0.2.9/apps/cli/tests/test_cli_smoke_paths.py +0 -301
- sovaria_cli-0.2.9/apps/cli/tests/test_config_profiles.py +0 -82
- sovaria_cli-0.2.9/apps/cli/tests/test_credential_file_permissions.py +0 -29
- sovaria_cli-0.2.9/apps/cli/tests/test_db_migrations.py +0 -62
- sovaria_cli-0.2.9/apps/cli/tests/test_main_startup.py +0 -29
- sovaria_cli-0.2.9/apps/cli/tests/test_mcp_command.py +0 -31
- sovaria_cli-0.2.9/apps/cli/tests/test_phase4_commands.py +0 -91
- sovaria_cli-0.2.9/apps/cli/tests/test_phase5_commands.py +0 -97
- sovaria_cli-0.2.9/apps/cli/tests/test_phase6_commands.py +0 -133
- sovaria_cli-0.2.9/apps/cli/tests/test_premium_display.py +0 -132
- sovaria_cli-0.2.9/apps/cli/tests/test_project_and_commands.py +0 -257
- sovaria_cli-0.2.9/apps/cli/tests/test_scope_picker.py +0 -17
- sovaria_cli-0.2.9/apps/cli/tests/test_supabase_style_commands.py +0 -63
- sovaria_cli-0.2.9/pytest.ini +0 -3
- sovaria_cli-0.2.9/requirements-dev.txt +0 -4
- sovaria_cli-0.2.9/requirements.txt +0 -9
- sovaria_cli-0.2.9/scripts/cli_sdk_happy_path.sh +0 -163
- sovaria_cli-0.2.9/scripts/smoke_all_commands.sh +0 -107
- sovaria_cli-0.2.9/scripts/smoke_chunks.sh +0 -90
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/__init__.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/__init__.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/admin.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/connect.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/context.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/dashboard.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/data.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/init_cmd.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/inspect.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/link_cmd.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/oidc_providers.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/passkeys.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/project.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/schema.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/scope.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/commands/test_cmd.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/__init__.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/api_helpers.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/display.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/local_function_server.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/log_tail.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/output_mode.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/scope_picker.py +0 -0
- {sovaria_cli-0.2.9 → sovaria_cli-0.3.0}/apps/cli/src/utils/theme.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sovaria-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Sovaria BaaS command-line interface (sov)
|
|
5
5
|
Project-URL: Homepage, https://sovaria.in
|
|
6
6
|
Project-URL: Documentation, https://docs.sovaria.in/cli
|
|
@@ -12,7 +12,7 @@ Requires-Dist: httpx>=0.28.1
|
|
|
12
12
|
Requires-Dist: keyring>=25.0
|
|
13
13
|
Requires-Dist: pyyaml>=6.0
|
|
14
14
|
Requires-Dist: rich>=13.7.0
|
|
15
|
-
Requires-Dist: sovaria-sdk==0.
|
|
15
|
+
Requires-Dist: sovaria-sdk==0.5.0
|
|
16
16
|
Requires-Dist: tomli-w>=1.2.0
|
|
17
17
|
Requires-Dist: typer>=0.17.4
|
|
18
18
|
Provides-Extra: dev
|
|
@@ -22,13 +22,22 @@ Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
|
22
22
|
Requires-Dist: ruff>=0.9; extra == 'dev'
|
|
23
23
|
Requires-Dist: twine>=6.0; extra == 'dev'
|
|
24
24
|
Provides-Extra: mcp
|
|
25
|
-
Requires-Dist: sovaria-sdk[mcp]==0.
|
|
25
|
+
Requires-Dist: sovaria-sdk[mcp]==0.5.0; extra == 'mcp'
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
|
|
28
28
|
# Sovaria CLI
|
|
29
29
|
|
|
30
30
|
Operator CLI for gateway-backed Sovaria project and platform workflows.
|
|
31
31
|
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install -g @sovaria/cli # or: npx @sovaria/cli --help
|
|
36
|
+
pip install sovaria-cli # same CLI from PyPI
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Both need Python 3.11+. The npm package sets up a private environment in `~/.sovaria/npm-cli` on first run.
|
|
40
|
+
|
|
32
41
|
## Run
|
|
33
42
|
|
|
34
43
|
Examples below use `sov ...` command syntax.
|
|
@@ -113,6 +122,12 @@ sov realtime ws-url my-channel --org <org_id> --project <project_id>
|
|
|
113
122
|
- `sov fn ...` Forge function deploy/status/invoke
|
|
114
123
|
- `sov project ...` project config template + scaffold creation
|
|
115
124
|
- `sov api call ...` raw gateway API request
|
|
125
|
+
- `sov branches list|create|get|update|use|delete` environments as branches (`create x --from staging`, `use x`)
|
|
126
|
+
- `sov gen types typescript|python|dart|swift|kotlin -o <file>` row models from the live schema
|
|
127
|
+
- `sov storage cp [-r] <src> <dst>` copy between local paths and `ss://bucket/path` (or between buckets)
|
|
128
|
+
- `sov collections delete <path> [-r]` delete a collection or document (and its subcollections with `-r`)
|
|
129
|
+
- `sov auth-product export users.json|users.csv` every app user (profile fields only)
|
|
130
|
+
- `sov functions new <name> --runtime python|node|typescript|go` a function folder with a starter handler
|
|
116
131
|
|
|
117
132
|
## Project Bootstrap (Config JSON)
|
|
118
133
|
|
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Operator CLI for gateway-backed Sovaria project and platform workflows.
|
|
4
4
|
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @sovaria/cli # or: npx @sovaria/cli --help
|
|
9
|
+
pip install sovaria-cli # same CLI from PyPI
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Both need Python 3.11+. The npm package sets up a private environment in `~/.sovaria/npm-cli` on first run.
|
|
13
|
+
|
|
5
14
|
## Run
|
|
6
15
|
|
|
7
16
|
Examples below use `sov ...` command syntax.
|
|
@@ -86,6 +95,12 @@ sov realtime ws-url my-channel --org <org_id> --project <project_id>
|
|
|
86
95
|
- `sov fn ...` Forge function deploy/status/invoke
|
|
87
96
|
- `sov project ...` project config template + scaffold creation
|
|
88
97
|
- `sov api call ...` raw gateway API request
|
|
98
|
+
- `sov branches list|create|get|update|use|delete` environments as branches (`create x --from staging`, `use x`)
|
|
99
|
+
- `sov gen types typescript|python|dart|swift|kotlin -o <file>` row models from the live schema
|
|
100
|
+
- `sov storage cp [-r] <src> <dst>` copy between local paths and `ss://bucket/path` (or between buckets)
|
|
101
|
+
- `sov collections delete <path> [-r]` delete a collection or document (and its subcollections with `-r`)
|
|
102
|
+
- `sov auth-product export users.json|users.csv` every app user (profile fields only)
|
|
103
|
+
- `sov functions new <name> --runtime python|node|typescript|go` a function folder with a starter handler
|
|
89
104
|
|
|
90
105
|
## Project Bootstrap (Config JSON)
|
|
91
106
|
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""AI & data commands (W9), attached to existing groups without editing them:
|
|
2
|
+
|
|
3
|
+
* ``sov db advisor`` — index advisor (hypothetical indexes), ``apply`` / ``status``;
|
|
4
|
+
* ``sov db insights`` — query insights (pg_stat_statements) and ``reset``;
|
|
5
|
+
* ``sov collections pipeline`` — document pipelines (where / aggregate / sort / limit / select …);
|
|
6
|
+
* ``sov agents versions | publish | rollback | compare`` — agent versions;
|
|
7
|
+
* ``sov gateway`` — the project's model gateway: routes, completions, usage, cache.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from apps.cli.src.utils.api_client import CLIRequestError, call_api
|
|
19
|
+
from apps.cli.src.utils.display import handle_cli_error, print_json
|
|
20
|
+
|
|
21
|
+
_ORG = typer.Option(None, "--org")
|
|
22
|
+
_PROJECT = typer.Option(None, "--project")
|
|
23
|
+
_ENV = typer.Option(None, "--env")
|
|
24
|
+
_PROFILE = typer.Option(None, "--profile")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _seg(value: str) -> str:
|
|
28
|
+
return quote(str(value), safe="")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _call(method: str, path: str, *, profile, org_id, project_id, env_id=None, body=None, params=None,
|
|
32
|
+
timeout: float = 60.0):
|
|
33
|
+
try:
|
|
34
|
+
return call_api(method=method, path=path, profile=profile, org_id=org_id, project_id=project_id,
|
|
35
|
+
env_id=env_id, require_scope=True, body=body, params=params, timeout=timeout)
|
|
36
|
+
except CLIRequestError as exc:
|
|
37
|
+
handle_cli_error(exc)
|
|
38
|
+
raise typer.Exit(1) from exc
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _db_path(profile, org_id, project_id, suffix: str) -> str:
|
|
42
|
+
from apps.cli.src.utils.project_context import resolve_scope
|
|
43
|
+
|
|
44
|
+
org, project, _env = resolve_scope(profile=profile, org_id=org_id, project_id=project_id, env_id=None, require=True)
|
|
45
|
+
return f"/api/v1/governance/tenants/{_seg(org)}/databases/{_seg(project)}/{suffix}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── sov db advisor / insights ──────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
advisor_app = typer.Typer(help="Index advisor: indexes that would speed up the slowest queries, tested as "
|
|
51
|
+
"hypothetical indexes first", invoke_without_command=True)
|
|
52
|
+
insights_app = typer.Typer(help="Query insights: the project's statements by total time, average, calls…",
|
|
53
|
+
invoke_without_command=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@advisor_app.callback()
|
|
57
|
+
def advisor(
|
|
58
|
+
ctx: typer.Context,
|
|
59
|
+
queryid: str | None = typer.Option(None, "--queryid", help="Only this statement (from sov db insights)"),
|
|
60
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
61
|
+
profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Recommend indexes for the project's most expensive statements."""
|
|
64
|
+
if ctx.invoked_subcommand:
|
|
65
|
+
return
|
|
66
|
+
out = _call("GET", _db_path(profile, org_id, project_id, "index-advisor"), profile=profile, org_id=org_id,
|
|
67
|
+
project_id=project_id, params={"queryid": queryid} if queryid else None, timeout=120)
|
|
68
|
+
if as_json:
|
|
69
|
+
print_json(out)
|
|
70
|
+
return
|
|
71
|
+
_print_recommendations(out)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _print_recommendations(out: dict) -> None:
|
|
75
|
+
mode = "tested with hypothetical indexes" if out.get("mode") == "hypopg" else "unverified (no hypopg here)"
|
|
76
|
+
recs = out.get("recommendations") or []
|
|
77
|
+
typer.echo(f"{len(recs)} recommendation(s), {mode}, {len(out.get('statements') or [])} statement(s) examined.")
|
|
78
|
+
for w in out.get("warnings") or []:
|
|
79
|
+
typer.echo(f"! {w}", err=True)
|
|
80
|
+
for r in recs:
|
|
81
|
+
best = max((s.get("gain_pct") or 0 for s in r.get("statements") or []), default=0)
|
|
82
|
+
typer.echo(f"\n {r['schema']}.{r['table']} ({', '.join(r['columns'])})"
|
|
83
|
+
+ (f" — plan cost −{best}%" if best else "") + f", {r.get('table_rows', 0):,} rows")
|
|
84
|
+
typer.echo(f" {r['sql']}")
|
|
85
|
+
for s in (r.get("statements") or [])[:3]:
|
|
86
|
+
typer.echo(f" · {s['query'][:110]}")
|
|
87
|
+
if recs and (out.get("limits") or {}).get("apply"):
|
|
88
|
+
r = recs[0]
|
|
89
|
+
typer.echo(f"\nApply: sov db advisor apply --table {r['schema']}.{r['table']} --columns {','.join(r['columns'])}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@advisor_app.command("query")
|
|
93
|
+
def advisor_query(
|
|
94
|
+
sql: str = typer.Argument(..., help="One SELECT / WITH / UPDATE / DELETE (only EXPLAINed, never run)"),
|
|
95
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
96
|
+
profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Recommend indexes for one statement."""
|
|
99
|
+
out = _call("POST", _db_path(profile, org_id, project_id, "index-advisor/query"), profile=profile, org_id=org_id,
|
|
100
|
+
project_id=project_id, body={"query": sql})
|
|
101
|
+
print_json(out) if as_json else _print_recommendations(out)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@advisor_app.command("apply")
|
|
105
|
+
def advisor_apply(
|
|
106
|
+
table: str = typer.Option(..., "--table", help="schema.table (public. may be left out)"),
|
|
107
|
+
columns: str = typer.Option(..., "--columns", help="Comma list, in index order (1-3)"),
|
|
108
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Don't ask for confirmation"),
|
|
109
|
+
profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Build a recommended index with CREATE INDEX CONCURRENTLY (no write lock); follow it with `status`."""
|
|
112
|
+
schema, _, name = table.rpartition(".")
|
|
113
|
+
cols = [c.strip() for c in columns.split(",") if c.strip()]
|
|
114
|
+
if not yes and not typer.confirm(f"Create an index on {schema or 'public'}.{name} ({', '.join(cols)})?"):
|
|
115
|
+
raise typer.Exit(1)
|
|
116
|
+
out = _call("POST", _db_path(profile, org_id, project_id, "index-advisor/apply"), profile=profile, org_id=org_id,
|
|
117
|
+
project_id=project_id, body={"schema": schema or "public", "table": name, "columns": cols})
|
|
118
|
+
typer.echo(f"{out['index_name']}: {out['status']} (sov db advisor status {out['index_name']})")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@advisor_app.command("status")
|
|
122
|
+
def advisor_status(
|
|
123
|
+
index_name: str = typer.Argument(...),
|
|
124
|
+
profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Whether an index the advisor started is building, ready, failed or invalid."""
|
|
127
|
+
print_json(_call("GET", _db_path(profile, org_id, project_id, f"index-advisor/indexes/{_seg(index_name)}"),
|
|
128
|
+
profile=profile, org_id=org_id, project_id=project_id))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@insights_app.callback()
|
|
132
|
+
def insights(
|
|
133
|
+
ctx: typer.Context,
|
|
134
|
+
sort: str = typer.Option("total", "--sort", help="total, mean, calls, max or rows"),
|
|
135
|
+
limit: int = typer.Option(20, "--limit", min=1, max=1000),
|
|
136
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
137
|
+
profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""The project's statements from pg_stat_statements (plan caps the rows: db.query_insights_rows)."""
|
|
140
|
+
if ctx.invoked_subcommand:
|
|
141
|
+
return
|
|
142
|
+
out = _call("GET", _db_path(profile, org_id, project_id, "query-insights"), profile=profile, org_id=org_id,
|
|
143
|
+
project_id=project_id, params={"sort": sort, "limit": str(limit)})
|
|
144
|
+
if as_json or not out.get("enabled"):
|
|
145
|
+
print_json(out)
|
|
146
|
+
return
|
|
147
|
+
typer.echo(f"{'calls':>9} {'total ms':>11} {'mean ms':>9} {'max ms':>9} {'hit %':>6} query")
|
|
148
|
+
for s in out.get("statements") or []:
|
|
149
|
+
hit = "" if s.get("cache_hit_pct") is None else f"{s['cache_hit_pct']:.0f}"
|
|
150
|
+
flag = "*" if s.get("slow") else " "
|
|
151
|
+
typer.echo(f"{s['calls']:>9,} {s['total_ms']:>11,.1f} {s['mean_ms']:>9.1f} {s['max_ms']:>9.1f} {hit:>6} {flag}"
|
|
152
|
+
f"{' '.join(s['query'].split())[:90]} [{s['queryid']}]")
|
|
153
|
+
typer.echo(f"* slower than {out.get('slow_ms')} ms on average · since {out.get('since') or 'the last reset'}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@insights_app.command("reset")
|
|
157
|
+
def insights_reset(
|
|
158
|
+
yes: bool = typer.Option(False, "--yes", "-y"),
|
|
159
|
+
profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Clear this database's query statistics."""
|
|
162
|
+
if not yes and not typer.confirm("Clear the query statistics of this project's database?"):
|
|
163
|
+
raise typer.Exit(1)
|
|
164
|
+
print_json(_call("POST", _db_path(profile, org_id, project_id, "query-insights/reset"), profile=profile,
|
|
165
|
+
org_id=org_id, project_id=project_id, body={}))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ── sov collections pipeline ───────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def pipeline(
|
|
172
|
+
collection: str = typer.Argument(..., help="Collection path, or the group id with --group"),
|
|
173
|
+
stages_file: Path | None = typer.Option(None, "--file", "-f", help="JSON file: a list of stages or {\"stages\": [...]}"),
|
|
174
|
+
stages: str | None = typer.Option(None, "--stages", help="Stages as inline JSON"),
|
|
175
|
+
group: bool = typer.Option(False, "--group", help="Run over the collection group (every collection with this id)"),
|
|
176
|
+
profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
|
|
177
|
+
env_id: str | None = _ENV,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Run a document pipeline: where, aggregate, distinct, sort, offset, limit, select, remove_fields, sample.
|
|
180
|
+
|
|
181
|
+
Example: sov collections pipeline orders --stages '[{"where": ["status","==","paid"]},
|
|
182
|
+
{"aggregate": {"accumulators": [{"op": "sum", "field": "amount", "alias": "total"}], "groups": ["country"]}},
|
|
183
|
+
{"sort": [["total","desc"]]}, {"limit": 5}]'"""
|
|
184
|
+
raw = stages_file.read_text(encoding="utf-8") if stages_file else stages
|
|
185
|
+
if not raw:
|
|
186
|
+
raise typer.BadParameter("Give the stages with --file or --stages")
|
|
187
|
+
try:
|
|
188
|
+
parsed = json.loads(raw)
|
|
189
|
+
except ValueError as exc:
|
|
190
|
+
raise typer.BadParameter(f"Stages aren't valid JSON: {exc}") from exc
|
|
191
|
+
body = parsed if isinstance(parsed, dict) else {"stages": parsed}
|
|
192
|
+
if group:
|
|
193
|
+
body["collection_group"] = True
|
|
194
|
+
print_json(_call("POST", f"/api/v1/data/doc/_pipeline/{quote(collection, safe='/')}", profile=profile,
|
|
195
|
+
org_id=org_id, project_id=project_id, env_id=env_id, body=body))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ── sov agents versions / publish / rollback / compare ─────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def agent_versions(agent_id: str = typer.Argument(...), profile: str | None = _PROFILE, org_id: str | None = _ORG,
|
|
202
|
+
project_id: str | None = _PROJECT) -> None:
|
|
203
|
+
"""An agent's published version and its kept versions."""
|
|
204
|
+
print_json(_call("GET", f"/api/v1/sovariq/agents/{_seg(agent_id)}/versions", profile=profile, org_id=org_id,
|
|
205
|
+
project_id=project_id))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def agent_publish(agent_id: str = typer.Argument(...), note: str = typer.Option("", "--note", "-m"),
|
|
209
|
+
profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT) -> None:
|
|
210
|
+
"""Publish the agent's current definition; app users run it from now on."""
|
|
211
|
+
out = _call("POST", f"/api/v1/sovariq/agents/{_seg(agent_id)}/versions", profile=profile, org_id=org_id,
|
|
212
|
+
project_id=project_id, body={"note": note})
|
|
213
|
+
typer.echo(f"Published version {out['version']}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def agent_rollback(agent_id: str = typer.Argument(...), version: int = typer.Argument(...),
|
|
217
|
+
note: str = typer.Option("", "--note", "-m"), yes: bool = typer.Option(False, "--yes", "-y"),
|
|
218
|
+
profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT) -> None:
|
|
219
|
+
"""Make an older version current again (it is published as a new version)."""
|
|
220
|
+
if not yes and not typer.confirm(f"Roll agent {agent_id} back to version {version}? It replaces the draft too."):
|
|
221
|
+
raise typer.Exit(1)
|
|
222
|
+
out = _call("POST", f"/api/v1/sovariq/agents/{_seg(agent_id)}/versions/{version}/rollback", profile=profile,
|
|
223
|
+
org_id=org_id, project_id=project_id, body={"note": note})
|
|
224
|
+
typer.echo(f"Rolled back to version {out['rolled_back_to']} (published as version {out['version']})")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def agent_compare(agent_id: str = typer.Argument(...), a: str = typer.Argument(..., help="Version number"),
|
|
228
|
+
b: str = typer.Argument("draft", help="Version number or 'draft'"),
|
|
229
|
+
profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT) -> None:
|
|
230
|
+
"""What changed between two versions (or a version and the draft)."""
|
|
231
|
+
out = _call("GET", f"/api/v1/sovariq/agents/{_seg(agent_id)}/versions/compare", profile=profile, org_id=org_id,
|
|
232
|
+
project_id=project_id, params={"a": a, "b": b})
|
|
233
|
+
typer.echo(f"Changed: {', '.join(out.get('changed_fields') or []) or 'nothing'}")
|
|
234
|
+
for key in ("custom_tools", "tools", "knowledge_bases", "playbooks", "mcp_servers"):
|
|
235
|
+
if key in out:
|
|
236
|
+
typer.echo(f" {key}: +{out[key]['added']} −{out[key]['removed']}")
|
|
237
|
+
if out.get("model"):
|
|
238
|
+
typer.echo(f" model: {out['model']['before']} → {out['model']['after']}")
|
|
239
|
+
for line in out.get("instructions_diff") or []:
|
|
240
|
+
typer.echo(f" {line}")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ── sov gateway ────────────────────────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
gateway_app = typer.Typer(help="Model gateway: routes over your own model keys, with fallbacks, a cache and usage per model")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@gateway_app.command("routes")
|
|
249
|
+
def gateway_routes(profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT) -> None:
|
|
250
|
+
"""Routes, the models they may use and the plan's limits."""
|
|
251
|
+
print_json(_call("GET", "/api/v1/sovariq/gateway", profile=profile, org_id=org_id, project_id=project_id))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@gateway_app.command("set")
|
|
255
|
+
def gateway_set(
|
|
256
|
+
name: str = typer.Argument(...),
|
|
257
|
+
models: str = typer.Option(..., "--models", help="provider/model, first one answers, the rest are fallbacks"),
|
|
258
|
+
cache_ttl: int = typer.Option(0, "--cache-ttl", help="Cache identical requests this many seconds (0 = off)"),
|
|
259
|
+
retry_on: str | None = typer.Option(None, "--retry-on", help="Comma list: rate_limit, server_error, timeout, auth"),
|
|
260
|
+
app_users: bool = typer.Option(False, "--app-users", help="Let the app's signed-in users call this route"),
|
|
261
|
+
daily_per_user: int = typer.Option(200, "--daily-per-user", help="Requests one app user may make a day"),
|
|
262
|
+
max_output_tokens: int | None = typer.Option(None, "--max-output-tokens"),
|
|
263
|
+
profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
|
|
264
|
+
) -> None:
|
|
265
|
+
"""Create or change a route."""
|
|
266
|
+
body: dict = {"models": [m.strip() for m in models.split(",") if m.strip()],
|
|
267
|
+
"cache": {"enabled": cache_ttl > 0, "ttl_s": cache_ttl},
|
|
268
|
+
"access": "app_users" if app_users else "members", "daily_requests_per_user": daily_per_user,
|
|
269
|
+
"max_output_tokens": max_output_tokens}
|
|
270
|
+
if retry_on:
|
|
271
|
+
body["retry_on"] = [r.strip() for r in retry_on.split(",") if r.strip()]
|
|
272
|
+
print_json(_call("PUT", f"/api/v1/sovariq/gateway/routes/{_seg(name)}", profile=profile, org_id=org_id,
|
|
273
|
+
project_id=project_id, body=body))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@gateway_app.command("delete")
|
|
277
|
+
def gateway_delete(name: str = typer.Argument(...), yes: bool = typer.Option(False, "--yes", "-y"),
|
|
278
|
+
profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT) -> None:
|
|
279
|
+
if not yes and not typer.confirm(f"Delete route {name}?"):
|
|
280
|
+
raise typer.Exit(1)
|
|
281
|
+
print_json(_call("DELETE", f"/api/v1/sovariq/gateway/routes/{_seg(name)}", profile=profile, org_id=org_id,
|
|
282
|
+
project_id=project_id))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@gateway_app.command("complete")
|
|
286
|
+
def gateway_complete(
|
|
287
|
+
route: str = typer.Argument(...),
|
|
288
|
+
prompt: str = typer.Argument(..., help="The user message"),
|
|
289
|
+
system: str = typer.Option("", "--system"),
|
|
290
|
+
max_tokens: int = typer.Option(1024, "--max-tokens"),
|
|
291
|
+
no_cache: bool = typer.Option(False, "--no-cache"),
|
|
292
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
293
|
+
profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
|
|
294
|
+
) -> None:
|
|
295
|
+
"""One completion on a route (shows which model answered and whether it came from the cache)."""
|
|
296
|
+
out = _call("POST", f"/api/v1/sovariq/gateway/{_seg(route)}/complete", profile=profile, org_id=org_id,
|
|
297
|
+
project_id=project_id, timeout=180,
|
|
298
|
+
body={"system": system, "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens,
|
|
299
|
+
"cache": not no_cache})
|
|
300
|
+
if as_json:
|
|
301
|
+
print_json(out)
|
|
302
|
+
return
|
|
303
|
+
typer.echo(out.get("text") or "")
|
|
304
|
+
tried = " → ".join(f"{a['model']}{'' if a['ok'] else ' (' + str(a.get('error')) + ')'}" for a in out.get("attempts") or [])
|
|
305
|
+
typer.echo(f"\n[{out.get('model')}{', cached' if out.get('cached') else ''}{'; ' + tried if tried else ''}]", err=True)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@gateway_app.command("usage")
|
|
309
|
+
def gateway_usage(days: int = typer.Option(7, "--days", min=1, max=90), as_json: bool = typer.Option(False, "--json"),
|
|
310
|
+
profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT) -> None:
|
|
311
|
+
"""Requests, errors, fallbacks, cache hits, tokens and latency per model."""
|
|
312
|
+
out = _call("GET", "/api/v1/sovariq/gateway/usage", profile=profile, org_id=org_id, project_id=project_id,
|
|
313
|
+
params={"days": str(days)})
|
|
314
|
+
if as_json:
|
|
315
|
+
print_json(out)
|
|
316
|
+
return
|
|
317
|
+
typer.echo(f"{'model':<40} {'requests':>9} {'errors':>7} {'fallbacks':>9} {'cached':>7} {'tokens in/out':>16} {'avg ms':>7}")
|
|
318
|
+
for model, m in sorted((out.get("models") or {}).items()):
|
|
319
|
+
typer.echo(f"{model:<40} {m['requests']:>9,} {m['errors']:>7,} {m['fallbacks']:>9,} {m['cache_hits']:>7,} "
|
|
320
|
+
f"{m['input_tokens']:>8,}/{m['output_tokens']:<7,} {m['avg_latency_ms']:>7,}")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@gateway_app.command("cache-clear")
|
|
324
|
+
def gateway_cache_clear(profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT) -> None:
|
|
325
|
+
print_json(_call("POST", "/api/v1/sovariq/gateway/cache/clear", profile=profile, org_id=org_id, project_id=project_id))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def register(main_app: typer.Typer) -> None:
|
|
329
|
+
"""Attach the commands to their groups (called once from main)."""
|
|
330
|
+
from apps.cli.src.commands import collections as collections_mod
|
|
331
|
+
from apps.cli.src.commands import db as db_mod
|
|
332
|
+
from apps.cli.src.commands import knowledge as knowledge_mod
|
|
333
|
+
|
|
334
|
+
db_mod.app.add_typer(advisor_app, name="advisor")
|
|
335
|
+
db_mod.app.add_typer(insights_app, name="insights")
|
|
336
|
+
collections_mod.app.command("pipeline")(pipeline)
|
|
337
|
+
knowledge_mod.agents_app.command("versions")(agent_versions)
|
|
338
|
+
knowledge_mod.agents_app.command("publish")(agent_publish)
|
|
339
|
+
knowledge_mod.agents_app.command("rollback")(agent_rollback)
|
|
340
|
+
knowledge_mod.agents_app.command("compare")(agent_compare)
|
|
341
|
+
main_app.add_typer(gateway_app, name="gateway", help="Model gateway: routes, fallbacks, cache, usage per model")
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""`sov alerts`: SLO alerts — email the team or a signed webhook when latency, 5xx rate, function failures or
|
|
2
|
+
database connections cross a threshold (Observability › Alerts).
|
|
3
|
+
|
|
4
|
+
sov alerts list
|
|
5
|
+
sov alerts create --metric latency_p95_ms --threshold 800 [--window 15] [--target "GET /rest/v1/orders"]
|
|
6
|
+
[--no-email] [--webhook https://hooks.example.com/sovaria]
|
|
7
|
+
sov alerts pause|resume <rule-id>
|
|
8
|
+
sov alerts test <rule-id>
|
|
9
|
+
sov alerts delete <rule-id>
|
|
10
|
+
sov alerts events [--limit 50]
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from urllib.parse import quote
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from apps.cli.src.utils.api_client import CLIRequestError, call_api
|
|
20
|
+
from apps.cli.src.utils.display import handle_cli_error, print_json, print_success
|
|
21
|
+
from apps.cli.src.utils.output_mode import wants_json
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(help="SLO alerts: latency, 5xx rate, function failures, DB connections → email / signed webhook")
|
|
24
|
+
|
|
25
|
+
METRICS = ("latency_p95_ms", "latency_p99_ms", "error_rate", "function_failures", "db_connections")
|
|
26
|
+
BASE = "/api/v1/observability/alerts"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _opts(profile, org_id, project_id, env_id) -> dict:
|
|
30
|
+
return {"profile": profile, "org_id": org_id, "project_id": project_id, "env_id": env_id, "require_scope": True}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _call(method: str, path: str, opts: dict, **kw) -> dict:
|
|
34
|
+
try:
|
|
35
|
+
out = call_api(method=method, path=path, **opts, **kw)
|
|
36
|
+
except CLIRequestError as exc:
|
|
37
|
+
handle_cli_error(exc)
|
|
38
|
+
raise typer.Exit(1) from exc
|
|
39
|
+
return out if isinstance(out, dict) else {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _rid(rule_id: str) -> str:
|
|
43
|
+
return f"{BASE}/{quote(rule_id.strip(), safe='')}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
Profile = typer.Option(None, "--profile")
|
|
47
|
+
Org = typer.Option(None, "--org")
|
|
48
|
+
Project = typer.Option(None, "--project")
|
|
49
|
+
Env = typer.Option(None, "--env")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("list")
|
|
53
|
+
def alerts_list(profile: str | None = Profile, org_id: str | None = Org, project_id: str | None = Project, env_id: str | None = Env) -> None:
|
|
54
|
+
"""Alert rules with their state (ok, firing, no_data, paused) and the plan limit."""
|
|
55
|
+
data = _call("GET", BASE, _opts(profile, org_id, project_id, env_id))
|
|
56
|
+
if wants_json():
|
|
57
|
+
print_json(data)
|
|
58
|
+
return
|
|
59
|
+
rows = [{"id": r.get("id"), "name": r.get("name"), "metric": r.get("metric"),
|
|
60
|
+
"over": f"{r.get('threshold')} {r.get('unit') or ''}".strip(), "window": f"{r.get('window_minutes')} min",
|
|
61
|
+
"target": r.get("target") or "project", "state": r.get("state") if r.get("enabled") else "paused",
|
|
62
|
+
"value": r.get("value"), "to": "+".join(x for x in ("email" if r.get("email") else "", "webhook" if r.get("webhook_url") else "") if x)}
|
|
63
|
+
for r in data.get("rules", [])]
|
|
64
|
+
print_json(rows, title=f"Alert rules ({data.get('used', 0)} of {data.get('limit', 0)})")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command("create")
|
|
68
|
+
def alerts_create(
|
|
69
|
+
metric: str = typer.Option(..., "--metric", help=", ".join(METRICS)),
|
|
70
|
+
threshold: float = typer.Option(..., "--threshold", help="ms for latency, percent for error_rate, a count otherwise"),
|
|
71
|
+
window: int = typer.Option(15, "--window", help="Minutes: 5, 10, 15, 30 or 60 (route alerts: 15+)"),
|
|
72
|
+
target: str | None = typer.Option(None, "--target", help="Route ('GET /rest/v1/orders') or function name; default: whole project"),
|
|
73
|
+
envs: str | None = typer.Option(None, "--envs", help="Route alerts: environment ids, comma-separated (default: all)"),
|
|
74
|
+
name: str | None = typer.Option(None, "--name"),
|
|
75
|
+
email: bool = typer.Option(True, "--email/--no-email", help="Email the project team"),
|
|
76
|
+
webhook: str | None = typer.Option(None, "--webhook", help="HTTPS URL that receives a signed POST"),
|
|
77
|
+
min_requests: int = typer.Option(20, "--min-requests", min=1, help="Request alerts: ignore windows with fewer requests"),
|
|
78
|
+
profile: str | None = Profile, org_id: str | None = Org, project_id: str | None = Project, env_id: str | None = Env,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Create an alert rule. A webhook's signing secret is printed once: store it to verify X-Sovaria-Signature."""
|
|
81
|
+
if metric not in METRICS:
|
|
82
|
+
raise typer.BadParameter(f"--metric must be one of {', '.join(METRICS)}")
|
|
83
|
+
body: dict = {"metric": metric, "threshold": threshold, "window_minutes": window, "email": email, "min_requests": min_requests}
|
|
84
|
+
for key, value in (("target", target), ("env", envs), ("name", name), ("webhook_url", webhook)):
|
|
85
|
+
if value:
|
|
86
|
+
body[key] = value
|
|
87
|
+
data = _call("POST", BASE, _opts(profile, org_id, project_id, env_id), body=body)
|
|
88
|
+
if data.get("signing_secret"):
|
|
89
|
+
typer.echo(f"Signing secret (shown once): {data['signing_secret']}")
|
|
90
|
+
print_json({k: v for k, v in data.items() if k != "signing_secret"}, title="Alert rule created")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _toggle(rule_id: str, enabled: bool, opts: dict) -> None:
|
|
94
|
+
data = _call("PATCH", _rid(rule_id), opts, body={"enabled": enabled})
|
|
95
|
+
print_success(f"{data.get('name') or rule_id}: {'resumed' if enabled else 'paused'}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command("pause")
|
|
99
|
+
def alerts_pause(rule_id: str, profile: str | None = Profile, org_id: str | None = Org, project_id: str | None = Project, env_id: str | None = Env) -> None:
|
|
100
|
+
"""Stop checking and notifying for a rule."""
|
|
101
|
+
_toggle(rule_id, False, _opts(profile, org_id, project_id, env_id))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command("resume")
|
|
105
|
+
def alerts_resume(rule_id: str, profile: str | None = Profile, org_id: str | None = Org, project_id: str | None = Project, env_id: str | None = Env) -> None:
|
|
106
|
+
"""Check and notify again."""
|
|
107
|
+
_toggle(rule_id, True, _opts(profile, org_id, project_id, env_id))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command("test")
|
|
111
|
+
def alerts_test(rule_id: str, profile: str | None = Profile, org_id: str | None = Org, project_id: str | None = Project, env_id: str | None = Env) -> None:
|
|
112
|
+
"""Measure the rule now and send a test to its webhook (no email)."""
|
|
113
|
+
data = _call("POST", f"{_rid(rule_id)}/test", _opts(profile, org_id, project_id, env_id))
|
|
114
|
+
value = data.get("value")
|
|
115
|
+
typer.echo(f"Now: {'no data' if value is None else value} — {'would fire' if data.get('would_fire') else 'would not fire'}"
|
|
116
|
+
+ (f" ({data['note']})" if data.get("note") else ""))
|
|
117
|
+
hook = data.get("webhook")
|
|
118
|
+
if hook:
|
|
119
|
+
typer.echo("Webhook: delivered" if hook.get("ok") else f"Webhook failed: {hook.get('error')}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command("delete")
|
|
123
|
+
def alerts_delete(
|
|
124
|
+
rule_id: str,
|
|
125
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Don't ask"),
|
|
126
|
+
profile: str | None = Profile, org_id: str | None = Org, project_id: str | None = Project, env_id: str | None = Env,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Delete an alert rule."""
|
|
129
|
+
if not yes and not typer.confirm(f"Delete alert rule {rule_id}?"):
|
|
130
|
+
raise typer.Exit(1)
|
|
131
|
+
_call("DELETE", _rid(rule_id), _opts(profile, org_id, project_id, env_id))
|
|
132
|
+
print_success(f"Deleted {rule_id}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@app.command("events")
|
|
136
|
+
def alerts_events(
|
|
137
|
+
limit: int = typer.Option(50, "--limit", min=1, max=200),
|
|
138
|
+
profile: str | None = Profile, org_id: str | None = Org, project_id: str | None = Project, env_id: str | None = Env,
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Fired and resolved alerts, newest first."""
|
|
141
|
+
data = _call("GET", f"{BASE}/events", _opts(profile, org_id, project_id, env_id), params={"limit": str(limit)})
|
|
142
|
+
print_json(data.get("events", []), title="Alert events")
|
|
@@ -13,7 +13,7 @@ app = typer.Typer(help="Raw gateway API calls")
|
|
|
13
13
|
@app.command("call")
|
|
14
14
|
def call_command(
|
|
15
15
|
method: str = typer.Argument(..., help="HTTP method, e.g. GET/POST"),
|
|
16
|
-
path: str = typer.Argument(..., help="Gateway path, e.g. /api/v1/
|
|
16
|
+
path: str = typer.Argument(..., help="Gateway path, e.g. /api/v1/auth/me"),
|
|
17
17
|
body: str | None = typer.Option(None, "--body", help="JSON request body"),
|
|
18
18
|
query: str | None = typer.Option(None, "--query", help="JSON object query params"),
|
|
19
19
|
profile: str | None = typer.Option(None, "--profile"),
|
|
@@ -30,12 +30,19 @@ def call_command(
|
|
|
30
30
|
if parsed_query is not None and not isinstance(parsed_query, dict):
|
|
31
31
|
raise CLIRequestError("--query must be a JSON object")
|
|
32
32
|
|
|
33
|
+
# A query string written in the path (`/x?limit=4`) is kept: the HTTP client would
|
|
34
|
+
# otherwise replace it with --query's params.
|
|
35
|
+
from urllib.parse import parse_qsl
|
|
36
|
+
|
|
37
|
+
bare_path, _, inline = path.partition("?")
|
|
38
|
+
params = dict(parse_qsl(inline, keep_blank_values=True))
|
|
39
|
+
params.update({str(k): str(v) for k, v in (parsed_query or {}).items()})
|
|
33
40
|
result = call_api(
|
|
34
41
|
method=method,
|
|
35
|
-
path=
|
|
42
|
+
path=bare_path,
|
|
36
43
|
profile=profile,
|
|
37
44
|
body=parsed_body,
|
|
38
|
-
params=
|
|
45
|
+
params=params,
|
|
39
46
|
require_auth=not no_auth,
|
|
40
47
|
require_scope=require_scope,
|
|
41
48
|
org_id=org_id,
|
|
@@ -122,20 +122,16 @@ def sim_run(
|
|
|
122
122
|
if parsed_context is not None and not isinstance(parsed_context, dict):
|
|
123
123
|
raise CLIRequestError("--context must be a JSON object")
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
# The name/operations are accepted for older scripts; the run covers the whole project
|
|
126
|
+
# (started in the background and polled — a full run outlasts one request).
|
|
127
|
+
from apps.cli.src.commands.sim import run_simulation_api
|
|
128
|
+
from apps.cli.src.utils.display import print_json
|
|
129
|
+
from apps.cli.src.utils.premium_display import run_with_spinner
|
|
130
|
+
|
|
131
|
+
print_json(
|
|
132
|
+
run_with_spinner(f"Running simulation '{simulation_name}'…", lambda: run_simulation_api(profile=profile),
|
|
133
|
+
success="Completed"),
|
|
128
134
|
title="Simulation",
|
|
129
|
-
method="POST",
|
|
130
|
-
path="/api/v1/sim/run",
|
|
131
|
-
profile=profile,
|
|
132
|
-
body={
|
|
133
|
-
"simulation_name": simulation_name,
|
|
134
|
-
"operations": parsed_ops,
|
|
135
|
-
"echo_flag": True,
|
|
136
|
-
"include_execution_tree": include_execution_tree,
|
|
137
|
-
"context": parsed_context or {},
|
|
138
|
-
},
|
|
139
135
|
)
|
|
140
136
|
|
|
141
137
|
|