sovaria-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apps/cli/README.md +147 -0
- apps/cli/__init__.py +0 -0
- apps/cli/src/__init__.py +3 -0
- apps/cli/src/commands/__init__.py +1 -0
- apps/cli/src/commands/admin.py +160 -0
- apps/cli/src/commands/api.py +48 -0
- apps/cli/src/commands/audit.py +181 -0
- apps/cli/src/commands/auth_product.py +187 -0
- apps/cli/src/commands/billing.py +513 -0
- apps/cli/src/commands/chrono.py +248 -0
- apps/cli/src/commands/context.py +99 -0
- apps/cli/src/commands/data.py +272 -0
- apps/cli/src/commands/db.py +528 -0
- apps/cli/src/commands/deploy.py +1099 -0
- apps/cli/src/commands/governance.py +491 -0
- apps/cli/src/commands/iam.py +436 -0
- apps/cli/src/commands/init_cmd.py +177 -0
- apps/cli/src/commands/keys.py +120 -0
- apps/cli/src/commands/login.py +842 -0
- apps/cli/src/commands/org.py +157 -0
- apps/cli/src/commands/policy.py +317 -0
- apps/cli/src/commands/project.py +130 -0
- apps/cli/src/commands/realtime.py +364 -0
- apps/cli/src/commands/scope.py +42 -0
- apps/cli/src/commands/shell.py +415 -0
- apps/cli/src/commands/sim.py +178 -0
- apps/cli/src/commands/storage.py +405 -0
- apps/cli/src/commands/vector.py +204 -0
- apps/cli/src/main.py +169 -0
- apps/cli/src/utils/__init__.py +1 -0
- apps/cli/src/utils/api_client.py +329 -0
- apps/cli/src/utils/auth_store.py +299 -0
- apps/cli/src/utils/config.py +185 -0
- apps/cli/src/utils/display.py +62 -0
- apps/cli/src/utils/migrations.py +69 -0
- apps/cli/src/utils/project_context.py +124 -0
- apps/cli/tests/test_auth_store_profiles.py +103 -0
- apps/cli/tests/test_cli_security.py +110 -0
- apps/cli/tests/test_cli_smoke_paths.py +279 -0
- apps/cli/tests/test_config_profiles.py +55 -0
- apps/cli/tests/test_main_startup.py +29 -0
- apps/cli/tests/test_project_and_commands.py +196 -0
- sovaria_cli-0.2.0.dist-info/METADATA +170 -0
- sovaria_cli-0.2.0.dist-info/RECORD +46 -0
- sovaria_cli-0.2.0.dist-info/WHEEL +4 -0
- sovaria_cli-0.2.0.dist-info/entry_points.txt +2 -0
apps/cli/src/main.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""apps.cli.src.main — ``sov`` CLI entrypoint.
|
|
2
|
+
|
|
3
|
+
The CLI is a platform tool for Sovaria customers on plans that unlock CLI
|
|
4
|
+
access (``ultra`` and ``enterprise`` by default — see plan_service in axis).
|
|
5
|
+
It authenticates against the platform console lane and never against
|
|
6
|
+
project end-user credentials.
|
|
7
|
+
|
|
8
|
+
Top-level commands cover the day-to-day operations a platform user runs:
|
|
9
|
+
|
|
10
|
+
sov auth sessions List/revoke login sessions
|
|
11
|
+
sov auth mfa-enroll Enroll TOTP MFA
|
|
12
|
+
sov fn logs|get|delete Function logs and lifecycle
|
|
13
|
+
sov logout Revoke and clear local tokens
|
|
14
|
+
sov whoami Show current user
|
|
15
|
+
sov auth-product ... Manage product users (project scope)
|
|
16
|
+
sov scope ... Manage default org/project/env scope
|
|
17
|
+
sov project ... Scaffold + manage projects
|
|
18
|
+
sov init Interactive project bootstrap wizard
|
|
19
|
+
sov shell Interactive REPL (SQL, sim, keys, policy)
|
|
20
|
+
sov sim ... Simulation discover / runs / compare
|
|
21
|
+
sov keys ... Project API keys
|
|
22
|
+
sov iam ... Organisations, members, settings
|
|
23
|
+
sov org ... Organisation membership / create
|
|
24
|
+
sov db ... Atlas-Rel SQL operations
|
|
25
|
+
sov db tx begin|commit|rollback
|
|
26
|
+
sov db migrations push|pull|list
|
|
27
|
+
sov db column add|drop
|
|
28
|
+
sov db fk add|drop
|
|
29
|
+
sov data ... Atlas-Doc + Atlas-Rel data plane
|
|
30
|
+
sov storage ... Vault buckets/objects
|
|
31
|
+
sov fn ... Forge function management
|
|
32
|
+
sov chrono ... Timelines + snapshots
|
|
33
|
+
sov audit ... Echo audit trail + simulations
|
|
34
|
+
sov policy ... Guard policy admin
|
|
35
|
+
sov realtime ... Pulse channels + E2EE keys
|
|
36
|
+
sov api ... Raw gateway API call (escape hatch)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
import typer
|
|
40
|
+
|
|
41
|
+
from apps.cli.src.commands import api as api_mod
|
|
42
|
+
from apps.cli.src.commands import admin as admin_mod
|
|
43
|
+
from apps.cli.src.commands import audit as audit_mod
|
|
44
|
+
from apps.cli.src.commands import auth_product as auth_product_mod
|
|
45
|
+
from apps.cli.src.commands import billing as billing_mod
|
|
46
|
+
from apps.cli.src.commands import chrono as chrono_mod
|
|
47
|
+
from apps.cli.src.commands import context as context_mod
|
|
48
|
+
from apps.cli.src.commands import data as data_mod
|
|
49
|
+
from apps.cli.src.commands import db as db_mod
|
|
50
|
+
from apps.cli.src.commands import deploy as deploy_mod
|
|
51
|
+
from apps.cli.src.commands import governance as governance_mod
|
|
52
|
+
from apps.cli.src.commands import iam as iam_mod
|
|
53
|
+
from apps.cli.src.commands import init_cmd as init_mod
|
|
54
|
+
from apps.cli.src.commands import keys as keys_mod
|
|
55
|
+
from apps.cli.src.commands import login as login_mod
|
|
56
|
+
from apps.cli.src.commands import org as org_mod
|
|
57
|
+
from apps.cli.src.commands import policy as policy_mod
|
|
58
|
+
from apps.cli.src.commands import project as project_mod
|
|
59
|
+
from apps.cli.src.commands import realtime as realtime_mod
|
|
60
|
+
from apps.cli.src.commands import scope as scope_mod
|
|
61
|
+
from apps.cli.src.commands import shell as shell_mod
|
|
62
|
+
from apps.cli.src.commands import sim as sim_mod
|
|
63
|
+
from apps.cli.src.commands import storage as storage_mod
|
|
64
|
+
from apps.cli.src.commands import vector as vector_mod
|
|
65
|
+
from apps.cli.src.utils.config import write_default_config
|
|
66
|
+
|
|
67
|
+
app = typer.Typer(
|
|
68
|
+
name="sov",
|
|
69
|
+
help="Sovaria BaaS command-line interface",
|
|
70
|
+
add_completion=True,
|
|
71
|
+
pretty_exceptions_enable=False, # let Python print raw tracebacks in dev
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# ── Sub-app registration ──────────────────────────────────────────────────────
|
|
75
|
+
app.add_typer(login_mod.app, name="auth", help="Authentication commands")
|
|
76
|
+
app.add_typer(auth_product_mod.app, name="auth-product", help="Product auth admin commands")
|
|
77
|
+
app.add_typer(context_mod.app, name="context", help="Context and identity commands")
|
|
78
|
+
app.add_typer(scope_mod.app, name="scope", help="Manage default org/project/env scope")
|
|
79
|
+
app.add_typer(project_mod.app, name="project", help="Project scaffold commands")
|
|
80
|
+
app.add_typer(org_mod.app, name="org", help="Organisation management")
|
|
81
|
+
app.add_typer(db_mod.app, name="db", help="Database (SQL) management")
|
|
82
|
+
app.add_typer(data_mod.app, name="data", help="Atlas-Doc and Atlas-Rel data plane")
|
|
83
|
+
app.add_typer(storage_mod.app, name="storage", help="Object storage management")
|
|
84
|
+
app.add_typer(deploy_mod.app, name="fn", help="Cloud function management")
|
|
85
|
+
app.add_typer(chrono_mod.app, name="chrono", help="Timeline and snapshot operations")
|
|
86
|
+
app.add_typer(audit_mod.app, name="audit", help="Echo audit trail and simulations")
|
|
87
|
+
app.add_typer(policy_mod.app, name="policy", help="Guard policy engine commands")
|
|
88
|
+
app.add_typer(realtime_mod.app, name="realtime", help="Realtime stream and E2EE")
|
|
89
|
+
app.add_typer(billing_mod.app, name="billing", help="Billing plans, subscriptions, and usage")
|
|
90
|
+
app.add_typer(governance_mod.app, name="governance", help="Platform governance and tenant databases")
|
|
91
|
+
app.add_typer(admin_mod.app, name="admin", help="Platform admin: error traces and usage")
|
|
92
|
+
app.add_typer(vector_mod.app, name="vector", help="Vector embeddings and semantic search")
|
|
93
|
+
app.add_typer(sim_mod.app, name="sim", help="Scenario discovery, runs, and comparisons")
|
|
94
|
+
app.add_typer(keys_mod.app, name="keys", help="Project API keys")
|
|
95
|
+
app.add_typer(iam_mod.app, name="iam", help="Organisations, members, and project settings")
|
|
96
|
+
app.add_typer(api_mod.app, name="api", help="Raw gateway API calls")
|
|
97
|
+
|
|
98
|
+
# Top-level interactive / bootstrap commands
|
|
99
|
+
app.add_typer(init_mod.app, name="init", help="Interactive project bootstrap")
|
|
100
|
+
app.add_typer(shell_mod.app, name="shell", help="Interactive Sovaria REPL")
|
|
101
|
+
|
|
102
|
+
# Convenience top-level aliases for the most common verbs.
|
|
103
|
+
app.command("login")(login_mod.login)
|
|
104
|
+
app.command("logout")(login_mod.logout)
|
|
105
|
+
app.command("whoami")(context_mod.whoami)
|
|
106
|
+
app.command("context")(context_mod.context)
|
|
107
|
+
app.command("deploy")(deploy_mod.deploy)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── Version flag ──────────────────────────────────────────────────────────────
|
|
111
|
+
def _version_callback(value: bool) -> None:
|
|
112
|
+
if value:
|
|
113
|
+
from apps.cli.src import __version__
|
|
114
|
+
|
|
115
|
+
typer.echo(f"sov version {__version__}")
|
|
116
|
+
raise typer.Exit()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_PLAN_GATE_BYPASS_COMMANDS = frozenset(
|
|
120
|
+
{"login", "auth", "logout", "scope", "whoami", "context", "init", "project", None}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.callback(invoke_without_command=True)
|
|
125
|
+
def main(
|
|
126
|
+
ctx: typer.Context,
|
|
127
|
+
version: bool = typer.Option(
|
|
128
|
+
False, "--version", "-V", callback=_version_callback, is_eager=True
|
|
129
|
+
),
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Sovaria BaaS CLI — manage your identity graph, deploy functions, and more."""
|
|
132
|
+
# Ensure ~/.sovaria/config.yaml exists on first run
|
|
133
|
+
write_default_config()
|
|
134
|
+
|
|
135
|
+
invoked = ctx.invoked_subcommand
|
|
136
|
+
if invoked is None:
|
|
137
|
+
typer.echo(ctx.get_help())
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
# Plan-gate every command that actually talks to the API, except the
|
|
141
|
+
# auth-bootstrap commands themselves (you can't check your plan before
|
|
142
|
+
# you've logged in) and the version flag.
|
|
143
|
+
if invoked not in _PLAN_GATE_BYPASS_COMMANDS:
|
|
144
|
+
from apps.cli.src.utils.api_client import (
|
|
145
|
+
CLIPlanError,
|
|
146
|
+
CLIRequestError,
|
|
147
|
+
assert_cli_access,
|
|
148
|
+
)
|
|
149
|
+
from apps.cli.src.utils.auth_store import load_tokens
|
|
150
|
+
|
|
151
|
+
if load_tokens():
|
|
152
|
+
try:
|
|
153
|
+
assert_cli_access()
|
|
154
|
+
except CLIPlanError as exc:
|
|
155
|
+
typer.echo(typer.style(str(exc), fg=typer.colors.RED), err=True)
|
|
156
|
+
raise typer.Exit(2) from exc
|
|
157
|
+
except CLIRequestError:
|
|
158
|
+
# Network/auth errors are not fatal here — the actual command
|
|
159
|
+
# will surface a more actionable error if relevant.
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def cli_entry() -> None:
|
|
164
|
+
"""Console script entry point for the ``sov`` command."""
|
|
165
|
+
app()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
cli_entry()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI utilities package."""
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Shared HTTP helpers for CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from apps.cli.src.utils.auth_store import load_mfa_pending, load_tokens, save_tokens
|
|
14
|
+
from apps.cli.src.utils.config import get_api_url, get_scope
|
|
15
|
+
from apps.cli.src.utils.project_context import resolve_scope as resolve_project_scope
|
|
16
|
+
|
|
17
|
+
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
18
|
+
_REFRESH_MARGIN_SECONDS = 60
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CLIRequestError(RuntimeError):
|
|
22
|
+
"""Raised when a CLI API request fails."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.status_code = status_code
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CLIPlanError(CLIRequestError):
|
|
30
|
+
"""Raised when the user's plan does not unlock CLI access."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CLIMfaRequiredError(CLIRequestError):
|
|
34
|
+
"""Raised when login returned a pre-MFA challenge instead of a full session."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _plan_check_enabled() -> bool:
|
|
38
|
+
"""Return True if the CLI should enforce plan-gating before commands run."""
|
|
39
|
+
return (os.getenv("SOV_DISABLE_PLAN_CHECK", "") or "").strip().lower() not in {
|
|
40
|
+
"1",
|
|
41
|
+
"true",
|
|
42
|
+
"yes",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def assert_cli_access(profile: str | None = None) -> dict[str, Any]:
|
|
47
|
+
"""Fetch /auth/me, return the response, and refuse if cli_access is False."""
|
|
48
|
+
me = call_api(method="GET", path="/api/v1/auth/me", profile=profile, _skip_plan_check=True)
|
|
49
|
+
if not isinstance(me, dict):
|
|
50
|
+
return {}
|
|
51
|
+
if not _plan_check_enabled():
|
|
52
|
+
return me
|
|
53
|
+
plan = str(me.get("plan") or "").strip().lower() or "free"
|
|
54
|
+
features = me.get("plan_features") or {}
|
|
55
|
+
if isinstance(features, dict) and not features.get("cli_access", False):
|
|
56
|
+
raise CLIPlanError(
|
|
57
|
+
f"Your '{plan}' plan does not include CLI access. "
|
|
58
|
+
"Upgrade to 'ultra' or 'enterprise' to use the Sovaria CLI: "
|
|
59
|
+
"https://sovaria.in/pricing"
|
|
60
|
+
)
|
|
61
|
+
return me
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _resolve_scope(
|
|
65
|
+
*,
|
|
66
|
+
profile: str | None,
|
|
67
|
+
org_id: str | None,
|
|
68
|
+
project_id: str | None,
|
|
69
|
+
env_id: str | None,
|
|
70
|
+
require_scope: bool = False,
|
|
71
|
+
) -> tuple[str, str, str]:
|
|
72
|
+
try:
|
|
73
|
+
return resolve_project_scope(
|
|
74
|
+
profile=profile,
|
|
75
|
+
org_id=org_id,
|
|
76
|
+
project_id=project_id,
|
|
77
|
+
env_id=env_id,
|
|
78
|
+
require=require_scope,
|
|
79
|
+
)
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
raise CLIRequestError(str(exc)) from exc
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _scope_headers(org_id: str, project_id: str, env_id: str) -> dict[str, str]:
|
|
85
|
+
headers: dict[str, str] = {}
|
|
86
|
+
if org_id:
|
|
87
|
+
headers["X-Sovaria-Org-ID"] = org_id
|
|
88
|
+
if project_id:
|
|
89
|
+
headers["X-Sovaria-Project-ID"] = project_id
|
|
90
|
+
if env_id:
|
|
91
|
+
headers["X-Sovaria-Env-ID"] = env_id
|
|
92
|
+
return headers
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _token_needs_refresh(tokens: dict[str, Any]) -> bool:
|
|
96
|
+
refresh_token = str(tokens.get("refresh_token") or "").strip()
|
|
97
|
+
if not refresh_token:
|
|
98
|
+
return False
|
|
99
|
+
expires_at = tokens.get("expires_at")
|
|
100
|
+
if expires_at is None:
|
|
101
|
+
return False
|
|
102
|
+
try:
|
|
103
|
+
return time.time() >= float(expires_at) - _REFRESH_MARGIN_SECONDS
|
|
104
|
+
except (TypeError, ValueError):
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _refresh_access_token(profile: str | None) -> dict[str, Any]:
|
|
109
|
+
tokens = load_tokens(profile=profile) or {}
|
|
110
|
+
refresh_token = str(tokens.get("refresh_token") or "").strip()
|
|
111
|
+
if not refresh_token:
|
|
112
|
+
return tokens
|
|
113
|
+
|
|
114
|
+
api_url = get_api_url(profile).rstrip("/")
|
|
115
|
+
try:
|
|
116
|
+
response = httpx.post(
|
|
117
|
+
f"{api_url}/api/v1/auth/refresh",
|
|
118
|
+
json={"refresh_token": refresh_token},
|
|
119
|
+
timeout=15,
|
|
120
|
+
)
|
|
121
|
+
response.raise_for_status()
|
|
122
|
+
except httpx.HTTPError as exc:
|
|
123
|
+
raise CLIRequestError(
|
|
124
|
+
"Session expired and refresh failed. Run: sov auth login",
|
|
125
|
+
status_code=getattr(getattr(exc, "response", None), "status_code", None),
|
|
126
|
+
) from exc
|
|
127
|
+
|
|
128
|
+
data = response.json()
|
|
129
|
+
access_token = str(data.get("access_token") or "").strip()
|
|
130
|
+
new_refresh = str(data.get("refresh_token") or refresh_token).strip()
|
|
131
|
+
expires_in = int(data.get("expires_in") or 3600)
|
|
132
|
+
if not access_token:
|
|
133
|
+
raise CLIRequestError("Refresh response did not include an access token")
|
|
134
|
+
save_tokens(access_token, new_refresh, profile=profile, expires_in=expires_in)
|
|
135
|
+
return load_tokens(profile=profile) or {}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _ensure_fresh_tokens(profile: str | None) -> dict[str, Any]:
|
|
139
|
+
tokens = load_tokens(profile=profile) or {}
|
|
140
|
+
if _token_needs_refresh(tokens):
|
|
141
|
+
tokens = _refresh_access_token(profile)
|
|
142
|
+
return tokens
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _apply_login_response(data: dict[str, Any], *, profile: str | None) -> None:
|
|
146
|
+
if data.get("mfa_required"):
|
|
147
|
+
access_token = str(data.get("access_token") or "").strip()
|
|
148
|
+
if not access_token:
|
|
149
|
+
raise CLIRequestError("MFA required but gateway did not return a challenge token")
|
|
150
|
+
expires_in = int(data.get("expires_in") or 300)
|
|
151
|
+
from apps.cli.src.utils.auth_store import save_mfa_pending
|
|
152
|
+
|
|
153
|
+
save_mfa_pending(access_token, profile=profile, expires_in=expires_in)
|
|
154
|
+
raise CLIMfaRequiredError(
|
|
155
|
+
"Multi-factor authentication required. Complete login with: "
|
|
156
|
+
"sov auth mfa-verify --code <totp-or-backup-code>"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
access_token = str(data.get("access_token") or "").strip()
|
|
160
|
+
refresh_token = str(data.get("refresh_token") or "").strip()
|
|
161
|
+
if not access_token or not refresh_token:
|
|
162
|
+
raise CLIRequestError("Login response did not include a full token pair")
|
|
163
|
+
expires_in = int(data.get("expires_in") or 3600)
|
|
164
|
+
save_tokens(access_token, refresh_token, profile=profile, expires_in=expires_in)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def call_api(
|
|
168
|
+
*,
|
|
169
|
+
method: str,
|
|
170
|
+
path: str,
|
|
171
|
+
profile: str | None = None,
|
|
172
|
+
body: dict[str, Any] | list[Any] | None = None,
|
|
173
|
+
params: dict[str, str] | None = None,
|
|
174
|
+
require_auth: bool = True,
|
|
175
|
+
require_scope: bool = False,
|
|
176
|
+
org_id: str | None = None,
|
|
177
|
+
project_id: str | None = None,
|
|
178
|
+
env_id: str | None = None,
|
|
179
|
+
extra_headers: dict[str, str] | None = None,
|
|
180
|
+
timeout: float = 30.0,
|
|
181
|
+
_skip_plan_check: bool = False,
|
|
182
|
+
_allow_mfa_token: bool = False,
|
|
183
|
+
) -> Any:
|
|
184
|
+
"""Execute an API request against the configured gateway endpoint."""
|
|
185
|
+
normalized_method = method.upper()
|
|
186
|
+
url = get_api_url(profile).rstrip("/") + (path if path.startswith("/") else f"/{path}")
|
|
187
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
188
|
+
|
|
189
|
+
access_token = ""
|
|
190
|
+
if require_auth:
|
|
191
|
+
tokens = _ensure_fresh_tokens(profile)
|
|
192
|
+
access_token = str(tokens.get("access_token") or "").strip()
|
|
193
|
+
if not access_token:
|
|
194
|
+
pending = load_mfa_pending(profile=profile)
|
|
195
|
+
if pending and _allow_mfa_token:
|
|
196
|
+
access_token = str(pending.get("access_token") or "").strip()
|
|
197
|
+
if not access_token:
|
|
198
|
+
if pending:
|
|
199
|
+
raise CLIMfaRequiredError(
|
|
200
|
+
"MFA verification pending. Run: sov auth mfa-verify --code <totp>"
|
|
201
|
+
)
|
|
202
|
+
raise CLIRequestError("Not logged in. Run: sov auth login")
|
|
203
|
+
headers["Authorization"] = f"Bearer {access_token}"
|
|
204
|
+
|
|
205
|
+
resolved_org, resolved_project, resolved_env = _resolve_scope(
|
|
206
|
+
profile=profile,
|
|
207
|
+
org_id=org_id,
|
|
208
|
+
project_id=project_id,
|
|
209
|
+
env_id=env_id,
|
|
210
|
+
require_scope=require_scope,
|
|
211
|
+
)
|
|
212
|
+
if require_scope and (not resolved_org or not resolved_project):
|
|
213
|
+
raise CLIRequestError(
|
|
214
|
+
"Missing required scope. Set org/project using --org/--project or: "
|
|
215
|
+
"sov scope set <org_id> <project_id>"
|
|
216
|
+
)
|
|
217
|
+
headers.update(_scope_headers(resolved_org, resolved_project, resolved_env))
|
|
218
|
+
|
|
219
|
+
if extra_headers:
|
|
220
|
+
headers.update(extra_headers)
|
|
221
|
+
|
|
222
|
+
if normalized_method in _MUTATING_METHODS and "Idempotency-Key" not in headers:
|
|
223
|
+
headers["Idempotency-Key"] = str(uuid.uuid4())
|
|
224
|
+
|
|
225
|
+
def _execute(auth_header: str | None = None) -> httpx.Response:
|
|
226
|
+
request_headers = dict(headers)
|
|
227
|
+
if auth_header is not None:
|
|
228
|
+
request_headers["Authorization"] = f"Bearer {auth_header}"
|
|
229
|
+
return httpx.request(
|
|
230
|
+
method=normalized_method,
|
|
231
|
+
url=url,
|
|
232
|
+
headers=request_headers,
|
|
233
|
+
json=body,
|
|
234
|
+
params=params,
|
|
235
|
+
timeout=timeout,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
response = _execute()
|
|
240
|
+
if response.status_code == 401 and require_auth and not _allow_mfa_token:
|
|
241
|
+
refreshed = _refresh_access_token(profile)
|
|
242
|
+
retry_token = str(refreshed.get("access_token") or "").strip()
|
|
243
|
+
if retry_token and retry_token != access_token:
|
|
244
|
+
response = _execute(retry_token)
|
|
245
|
+
response.raise_for_status()
|
|
246
|
+
except httpx.HTTPStatusError as exc:
|
|
247
|
+
detail: Any
|
|
248
|
+
try:
|
|
249
|
+
detail = exc.response.json()
|
|
250
|
+
except Exception: # noqa: BLE001
|
|
251
|
+
detail = exc.response.text
|
|
252
|
+
raise CLIRequestError(
|
|
253
|
+
f"{normalized_method} {path} failed ({exc.response.status_code}): {detail}",
|
|
254
|
+
status_code=exc.response.status_code,
|
|
255
|
+
) from exc
|
|
256
|
+
except httpx.RequestError as exc:
|
|
257
|
+
raise CLIRequestError(f"Network error while calling {path}: {exc}") from exc
|
|
258
|
+
|
|
259
|
+
if response.status_code == 204:
|
|
260
|
+
return {"status": "ok"}
|
|
261
|
+
|
|
262
|
+
content_type = (response.headers.get("content-type") or "").lower()
|
|
263
|
+
if "application/json" in content_type:
|
|
264
|
+
return response.json()
|
|
265
|
+
|
|
266
|
+
text = response.text.strip()
|
|
267
|
+
return {"text": text} if text else {"status": "ok"}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def parse_json_arg(value: str | None, *, label: str) -> dict[str, Any] | list[Any] | None:
|
|
271
|
+
"""Parse a JSON CLI argument and raise a readable error when invalid."""
|
|
272
|
+
if value is None:
|
|
273
|
+
return None
|
|
274
|
+
try:
|
|
275
|
+
parsed = json.loads(value)
|
|
276
|
+
except json.JSONDecodeError as exc:
|
|
277
|
+
raise CLIRequestError(f"Invalid JSON for {label}: {exc}") from exc
|
|
278
|
+
if not isinstance(parsed, dict | list):
|
|
279
|
+
raise CLIRequestError(f"{label} must be a JSON object or array")
|
|
280
|
+
return parsed
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def print_json(payload: Any) -> str:
|
|
284
|
+
"""Return pretty JSON text for terminal output."""
|
|
285
|
+
return json.dumps(payload, indent=2, sort_keys=True, default=str)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def build_request_context(
|
|
289
|
+
*,
|
|
290
|
+
profile: str | None = None,
|
|
291
|
+
org_id: str | None = None,
|
|
292
|
+
project_id: str | None = None,
|
|
293
|
+
env_id: str | None = None,
|
|
294
|
+
require_scope: bool = False,
|
|
295
|
+
require_auth: bool = True,
|
|
296
|
+
extra_headers: dict[str, str] | None = None,
|
|
297
|
+
) -> tuple[str, dict[str, str]]:
|
|
298
|
+
"""Return (api_base_url, headers) for raw httpx calls with refresh + scope."""
|
|
299
|
+
api_url = get_api_url(profile).rstrip("/")
|
|
300
|
+
headers: dict[str, str] = {}
|
|
301
|
+
|
|
302
|
+
if require_auth:
|
|
303
|
+
tokens = _ensure_fresh_tokens(profile)
|
|
304
|
+
access_token = str(tokens.get("access_token") or "").strip()
|
|
305
|
+
if not access_token:
|
|
306
|
+
pending = load_mfa_pending(profile=profile)
|
|
307
|
+
if pending:
|
|
308
|
+
raise CLIMfaRequiredError(
|
|
309
|
+
"MFA verification pending. Run: sov auth mfa-verify --code <totp>"
|
|
310
|
+
)
|
|
311
|
+
raise CLIRequestError("Not logged in. Run: sov auth login")
|
|
312
|
+
headers["Authorization"] = f"Bearer {access_token}"
|
|
313
|
+
|
|
314
|
+
resolved_org, resolved_project, resolved_env = _resolve_scope(
|
|
315
|
+
profile=profile,
|
|
316
|
+
org_id=org_id,
|
|
317
|
+
project_id=project_id,
|
|
318
|
+
env_id=env_id,
|
|
319
|
+
require_scope=require_scope,
|
|
320
|
+
)
|
|
321
|
+
if require_scope and (not resolved_org or not resolved_project):
|
|
322
|
+
raise CLIRequestError(
|
|
323
|
+
"Missing required scope. Set org/project using --org/--project or: "
|
|
324
|
+
"sov project link"
|
|
325
|
+
)
|
|
326
|
+
headers.update(_scope_headers(resolved_org, resolved_project, resolved_env))
|
|
327
|
+
if extra_headers:
|
|
328
|
+
headers.update(extra_headers)
|
|
329
|
+
return api_url, headers
|