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
|
@@ -0,0 +1,1099 @@
|
|
|
1
|
+
"""CLI commands for Forge deploy/versioning/invoke/simulate/history workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import zipfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from apps.cli.src.utils.auth_store import load_tokens
|
|
16
|
+
from apps.cli.src.utils.config import get_api_url
|
|
17
|
+
|
|
18
|
+
app = typer.Typer()
|
|
19
|
+
|
|
20
|
+
_POLL_INTERVAL = 2 # seconds
|
|
21
|
+
_POLL_TIMEOUT = 120 # seconds
|
|
22
|
+
_ASYNC_POLL_INTERVAL = 2 # seconds
|
|
23
|
+
_ASYNC_POLL_TIMEOUT = 600 # seconds
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command("deploy")
|
|
27
|
+
def deploy(
|
|
28
|
+
directory: Path = typer.Argument(
|
|
29
|
+
Path("."),
|
|
30
|
+
exists=True,
|
|
31
|
+
file_okay=False,
|
|
32
|
+
dir_okay=True,
|
|
33
|
+
readable=True,
|
|
34
|
+
help="Path to the function directory to deploy",
|
|
35
|
+
),
|
|
36
|
+
name: str | None = typer.Option(None, "--name", "-n", help="Function name"),
|
|
37
|
+
project_id: str | None = typer.Option(None, "--project", "-p", help="Target project node ID"),
|
|
38
|
+
note: str = typer.Option("", "--note", help="Version change note for deployment history"),
|
|
39
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
40
|
+
) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Deploy a cloud function to the Forge engine.
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
sov deploy ./my-function --name greet --project proj-abc123
|
|
46
|
+
"""
|
|
47
|
+
tokens = load_tokens(profile=profile)
|
|
48
|
+
if not tokens:
|
|
49
|
+
typer.echo("Not logged in. Run: sov login", err=True)
|
|
50
|
+
raise typer.Exit(1)
|
|
51
|
+
|
|
52
|
+
api_url = get_api_url(profile)
|
|
53
|
+
fn_name = name or directory.resolve().name
|
|
54
|
+
|
|
55
|
+
typer.echo(f"Packaging {directory.resolve()} …")
|
|
56
|
+
zip_bytes = _zip_directory(directory)
|
|
57
|
+
typer.echo(f" Compressed size: {len(zip_bytes):,} bytes")
|
|
58
|
+
|
|
59
|
+
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
|
|
60
|
+
|
|
61
|
+
# Upload
|
|
62
|
+
try:
|
|
63
|
+
resp = httpx.post(
|
|
64
|
+
f"{api_url}/api/v1/fn/deploy",
|
|
65
|
+
headers=headers,
|
|
66
|
+
files={"bundle": (f"{fn_name}.zip", zip_bytes, "application/zip")},
|
|
67
|
+
data={"name": fn_name, "project_id": project_id or "", "change_note": note},
|
|
68
|
+
timeout=60,
|
|
69
|
+
)
|
|
70
|
+
resp.raise_for_status()
|
|
71
|
+
except httpx.HTTPStatusError as exc:
|
|
72
|
+
typer.echo(f"Upload failed: {exc.response.text}", err=True)
|
|
73
|
+
raise typer.Exit(1) from exc
|
|
74
|
+
|
|
75
|
+
deploy_data = resp.json()
|
|
76
|
+
deploy_id = deploy_data.get("deploy_id")
|
|
77
|
+
deploy_version = deploy_data.get("version")
|
|
78
|
+
resolved_project_id = project_id or deploy_data.get("project_id")
|
|
79
|
+
if not deploy_id:
|
|
80
|
+
typer.echo(f"Unexpected response: {resp.text}", err=True)
|
|
81
|
+
raise typer.Exit(1)
|
|
82
|
+
|
|
83
|
+
if deploy_version:
|
|
84
|
+
typer.echo(f"Version: v{deploy_version}")
|
|
85
|
+
|
|
86
|
+
typer.echo(f"Deploy started (id={deploy_id}). Waiting for build…")
|
|
87
|
+
|
|
88
|
+
# Poll for status
|
|
89
|
+
deadline = time.time() + _POLL_TIMEOUT
|
|
90
|
+
while time.time() < deadline:
|
|
91
|
+
try:
|
|
92
|
+
poll = httpx.get(
|
|
93
|
+
f"{api_url}/api/v1/fn/{deploy_id}/status",
|
|
94
|
+
headers=headers,
|
|
95
|
+
params={"project_id": resolved_project_id} if resolved_project_id else None,
|
|
96
|
+
timeout=10,
|
|
97
|
+
)
|
|
98
|
+
poll.raise_for_status()
|
|
99
|
+
status_data = poll.json()
|
|
100
|
+
status = status_data.get("status", "UNKNOWN")
|
|
101
|
+
|
|
102
|
+
if status == "READY":
|
|
103
|
+
invoke_url = status_data.get("invoke_url", "<unknown>")
|
|
104
|
+
typer.echo(
|
|
105
|
+
typer.style(f"✓ Deployed! Invoke URL: {invoke_url}", fg=typer.colors.GREEN)
|
|
106
|
+
)
|
|
107
|
+
return
|
|
108
|
+
elif status == "FAILED":
|
|
109
|
+
typer.echo(f"Deploy failed: {status_data.get('error', 'unknown error')}", err=True)
|
|
110
|
+
raise typer.Exit(1)
|
|
111
|
+
else:
|
|
112
|
+
typer.echo(f" Status: {status} …", nl=False)
|
|
113
|
+
typer.echo("\r", nl=False)
|
|
114
|
+
except httpx.HTTPStatusError as exc:
|
|
115
|
+
typer.echo(f"Poll error: {exc.response.text}", err=True)
|
|
116
|
+
raise typer.Exit(1) from exc
|
|
117
|
+
|
|
118
|
+
time.sleep(_POLL_INTERVAL)
|
|
119
|
+
|
|
120
|
+
typer.echo("Deploy timed out", err=True)
|
|
121
|
+
raise typer.Exit(1)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.command("status")
|
|
125
|
+
def status(
|
|
126
|
+
deploy_id: str = typer.Argument(..., help="Deployment id returned by `sov fn deploy`"),
|
|
127
|
+
project_id: str | None = typer.Option(
|
|
128
|
+
None,
|
|
129
|
+
"--project",
|
|
130
|
+
"-p",
|
|
131
|
+
help="Optional project scope (recommended for strict sandbox isolation)",
|
|
132
|
+
),
|
|
133
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Check status of a Forge deployment."""
|
|
136
|
+
tokens = load_tokens(profile=profile)
|
|
137
|
+
if not tokens:
|
|
138
|
+
typer.echo("Not logged in. Run: sov login", err=True)
|
|
139
|
+
raise typer.Exit(1)
|
|
140
|
+
|
|
141
|
+
api_url = get_api_url(profile)
|
|
142
|
+
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
resp = httpx.get(
|
|
146
|
+
f"{api_url}/api/v1/fn/{deploy_id}/status",
|
|
147
|
+
headers=headers,
|
|
148
|
+
params={"project_id": project_id} if project_id else None,
|
|
149
|
+
timeout=15,
|
|
150
|
+
)
|
|
151
|
+
resp.raise_for_status()
|
|
152
|
+
except httpx.HTTPStatusError as exc:
|
|
153
|
+
typer.echo(f"Status request failed: {exc.response.text}", err=True)
|
|
154
|
+
raise typer.Exit(1) from exc
|
|
155
|
+
|
|
156
|
+
data = resp.json()
|
|
157
|
+
typer.echo(json.dumps(data, indent=2, sort_keys=True))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command("invoke")
|
|
161
|
+
def invoke(
|
|
162
|
+
function_name: str = typer.Argument(..., help="Function name/path (e.g. my-fn)"),
|
|
163
|
+
project_id: str | None = typer.Option(
|
|
164
|
+
None,
|
|
165
|
+
"--project",
|
|
166
|
+
"-p",
|
|
167
|
+
help="Project scope for invocation sandbox",
|
|
168
|
+
),
|
|
169
|
+
deploy_id: str | None = typer.Option(
|
|
170
|
+
None,
|
|
171
|
+
"--deploy-id",
|
|
172
|
+
help="Invoke a specific deployment id instead of latest",
|
|
173
|
+
),
|
|
174
|
+
execution_profile: str = typer.Option(
|
|
175
|
+
"standard",
|
|
176
|
+
"--execution-profile",
|
|
177
|
+
help="Execution profile: standard or heavy",
|
|
178
|
+
),
|
|
179
|
+
async_mode: bool = typer.Option(
|
|
180
|
+
False,
|
|
181
|
+
"--async",
|
|
182
|
+
help="Queue async invocation (recommended for heavy functions)",
|
|
183
|
+
),
|
|
184
|
+
wait: bool = typer.Option(
|
|
185
|
+
True,
|
|
186
|
+
"--wait/--no-wait",
|
|
187
|
+
help="When --async is used, poll until completion",
|
|
188
|
+
),
|
|
189
|
+
simulate: bool = typer.Option(
|
|
190
|
+
False,
|
|
191
|
+
"--simulate",
|
|
192
|
+
help="Run simulation mode and store simulation history",
|
|
193
|
+
),
|
|
194
|
+
data: str | None = typer.Option(
|
|
195
|
+
None,
|
|
196
|
+
"--data",
|
|
197
|
+
help="Inline JSON payload passed to the function",
|
|
198
|
+
),
|
|
199
|
+
data_file: Path | None = typer.Option(
|
|
200
|
+
None,
|
|
201
|
+
"--data-file",
|
|
202
|
+
exists=True,
|
|
203
|
+
file_okay=True,
|
|
204
|
+
dir_okay=False,
|
|
205
|
+
readable=True,
|
|
206
|
+
help="Path to JSON payload file",
|
|
207
|
+
),
|
|
208
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Invoke a deployed function via Forge sandbox runtime."""
|
|
211
|
+
tokens = load_tokens(profile=profile)
|
|
212
|
+
if not tokens:
|
|
213
|
+
typer.echo("Not logged in. Run: sov login", err=True)
|
|
214
|
+
raise typer.Exit(1)
|
|
215
|
+
|
|
216
|
+
api_url = get_api_url(profile)
|
|
217
|
+
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
|
|
218
|
+
payload = _load_json_payload(data=data, data_file=data_file)
|
|
219
|
+
|
|
220
|
+
query: dict[str, str] = {}
|
|
221
|
+
if project_id:
|
|
222
|
+
query["project_id"] = project_id
|
|
223
|
+
if deploy_id:
|
|
224
|
+
query["deploy_id"] = deploy_id
|
|
225
|
+
|
|
226
|
+
query["execution_profile"] = execution_profile
|
|
227
|
+
|
|
228
|
+
if async_mode:
|
|
229
|
+
query["simulation"] = "true" if simulate else "false"
|
|
230
|
+
try:
|
|
231
|
+
resp = httpx.post(
|
|
232
|
+
f"{api_url}/api/v1/fn/{function_name}/invoke/async",
|
|
233
|
+
headers=headers,
|
|
234
|
+
params=query,
|
|
235
|
+
json=payload,
|
|
236
|
+
timeout=30,
|
|
237
|
+
)
|
|
238
|
+
resp.raise_for_status()
|
|
239
|
+
except httpx.HTTPStatusError as exc:
|
|
240
|
+
typer.echo(f"Async invocation failed: {exc.response.text}", err=True)
|
|
241
|
+
raise typer.Exit(1) from exc
|
|
242
|
+
|
|
243
|
+
accepted = resp.json()
|
|
244
|
+
typer.echo(json.dumps(accepted, indent=2, sort_keys=True))
|
|
245
|
+
|
|
246
|
+
invocation_id = accepted.get("invocation_id")
|
|
247
|
+
if wait and invocation_id:
|
|
248
|
+
final_status = _poll_async_invocation(
|
|
249
|
+
api_url=api_url,
|
|
250
|
+
headers=headers,
|
|
251
|
+
invocation_id=str(invocation_id),
|
|
252
|
+
project_id=project_id,
|
|
253
|
+
)
|
|
254
|
+
typer.echo(json.dumps(final_status, indent=2, sort_keys=True))
|
|
255
|
+
if final_status.get("status") not in {"SUCCEEDED"}:
|
|
256
|
+
raise typer.Exit(1)
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
endpoint = "simulate" if simulate else "invoke"
|
|
260
|
+
try:
|
|
261
|
+
resp = httpx.post(
|
|
262
|
+
f"{api_url}/api/v1/fn/{function_name}/{endpoint}",
|
|
263
|
+
headers=headers,
|
|
264
|
+
params=query if query else None,
|
|
265
|
+
json=payload,
|
|
266
|
+
timeout=60,
|
|
267
|
+
)
|
|
268
|
+
resp.raise_for_status()
|
|
269
|
+
except httpx.HTTPStatusError as exc:
|
|
270
|
+
typer.echo(f"Invocation failed: {exc.response.text}", err=True)
|
|
271
|
+
raise typer.Exit(1) from exc
|
|
272
|
+
|
|
273
|
+
typer.echo(json.dumps(resp.json(), indent=2, sort_keys=True))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@app.command("versions")
|
|
277
|
+
def versions(
|
|
278
|
+
function_name: str = typer.Argument(..., help="Function name/path (e.g. my-fn)"),
|
|
279
|
+
project_id: str | None = typer.Option(
|
|
280
|
+
None,
|
|
281
|
+
"--project",
|
|
282
|
+
"-p",
|
|
283
|
+
help="Project scope for version history",
|
|
284
|
+
),
|
|
285
|
+
limit: int = typer.Option(50, "--limit", min=1, max=500),
|
|
286
|
+
offset: int = typer.Option(0, "--offset", min=0),
|
|
287
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
288
|
+
) -> None:
|
|
289
|
+
"""List deployment versions for one function."""
|
|
290
|
+
tokens = load_tokens(profile=profile)
|
|
291
|
+
if not tokens:
|
|
292
|
+
typer.echo("Not logged in. Run: sov login", err=True)
|
|
293
|
+
raise typer.Exit(1)
|
|
294
|
+
|
|
295
|
+
api_url = get_api_url(profile)
|
|
296
|
+
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
|
|
297
|
+
params: dict[str, str | int] = {"limit": limit, "offset": offset}
|
|
298
|
+
if project_id:
|
|
299
|
+
params["project_id"] = project_id
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
resp = httpx.get(
|
|
303
|
+
f"{api_url}/api/v1/fn/{function_name}/versions",
|
|
304
|
+
headers=headers,
|
|
305
|
+
params=params,
|
|
306
|
+
timeout=30,
|
|
307
|
+
)
|
|
308
|
+
resp.raise_for_status()
|
|
309
|
+
except httpx.HTTPStatusError as exc:
|
|
310
|
+
typer.echo(f"Version history request failed: {exc.response.text}", err=True)
|
|
311
|
+
raise typer.Exit(1) from exc
|
|
312
|
+
|
|
313
|
+
typer.echo(json.dumps(resp.json(), indent=2, sort_keys=True))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@app.command("history")
|
|
317
|
+
def history(
|
|
318
|
+
function_name: str = typer.Argument(..., help="Function name/path (e.g. my-fn)"),
|
|
319
|
+
project_id: str | None = typer.Option(
|
|
320
|
+
None,
|
|
321
|
+
"--project",
|
|
322
|
+
"-p",
|
|
323
|
+
help="Project scope for invocation history",
|
|
324
|
+
),
|
|
325
|
+
mode: str | None = typer.Option(
|
|
326
|
+
None,
|
|
327
|
+
"--mode",
|
|
328
|
+
help="Optional mode filter: invoke or simulate",
|
|
329
|
+
),
|
|
330
|
+
limit: int = typer.Option(50, "--limit", min=1, max=500),
|
|
331
|
+
offset: int = typer.Option(0, "--offset", min=0),
|
|
332
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
333
|
+
) -> None:
|
|
334
|
+
"""List invocation history for one function."""
|
|
335
|
+
tokens = load_tokens(profile=profile)
|
|
336
|
+
if not tokens:
|
|
337
|
+
typer.echo("Not logged in. Run: sov login", err=True)
|
|
338
|
+
raise typer.Exit(1)
|
|
339
|
+
|
|
340
|
+
api_url = get_api_url(profile)
|
|
341
|
+
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
|
|
342
|
+
params: dict[str, str | int] = {"limit": limit, "offset": offset}
|
|
343
|
+
if project_id:
|
|
344
|
+
params["project_id"] = project_id
|
|
345
|
+
if mode:
|
|
346
|
+
params["mode"] = mode
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
resp = httpx.get(
|
|
350
|
+
f"{api_url}/api/v1/fn/{function_name}/history/invocations",
|
|
351
|
+
headers=headers,
|
|
352
|
+
params=params,
|
|
353
|
+
timeout=30,
|
|
354
|
+
)
|
|
355
|
+
resp.raise_for_status()
|
|
356
|
+
except httpx.HTTPStatusError as exc:
|
|
357
|
+
typer.echo(f"Invocation history request failed: {exc.response.text}", err=True)
|
|
358
|
+
raise typer.Exit(1) from exc
|
|
359
|
+
|
|
360
|
+
typer.echo(json.dumps(resp.json(), indent=2, sort_keys=True))
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@app.command("functions")
|
|
364
|
+
def functions(
|
|
365
|
+
project_id: str | None = typer.Option(
|
|
366
|
+
None,
|
|
367
|
+
"--project",
|
|
368
|
+
"-p",
|
|
369
|
+
help="Project scope for function listing",
|
|
370
|
+
),
|
|
371
|
+
limit: int = typer.Option(100, "--limit", min=1, max=500),
|
|
372
|
+
offset: int = typer.Option(0, "--offset", min=0),
|
|
373
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
374
|
+
) -> None:
|
|
375
|
+
"""List Forge functions with active versions and queue summary."""
|
|
376
|
+
tokens = load_tokens(profile=profile)
|
|
377
|
+
if not tokens:
|
|
378
|
+
typer.echo("Not logged in. Run: sov login", err=True)
|
|
379
|
+
raise typer.Exit(1)
|
|
380
|
+
|
|
381
|
+
api_url = get_api_url(profile)
|
|
382
|
+
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
|
|
383
|
+
params: dict[str, str | int] = {"limit": limit, "offset": offset}
|
|
384
|
+
if project_id:
|
|
385
|
+
params["project_id"] = project_id
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
resp = httpx.get(
|
|
389
|
+
f"{api_url}/api/v1/fn/functions",
|
|
390
|
+
headers=headers,
|
|
391
|
+
params=params,
|
|
392
|
+
timeout=30,
|
|
393
|
+
)
|
|
394
|
+
resp.raise_for_status()
|
|
395
|
+
except httpx.HTTPStatusError as exc:
|
|
396
|
+
typer.echo(f"Function listing failed: {exc.response.text}", err=True)
|
|
397
|
+
raise typer.Exit(1) from exc
|
|
398
|
+
|
|
399
|
+
typer.echo(json.dumps(resp.json(), indent=2, sort_keys=True))
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@app.command("jobs")
|
|
403
|
+
def jobs(
|
|
404
|
+
function_name: str = typer.Argument(..., help="Function name/path (e.g. my-fn)"),
|
|
405
|
+
project_id: str | None = typer.Option(
|
|
406
|
+
None,
|
|
407
|
+
"--project",
|
|
408
|
+
"-p",
|
|
409
|
+
help="Project scope for durable async jobs",
|
|
410
|
+
),
|
|
411
|
+
status: str | None = typer.Option(None, "--status", help="Optional status filter"),
|
|
412
|
+
limit: int = typer.Option(100, "--limit", min=1, max=500),
|
|
413
|
+
offset: int = typer.Option(0, "--offset", min=0),
|
|
414
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
415
|
+
) -> None:
|
|
416
|
+
"""List durable async jobs for a function."""
|
|
417
|
+
tokens = load_tokens(profile=profile)
|
|
418
|
+
if not tokens:
|
|
419
|
+
typer.echo("Not logged in. Run: sov login", err=True)
|
|
420
|
+
raise typer.Exit(1)
|
|
421
|
+
|
|
422
|
+
api_url = get_api_url(profile)
|
|
423
|
+
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
|
|
424
|
+
params: dict[str, str | int] = {"limit": limit, "offset": offset}
|
|
425
|
+
if project_id:
|
|
426
|
+
params["project_id"] = project_id
|
|
427
|
+
if status:
|
|
428
|
+
params["status"] = status
|
|
429
|
+
|
|
430
|
+
try:
|
|
431
|
+
resp = httpx.get(
|
|
432
|
+
f"{api_url}/api/v1/fn/{function_name}/jobs",
|
|
433
|
+
headers=headers,
|
|
434
|
+
params=params,
|
|
435
|
+
timeout=30,
|
|
436
|
+
)
|
|
437
|
+
resp.raise_for_status()
|
|
438
|
+
except httpx.HTTPStatusError as exc:
|
|
439
|
+
typer.echo(f"Job listing failed: {exc.response.text}", err=True)
|
|
440
|
+
raise typer.Exit(1) from exc
|
|
441
|
+
|
|
442
|
+
typer.echo(json.dumps(resp.json(), indent=2, sort_keys=True))
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@app.command("sim-diff")
|
|
446
|
+
def sim_diff(
|
|
447
|
+
function_name: str = typer.Argument(..., help="Function name/path (e.g. my-fn)"),
|
|
448
|
+
left_invocation_id: str = typer.Argument(..., help="Left simulation invocation id"),
|
|
449
|
+
right_invocation_id: str = typer.Argument(..., help="Right simulation invocation id"),
|
|
450
|
+
project_id: str | None = typer.Option(
|
|
451
|
+
None,
|
|
452
|
+
"--project",
|
|
453
|
+
"-p",
|
|
454
|
+
help="Project scope for simulation diff",
|
|
455
|
+
),
|
|
456
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
457
|
+
) -> None:
|
|
458
|
+
"""Show unified diff between two simulation invocations."""
|
|
459
|
+
tokens = load_tokens(profile=profile)
|
|
460
|
+
if not tokens:
|
|
461
|
+
typer.echo("Not logged in. Run: sov login", err=True)
|
|
462
|
+
raise typer.Exit(1)
|
|
463
|
+
|
|
464
|
+
api_url = get_api_url(profile)
|
|
465
|
+
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
|
|
466
|
+
params: dict[str, str] = {
|
|
467
|
+
"left_invocation_id": left_invocation_id,
|
|
468
|
+
"right_invocation_id": right_invocation_id,
|
|
469
|
+
}
|
|
470
|
+
if project_id:
|
|
471
|
+
params["project_id"] = project_id
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
resp = httpx.get(
|
|
475
|
+
f"{api_url}/api/v1/fn/{function_name}/history/simulation-diff",
|
|
476
|
+
headers=headers,
|
|
477
|
+
params=params,
|
|
478
|
+
timeout=30,
|
|
479
|
+
)
|
|
480
|
+
resp.raise_for_status()
|
|
481
|
+
except httpx.HTTPStatusError as exc:
|
|
482
|
+
typer.echo(f"Simulation diff request failed: {exc.response.text}", err=True)
|
|
483
|
+
raise typer.Exit(1) from exc
|
|
484
|
+
|
|
485
|
+
typer.echo(json.dumps(resp.json(), indent=2, sort_keys=True))
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@app.command("promote")
|
|
489
|
+
def promote(
|
|
490
|
+
function_name: str = typer.Argument(..., help="Function name/path (e.g. my-fn)"),
|
|
491
|
+
project_id: str | None = typer.Option(
|
|
492
|
+
None,
|
|
493
|
+
"--project",
|
|
494
|
+
"-p",
|
|
495
|
+
help="Project scope for version promotion",
|
|
496
|
+
),
|
|
497
|
+
version: int | None = typer.Option(None, "--version", min=1, help="Target version to promote"),
|
|
498
|
+
deploy_id: str | None = typer.Option(None, "--deploy-id", help="Target deploy id to promote"),
|
|
499
|
+
reason: str = typer.Option("", "--reason", help="Promotion reason"),
|
|
500
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
501
|
+
) -> None:
|
|
502
|
+
"""Promote a deployment/version to become active."""
|
|
503
|
+
if version is None and not deploy_id:
|
|
504
|
+
typer.echo("Provide either --version or --deploy-id", err=True)
|
|
505
|
+
raise typer.Exit(1)
|
|
506
|
+
|
|
507
|
+
tokens = load_tokens(profile=profile)
|
|
508
|
+
if not tokens:
|
|
509
|
+
typer.echo("Not logged in. Run: sov login", err=True)
|
|
510
|
+
raise typer.Exit(1)
|
|
511
|
+
|
|
512
|
+
api_url = get_api_url(profile)
|
|
513
|
+
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
|
|
514
|
+
params: dict[str, str] = {}
|
|
515
|
+
if project_id:
|
|
516
|
+
params["project_id"] = project_id
|
|
517
|
+
|
|
518
|
+
body: dict[str, object] = {"reason": reason}
|
|
519
|
+
if version is not None:
|
|
520
|
+
body["version"] = version
|
|
521
|
+
if deploy_id:
|
|
522
|
+
body["deploy_id"] = deploy_id
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
resp = httpx.post(
|
|
526
|
+
f"{api_url}/api/v1/fn/{function_name}/promote",
|
|
527
|
+
headers=headers,
|
|
528
|
+
params=params if params else None,
|
|
529
|
+
json=body,
|
|
530
|
+
timeout=30,
|
|
531
|
+
)
|
|
532
|
+
resp.raise_for_status()
|
|
533
|
+
except httpx.HTTPStatusError as exc:
|
|
534
|
+
typer.echo(f"Promote request failed: {exc.response.text}", err=True)
|
|
535
|
+
raise typer.Exit(1) from exc
|
|
536
|
+
|
|
537
|
+
typer.echo(json.dumps(resp.json(), indent=2, sort_keys=True))
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
@app.command("rollback")
|
|
541
|
+
def rollback(
|
|
542
|
+
function_name: str = typer.Argument(..., help="Function name/path (e.g. my-fn)"),
|
|
543
|
+
project_id: str | None = typer.Option(
|
|
544
|
+
None,
|
|
545
|
+
"--project",
|
|
546
|
+
"-p",
|
|
547
|
+
help="Project scope for rollback",
|
|
548
|
+
),
|
|
549
|
+
version: int | None = typer.Option(
|
|
550
|
+
None,
|
|
551
|
+
"--version",
|
|
552
|
+
min=1,
|
|
553
|
+
help="Optional explicit target version (defaults to previous active)",
|
|
554
|
+
),
|
|
555
|
+
reason: str = typer.Option("", "--reason", help="Rollback reason"),
|
|
556
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
557
|
+
) -> None:
|
|
558
|
+
"""Rollback active function deployment to a previous version."""
|
|
559
|
+
tokens = load_tokens(profile=profile)
|
|
560
|
+
if not tokens:
|
|
561
|
+
typer.echo("Not logged in. Run: sov login", err=True)
|
|
562
|
+
raise typer.Exit(1)
|
|
563
|
+
|
|
564
|
+
api_url = get_api_url(profile)
|
|
565
|
+
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
|
|
566
|
+
params: dict[str, str] = {}
|
|
567
|
+
if project_id:
|
|
568
|
+
params["project_id"] = project_id
|
|
569
|
+
|
|
570
|
+
body: dict[str, object] = {"reason": reason}
|
|
571
|
+
if version is not None:
|
|
572
|
+
body["version"] = version
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
resp = httpx.post(
|
|
576
|
+
f"{api_url}/api/v1/fn/{function_name}/rollback",
|
|
577
|
+
headers=headers,
|
|
578
|
+
params=params if params else None,
|
|
579
|
+
json=body,
|
|
580
|
+
timeout=30,
|
|
581
|
+
)
|
|
582
|
+
resp.raise_for_status()
|
|
583
|
+
except httpx.HTTPStatusError as exc:
|
|
584
|
+
typer.echo(f"Rollback request failed: {exc.response.text}", err=True)
|
|
585
|
+
raise typer.Exit(1) from exc
|
|
586
|
+
|
|
587
|
+
typer.echo(json.dumps(resp.json(), indent=2, sort_keys=True))
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@app.command("logs")
|
|
591
|
+
def logs(
|
|
592
|
+
function_name: str = typer.Argument(..., help="Function name"),
|
|
593
|
+
limit: int = typer.Option(50, "--limit", min=1, max=200),
|
|
594
|
+
offset: int = typer.Option(0, "--offset", min=0),
|
|
595
|
+
status: str | None = typer.Option(None, "--status", help="Filter: SUCCEEDED, FAILED, TIMEOUT"),
|
|
596
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
597
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
598
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
599
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
600
|
+
) -> None:
|
|
601
|
+
"""Fetch recent invocation logs for a function."""
|
|
602
|
+
from urllib.parse import quote
|
|
603
|
+
|
|
604
|
+
from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
|
|
605
|
+
|
|
606
|
+
params: dict[str, str] = {"limit": str(limit), "offset": str(offset)}
|
|
607
|
+
if status:
|
|
608
|
+
params["status"] = status
|
|
609
|
+
safe_name = quote(function_name, safe="")
|
|
610
|
+
try:
|
|
611
|
+
result = call_api(
|
|
612
|
+
method="GET",
|
|
613
|
+
path=f"/api/v1/fn/functions/{safe_name}/logs",
|
|
614
|
+
profile=profile,
|
|
615
|
+
org_id=org_id,
|
|
616
|
+
project_id=project_id,
|
|
617
|
+
env_id=env_id,
|
|
618
|
+
require_scope=True,
|
|
619
|
+
params=params,
|
|
620
|
+
)
|
|
621
|
+
except CLIRequestError as exc:
|
|
622
|
+
typer.echo(str(exc), err=True)
|
|
623
|
+
raise typer.Exit(1) from exc
|
|
624
|
+
typer.echo(print_json(result))
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
@app.command("get")
|
|
628
|
+
def get_function(
|
|
629
|
+
function_name: str = typer.Argument(..., help="Function name"),
|
|
630
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
631
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
632
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
633
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
634
|
+
) -> None:
|
|
635
|
+
"""Get function metadata."""
|
|
636
|
+
from urllib.parse import quote
|
|
637
|
+
|
|
638
|
+
from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
|
|
639
|
+
|
|
640
|
+
safe_name = quote(function_name, safe="")
|
|
641
|
+
try:
|
|
642
|
+
result = call_api(
|
|
643
|
+
method="GET",
|
|
644
|
+
path=f"/api/v1/fn/functions/{safe_name}",
|
|
645
|
+
profile=profile,
|
|
646
|
+
org_id=org_id,
|
|
647
|
+
project_id=project_id,
|
|
648
|
+
env_id=env_id,
|
|
649
|
+
require_scope=True,
|
|
650
|
+
)
|
|
651
|
+
except CLIRequestError as exc:
|
|
652
|
+
typer.echo(str(exc), err=True)
|
|
653
|
+
raise typer.Exit(1) from exc
|
|
654
|
+
typer.echo(print_json(result))
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@app.command("delete")
|
|
658
|
+
def delete_function(
|
|
659
|
+
function_name: str = typer.Argument(..., help="Function name"),
|
|
660
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
661
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
662
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
663
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
664
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
665
|
+
) -> None:
|
|
666
|
+
"""Delete a deployed function."""
|
|
667
|
+
from urllib.parse import quote
|
|
668
|
+
|
|
669
|
+
from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
|
|
670
|
+
|
|
671
|
+
if not yes and not typer.confirm(f"Delete function '{function_name}'?"):
|
|
672
|
+
raise typer.Exit(0)
|
|
673
|
+
|
|
674
|
+
safe_name = quote(function_name, safe="")
|
|
675
|
+
try:
|
|
676
|
+
result = call_api(
|
|
677
|
+
method="DELETE",
|
|
678
|
+
path=f"/api/v1/fn/functions/{safe_name}",
|
|
679
|
+
profile=profile,
|
|
680
|
+
org_id=org_id,
|
|
681
|
+
project_id=project_id,
|
|
682
|
+
env_id=env_id,
|
|
683
|
+
require_scope=True,
|
|
684
|
+
)
|
|
685
|
+
except CLIRequestError as exc:
|
|
686
|
+
typer.echo(str(exc), err=True)
|
|
687
|
+
raise typer.Exit(1) from exc
|
|
688
|
+
typer.echo(print_json(result))
|
|
689
|
+
typer.echo(typer.style(f"Deleted function '{function_name}'", fg=typer.colors.GREEN))
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
@app.command("source")
|
|
693
|
+
def function_source(
|
|
694
|
+
function_name: str = typer.Argument(..., help="Function name"),
|
|
695
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
696
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
697
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
698
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
699
|
+
) -> None:
|
|
700
|
+
"""Fetch function source code."""
|
|
701
|
+
from urllib.parse import quote
|
|
702
|
+
|
|
703
|
+
from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
|
|
704
|
+
|
|
705
|
+
safe_name = quote(function_name, safe="")
|
|
706
|
+
try:
|
|
707
|
+
result = call_api(
|
|
708
|
+
method="GET",
|
|
709
|
+
path=f"/api/v1/fn/functions/{safe_name}/source",
|
|
710
|
+
profile=profile,
|
|
711
|
+
org_id=org_id,
|
|
712
|
+
project_id=project_id,
|
|
713
|
+
env_id=env_id,
|
|
714
|
+
require_scope=True,
|
|
715
|
+
)
|
|
716
|
+
except CLIRequestError as exc:
|
|
717
|
+
typer.echo(str(exc), err=True)
|
|
718
|
+
raise typer.Exit(1) from exc
|
|
719
|
+
typer.echo(print_json(result))
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
@app.command("update")
|
|
723
|
+
def update_function(
|
|
724
|
+
function_name: str = typer.Argument(..., help="Function name"),
|
|
725
|
+
source: str | None = typer.Option(None, "--source", help="Inline source code"),
|
|
726
|
+
source_file: Path | None = typer.Option(None, "--source-file", exists=True, readable=True),
|
|
727
|
+
runtime: str | None = typer.Option(None, "--runtime"),
|
|
728
|
+
entrypoint: str | None = typer.Option(None, "--entrypoint"),
|
|
729
|
+
region: str | None = typer.Option(None, "--region"),
|
|
730
|
+
description: str | None = typer.Option(None, "--description"),
|
|
731
|
+
verify_jwt: bool | None = typer.Option(None, "--verify-jwt/--no-verify-jwt"),
|
|
732
|
+
env_vars_json: str | None = typer.Option(None, "--env-vars", help="JSON object"),
|
|
733
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
734
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
735
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
736
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
737
|
+
) -> None:
|
|
738
|
+
"""Patch function metadata and/or source."""
|
|
739
|
+
from urllib.parse import quote
|
|
740
|
+
|
|
741
|
+
from apps.cli.src.utils.api_client import CLIRequestError, call_api, parse_json_arg, print_json
|
|
742
|
+
|
|
743
|
+
if source and source_file:
|
|
744
|
+
raise typer.BadParameter("Use either --source or --source-file, not both")
|
|
745
|
+
body: dict[str, object] = {}
|
|
746
|
+
if source_file:
|
|
747
|
+
body["source"] = source_file.read_text(encoding="utf-8")
|
|
748
|
+
elif source is not None:
|
|
749
|
+
body["source"] = source
|
|
750
|
+
if runtime is not None:
|
|
751
|
+
body["runtime"] = runtime
|
|
752
|
+
if entrypoint is not None:
|
|
753
|
+
body["entrypoint"] = entrypoint
|
|
754
|
+
if region is not None:
|
|
755
|
+
body["region"] = region
|
|
756
|
+
if description is not None:
|
|
757
|
+
body["description"] = description
|
|
758
|
+
if verify_jwt is not None:
|
|
759
|
+
body["verify_jwt"] = verify_jwt
|
|
760
|
+
if env_vars_json is not None:
|
|
761
|
+
env_vars = parse_json_arg(env_vars_json, label="env-vars")
|
|
762
|
+
if env_vars is None or not isinstance(env_vars, dict):
|
|
763
|
+
raise typer.BadParameter("--env-vars must be a JSON object")
|
|
764
|
+
body["env_vars"] = env_vars
|
|
765
|
+
if not body:
|
|
766
|
+
raise typer.BadParameter("Provide at least one field to update")
|
|
767
|
+
safe_name = quote(function_name, safe="")
|
|
768
|
+
try:
|
|
769
|
+
result = call_api(
|
|
770
|
+
method="PATCH",
|
|
771
|
+
path=f"/api/v1/fn/functions/{safe_name}",
|
|
772
|
+
profile=profile,
|
|
773
|
+
org_id=org_id,
|
|
774
|
+
project_id=project_id,
|
|
775
|
+
env_id=env_id,
|
|
776
|
+
require_scope=True,
|
|
777
|
+
body=body,
|
|
778
|
+
)
|
|
779
|
+
except CLIRequestError as exc:
|
|
780
|
+
typer.echo(str(exc), err=True)
|
|
781
|
+
raise typer.Exit(1) from exc
|
|
782
|
+
typer.echo(print_json(result))
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
@app.command("policy-get")
|
|
786
|
+
def policy_get(
|
|
787
|
+
function_name: str = typer.Argument(..., help="Function name"),
|
|
788
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
789
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
790
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
791
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
792
|
+
) -> None:
|
|
793
|
+
"""Fetch function invoke policy."""
|
|
794
|
+
from urllib.parse import quote
|
|
795
|
+
|
|
796
|
+
from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
|
|
797
|
+
|
|
798
|
+
safe_name = quote(function_name, safe="")
|
|
799
|
+
try:
|
|
800
|
+
result = call_api(
|
|
801
|
+
method="GET",
|
|
802
|
+
path=f"/api/v1/fn/functions/{safe_name}/policy",
|
|
803
|
+
profile=profile,
|
|
804
|
+
org_id=org_id,
|
|
805
|
+
project_id=project_id,
|
|
806
|
+
env_id=env_id,
|
|
807
|
+
require_scope=True,
|
|
808
|
+
)
|
|
809
|
+
except CLIRequestError as exc:
|
|
810
|
+
typer.echo(str(exc), err=True)
|
|
811
|
+
raise typer.Exit(1) from exc
|
|
812
|
+
typer.echo(print_json(result))
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
@app.command("policy-set")
|
|
816
|
+
def policy_set(
|
|
817
|
+
function_name: str = typer.Argument(..., help="Function name"),
|
|
818
|
+
policy_json: str = typer.Argument(..., help="JSON policy object"),
|
|
819
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
820
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
821
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
822
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
823
|
+
) -> None:
|
|
824
|
+
"""Replace function invoke policy."""
|
|
825
|
+
from urllib.parse import quote
|
|
826
|
+
|
|
827
|
+
from apps.cli.src.utils.api_client import CLIRequestError, call_api, parse_json_arg, print_json
|
|
828
|
+
|
|
829
|
+
policy = parse_json_arg(policy_json, label="policy")
|
|
830
|
+
if policy is None or not isinstance(policy, dict):
|
|
831
|
+
raise typer.BadParameter("policy must be a JSON object")
|
|
832
|
+
safe_name = quote(function_name, safe="")
|
|
833
|
+
try:
|
|
834
|
+
result = call_api(
|
|
835
|
+
method="PUT",
|
|
836
|
+
path=f"/api/v1/fn/functions/{safe_name}/policy",
|
|
837
|
+
profile=profile,
|
|
838
|
+
org_id=org_id,
|
|
839
|
+
project_id=project_id,
|
|
840
|
+
env_id=env_id,
|
|
841
|
+
require_scope=True,
|
|
842
|
+
body=policy,
|
|
843
|
+
)
|
|
844
|
+
except CLIRequestError as exc:
|
|
845
|
+
typer.echo(str(exc), err=True)
|
|
846
|
+
raise typer.Exit(1) from exc
|
|
847
|
+
typer.echo(print_json(result))
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
@app.command("bindings-get")
|
|
851
|
+
def bindings_get(
|
|
852
|
+
function_name: str = typer.Argument(..., help="Function name"),
|
|
853
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
854
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
855
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
856
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
857
|
+
) -> None:
|
|
858
|
+
"""Fetch function secret bindings."""
|
|
859
|
+
from urllib.parse import quote
|
|
860
|
+
|
|
861
|
+
from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
|
|
862
|
+
|
|
863
|
+
safe_name = quote(function_name, safe="")
|
|
864
|
+
try:
|
|
865
|
+
result = call_api(
|
|
866
|
+
method="GET",
|
|
867
|
+
path=f"/api/v1/fn/functions/{safe_name}/bindings",
|
|
868
|
+
profile=profile,
|
|
869
|
+
org_id=org_id,
|
|
870
|
+
project_id=project_id,
|
|
871
|
+
env_id=env_id,
|
|
872
|
+
require_scope=True,
|
|
873
|
+
)
|
|
874
|
+
except CLIRequestError as exc:
|
|
875
|
+
typer.echo(str(exc), err=True)
|
|
876
|
+
raise typer.Exit(1) from exc
|
|
877
|
+
typer.echo(print_json(result))
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
@app.command("bindings-set")
|
|
881
|
+
def bindings_set(
|
|
882
|
+
function_name: str = typer.Argument(..., help="Function name"),
|
|
883
|
+
bindings_json: str = typer.Argument(..., help='JSON array, e.g. [{"name":"API_KEY","value":"..."}]'),
|
|
884
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
885
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
886
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
887
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
888
|
+
) -> None:
|
|
889
|
+
"""Replace function secret bindings."""
|
|
890
|
+
from urllib.parse import quote
|
|
891
|
+
|
|
892
|
+
from apps.cli.src.utils.api_client import CLIRequestError, call_api, parse_json_arg, print_json
|
|
893
|
+
|
|
894
|
+
bindings = parse_json_arg(bindings_json, label="bindings")
|
|
895
|
+
if bindings is None or not isinstance(bindings, list):
|
|
896
|
+
raise typer.BadParameter("bindings must be a JSON array")
|
|
897
|
+
safe_name = quote(function_name, safe="")
|
|
898
|
+
try:
|
|
899
|
+
result = call_api(
|
|
900
|
+
method="PUT",
|
|
901
|
+
path=f"/api/v1/fn/functions/{safe_name}/bindings",
|
|
902
|
+
profile=profile,
|
|
903
|
+
org_id=org_id,
|
|
904
|
+
project_id=project_id,
|
|
905
|
+
env_id=env_id,
|
|
906
|
+
require_scope=True,
|
|
907
|
+
body={"bindings": bindings},
|
|
908
|
+
)
|
|
909
|
+
except CLIRequestError as exc:
|
|
910
|
+
typer.echo(str(exc), err=True)
|
|
911
|
+
raise typer.Exit(1) from exc
|
|
912
|
+
typer.echo(print_json(result))
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
@app.command("github-repos")
|
|
916
|
+
def github_repos(
|
|
917
|
+
token: str | None = typer.Option(None, "--token", help="Optional GitHub PAT override"),
|
|
918
|
+
limit: int = typer.Option(30, "--limit", min=1, max=100),
|
|
919
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
920
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
921
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
922
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
923
|
+
) -> None:
|
|
924
|
+
"""List GitHub repositories available for import."""
|
|
925
|
+
from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
|
|
926
|
+
|
|
927
|
+
body: dict[str, object] = {"limit": limit}
|
|
928
|
+
if token:
|
|
929
|
+
body["token"] = token
|
|
930
|
+
try:
|
|
931
|
+
result = call_api(
|
|
932
|
+
method="POST",
|
|
933
|
+
path="/api/v1/fn/github/repositories",
|
|
934
|
+
profile=profile,
|
|
935
|
+
org_id=org_id,
|
|
936
|
+
project_id=project_id,
|
|
937
|
+
env_id=env_id,
|
|
938
|
+
require_scope=True,
|
|
939
|
+
body=body,
|
|
940
|
+
)
|
|
941
|
+
except CLIRequestError as exc:
|
|
942
|
+
typer.echo(str(exc), err=True)
|
|
943
|
+
raise typer.Exit(1) from exc
|
|
944
|
+
typer.echo(print_json(result))
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
@app.command("github-import")
|
|
948
|
+
def github_import(
|
|
949
|
+
function_name: str = typer.Option(..., "--name", help="Function name to create/update"),
|
|
950
|
+
repository: str = typer.Option(..., "--repo", help="owner/repo"),
|
|
951
|
+
path: str = typer.Option(..., "--path", help="Path within the repository"),
|
|
952
|
+
ref: str = typer.Option("main", "--ref"),
|
|
953
|
+
token: str | None = typer.Option(None, "--token"),
|
|
954
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
955
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
956
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
957
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
958
|
+
) -> None:
|
|
959
|
+
"""Import a function from a GitHub repository path."""
|
|
960
|
+
from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
|
|
961
|
+
|
|
962
|
+
body: dict[str, object] = {
|
|
963
|
+
"function_name": function_name,
|
|
964
|
+
"repository": repository,
|
|
965
|
+
"path": path,
|
|
966
|
+
"ref": ref,
|
|
967
|
+
}
|
|
968
|
+
if token:
|
|
969
|
+
body["token"] = token
|
|
970
|
+
try:
|
|
971
|
+
result = call_api(
|
|
972
|
+
method="POST",
|
|
973
|
+
path="/api/v1/fn/github/import",
|
|
974
|
+
profile=profile,
|
|
975
|
+
org_id=org_id,
|
|
976
|
+
project_id=project_id,
|
|
977
|
+
env_id=env_id,
|
|
978
|
+
require_scope=True,
|
|
979
|
+
body=body,
|
|
980
|
+
)
|
|
981
|
+
except CLIRequestError as exc:
|
|
982
|
+
typer.echo(str(exc), err=True)
|
|
983
|
+
raise typer.Exit(1) from exc
|
|
984
|
+
typer.echo(print_json(result))
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
@app.command("github-commit")
|
|
988
|
+
def github_commit(
|
|
989
|
+
function_name: str = typer.Option(..., "--name", help="Local function name"),
|
|
990
|
+
repository: str = typer.Option(..., "--repo", help="owner/repo"),
|
|
991
|
+
path: str = typer.Option(..., "--path", help="Target path in the repository"),
|
|
992
|
+
message: str = typer.Option(..., "--message", help="Commit message"),
|
|
993
|
+
ref: str = typer.Option("main", "--ref"),
|
|
994
|
+
token: str | None = typer.Option(None, "--token"),
|
|
995
|
+
committer_name: str | None = typer.Option(None, "--committer-name"),
|
|
996
|
+
committer_email: str | None = typer.Option(None, "--committer-email"),
|
|
997
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
998
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
999
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
1000
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
1001
|
+
) -> None:
|
|
1002
|
+
"""Commit local function source back to GitHub."""
|
|
1003
|
+
from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
|
|
1004
|
+
|
|
1005
|
+
body: dict[str, object] = {
|
|
1006
|
+
"function_name": function_name,
|
|
1007
|
+
"repository": repository,
|
|
1008
|
+
"path": path,
|
|
1009
|
+
"message": message,
|
|
1010
|
+
"ref": ref,
|
|
1011
|
+
}
|
|
1012
|
+
if token:
|
|
1013
|
+
body["token"] = token
|
|
1014
|
+
if committer_name:
|
|
1015
|
+
body["committer_name"] = committer_name
|
|
1016
|
+
if committer_email:
|
|
1017
|
+
body["committer_email"] = committer_email
|
|
1018
|
+
try:
|
|
1019
|
+
result = call_api(
|
|
1020
|
+
method="POST",
|
|
1021
|
+
path="/api/v1/fn/github/commit",
|
|
1022
|
+
profile=profile,
|
|
1023
|
+
org_id=org_id,
|
|
1024
|
+
project_id=project_id,
|
|
1025
|
+
env_id=env_id,
|
|
1026
|
+
require_scope=True,
|
|
1027
|
+
body=body,
|
|
1028
|
+
)
|
|
1029
|
+
except CLIRequestError as exc:
|
|
1030
|
+
typer.echo(str(exc), err=True)
|
|
1031
|
+
raise typer.Exit(1) from exc
|
|
1032
|
+
typer.echo(print_json(result))
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _zip_directory(directory: Path) -> bytes:
|
|
1036
|
+
"""Recursively zip all files in *directory* and return raw zip bytes."""
|
|
1037
|
+
buf = io.BytesIO()
|
|
1038
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
1039
|
+
for path in sorted(directory.rglob("*")):
|
|
1040
|
+
if path.is_file() and not _should_exclude(path):
|
|
1041
|
+
zf.write(path, path.relative_to(directory))
|
|
1042
|
+
return buf.getvalue()
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _load_json_payload(*, data: str | None, data_file: Path | None) -> Any:
|
|
1046
|
+
if data and data_file:
|
|
1047
|
+
raise typer.BadParameter("Use either --data or --data-file, not both")
|
|
1048
|
+
if data_file:
|
|
1049
|
+
return json.loads(data_file.read_text(encoding="utf-8"))
|
|
1050
|
+
if data:
|
|
1051
|
+
return json.loads(data)
|
|
1052
|
+
return {}
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _poll_async_invocation(
|
|
1056
|
+
*,
|
|
1057
|
+
api_url: str,
|
|
1058
|
+
headers: dict[str, str],
|
|
1059
|
+
invocation_id: str,
|
|
1060
|
+
project_id: str | None,
|
|
1061
|
+
) -> dict[str, Any]:
|
|
1062
|
+
"""Poll async invocation status until terminal state or timeout."""
|
|
1063
|
+
deadline = time.time() + _ASYNC_POLL_TIMEOUT
|
|
1064
|
+
|
|
1065
|
+
while time.time() < deadline:
|
|
1066
|
+
params = {"project_id": project_id} if project_id else None
|
|
1067
|
+
resp = httpx.get(
|
|
1068
|
+
f"{api_url}/api/v1/fn/invocations/{invocation_id}",
|
|
1069
|
+
headers=headers,
|
|
1070
|
+
params=params,
|
|
1071
|
+
timeout=15,
|
|
1072
|
+
)
|
|
1073
|
+
if resp.status_code >= 400:
|
|
1074
|
+
typer.echo(f"Async status request failed: {resp.text}", err=True)
|
|
1075
|
+
raise typer.Exit(1)
|
|
1076
|
+
|
|
1077
|
+
body = resp.json()
|
|
1078
|
+
if not isinstance(body, dict):
|
|
1079
|
+
typer.echo("Async status response was not a JSON object", err=True)
|
|
1080
|
+
raise typer.Exit(1)
|
|
1081
|
+
status = str(body.get("status", "UNKNOWN")).upper()
|
|
1082
|
+
if status in {"SUCCEEDED", "FAILED", "TIMEOUT"}:
|
|
1083
|
+
return body
|
|
1084
|
+
|
|
1085
|
+
time.sleep(_ASYNC_POLL_INTERVAL)
|
|
1086
|
+
|
|
1087
|
+
typer.echo("Async invocation polling timed out", err=True)
|
|
1088
|
+
raise typer.Exit(1)
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
_EXCLUDE_NAMES = {".git", "__pycache__", ".venv", "node_modules", ".DS_Store"}
|
|
1092
|
+
_EXCLUDE_SUFFIXES = {".pyc", ".pyo", ".egg-info"}
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _should_exclude(path: Path) -> bool:
|
|
1096
|
+
for part in path.parts:
|
|
1097
|
+
if part in _EXCLUDE_NAMES:
|
|
1098
|
+
return True
|
|
1099
|
+
return path.suffix in _EXCLUDE_SUFFIXES
|