pragmatiks-cli 0.5.1__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.
- pragma_cli/__init__.py +32 -0
- pragma_cli/commands/__init__.py +1 -0
- pragma_cli/commands/auth.py +247 -0
- pragma_cli/commands/completions.py +54 -0
- pragma_cli/commands/config.py +79 -0
- pragma_cli/commands/dead_letter.py +233 -0
- pragma_cli/commands/ops.py +14 -0
- pragma_cli/commands/provider.py +1165 -0
- pragma_cli/commands/resources.py +199 -0
- pragma_cli/config.py +86 -0
- pragma_cli/helpers.py +21 -0
- pragma_cli/main.py +67 -0
- pragma_cli/py.typed +0 -0
- pragmatiks_cli-0.5.1.dist-info/METADATA +199 -0
- pragmatiks_cli-0.5.1.dist-info/RECORD +17 -0
- pragmatiks_cli-0.5.1.dist-info/WHEEL +4 -0
- pragmatiks_cli-0.5.1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,1165 @@
|
|
|
1
|
+
"""Provider management commands.
|
|
2
|
+
|
|
3
|
+
Commands for scaffolding, syncing, and pushing Pragmatiks providers to the platform.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import io
|
|
7
|
+
import os
|
|
8
|
+
import tarfile
|
|
9
|
+
import time
|
|
10
|
+
import tomllib
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
import copier
|
|
15
|
+
import httpx
|
|
16
|
+
import typer
|
|
17
|
+
from pragma_sdk import (
|
|
18
|
+
BuildResult,
|
|
19
|
+
BuildStatus,
|
|
20
|
+
Config,
|
|
21
|
+
DeploymentStatus,
|
|
22
|
+
PragmaClient,
|
|
23
|
+
ProviderDeleteResult,
|
|
24
|
+
ProviderInfo,
|
|
25
|
+
PushResult,
|
|
26
|
+
Resource,
|
|
27
|
+
)
|
|
28
|
+
from pragma_sdk.provider import discover_resources
|
|
29
|
+
from rich.console import Console
|
|
30
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
31
|
+
from rich.table import Table
|
|
32
|
+
|
|
33
|
+
from pragma_cli import get_client
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
app = typer.Typer(help="Provider management commands")
|
|
37
|
+
console = Console()
|
|
38
|
+
|
|
39
|
+
TARBALL_EXCLUDES = {
|
|
40
|
+
".git",
|
|
41
|
+
"__pycache__",
|
|
42
|
+
".venv",
|
|
43
|
+
".env",
|
|
44
|
+
".pytest_cache",
|
|
45
|
+
".mypy_cache",
|
|
46
|
+
".ruff_cache",
|
|
47
|
+
"*.pyc",
|
|
48
|
+
"*.pyo",
|
|
49
|
+
"*.egg-info",
|
|
50
|
+
"dist",
|
|
51
|
+
"build",
|
|
52
|
+
".tox",
|
|
53
|
+
".nox",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
DEFAULT_TEMPLATE_URL = "gh:pragmatiks/provider-template"
|
|
57
|
+
TEMPLATE_PATH_ENV = "PRAGMA_PROVIDER_TEMPLATE"
|
|
58
|
+
|
|
59
|
+
BUILD_POLL_INTERVAL = 2.0
|
|
60
|
+
BUILD_TIMEOUT = 600
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def create_tarball(source_dir: Path) -> bytes:
|
|
64
|
+
"""Create a gzipped tarball of the provider source directory.
|
|
65
|
+
|
|
66
|
+
Excludes common development artifacts like .git, __pycache__, .venv, etc.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
source_dir: Path to the provider source directory.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Gzipped tarball bytes suitable for upload.
|
|
73
|
+
"""
|
|
74
|
+
buffer = io.BytesIO()
|
|
75
|
+
|
|
76
|
+
def exclude_filter(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None:
|
|
77
|
+
"""Filter out excluded files and directories.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The TarInfo object if included, None if excluded.
|
|
81
|
+
"""
|
|
82
|
+
name = tarinfo.name
|
|
83
|
+
parts = Path(name).parts
|
|
84
|
+
|
|
85
|
+
for part in parts:
|
|
86
|
+
if part in TARBALL_EXCLUDES:
|
|
87
|
+
return None
|
|
88
|
+
for pattern in TARBALL_EXCLUDES:
|
|
89
|
+
if pattern.startswith("*") and part.endswith(pattern[1:]):
|
|
90
|
+
return None
|
|
91
|
+
return tarinfo
|
|
92
|
+
|
|
93
|
+
with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
|
|
94
|
+
tar.add(source_dir, arcname=".", filter=exclude_filter)
|
|
95
|
+
|
|
96
|
+
buffer.seek(0)
|
|
97
|
+
return buffer.read()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_template_source() -> str:
|
|
101
|
+
"""Get the template source path or URL.
|
|
102
|
+
|
|
103
|
+
Priority:
|
|
104
|
+
1. PRAGMA_PROVIDER_TEMPLATE environment variable
|
|
105
|
+
2. Local development path (if running from repo)
|
|
106
|
+
3. Default GitHub URL
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Template path (local) or URL (GitHub).
|
|
110
|
+
"""
|
|
111
|
+
if env_template := os.environ.get(TEMPLATE_PATH_ENV):
|
|
112
|
+
return env_template
|
|
113
|
+
|
|
114
|
+
local_template = Path(__file__).parents[5] / "templates" / "provider"
|
|
115
|
+
if local_template.exists() and (local_template / "copier.yml").exists():
|
|
116
|
+
return str(local_template)
|
|
117
|
+
|
|
118
|
+
return DEFAULT_TEMPLATE_URL
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command("list")
|
|
122
|
+
def list_providers():
|
|
123
|
+
"""List all deployed providers.
|
|
124
|
+
|
|
125
|
+
Shows providers with their deployment status. Displays:
|
|
126
|
+
- Provider ID
|
|
127
|
+
- Deployed version
|
|
128
|
+
- Status (running/stopped)
|
|
129
|
+
- Last deployed timestamp
|
|
130
|
+
|
|
131
|
+
Example:
|
|
132
|
+
pragma provider list
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
typer.Exit: If authentication is missing or API call fails.
|
|
136
|
+
"""
|
|
137
|
+
client = get_client()
|
|
138
|
+
|
|
139
|
+
if client._auth is None:
|
|
140
|
+
console.print("[red]Error:[/red] Authentication required. Run 'pragma auth login' first.")
|
|
141
|
+
raise typer.Exit(1)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
with Progress(
|
|
145
|
+
SpinnerColumn(),
|
|
146
|
+
TextColumn("[progress.description]{task.description}"),
|
|
147
|
+
console=console,
|
|
148
|
+
transient=True,
|
|
149
|
+
) as progress:
|
|
150
|
+
progress.add_task("Fetching providers...", total=None)
|
|
151
|
+
providers = client.list_providers()
|
|
152
|
+
except httpx.HTTPStatusError as e:
|
|
153
|
+
console.print(f"[red]Error:[/red] {e.response.text}")
|
|
154
|
+
raise typer.Exit(1)
|
|
155
|
+
except Exception as e:
|
|
156
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
157
|
+
raise typer.Exit(1)
|
|
158
|
+
|
|
159
|
+
if not providers:
|
|
160
|
+
console.print("[dim]No providers found.[/dim]")
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
_print_providers_table(providers)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _print_providers_table(providers: list[ProviderInfo]) -> None:
|
|
167
|
+
"""Print providers in a formatted table.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
providers: List of ProviderInfo to display.
|
|
171
|
+
"""
|
|
172
|
+
table = Table(show_header=True, header_style="bold")
|
|
173
|
+
table.add_column("Provider ID")
|
|
174
|
+
table.add_column("Version")
|
|
175
|
+
table.add_column("Status")
|
|
176
|
+
table.add_column("Last Deployed")
|
|
177
|
+
|
|
178
|
+
for provider in providers:
|
|
179
|
+
status = _format_deployment_status(provider.deployment_status)
|
|
180
|
+
version = provider.current_version or "[dim]never deployed[/dim]"
|
|
181
|
+
updated = (
|
|
182
|
+
provider.updated_at.strftime("%Y-%m-%d %H:%M:%S")
|
|
183
|
+
if provider.updated_at
|
|
184
|
+
else "[dim]-[/dim]"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
table.add_row(provider.provider_id, version, status, updated)
|
|
188
|
+
|
|
189
|
+
console.print(table)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _format_deployment_status(status: DeploymentStatus | None) -> str:
|
|
193
|
+
"""Format deployment status with color coding.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
status: Deployment status or None if not deployed.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Formatted status string with Rich markup.
|
|
200
|
+
"""
|
|
201
|
+
if status is None:
|
|
202
|
+
return "[dim]not deployed[/dim]"
|
|
203
|
+
|
|
204
|
+
match status:
|
|
205
|
+
case DeploymentStatus.AVAILABLE:
|
|
206
|
+
return "[green]running[/green]"
|
|
207
|
+
case DeploymentStatus.PROGRESSING:
|
|
208
|
+
return "[yellow]deploying[/yellow]"
|
|
209
|
+
case DeploymentStatus.PENDING:
|
|
210
|
+
return "[yellow]pending[/yellow]"
|
|
211
|
+
case DeploymentStatus.FAILED:
|
|
212
|
+
return "[red]failed[/red]"
|
|
213
|
+
case _:
|
|
214
|
+
return f"[dim]{status}[/dim]"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.command()
|
|
218
|
+
def init(
|
|
219
|
+
name: Annotated[str, typer.Argument(help="Provider name (e.g., 'postgres', 'mycompany')")],
|
|
220
|
+
output_dir: Annotated[
|
|
221
|
+
Path | None,
|
|
222
|
+
typer.Option("--output", "-o", help="Output directory (default: ./{name}-provider)"),
|
|
223
|
+
] = None,
|
|
224
|
+
description: Annotated[
|
|
225
|
+
str | None,
|
|
226
|
+
typer.Option("--description", "-d", help="Provider description"),
|
|
227
|
+
] = None,
|
|
228
|
+
author_name: Annotated[
|
|
229
|
+
str | None,
|
|
230
|
+
typer.Option("--author", help="Author name"),
|
|
231
|
+
] = None,
|
|
232
|
+
author_email: Annotated[
|
|
233
|
+
str | None,
|
|
234
|
+
typer.Option("--email", help="Author email"),
|
|
235
|
+
] = None,
|
|
236
|
+
defaults: Annotated[
|
|
237
|
+
bool,
|
|
238
|
+
typer.Option("--defaults", help="Accept all defaults without prompting"),
|
|
239
|
+
] = False,
|
|
240
|
+
):
|
|
241
|
+
"""Initialize a new provider project.
|
|
242
|
+
|
|
243
|
+
Creates a complete provider project structure with:
|
|
244
|
+
- pyproject.toml for packaging
|
|
245
|
+
- README.md with documentation
|
|
246
|
+
- src/{name}_provider/ with example resources
|
|
247
|
+
- tests/ with example tests
|
|
248
|
+
- mise.toml for tool management
|
|
249
|
+
|
|
250
|
+
Example:
|
|
251
|
+
pragma provider init mycompany
|
|
252
|
+
pragma provider init postgres --output ./providers/postgres
|
|
253
|
+
pragma provider init mycompany --defaults --description "My provider"
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
typer.Exit: If directory already exists or template copy fails.
|
|
257
|
+
"""
|
|
258
|
+
project_dir = output_dir or Path(f"./{name}-provider")
|
|
259
|
+
|
|
260
|
+
if project_dir.exists():
|
|
261
|
+
typer.echo(f"Error: Directory {project_dir} already exists", err=True)
|
|
262
|
+
raise typer.Exit(1)
|
|
263
|
+
|
|
264
|
+
template_source = get_template_source()
|
|
265
|
+
|
|
266
|
+
data = {"name": name}
|
|
267
|
+
if description:
|
|
268
|
+
data["description"] = description
|
|
269
|
+
if author_name:
|
|
270
|
+
data["author_name"] = author_name
|
|
271
|
+
if author_email:
|
|
272
|
+
data["author_email"] = author_email
|
|
273
|
+
|
|
274
|
+
typer.echo(f"Creating provider project: {project_dir}")
|
|
275
|
+
typer.echo(f" Template: {template_source}")
|
|
276
|
+
typer.echo("")
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
copier.run_copy(
|
|
280
|
+
src_path=template_source,
|
|
281
|
+
dst_path=project_dir,
|
|
282
|
+
data=data,
|
|
283
|
+
defaults=defaults,
|
|
284
|
+
unsafe=True,
|
|
285
|
+
)
|
|
286
|
+
except Exception as e:
|
|
287
|
+
typer.echo(f"Error creating provider: {e}", err=True)
|
|
288
|
+
raise typer.Exit(1)
|
|
289
|
+
|
|
290
|
+
package_name = name.lower().replace("-", "_").replace(" ", "_") + "_provider"
|
|
291
|
+
|
|
292
|
+
typer.echo("")
|
|
293
|
+
typer.echo(f"Created provider project: {project_dir}")
|
|
294
|
+
typer.echo("")
|
|
295
|
+
typer.echo("Next steps:")
|
|
296
|
+
typer.echo(f" cd {project_dir}")
|
|
297
|
+
typer.echo(" uv sync --dev")
|
|
298
|
+
typer.echo(" uv run pytest tests/")
|
|
299
|
+
typer.echo("")
|
|
300
|
+
typer.echo(f"Edit src/{package_name}/resources.py to add your resources.")
|
|
301
|
+
typer.echo("")
|
|
302
|
+
typer.echo("To update this project when the template changes:")
|
|
303
|
+
typer.echo(" copier update")
|
|
304
|
+
typer.echo("")
|
|
305
|
+
typer.echo("When ready to deploy:")
|
|
306
|
+
typer.echo(" pragma provider push")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@app.command()
|
|
310
|
+
def update(
|
|
311
|
+
project_dir: Annotated[
|
|
312
|
+
Path,
|
|
313
|
+
typer.Argument(help="Provider project directory"),
|
|
314
|
+
] = Path("."),
|
|
315
|
+
):
|
|
316
|
+
"""Update an existing provider project with latest template changes.
|
|
317
|
+
|
|
318
|
+
Uses Copier's 3-way merge to preserve your customizations while
|
|
319
|
+
incorporating template updates.
|
|
320
|
+
|
|
321
|
+
Example:
|
|
322
|
+
pragma provider update
|
|
323
|
+
pragma provider update ./my-provider
|
|
324
|
+
|
|
325
|
+
Raises:
|
|
326
|
+
typer.Exit: If directory is not a Copier project or update fails.
|
|
327
|
+
"""
|
|
328
|
+
answers_file = project_dir / ".copier-answers.yml"
|
|
329
|
+
if not answers_file.exists():
|
|
330
|
+
typer.echo(f"Error: {project_dir} is not a Copier-generated project", err=True)
|
|
331
|
+
typer.echo("(missing .copier-answers.yml)", err=True)
|
|
332
|
+
raise typer.Exit(1)
|
|
333
|
+
|
|
334
|
+
typer.echo(f"Updating provider project: {project_dir}")
|
|
335
|
+
typer.echo("")
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
copier.run_update(dst_path=project_dir, unsafe=True)
|
|
339
|
+
except Exception as e:
|
|
340
|
+
typer.echo(f"Error updating provider: {e}", err=True)
|
|
341
|
+
raise typer.Exit(1)
|
|
342
|
+
|
|
343
|
+
typer.echo("")
|
|
344
|
+
typer.echo("Provider project updated successfully.")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@app.command()
|
|
348
|
+
def push(
|
|
349
|
+
package: Annotated[
|
|
350
|
+
str | None,
|
|
351
|
+
typer.Option("--package", "-p", help="Provider package name (auto-detected if not specified)"),
|
|
352
|
+
] = None,
|
|
353
|
+
directory: Annotated[
|
|
354
|
+
Path,
|
|
355
|
+
typer.Option("--directory", "-d", help="Provider source directory"),
|
|
356
|
+
] = Path("."),
|
|
357
|
+
deploy: Annotated[
|
|
358
|
+
bool,
|
|
359
|
+
typer.Option("--deploy", help="Deploy after successful build"),
|
|
360
|
+
] = False,
|
|
361
|
+
logs: Annotated[
|
|
362
|
+
bool,
|
|
363
|
+
typer.Option("--logs", help="Stream build logs"),
|
|
364
|
+
] = False,
|
|
365
|
+
wait: Annotated[
|
|
366
|
+
bool,
|
|
367
|
+
typer.Option("--wait/--no-wait", help="Wait for build to complete"),
|
|
368
|
+
] = True,
|
|
369
|
+
):
|
|
370
|
+
"""Build and push provider code to the platform.
|
|
371
|
+
|
|
372
|
+
Creates a tarball of the provider source code and uploads it to the
|
|
373
|
+
Pragmatiks platform for building. The platform uses BuildKit to create
|
|
374
|
+
a container image.
|
|
375
|
+
|
|
376
|
+
Build only:
|
|
377
|
+
pragma provider push
|
|
378
|
+
-> Uploads code and waits for build
|
|
379
|
+
|
|
380
|
+
Build and deploy:
|
|
381
|
+
pragma provider push --deploy
|
|
382
|
+
-> Uploads code, builds, and deploys
|
|
383
|
+
|
|
384
|
+
Async build:
|
|
385
|
+
pragma provider push --no-wait
|
|
386
|
+
-> Uploads code and returns immediately
|
|
387
|
+
|
|
388
|
+
With logs:
|
|
389
|
+
pragma provider push --logs
|
|
390
|
+
-> Shows build output in real-time
|
|
391
|
+
|
|
392
|
+
Example:
|
|
393
|
+
pragma provider push
|
|
394
|
+
pragma provider push --deploy
|
|
395
|
+
pragma provider push --logs --deploy
|
|
396
|
+
|
|
397
|
+
Raises:
|
|
398
|
+
typer.Exit: If provider detection fails or build fails.
|
|
399
|
+
"""
|
|
400
|
+
provider_name = package or detect_provider_package()
|
|
401
|
+
|
|
402
|
+
if not provider_name:
|
|
403
|
+
console.print("[red]Error:[/red] Could not detect provider package.")
|
|
404
|
+
console.print("Run from a provider directory or specify --package")
|
|
405
|
+
raise typer.Exit(1)
|
|
406
|
+
|
|
407
|
+
provider_id = provider_name.replace("_", "-").removesuffix("-provider")
|
|
408
|
+
|
|
409
|
+
if not directory.exists():
|
|
410
|
+
console.print(f"[red]Error:[/red] Directory not found: {directory}")
|
|
411
|
+
raise typer.Exit(1)
|
|
412
|
+
|
|
413
|
+
console.print(f"[bold]Pushing provider:[/bold] {provider_id}")
|
|
414
|
+
console.print(f"[dim]Source directory:[/dim] {directory.absolute()}")
|
|
415
|
+
console.print()
|
|
416
|
+
|
|
417
|
+
with Progress(
|
|
418
|
+
SpinnerColumn(),
|
|
419
|
+
TextColumn("[progress.description]{task.description}"),
|
|
420
|
+
console=console,
|
|
421
|
+
transient=True,
|
|
422
|
+
) as progress:
|
|
423
|
+
progress.add_task("Creating tarball...", total=None)
|
|
424
|
+
tarball = create_tarball(directory)
|
|
425
|
+
|
|
426
|
+
console.print(f"[green]Created tarball:[/green] {len(tarball) / 1024:.1f} KB")
|
|
427
|
+
|
|
428
|
+
client = get_client()
|
|
429
|
+
|
|
430
|
+
if client._auth is None:
|
|
431
|
+
console.print("[red]Error:[/red] Authentication required. Run 'pragma login' first.")
|
|
432
|
+
raise typer.Exit(1)
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
push_result = _upload_code(client, provider_id, tarball)
|
|
436
|
+
|
|
437
|
+
if not wait:
|
|
438
|
+
console.print()
|
|
439
|
+
console.print("[dim]Build running in background. Check status with:[/dim]")
|
|
440
|
+
console.print(f" pragma provider status {provider_id} --job {push_result.job_name}")
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
build_result = _wait_for_build(client, provider_id, push_result.job_name, logs)
|
|
444
|
+
|
|
445
|
+
if deploy:
|
|
446
|
+
if not build_result.image:
|
|
447
|
+
console.print("[red]Error:[/red] Build succeeded but no image was produced")
|
|
448
|
+
raise typer.Exit(1)
|
|
449
|
+
|
|
450
|
+
console.print()
|
|
451
|
+
_deploy_provider(client, provider_id, build_result.image)
|
|
452
|
+
except Exception as e:
|
|
453
|
+
if isinstance(e, typer.Exit):
|
|
454
|
+
raise
|
|
455
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
456
|
+
raise typer.Exit(1)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _upload_code(client: PragmaClient, provider_id: str, tarball: bytes) -> PushResult:
|
|
460
|
+
"""Upload provider code tarball to the platform.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
client: SDK client instance.
|
|
464
|
+
provider_id: Provider identifier.
|
|
465
|
+
tarball: Gzipped tarball bytes of provider source.
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
PushResult with build job details.
|
|
469
|
+
"""
|
|
470
|
+
with Progress(
|
|
471
|
+
SpinnerColumn(),
|
|
472
|
+
TextColumn("[progress.description]{task.description}"),
|
|
473
|
+
console=console,
|
|
474
|
+
transient=True,
|
|
475
|
+
) as progress:
|
|
476
|
+
progress.add_task("Uploading code...", total=None)
|
|
477
|
+
push_result = client.push_provider(provider_id, tarball)
|
|
478
|
+
|
|
479
|
+
console.print(f"[green]Build started:[/green] {push_result.job_name}")
|
|
480
|
+
return push_result
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _wait_for_build(
|
|
484
|
+
client: PragmaClient,
|
|
485
|
+
provider_id: str,
|
|
486
|
+
job_name: str,
|
|
487
|
+
logs: bool,
|
|
488
|
+
) -> BuildResult:
|
|
489
|
+
"""Wait for build to complete, optionally streaming logs.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
client: SDK client instance.
|
|
493
|
+
provider_id: Provider identifier.
|
|
494
|
+
job_name: Build job name.
|
|
495
|
+
logs: Whether to stream build logs.
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
Final BuildResult.
|
|
499
|
+
|
|
500
|
+
Raises:
|
|
501
|
+
typer.Exit: On build failure or timeout.
|
|
502
|
+
"""
|
|
503
|
+
if logs:
|
|
504
|
+
_stream_build_logs(client, provider_id, job_name)
|
|
505
|
+
else:
|
|
506
|
+
build_result = _poll_build_status(client, provider_id, job_name)
|
|
507
|
+
|
|
508
|
+
if build_result.status == BuildStatus.FAILED:
|
|
509
|
+
console.print(f"[red]Build failed:[/red] {build_result.error_message}")
|
|
510
|
+
raise typer.Exit(1)
|
|
511
|
+
|
|
512
|
+
console.print(f"[green]Build successful:[/green] {build_result.image}")
|
|
513
|
+
|
|
514
|
+
final_build = client.get_build_status(provider_id, job_name)
|
|
515
|
+
|
|
516
|
+
if final_build.status != BuildStatus.SUCCESS:
|
|
517
|
+
console.print(f"[red]Build failed:[/red] {final_build.error_message}")
|
|
518
|
+
raise typer.Exit(1)
|
|
519
|
+
|
|
520
|
+
return final_build
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _poll_build_status(client: PragmaClient, provider_id: str, job_name: str) -> BuildResult:
|
|
524
|
+
"""Poll build status until completion or timeout.
|
|
525
|
+
|
|
526
|
+
Args:
|
|
527
|
+
client: SDK client instance.
|
|
528
|
+
provider_id: Provider identifier.
|
|
529
|
+
job_name: Build job name.
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
Final BuildResult.
|
|
533
|
+
|
|
534
|
+
Raises:
|
|
535
|
+
typer.Exit: If build times out.
|
|
536
|
+
"""
|
|
537
|
+
start_time = time.time()
|
|
538
|
+
|
|
539
|
+
with Progress(
|
|
540
|
+
SpinnerColumn(),
|
|
541
|
+
TextColumn("[progress.description]{task.description}"),
|
|
542
|
+
console=console,
|
|
543
|
+
transient=True,
|
|
544
|
+
) as progress:
|
|
545
|
+
task = progress.add_task("Building...", total=None)
|
|
546
|
+
|
|
547
|
+
while True:
|
|
548
|
+
build_result = client.get_build_status(provider_id, job_name)
|
|
549
|
+
|
|
550
|
+
if build_result.status in (BuildStatus.SUCCESS, BuildStatus.FAILED):
|
|
551
|
+
return build_result
|
|
552
|
+
|
|
553
|
+
elapsed = time.time() - start_time
|
|
554
|
+
if elapsed > BUILD_TIMEOUT:
|
|
555
|
+
console.print("[red]Error:[/red] Build timed out")
|
|
556
|
+
raise typer.Exit(1)
|
|
557
|
+
|
|
558
|
+
progress.update(task, description=f"Building... ({build_result.status.value})")
|
|
559
|
+
time.sleep(BUILD_POLL_INTERVAL)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _stream_build_logs(client: PragmaClient, provider_id: str, job_name: str) -> None:
|
|
563
|
+
"""Stream build logs to console.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
client: SDK client instance.
|
|
567
|
+
provider_id: Provider identifier.
|
|
568
|
+
job_name: Build job name.
|
|
569
|
+
"""
|
|
570
|
+
console.print()
|
|
571
|
+
console.print("[bold]Build logs:[/bold]")
|
|
572
|
+
console.print("-" * 40)
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
with client.stream_build_logs(provider_id, job_name) as response:
|
|
576
|
+
for line in response.iter_lines():
|
|
577
|
+
console.print(line)
|
|
578
|
+
except httpx.HTTPError as e:
|
|
579
|
+
console.print(f"[yellow]Warning:[/yellow] Could not stream logs: {e}")
|
|
580
|
+
console.print("[dim]Falling back to polling...[/dim]")
|
|
581
|
+
_poll_build_status(client, provider_id, job_name)
|
|
582
|
+
|
|
583
|
+
console.print("-" * 40)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _deploy_provider(client: PragmaClient, provider_id: str, image: str) -> None:
|
|
587
|
+
"""Deploy the provider.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
client: SDK client instance.
|
|
591
|
+
provider_id: Provider identifier.
|
|
592
|
+
image: Container image to deploy.
|
|
593
|
+
"""
|
|
594
|
+
with Progress(
|
|
595
|
+
SpinnerColumn(),
|
|
596
|
+
TextColumn("[progress.description]{task.description}"),
|
|
597
|
+
console=console,
|
|
598
|
+
transient=True,
|
|
599
|
+
) as progress:
|
|
600
|
+
progress.add_task("Deploying...", total=None)
|
|
601
|
+
deploy_result = client.deploy_provider(provider_id, image)
|
|
602
|
+
|
|
603
|
+
console.print(f"[green]Deployment started:[/green] {deploy_result.deployment_name}")
|
|
604
|
+
console.print(f"[dim]Status:[/dim] {deploy_result.status.value}")
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@app.command()
|
|
608
|
+
def deploy(
|
|
609
|
+
image: Annotated[
|
|
610
|
+
str,
|
|
611
|
+
typer.Option("--image", "-i", help="Container image to deploy (required)"),
|
|
612
|
+
],
|
|
613
|
+
package: Annotated[
|
|
614
|
+
str | None,
|
|
615
|
+
typer.Option("--package", "-p", help="Provider package name (auto-detected if not specified)"),
|
|
616
|
+
] = None,
|
|
617
|
+
):
|
|
618
|
+
"""Deploy a provider from a built container image.
|
|
619
|
+
|
|
620
|
+
Deploys a provider image to Kubernetes. Use after 'pragma provider push'
|
|
621
|
+
completes successfully, or to redeploy/rollback to a specific version.
|
|
622
|
+
|
|
623
|
+
Example:
|
|
624
|
+
pragma provider deploy --image europe-west4-docker.pkg.dev/project/repo/provider:tag
|
|
625
|
+
pragma provider deploy -i provider:latest --package my_provider
|
|
626
|
+
|
|
627
|
+
Raises:
|
|
628
|
+
typer.Exit: If deployment fails.
|
|
629
|
+
"""
|
|
630
|
+
provider_name = package or detect_provider_package()
|
|
631
|
+
|
|
632
|
+
if not provider_name:
|
|
633
|
+
console.print("[red]Error:[/red] Could not detect provider package.")
|
|
634
|
+
console.print("Run from a provider directory or specify --package")
|
|
635
|
+
raise typer.Exit(1)
|
|
636
|
+
|
|
637
|
+
provider_id = provider_name.replace("_", "-").removesuffix("-provider")
|
|
638
|
+
|
|
639
|
+
console.print(f"[bold]Deploying provider:[/bold] {provider_id}")
|
|
640
|
+
console.print(f"[dim]Image:[/dim] {image}")
|
|
641
|
+
console.print()
|
|
642
|
+
|
|
643
|
+
client = get_client()
|
|
644
|
+
|
|
645
|
+
if client._auth is None:
|
|
646
|
+
console.print("[red]Error:[/red] Authentication required. Run 'pragma auth login' first.")
|
|
647
|
+
raise typer.Exit(1)
|
|
648
|
+
|
|
649
|
+
try:
|
|
650
|
+
_deploy_provider(client, provider_id, image)
|
|
651
|
+
except Exception as e:
|
|
652
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
653
|
+
raise typer.Exit(1)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
@app.command()
|
|
657
|
+
def sync(
|
|
658
|
+
package: Annotated[
|
|
659
|
+
str | None,
|
|
660
|
+
typer.Option("--package", "-p", help="Provider package name (auto-detected if not specified)"),
|
|
661
|
+
] = None,
|
|
662
|
+
dry_run: Annotated[
|
|
663
|
+
bool,
|
|
664
|
+
typer.Option("--dry-run", help="Show what would be registered without making changes"),
|
|
665
|
+
] = False,
|
|
666
|
+
):
|
|
667
|
+
"""Sync resource type definitions to the Pragmatiks platform.
|
|
668
|
+
|
|
669
|
+
Discovers resources in the provider package and registers their schemas
|
|
670
|
+
with the API. This allows users to create instances of these resource types.
|
|
671
|
+
|
|
672
|
+
The command introspects the provider code to extract:
|
|
673
|
+
- Provider and resource names from @provider.resource() decorator
|
|
674
|
+
- JSON schema from Pydantic Config classes
|
|
675
|
+
|
|
676
|
+
Example:
|
|
677
|
+
pragma provider sync
|
|
678
|
+
pragma provider sync --package postgres_provider
|
|
679
|
+
pragma provider sync --dry-run
|
|
680
|
+
|
|
681
|
+
Raises:
|
|
682
|
+
typer.Exit: If package not found or registration fails.
|
|
683
|
+
"""
|
|
684
|
+
package_name = package or detect_provider_package()
|
|
685
|
+
|
|
686
|
+
if not package_name:
|
|
687
|
+
typer.echo("Error: Could not detect provider package.", err=True)
|
|
688
|
+
typer.echo("Run from a provider directory or specify --package", err=True)
|
|
689
|
+
raise typer.Exit(1)
|
|
690
|
+
|
|
691
|
+
typer.echo(f"Discovering resources in {package_name}...")
|
|
692
|
+
|
|
693
|
+
try:
|
|
694
|
+
resources = discover_resources(package_name)
|
|
695
|
+
except ImportError as e:
|
|
696
|
+
typer.echo(f"Error importing package: {e}", err=True)
|
|
697
|
+
raise typer.Exit(1)
|
|
698
|
+
|
|
699
|
+
if not resources:
|
|
700
|
+
typer.echo("No resources found. Ensure resources are decorated with @provider.resource().")
|
|
701
|
+
raise typer.Exit(0)
|
|
702
|
+
|
|
703
|
+
typer.echo(f"Found {len(resources)} resource(s):")
|
|
704
|
+
typer.echo("")
|
|
705
|
+
|
|
706
|
+
if dry_run:
|
|
707
|
+
for (provider, resource_name), resource_class in resources.items():
|
|
708
|
+
typer.echo(f" {provider}/{resource_name} ({resource_class.__name__})")
|
|
709
|
+
typer.echo("")
|
|
710
|
+
typer.echo("Dry run - no changes made.")
|
|
711
|
+
raise typer.Exit(0)
|
|
712
|
+
|
|
713
|
+
client = get_client()
|
|
714
|
+
|
|
715
|
+
for (provider, resource_name), resource_class in resources.items():
|
|
716
|
+
try:
|
|
717
|
+
config_class = get_config_class(resource_class)
|
|
718
|
+
schema = config_class.model_json_schema()
|
|
719
|
+
except ValueError as e:
|
|
720
|
+
typer.echo(f" {provider}/{resource_name}: skipped ({e})", err=True)
|
|
721
|
+
continue
|
|
722
|
+
|
|
723
|
+
try:
|
|
724
|
+
client.register_resource(
|
|
725
|
+
provider=provider,
|
|
726
|
+
resource=resource_name,
|
|
727
|
+
schema=schema,
|
|
728
|
+
)
|
|
729
|
+
typer.echo(f" {provider}/{resource_name}: registered")
|
|
730
|
+
except Exception as e:
|
|
731
|
+
typer.echo(f" {provider}/{resource_name}: failed ({e})", err=True)
|
|
732
|
+
|
|
733
|
+
typer.echo("")
|
|
734
|
+
typer.echo("Sync complete.")
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def get_config_class(resource_class: type[Resource]) -> type[Config]:
|
|
738
|
+
"""Extract Config subclass from Resource's config field annotation.
|
|
739
|
+
|
|
740
|
+
Args:
|
|
741
|
+
resource_class: A Resource subclass.
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
Config subclass type from the Resource's config field.
|
|
745
|
+
|
|
746
|
+
Raises:
|
|
747
|
+
ValueError: If Resource has no config field or wrong type.
|
|
748
|
+
"""
|
|
749
|
+
annotations = resource_class.model_fields
|
|
750
|
+
config_field = annotations.get("config")
|
|
751
|
+
|
|
752
|
+
if config_field is None:
|
|
753
|
+
raise ValueError(f"Resource {resource_class.__name__} has no config field")
|
|
754
|
+
|
|
755
|
+
config_type = config_field.annotation
|
|
756
|
+
|
|
757
|
+
if not isinstance(config_type, type) or not issubclass(config_type, Config):
|
|
758
|
+
raise ValueError(f"Resource {resource_class.__name__} config field is not a Config subclass")
|
|
759
|
+
|
|
760
|
+
return config_type
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def detect_provider_package() -> str | None:
|
|
764
|
+
"""Detect provider package name from current directory.
|
|
765
|
+
|
|
766
|
+
Returns:
|
|
767
|
+
Package name with underscores if found, None otherwise.
|
|
768
|
+
"""
|
|
769
|
+
pyproject = Path("pyproject.toml")
|
|
770
|
+
if not pyproject.exists():
|
|
771
|
+
return None
|
|
772
|
+
|
|
773
|
+
with open(pyproject, "rb") as f:
|
|
774
|
+
data = tomllib.load(f)
|
|
775
|
+
|
|
776
|
+
name = data.get("project", {}).get("name", "")
|
|
777
|
+
if name and name.endswith("-provider"):
|
|
778
|
+
return name.replace("-", "_")
|
|
779
|
+
|
|
780
|
+
return None
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
@app.command()
|
|
784
|
+
def delete(
|
|
785
|
+
provider_id: Annotated[
|
|
786
|
+
str,
|
|
787
|
+
typer.Argument(help="Provider ID to delete (e.g., 'postgres', 'my-provider')"),
|
|
788
|
+
],
|
|
789
|
+
cascade: Annotated[
|
|
790
|
+
bool,
|
|
791
|
+
typer.Option("--cascade", help="Delete all resources for this provider"),
|
|
792
|
+
] = False,
|
|
793
|
+
force: Annotated[
|
|
794
|
+
bool,
|
|
795
|
+
typer.Option("--force", "-f", help="Skip confirmation prompt"),
|
|
796
|
+
] = False,
|
|
797
|
+
):
|
|
798
|
+
"""Delete a provider and all associated resources.
|
|
799
|
+
|
|
800
|
+
Removes the provider deployment, resource definitions, and pending events
|
|
801
|
+
from the platform. By default, fails if the provider has any resources.
|
|
802
|
+
|
|
803
|
+
Without --cascade:
|
|
804
|
+
pragma provider delete my-provider
|
|
805
|
+
-> Fails if provider has resources
|
|
806
|
+
|
|
807
|
+
With --cascade:
|
|
808
|
+
pragma provider delete my-provider --cascade
|
|
809
|
+
-> Deletes provider and all its resources
|
|
810
|
+
|
|
811
|
+
Skip confirmation:
|
|
812
|
+
pragma provider delete my-provider --force
|
|
813
|
+
pragma provider delete my-provider --cascade --force
|
|
814
|
+
|
|
815
|
+
Example:
|
|
816
|
+
pragma provider delete postgres
|
|
817
|
+
pragma provider delete postgres --cascade
|
|
818
|
+
pragma provider delete postgres --cascade --force
|
|
819
|
+
|
|
820
|
+
Raises:
|
|
821
|
+
typer.Exit: If deletion fails or user cancels.
|
|
822
|
+
"""
|
|
823
|
+
client = get_client()
|
|
824
|
+
|
|
825
|
+
if client._auth is None:
|
|
826
|
+
console.print("[red]Error:[/red] Authentication required. Run 'pragma login' first.")
|
|
827
|
+
raise typer.Exit(1)
|
|
828
|
+
|
|
829
|
+
console.print(f"[bold]Provider:[/bold] {provider_id}")
|
|
830
|
+
if cascade:
|
|
831
|
+
console.print("[yellow]Warning:[/yellow] --cascade will delete all resources for this provider")
|
|
832
|
+
console.print()
|
|
833
|
+
|
|
834
|
+
if not force:
|
|
835
|
+
action = "DELETE provider and all its resources" if cascade else "DELETE provider"
|
|
836
|
+
confirm = typer.confirm(f"Are you sure you want to {action}?")
|
|
837
|
+
if not confirm:
|
|
838
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
839
|
+
raise typer.Exit(0)
|
|
840
|
+
|
|
841
|
+
try:
|
|
842
|
+
with Progress(
|
|
843
|
+
SpinnerColumn(),
|
|
844
|
+
TextColumn("[progress.description]{task.description}"),
|
|
845
|
+
console=console,
|
|
846
|
+
transient=True,
|
|
847
|
+
) as progress:
|
|
848
|
+
progress.add_task("Deleting provider...", total=None)
|
|
849
|
+
result = client.delete_provider(provider_id, cascade=cascade)
|
|
850
|
+
|
|
851
|
+
_print_delete_result(result)
|
|
852
|
+
|
|
853
|
+
except httpx.HTTPStatusError as e:
|
|
854
|
+
if e.response.status_code == 409:
|
|
855
|
+
detail = e.response.json().get("detail", "Provider has resources")
|
|
856
|
+
console.print(f"[red]Error:[/red] {detail}")
|
|
857
|
+
console.print("[dim]Use --cascade to delete all resources with the provider.[/dim]")
|
|
858
|
+
else:
|
|
859
|
+
console.print(f"[red]Error:[/red] {e.response.text}")
|
|
860
|
+
raise typer.Exit(1)
|
|
861
|
+
except Exception as e:
|
|
862
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
863
|
+
raise typer.Exit(1)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _print_delete_result(result: ProviderDeleteResult) -> None:
|
|
867
|
+
"""Print a summary of the deletion result.
|
|
868
|
+
|
|
869
|
+
Args:
|
|
870
|
+
result: ProviderDeleteResult from the API.
|
|
871
|
+
"""
|
|
872
|
+
console.print()
|
|
873
|
+
console.print(f"[green]Provider deleted:[/green] {result.provider_id}")
|
|
874
|
+
console.print()
|
|
875
|
+
|
|
876
|
+
table = Table(show_header=True, header_style="bold")
|
|
877
|
+
table.add_column("Component")
|
|
878
|
+
table.add_column("Deleted", justify="right")
|
|
879
|
+
|
|
880
|
+
table.add_row("Builds Cancelled", str(result.builds_cancelled))
|
|
881
|
+
table.add_row("Source Archives", str(result.source_archives_deleted))
|
|
882
|
+
table.add_row("Deployment", "Yes" if result.deployment_deleted else "No (not found)")
|
|
883
|
+
table.add_row("Resources", str(result.resources_deleted))
|
|
884
|
+
table.add_row("Resource Definitions", str(result.resource_definitions_deleted))
|
|
885
|
+
table.add_row("Outbox Events", str(result.outbox_events_deleted))
|
|
886
|
+
table.add_row("Dead Letter Events", str(result.dead_letter_events_deleted))
|
|
887
|
+
table.add_row("NATS Messages Purged", str(result.messages_purged))
|
|
888
|
+
table.add_row("NATS Consumer", "Deleted" if result.consumer_deleted else "Not found")
|
|
889
|
+
|
|
890
|
+
console.print(table)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
@app.command()
|
|
894
|
+
def rollback(
|
|
895
|
+
provider_id: Annotated[
|
|
896
|
+
str,
|
|
897
|
+
typer.Argument(help="Provider ID to rollback (e.g., 'postgres', 'my-provider')"),
|
|
898
|
+
],
|
|
899
|
+
version: Annotated[
|
|
900
|
+
str | None,
|
|
901
|
+
typer.Option("--version", "-v", help="Target version to rollback to (default: previous successful build)"),
|
|
902
|
+
] = None,
|
|
903
|
+
):
|
|
904
|
+
"""Rollback a provider to a previous version.
|
|
905
|
+
|
|
906
|
+
Redeploys a previously successful build. If no version is specified,
|
|
907
|
+
rolls back to the previous successful version.
|
|
908
|
+
|
|
909
|
+
Rollback to specific version:
|
|
910
|
+
pragma provider rollback my-provider --version 20250114.120000
|
|
911
|
+
|
|
912
|
+
Rollback to previous version:
|
|
913
|
+
pragma provider rollback my-provider
|
|
914
|
+
|
|
915
|
+
Example:
|
|
916
|
+
pragma provider rollback postgres --version 20250114.120000
|
|
917
|
+
pragma provider rollback postgres -v 20250114.120000
|
|
918
|
+
pragma provider rollback postgres
|
|
919
|
+
|
|
920
|
+
Raises:
|
|
921
|
+
typer.Exit: If rollback fails or no suitable version found.
|
|
922
|
+
"""
|
|
923
|
+
client = get_client()
|
|
924
|
+
|
|
925
|
+
if client._auth is None:
|
|
926
|
+
console.print("[red]Error:[/red] Authentication required. Run 'pragma login' first.")
|
|
927
|
+
raise typer.Exit(1)
|
|
928
|
+
|
|
929
|
+
try:
|
|
930
|
+
target_version = version
|
|
931
|
+
if target_version is None:
|
|
932
|
+
target_version = _find_previous_version(client, provider_id)
|
|
933
|
+
|
|
934
|
+
console.print(f"[bold]Rolling back provider:[/bold] {provider_id}")
|
|
935
|
+
console.print(f"[dim]Target version:[/dim] {target_version}")
|
|
936
|
+
console.print()
|
|
937
|
+
|
|
938
|
+
with Progress(
|
|
939
|
+
SpinnerColumn(),
|
|
940
|
+
TextColumn("[progress.description]{task.description}"),
|
|
941
|
+
console=console,
|
|
942
|
+
transient=True,
|
|
943
|
+
) as progress:
|
|
944
|
+
progress.add_task("Deploying previous version...", total=None)
|
|
945
|
+
result = client.rollback_provider(provider_id, target_version)
|
|
946
|
+
|
|
947
|
+
console.print(f"[green]Rollback initiated:[/green] {result.deployment_name}")
|
|
948
|
+
console.print(f"[dim]Status:[/dim] {result.status.value}")
|
|
949
|
+
|
|
950
|
+
except httpx.HTTPStatusError as e:
|
|
951
|
+
if e.response.status_code == 404:
|
|
952
|
+
detail = e.response.json().get("detail", "Build not found")
|
|
953
|
+
console.print(f"[red]Error:[/red] {detail}")
|
|
954
|
+
elif e.response.status_code == 400:
|
|
955
|
+
detail = e.response.json().get("detail", "Build not deployable")
|
|
956
|
+
console.print(f"[red]Error:[/red] {detail}")
|
|
957
|
+
else:
|
|
958
|
+
console.print(f"[red]Error:[/red] {e.response.text}")
|
|
959
|
+
raise typer.Exit(1)
|
|
960
|
+
except ValueError as e:
|
|
961
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
962
|
+
raise typer.Exit(1)
|
|
963
|
+
except Exception as e:
|
|
964
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
965
|
+
raise typer.Exit(1)
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _find_previous_version(client: PragmaClient, provider_id: str) -> str:
|
|
969
|
+
"""Find the previous successful version for rollback.
|
|
970
|
+
|
|
971
|
+
Gets the build history and finds the second-most-recent successful build,
|
|
972
|
+
which represents the version before the current deployment.
|
|
973
|
+
|
|
974
|
+
Args:
|
|
975
|
+
client: SDK client instance.
|
|
976
|
+
provider_id: Provider identifier.
|
|
977
|
+
|
|
978
|
+
Returns:
|
|
979
|
+
CalVer version string of the previous successful build.
|
|
980
|
+
|
|
981
|
+
Raises:
|
|
982
|
+
ValueError: If no previous successful build exists.
|
|
983
|
+
"""
|
|
984
|
+
builds = client.list_builds(provider_id)
|
|
985
|
+
successful_builds = [b for b in builds if b.status == BuildStatus.SUCCESS]
|
|
986
|
+
|
|
987
|
+
if len(successful_builds) < 2:
|
|
988
|
+
raise ValueError(
|
|
989
|
+
f"No previous successful build found for provider '{provider_id}'. "
|
|
990
|
+
"Specify a version explicitly with --version."
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
return successful_builds[1].version
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
@app.command()
|
|
997
|
+
def status(
|
|
998
|
+
provider_id: Annotated[
|
|
999
|
+
str,
|
|
1000
|
+
typer.Argument(help="Provider ID to check status (e.g., 'postgres', 'my-provider')"),
|
|
1001
|
+
],
|
|
1002
|
+
):
|
|
1003
|
+
"""Check the deployment status of a provider.
|
|
1004
|
+
|
|
1005
|
+
Displays:
|
|
1006
|
+
- Deployment status (pending/progressing/available/failed)
|
|
1007
|
+
- Replica count (available/ready)
|
|
1008
|
+
- Current image version
|
|
1009
|
+
- Last updated timestamp
|
|
1010
|
+
|
|
1011
|
+
Example:
|
|
1012
|
+
pragma provider status postgres
|
|
1013
|
+
pragma provider status my-provider
|
|
1014
|
+
|
|
1015
|
+
Raises:
|
|
1016
|
+
typer.Exit: If deployment not found or status check fails.
|
|
1017
|
+
"""
|
|
1018
|
+
client = get_client()
|
|
1019
|
+
|
|
1020
|
+
if client._auth is None:
|
|
1021
|
+
console.print("[red]Error:[/red] Authentication required. Run 'pragma login' first.")
|
|
1022
|
+
raise typer.Exit(1)
|
|
1023
|
+
|
|
1024
|
+
try:
|
|
1025
|
+
result = client.get_deployment_status(provider_id)
|
|
1026
|
+
except httpx.HTTPStatusError as e:
|
|
1027
|
+
if e.response.status_code == 404:
|
|
1028
|
+
console.print(f"[red]Error:[/red] Deployment not found for provider: {provider_id}")
|
|
1029
|
+
else:
|
|
1030
|
+
console.print(f"[red]Error:[/red] {e.response.text}")
|
|
1031
|
+
raise typer.Exit(1)
|
|
1032
|
+
except Exception as e:
|
|
1033
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
1034
|
+
raise typer.Exit(1)
|
|
1035
|
+
|
|
1036
|
+
_print_deployment_status(provider_id, result)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _print_deployment_status(provider_id: str, result) -> None:
|
|
1040
|
+
"""Print deployment status in a formatted table.
|
|
1041
|
+
|
|
1042
|
+
Args:
|
|
1043
|
+
provider_id: Provider identifier.
|
|
1044
|
+
result: DeploymentResult from the API.
|
|
1045
|
+
"""
|
|
1046
|
+
status_colors = {
|
|
1047
|
+
"pending": "yellow",
|
|
1048
|
+
"progressing": "cyan",
|
|
1049
|
+
"available": "green",
|
|
1050
|
+
"failed": "red",
|
|
1051
|
+
}
|
|
1052
|
+
status_color = status_colors.get(result.status.value, "white")
|
|
1053
|
+
|
|
1054
|
+
console.print()
|
|
1055
|
+
console.print(f"[bold]Provider:[/bold] {provider_id}")
|
|
1056
|
+
console.print()
|
|
1057
|
+
|
|
1058
|
+
table = Table(show_header=True, header_style="bold")
|
|
1059
|
+
table.add_column("Property")
|
|
1060
|
+
table.add_column("Value")
|
|
1061
|
+
|
|
1062
|
+
table.add_row("Status", f"[{status_color}]{result.status.value}[/{status_color}]")
|
|
1063
|
+
table.add_row("Replicas", f"{result.available_replicas} available / {result.ready_replicas} ready")
|
|
1064
|
+
|
|
1065
|
+
if result.image:
|
|
1066
|
+
version = result.image.split(":")[-1] if ":" in result.image else "unknown"
|
|
1067
|
+
table.add_row("Version", version)
|
|
1068
|
+
table.add_row("Image", f"[dim]{result.image}[/dim]")
|
|
1069
|
+
|
|
1070
|
+
if result.updated_at:
|
|
1071
|
+
table.add_row("Updated", result.updated_at.strftime("%Y-%m-%d %H:%M:%S UTC"))
|
|
1072
|
+
|
|
1073
|
+
if result.message:
|
|
1074
|
+
table.add_row("Message", result.message)
|
|
1075
|
+
|
|
1076
|
+
console.print(table)
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
@app.command()
|
|
1080
|
+
def builds(
|
|
1081
|
+
provider_id: Annotated[
|
|
1082
|
+
str,
|
|
1083
|
+
typer.Argument(help="Provider ID to list builds for (e.g., 'postgres', 'my-provider')"),
|
|
1084
|
+
],
|
|
1085
|
+
):
|
|
1086
|
+
"""List build history for a provider.
|
|
1087
|
+
|
|
1088
|
+
Shows the last 10 builds ordered by creation time (newest first).
|
|
1089
|
+
Useful for selecting versions for rollback and verifying build status.
|
|
1090
|
+
|
|
1091
|
+
Example:
|
|
1092
|
+
pragma provider builds postgres
|
|
1093
|
+
pragma provider builds my-provider
|
|
1094
|
+
|
|
1095
|
+
Raises:
|
|
1096
|
+
typer.Exit: If request fails.
|
|
1097
|
+
"""
|
|
1098
|
+
client = get_client()
|
|
1099
|
+
|
|
1100
|
+
if client._auth is None:
|
|
1101
|
+
console.print("[red]Error:[/red] Authentication required. Run 'pragma login' first.")
|
|
1102
|
+
raise typer.Exit(1)
|
|
1103
|
+
|
|
1104
|
+
try:
|
|
1105
|
+
with Progress(
|
|
1106
|
+
SpinnerColumn(),
|
|
1107
|
+
TextColumn("[progress.description]{task.description}"),
|
|
1108
|
+
console=console,
|
|
1109
|
+
transient=True,
|
|
1110
|
+
) as progress:
|
|
1111
|
+
progress.add_task("Fetching builds...", total=None)
|
|
1112
|
+
build_list = client.list_builds(provider_id)
|
|
1113
|
+
|
|
1114
|
+
if not build_list:
|
|
1115
|
+
console.print(f"[dim]No builds found for provider:[/dim] {provider_id}")
|
|
1116
|
+
raise typer.Exit(0)
|
|
1117
|
+
|
|
1118
|
+
console.print(f"[bold]Builds for provider:[/bold] {provider_id}")
|
|
1119
|
+
console.print()
|
|
1120
|
+
|
|
1121
|
+
table = Table(show_header=True, header_style="bold")
|
|
1122
|
+
table.add_column("Version")
|
|
1123
|
+
table.add_column("Status")
|
|
1124
|
+
table.add_column("Created")
|
|
1125
|
+
table.add_column("Error")
|
|
1126
|
+
|
|
1127
|
+
for build in build_list:
|
|
1128
|
+
status_color = _get_build_status_color(build.status)
|
|
1129
|
+
error_display = (
|
|
1130
|
+
build.error_message[:50] + "..."
|
|
1131
|
+
if build.error_message and len(build.error_message) > 50
|
|
1132
|
+
else (build.error_message or "-")
|
|
1133
|
+
)
|
|
1134
|
+
table.add_row(
|
|
1135
|
+
build.version,
|
|
1136
|
+
f"[{status_color}]{build.status.value}[/{status_color}]",
|
|
1137
|
+
build.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
|
1138
|
+
error_display,
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
console.print(table)
|
|
1142
|
+
|
|
1143
|
+
except httpx.HTTPStatusError as e:
|
|
1144
|
+
console.print(f"[red]Error:[/red] {e.response.text}")
|
|
1145
|
+
raise typer.Exit(1)
|
|
1146
|
+
except Exception as e:
|
|
1147
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
1148
|
+
raise typer.Exit(1)
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _get_build_status_color(status: BuildStatus) -> str:
|
|
1152
|
+
"""Get the color for a build status.
|
|
1153
|
+
|
|
1154
|
+
Args:
|
|
1155
|
+
status: Build status enum value.
|
|
1156
|
+
|
|
1157
|
+
Returns:
|
|
1158
|
+
Rich color name for the status.
|
|
1159
|
+
"""
|
|
1160
|
+
return {
|
|
1161
|
+
BuildStatus.PENDING: "yellow",
|
|
1162
|
+
BuildStatus.BUILDING: "blue",
|
|
1163
|
+
BuildStatus.SUCCESS: "green",
|
|
1164
|
+
BuildStatus.FAILED: "red",
|
|
1165
|
+
}.get(status, "white")
|