urun-cli 0.5.2__tar.gz → 0.5.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {urun_cli-0.5.2 → urun_cli-0.5.3}/CHANGELOG.md +11 -0
- {urun_cli-0.5.2 → urun_cli-0.5.3}/PKG-INFO +13 -8
- {urun_cli-0.5.2 → urun_cli-0.5.3}/README.md +12 -7
- {urun_cli-0.5.2 → urun_cli-0.5.3}/pyproject.toml +1 -1
- {urun_cli-0.5.2 → urun_cli-0.5.3}/src/urun/api.py +14 -0
- {urun_cli-0.5.2 → urun_cli-0.5.3}/src/urun/catalog.py +92 -23
- {urun_cli-0.5.2 → urun_cli-0.5.3}/src/urun/cli.py +414 -50
- {urun_cli-0.5.2 → urun_cli-0.5.3}/src/urun/manifest.py +15 -1
- {urun_cli-0.5.2 → urun_cli-0.5.3}/src/urun/serve.py +38 -16
- {urun_cli-0.5.2 → urun_cli-0.5.3}/.gitignore +0 -0
- {urun_cli-0.5.2 → urun_cli-0.5.3}/LICENSE +0 -0
- {urun_cli-0.5.2 → urun_cli-0.5.3}/SECURITY.md +0 -0
- {urun_cli-0.5.2 → urun_cli-0.5.3}/src/urun/__init__.py +0 -0
- {urun_cli-0.5.2 → urun_cli-0.5.3}/src/urun/config.py +0 -0
- {urun_cli-0.5.2 → urun_cli-0.5.3}/src/urun/deps.py +0 -0
- {urun_cli-0.5.2 → urun_cli-0.5.3}/src/urun/discovery.py +0 -0
- {urun_cli-0.5.2 → urun_cli-0.5.3}/src/urun/errors.py +0 -0
- {urun_cli-0.5.2 → urun_cli-0.5.3}/src/urun/retry.py +0 -0
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.3
|
|
4
|
+
|
|
5
|
+
- Publish the previously-unreleased `urun app purge` and `urun build purge`
|
|
6
|
+
subcommands. Both landed on `main` after the `v0.5.2` tag was cut, and the
|
|
7
|
+
release workflow skips re-publishing when the `pyproject` version is unchanged,
|
|
8
|
+
so they were never available on PyPI. This is a version-bump-only release that
|
|
9
|
+
carries the existing, already-tested commands to PyPI so CI/operators can run
|
|
10
|
+
`uvx --from urun-cli==0.5.3 urun app purge <slug>` (hard-delete orphaned app
|
|
11
|
+
metadata after `urun app delete`) without a private git install. No CLI
|
|
12
|
+
behavior changes beyond making these commands installable.
|
|
13
|
+
|
|
3
14
|
## Unreleased
|
|
4
15
|
|
|
5
16
|
- Make `urun deploy` resilient to transient network blips. The manifest/blob
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: urun-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.3
|
|
4
4
|
Summary: End-user CLI for deploying apps to urun
|
|
5
5
|
Project-URL: Homepage, https://urun.sh
|
|
6
6
|
Project-URL: Repository, https://github.com/urun-sh/urun-cli
|
|
@@ -51,21 +51,26 @@ uvx urun-cli --version
|
|
|
51
51
|
|
|
52
52
|
## Quick start
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
with `urun login`:
|
|
54
|
+
Save an org-scoped deploy API key locally with `urun login`:
|
|
56
55
|
|
|
57
56
|
```bash
|
|
58
|
-
urun login
|
|
57
|
+
urun login
|
|
59
58
|
```
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
If `URUN_API_KEY` is not already set, `urun login` opens the urun console API
|
|
61
|
+
keys page, prompts you to paste the generated `urun_sk_...` key, verifies it
|
|
62
|
+
with the urun API, and stores credentials for later commands.
|
|
63
|
+
|
|
64
|
+
If you already have a key, you can pass it directly:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
urun login --api-key urun_sk_<secret>
|
|
68
|
+
```
|
|
64
69
|
|
|
65
70
|
For CI or one-off commands, you can still use the environment variable:
|
|
66
71
|
|
|
67
72
|
```bash
|
|
68
|
-
export URUN_API_KEY=
|
|
73
|
+
export URUN_API_KEY=urun_sk_<secret>
|
|
69
74
|
```
|
|
70
75
|
|
|
71
76
|
Create `app.py`:
|
|
@@ -29,21 +29,26 @@ uvx urun-cli --version
|
|
|
29
29
|
|
|
30
30
|
## Quick start
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
with `urun login`:
|
|
32
|
+
Save an org-scoped deploy API key locally with `urun login`:
|
|
34
33
|
|
|
35
34
|
```bash
|
|
36
|
-
urun login
|
|
35
|
+
urun login
|
|
37
36
|
```
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
If `URUN_API_KEY` is not already set, `urun login` opens the urun console API
|
|
39
|
+
keys page, prompts you to paste the generated `urun_sk_...` key, verifies it
|
|
40
|
+
with the urun API, and stores credentials for later commands.
|
|
41
|
+
|
|
42
|
+
If you already have a key, you can pass it directly:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
urun login --api-key urun_sk_<secret>
|
|
46
|
+
```
|
|
42
47
|
|
|
43
48
|
For CI or one-off commands, you can still use the environment variable:
|
|
44
49
|
|
|
45
50
|
```bash
|
|
46
|
-
export URUN_API_KEY=
|
|
51
|
+
export URUN_API_KEY=urun_sk_<secret>
|
|
47
52
|
```
|
|
48
53
|
|
|
49
54
|
Create `app.py`:
|
|
@@ -180,6 +180,11 @@ class ApiClient:
|
|
|
180
180
|
f"/cli/builds/{urllib.parse.quote(build_id, safe='')}/logs", {"tail": tail}
|
|
181
181
|
)
|
|
182
182
|
|
|
183
|
+
def purge_build(self, manifest_hash_or_prefix: str) -> dict[str, Any]:
|
|
184
|
+
return self._delete(
|
|
185
|
+
f"/cli/builds/{urllib.parse.quote(manifest_hash_or_prefix, safe='')}/purge"
|
|
186
|
+
)
|
|
187
|
+
|
|
183
188
|
def list_releases(self, app: str, environment: str | None = None) -> dict[str, Any]:
|
|
184
189
|
return self._get("/cli/releases", {"app": app, "environment": environment})
|
|
185
190
|
|
|
@@ -399,6 +404,15 @@ class ApiClient:
|
|
|
399
404
|
body: dict[str, Any] | None = {"environment": environment} if environment else None
|
|
400
405
|
return self._json("POST", f"/app/{urllib.parse.quote(app, safe='')}/activate", body)
|
|
401
406
|
|
|
407
|
+
def purge_app(self, app: str) -> dict[str, Any]:
|
|
408
|
+
"""Permanently purge orphaned app metadata for a partially-deleted app.
|
|
409
|
+
|
|
410
|
+
Hits ``DELETE /cli/apps/<slug>/purge``. The backend scopes the purge to
|
|
411
|
+
the API key's org and refuses to act while deployment rows still exist,
|
|
412
|
+
so ordinary deployed apps must use lifecycle commands first.
|
|
413
|
+
"""
|
|
414
|
+
return self._delete(f"/cli/apps/{urllib.parse.quote(app, safe='')}/purge")
|
|
415
|
+
|
|
402
416
|
def org_config(self) -> dict[str, Any]:
|
|
403
417
|
return self._json("GET", "/org-config", None)
|
|
404
418
|
|
|
@@ -1,16 +1,23 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Catalog client for the control-plane model catalog.
|
|
2
2
|
|
|
3
|
-
``public.model_catalog`` is org-AGNOSTIC
|
|
4
|
-
|
|
3
|
+
``public.model_catalog`` is org-AGNOSTIC reference data published by a urun-infra
|
|
4
|
+
migration (one row per ``(model_id, variant, gpu_spec)``; see
|
|
5
5
|
``urun-infra/catalog/CONTRACT.md``). ``urun serve catalog`` enumerates it and
|
|
6
6
|
``urun serve <id>[:<variant>]`` resolves a single row to deploy.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
DEFAULT PATH (no silent fallbacks — see CLAUDE.md): the catalog is read over the
|
|
9
|
+
uRun API's ``/catalog`` edge function, authed with ONLY the user's API key
|
|
10
|
+
(``Authorization: Bearer <api_key>`` — NO Supabase anon key). The base is the
|
|
11
|
+
SAME control-plane API URL the rest of the CLI uses (``--api-url`` /
|
|
12
|
+
``URUN_API_URL`` / saved login, default ``api.urun.sh``); the request GETs
|
|
13
|
+
``<api_url>/catalog`` and the JSON body is ``{"rows": [ ...model_catalog rows... ]}``.
|
|
14
|
+
Build this client with :meth:`CatalogClient.from_api`.
|
|
15
|
+
|
|
16
|
+
DEPRECATED PATH: the old direct-PostgREST read at
|
|
17
|
+
``/rest/v1/model_catalog`` with the public Supabase anon key
|
|
18
|
+
(``apikey`` + ``Authorization: Bearer <anon>``, response a top-level JSON array)
|
|
19
|
+
stays as an explicit opt-in fallback for callers that pass an anon key or a
|
|
20
|
+
``--catalog-url``. There is exactly ONE resolution for each input.
|
|
14
21
|
"""
|
|
15
22
|
|
|
16
23
|
from __future__ import annotations
|
|
@@ -549,12 +556,18 @@ def valid_skus_for_row(row: CatalogRow) -> list[str]:
|
|
|
549
556
|
|
|
550
557
|
|
|
551
558
|
class CatalogClient:
|
|
552
|
-
"""Reads ``public.model_catalog`` over
|
|
559
|
+
"""Reads ``public.model_catalog`` over one of two transports.
|
|
553
560
|
|
|
554
|
-
``
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
561
|
+
DEFAULT (``mode="api"``, built via :meth:`from_api`): GET ``<api_url>/catalog``
|
|
562
|
+
with ONLY the user's API key (``Authorization: Bearer <api_key>`` — no
|
|
563
|
+
``apikey`` header). The edge function returns ``{"rows": [ ...catalog rows... ]}``.
|
|
564
|
+
|
|
565
|
+
DEPRECATED (``mode="postgrest"``, the raw ``__init__``): GET the PostgREST base
|
|
566
|
+
(``.../rest/v1``) with the public Supabase anon key (``apikey`` + ``Bearer``
|
|
567
|
+
headers); the response is a top-level JSON array. Used only when a caller
|
|
568
|
+
explicitly opts in with an anon key or a ``--catalog-url``.
|
|
569
|
+
|
|
570
|
+
Both are resolved by :func:`urun.serve.resolve_catalog_client`.
|
|
558
571
|
"""
|
|
559
572
|
|
|
560
573
|
def __init__(
|
|
@@ -565,21 +578,69 @@ class CatalogClient:
|
|
|
565
578
|
backoff: BackoffPolicy = DEPLOY_BACKOFF,
|
|
566
579
|
on_retry: Callable[[str], None] | None = None,
|
|
567
580
|
):
|
|
581
|
+
"""Build a DEPRECATED PostgREST/anon-key client.
|
|
582
|
+
|
|
583
|
+
``base_url`` is the PostgREST base (``.../rest/v1``); ``anon_key`` is the
|
|
584
|
+
public Supabase anon key. Prefer :meth:`from_api` (the default API-key
|
|
585
|
+
transport); this path is kept only as an explicit opt-in fallback.
|
|
586
|
+
"""
|
|
568
587
|
require_safe_url(base_url, "catalog URL")
|
|
569
588
|
if not anon_key:
|
|
570
589
|
raise UrunError("missing catalog anon key")
|
|
571
590
|
self.base_url = base_url.rstrip("/")
|
|
572
591
|
self.anon_key = anon_key
|
|
592
|
+
self.api_key: str | None = None
|
|
593
|
+
self._mode = "postgrest"
|
|
573
594
|
self.timeout = timeout
|
|
574
595
|
self.backoff = backoff
|
|
575
596
|
self.on_retry = on_retry
|
|
576
597
|
|
|
598
|
+
@classmethod
|
|
599
|
+
def from_api(
|
|
600
|
+
cls,
|
|
601
|
+
api_url: str,
|
|
602
|
+
api_key: str,
|
|
603
|
+
timeout: float = 30.0,
|
|
604
|
+
backoff: BackoffPolicy = DEPLOY_BACKOFF,
|
|
605
|
+
on_retry: Callable[[str], None] | None = None,
|
|
606
|
+
) -> CatalogClient:
|
|
607
|
+
"""Build the DEFAULT API-key catalog client.
|
|
608
|
+
|
|
609
|
+
Targets ``<api_url rstrip '/'>/catalog`` and authenticates with ONLY the
|
|
610
|
+
user's API key (``Authorization: Bearer <api_key>``; no anon key). The
|
|
611
|
+
edge function returns ``{"rows": [ ...full model_catalog rows... ]}``.
|
|
612
|
+
"""
|
|
613
|
+
base = (api_url or "").rstrip("/") + "/catalog"
|
|
614
|
+
require_safe_url(base, "catalog URL")
|
|
615
|
+
if not api_key:
|
|
616
|
+
raise UrunError("missing catalog API key")
|
|
617
|
+
self = cls.__new__(cls)
|
|
618
|
+
self.base_url = base
|
|
619
|
+
self.anon_key = ""
|
|
620
|
+
self.api_key = api_key
|
|
621
|
+
self._mode = "api"
|
|
622
|
+
self.timeout = timeout
|
|
623
|
+
self.backoff = backoff
|
|
624
|
+
self.on_retry = on_retry
|
|
625
|
+
return self
|
|
626
|
+
|
|
577
627
|
def select_rows(self) -> list[CatalogRow]:
|
|
578
628
|
"""Select the full catalog, PK-ordered, as :class:`CatalogRow` objects.
|
|
579
629
|
|
|
580
|
-
|
|
581
|
-
|
|
630
|
+
API mode reads the ``rows`` array from the ``{"rows": [...]}`` JSON object
|
|
631
|
+
the ``/catalog`` edge function returns; PostgREST mode reads a top-level
|
|
632
|
+
JSON array. Raises a clear, actionable error when the catalog is not
|
|
633
|
+
seeded yet (it ships via a urun-infra migration) instead of a raw 404.
|
|
582
634
|
"""
|
|
635
|
+
if self._mode == "api":
|
|
636
|
+
payload = self._get("")
|
|
637
|
+
if not isinstance(payload, dict):
|
|
638
|
+
raise UrunError("catalog response was not a JSON object with a 'rows' array")
|
|
639
|
+
rows = payload.get("rows")
|
|
640
|
+
if not isinstance(rows, list):
|
|
641
|
+
raise UrunError("catalog response 'rows' was not a JSON array")
|
|
642
|
+
return [parse_row(item) for item in rows]
|
|
643
|
+
|
|
583
644
|
params = urllib.parse.urlencode(
|
|
584
645
|
{"select": "*", "order": "model_id,variant,gpu_spec"}, safe=",:"
|
|
585
646
|
)
|
|
@@ -588,17 +649,25 @@ class CatalogClient:
|
|
|
588
649
|
raise UrunError("catalog response was not a JSON array")
|
|
589
650
|
return [parse_row(item) for item in payload]
|
|
590
651
|
|
|
652
|
+
def _headers(self) -> dict[str, str]:
|
|
653
|
+
common = {
|
|
654
|
+
"Accept": "application/json",
|
|
655
|
+
"User-Agent": f"urun-cli/{__version__}",
|
|
656
|
+
}
|
|
657
|
+
if self._mode == "api":
|
|
658
|
+
return {**common, "Authorization": f"Bearer {self.api_key}"}
|
|
659
|
+
return {
|
|
660
|
+
**common,
|
|
661
|
+
"apikey": self.anon_key,
|
|
662
|
+
"Authorization": f"Bearer {self.anon_key}",
|
|
663
|
+
}
|
|
664
|
+
|
|
591
665
|
def _get(self, path: str) -> Any:
|
|
592
666
|
def attempt() -> bytes:
|
|
593
667
|
req = urllib.request.Request(
|
|
594
668
|
self.base_url + path,
|
|
595
669
|
method="GET",
|
|
596
|
-
headers=
|
|
597
|
-
"apikey": self.anon_key,
|
|
598
|
-
"Authorization": f"Bearer {self.anon_key}",
|
|
599
|
-
"Accept": "application/json",
|
|
600
|
-
"User-Agent": f"urun-cli/{__version__}",
|
|
601
|
-
},
|
|
670
|
+
headers=self._headers(),
|
|
602
671
|
)
|
|
603
672
|
try:
|
|
604
673
|
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
@@ -611,7 +680,7 @@ class CatalogClient:
|
|
|
611
680
|
attempt,
|
|
612
681
|
policy=self.backoff,
|
|
613
682
|
on_retry=self.on_retry,
|
|
614
|
-
description=f"GET {path}",
|
|
683
|
+
description=f"GET {self.base_url + path}",
|
|
615
684
|
)
|
|
616
685
|
except (urllib.error.URLError, TimeoutError) as exc:
|
|
617
686
|
reason = getattr(exc, "reason", exc)
|
|
@@ -9,6 +9,7 @@ import sys
|
|
|
9
9
|
import urllib.parse
|
|
10
10
|
import webbrowser
|
|
11
11
|
from collections.abc import Callable, Sequence
|
|
12
|
+
from contextlib import contextmanager, redirect_stderr, redirect_stdout
|
|
12
13
|
from datetime import UTC, datetime
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
from typing import Any
|
|
@@ -34,6 +35,7 @@ CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
|
|
|
34
35
|
MAX_TABLE_CELL_WIDTH = 80
|
|
35
36
|
MAX_DETAIL_CELL_WIDTH = 96
|
|
36
37
|
DEFAULT_ENVIRONMENT = "main"
|
|
38
|
+
DEFAULT_CONSOLE_URL = "https://app.urun.sh"
|
|
37
39
|
ENVIRONMENT_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$")
|
|
38
40
|
ENVIRONMENT_MESSAGE = (
|
|
39
41
|
"environment name must be 2-32 lowercase letters, digits or hyphens, "
|
|
@@ -149,6 +151,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
149
151
|
return use_command(args)
|
|
150
152
|
if args.command == "app":
|
|
151
153
|
return app_command(args)
|
|
154
|
+
if args.command == "build":
|
|
155
|
+
return build_command(args)
|
|
152
156
|
if args.command == "serve":
|
|
153
157
|
from .serve import serve_command
|
|
154
158
|
|
|
@@ -172,8 +176,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
172
176
|
"login",
|
|
173
177
|
help="Save manually vended deploy credentials",
|
|
174
178
|
description=(
|
|
175
|
-
"Save a manually vended URUN deploy API key. "
|
|
176
|
-
"
|
|
179
|
+
"Save a manually vended URUN deploy API key. If no key is provided, "
|
|
180
|
+
"the CLI opens the console API keys page and prompts for the key."
|
|
177
181
|
),
|
|
178
182
|
)
|
|
179
183
|
add_login_args(login_parser)
|
|
@@ -309,6 +313,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
309
313
|
add_api_args(p)
|
|
310
314
|
|
|
311
315
|
add_app_parser(sub)
|
|
316
|
+
add_build_parser(sub)
|
|
312
317
|
add_cli_parity_parsers(sub)
|
|
313
318
|
from .serve import add_serve_parser
|
|
314
319
|
|
|
@@ -571,9 +576,7 @@ def add_open_use_parsers(sub: argparse._SubParsersAction) -> None:
|
|
|
571
576
|
open_sub = open_parser.add_subparsers(dest="open_target")
|
|
572
577
|
for target in ("billing", "git-connect"):
|
|
573
578
|
p = open_sub.add_parser(target, help=f"Open {target}")
|
|
574
|
-
p.add_argument(
|
|
575
|
-
"--console-url", default=os.getenv("URUN_CONSOLE_URL", "https://app.urun.sh")
|
|
576
|
-
)
|
|
579
|
+
p.add_argument("--console-url", default=os.getenv("URUN_CONSOLE_URL", DEFAULT_CONSOLE_URL))
|
|
577
580
|
use = sub.add_parser("use", help="Use a local default")
|
|
578
581
|
use_sub = use.add_subparsers(dest="use_target")
|
|
579
582
|
env = use_sub.add_parser("environment", help="Set default environment")
|
|
@@ -583,7 +586,7 @@ def add_open_use_parsers(sub: argparse._SubParsersAction) -> None:
|
|
|
583
586
|
def add_app_parser(sub: argparse._SubParsersAction) -> None:
|
|
584
587
|
app_parser = sub.add_parser(
|
|
585
588
|
"app",
|
|
586
|
-
help="Manage a deployed app (status, scale, delete)",
|
|
589
|
+
help="Manage a deployed app (status, scale, delete, purge)",
|
|
587
590
|
description=(
|
|
588
591
|
"Manage the lifecycle of a single deployed app in the authenticated "
|
|
589
592
|
"org. The app is addressed by its slug (the name shown under APP in "
|
|
@@ -674,6 +677,29 @@ def add_app_parser(sub: argparse._SubParsersAction) -> None:
|
|
|
674
677
|
)
|
|
675
678
|
add_api_args(delete_parser)
|
|
676
679
|
|
|
680
|
+
purge_parser = app_sub.add_parser(
|
|
681
|
+
"purge",
|
|
682
|
+
help="Permanently purge orphaned app metadata after a partial deletion",
|
|
683
|
+
description=(
|
|
684
|
+
"Permanently purge stale app metadata and capacity snapshots for an "
|
|
685
|
+
"app that no longer has a deployment row. This is not the normal "
|
|
686
|
+
"way to take an app down — use `urun app delete` first for deployed "
|
|
687
|
+
"apps. The backend refuses to purge apps that still have deployment "
|
|
688
|
+
"rows."
|
|
689
|
+
),
|
|
690
|
+
)
|
|
691
|
+
purge_parser.add_argument("app", help="App slug (as shown by `urun list apps`)")
|
|
692
|
+
purge_parser.add_argument(
|
|
693
|
+
"--yes",
|
|
694
|
+
"-y",
|
|
695
|
+
action="store_true",
|
|
696
|
+
help="Skip the interactive confirmation prompt",
|
|
697
|
+
)
|
|
698
|
+
purge_parser.add_argument(
|
|
699
|
+
"--json", action="store_true", help="Emit raw JSON instead of a formatted line"
|
|
700
|
+
)
|
|
701
|
+
add_api_args(purge_parser)
|
|
702
|
+
|
|
677
703
|
activate_parser = app_sub.add_parser(
|
|
678
704
|
"activate",
|
|
679
705
|
help="Reverse a retire: bring a deleted app back online",
|
|
@@ -756,6 +782,36 @@ def add_config_parser(sub: argparse._SubParsersAction) -> None:
|
|
|
756
782
|
set_parser.add_argument("value", help="Value to store")
|
|
757
783
|
|
|
758
784
|
|
|
785
|
+
def add_build_parser(sub: argparse._SubParsersAction) -> None:
|
|
786
|
+
build_parser = sub.add_parser(
|
|
787
|
+
"build",
|
|
788
|
+
help="Manage build artifacts",
|
|
789
|
+
description="Manage build artifacts that are not represented by app lifecycle commands.",
|
|
790
|
+
)
|
|
791
|
+
build_sub = build_parser.add_subparsers(dest="build_command")
|
|
792
|
+
purge_parser = build_sub.add_parser(
|
|
793
|
+
"purge",
|
|
794
|
+
help="Permanently purge a failed build artifact",
|
|
795
|
+
description=(
|
|
796
|
+
"Delete a failed deployment status artifact from the authenticated "
|
|
797
|
+
"org. Pass the full 64-character manifest hash, or a unique 12+ "
|
|
798
|
+
"character prefix as shown in app/build listings. Only failed "
|
|
799
|
+
"artifacts can be purged."
|
|
800
|
+
),
|
|
801
|
+
)
|
|
802
|
+
purge_parser.add_argument("manifest", help="Manifest hash or unique 12+ char prefix")
|
|
803
|
+
purge_parser.add_argument(
|
|
804
|
+
"--yes",
|
|
805
|
+
"-y",
|
|
806
|
+
action="store_true",
|
|
807
|
+
help="Skip the interactive confirmation prompt",
|
|
808
|
+
)
|
|
809
|
+
purge_parser.add_argument(
|
|
810
|
+
"--json", action="store_true", help="Emit raw JSON instead of a formatted line"
|
|
811
|
+
)
|
|
812
|
+
add_api_args(purge_parser)
|
|
813
|
+
|
|
814
|
+
|
|
759
815
|
def add_login_args(parser: argparse.ArgumentParser) -> None:
|
|
760
816
|
parser.add_argument(
|
|
761
817
|
"--api-url",
|
|
@@ -763,7 +819,15 @@ def add_login_args(parser: argparse.ArgumentParser) -> None:
|
|
|
763
819
|
)
|
|
764
820
|
parser.add_argument(
|
|
765
821
|
"--api-key",
|
|
766
|
-
help=
|
|
822
|
+
help=(
|
|
823
|
+
"Deploy API key; defaults to URUN_API_KEY, otherwise opens the console "
|
|
824
|
+
"and prompts interactively"
|
|
825
|
+
),
|
|
826
|
+
)
|
|
827
|
+
parser.add_argument(
|
|
828
|
+
"--no-browser",
|
|
829
|
+
action="store_true",
|
|
830
|
+
help="Print the API keys URL instead of opening a browser",
|
|
767
831
|
)
|
|
768
832
|
|
|
769
833
|
|
|
@@ -822,8 +886,23 @@ def login(args: argparse.Namespace) -> int:
|
|
|
822
886
|
api_key = args.api_key or os.getenv("URUN_API_KEY")
|
|
823
887
|
if not api_key:
|
|
824
888
|
if not sys.stdin.isatty():
|
|
825
|
-
raise UrunError(
|
|
826
|
-
|
|
889
|
+
raise UrunError(
|
|
890
|
+
"missing API key; set URUN_API_KEY, pass --api-key, or run `urun login` "
|
|
891
|
+
"in an interactive terminal"
|
|
892
|
+
)
|
|
893
|
+
console_url = api_keys_console_url()
|
|
894
|
+
print("No URUN_API_KEY found.")
|
|
895
|
+
print("Create an API key in the urun console, then paste it here.")
|
|
896
|
+
print(f"Console URL: {console_url}")
|
|
897
|
+
if args.no_browser:
|
|
898
|
+
print("Browser launch skipped because --no-browser was passed.")
|
|
899
|
+
else:
|
|
900
|
+
opened = open_browser(console_url)
|
|
901
|
+
if opened:
|
|
902
|
+
print("Opened the console API keys page in your browser.")
|
|
903
|
+
else:
|
|
904
|
+
print("Could not open a browser automatically; open the Console URL above.")
|
|
905
|
+
api_key = getpass.getpass("Paste your urun API key: ").strip()
|
|
827
906
|
|
|
828
907
|
validate_api_key(api_key)
|
|
829
908
|
config = ApiClient(api_url, api_key).org_config()
|
|
@@ -846,6 +925,47 @@ def login(args: argparse.Namespace) -> int:
|
|
|
846
925
|
return 0
|
|
847
926
|
|
|
848
927
|
|
|
928
|
+
def api_keys_console_url() -> str:
|
|
929
|
+
console_url = os.getenv("URUN_CONSOLE_URL", DEFAULT_CONSOLE_URL)
|
|
930
|
+
url = console_url.rstrip("/") + "/settings/api-keys"
|
|
931
|
+
require_safe_url(url, "console URL")
|
|
932
|
+
return url
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def open_browser(url: str) -> bool:
|
|
936
|
+
"""Open a URL without letting browser/desktop-launcher noise pollute the prompt."""
|
|
937
|
+
with silence_stdio_fds():
|
|
938
|
+
return webbrowser.open(url)
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
@contextmanager
|
|
942
|
+
def silence_stdio_fds():
|
|
943
|
+
"""Temporarily send child-process stdout/stderr to /dev/null.
|
|
944
|
+
|
|
945
|
+
``contextlib.redirect_stderr`` only swaps Python's ``sys.stderr`` object; GUI
|
|
946
|
+
launchers and browsers spawned by ``webbrowser.open`` write to the inherited
|
|
947
|
+
OS file descriptors. Redirect those descriptors briefly so GTK/browser
|
|
948
|
+
warnings do not appear in the middle of the API-key prompt.
|
|
949
|
+
"""
|
|
950
|
+
saved_stdout = None
|
|
951
|
+
saved_stderr = None
|
|
952
|
+
try:
|
|
953
|
+
saved_stdout = os.dup(1)
|
|
954
|
+
saved_stderr = os.dup(2)
|
|
955
|
+
with open(os.devnull, "w", encoding="utf-8") as devnull:
|
|
956
|
+
os.dup2(devnull.fileno(), 1)
|
|
957
|
+
os.dup2(devnull.fileno(), 2)
|
|
958
|
+
with redirect_stdout(devnull), redirect_stderr(devnull):
|
|
959
|
+
yield
|
|
960
|
+
finally:
|
|
961
|
+
if saved_stdout is not None:
|
|
962
|
+
os.dup2(saved_stdout, 1)
|
|
963
|
+
os.close(saved_stdout)
|
|
964
|
+
if saved_stderr is not None:
|
|
965
|
+
os.dup2(saved_stderr, 2)
|
|
966
|
+
os.close(saved_stderr)
|
|
967
|
+
|
|
968
|
+
|
|
849
969
|
def org(args: argparse.Namespace) -> int:
|
|
850
970
|
# `urun org` and `urun org id` both print just the org id. There is no other
|
|
851
971
|
# `org` subcommand, so a bare `org` defaults to `id`.
|
|
@@ -874,17 +994,21 @@ def config_command(args: argparse.Namespace) -> int:
|
|
|
874
994
|
)
|
|
875
995
|
return 0
|
|
876
996
|
if command == "set" and key == "default-environment":
|
|
877
|
-
|
|
878
|
-
config = load_config()
|
|
879
|
-
config["default-environment"] = environment
|
|
880
|
-
path = save_config(config)
|
|
881
|
-
print(f"default-environment = {environment}")
|
|
882
|
-
print(f"Config: {path}")
|
|
883
|
-
return 0
|
|
997
|
+
return _set_default_environment(args.value)
|
|
884
998
|
print("usage: urun config {get,set} default-environment [value]", file=sys.stderr)
|
|
885
999
|
return 2
|
|
886
1000
|
|
|
887
1001
|
|
|
1002
|
+
def _set_default_environment(value: str) -> int:
|
|
1003
|
+
environment = validate_environment(value)
|
|
1004
|
+
config = load_config()
|
|
1005
|
+
config["default-environment"] = environment
|
|
1006
|
+
path = save_config(config)
|
|
1007
|
+
print(f"default-environment = {environment}")
|
|
1008
|
+
print(f"Config: {path}")
|
|
1009
|
+
return 0
|
|
1010
|
+
|
|
1011
|
+
|
|
888
1012
|
def trust_jwk(args: argparse.Namespace) -> int:
|
|
889
1013
|
jwks_url = trust_jwk_url(args)
|
|
890
1014
|
validate_jwks_url(jwks_url, allow_insecure=args.allow_insecure_jwks_url)
|
|
@@ -1473,13 +1597,7 @@ def use_command(args: argparse.Namespace) -> int:
|
|
|
1473
1597
|
if getattr(args, "use_target", None) != "environment":
|
|
1474
1598
|
print("usage: urun use environment <env>", file=sys.stderr)
|
|
1475
1599
|
return 2
|
|
1476
|
-
|
|
1477
|
-
config = load_config()
|
|
1478
|
-
config["default-environment"] = environment
|
|
1479
|
-
path = save_config(config)
|
|
1480
|
-
print(f"default-environment = {environment}")
|
|
1481
|
-
print(f"Config: {path}")
|
|
1482
|
-
return 0
|
|
1600
|
+
return _set_default_environment(args.environment)
|
|
1483
1601
|
|
|
1484
1602
|
|
|
1485
1603
|
def list_apps(args: argparse.Namespace) -> int:
|
|
@@ -1490,6 +1608,7 @@ def list_apps(args: argparse.Namespace) -> int:
|
|
|
1490
1608
|
empty_message="No apps found.",
|
|
1491
1609
|
headers=("APP", "ENV", "FUNCTION", "COMPUTE", "STATUS", "RELEASE", "DETAIL"),
|
|
1492
1610
|
rows=lambda apps: [_app_row(a) for a in apps],
|
|
1611
|
+
enrich=enrich_app_row,
|
|
1493
1612
|
)
|
|
1494
1613
|
|
|
1495
1614
|
|
|
@@ -1501,6 +1620,7 @@ def _render_list(
|
|
|
1501
1620
|
empty_message: str,
|
|
1502
1621
|
headers: tuple[str, ...],
|
|
1503
1622
|
rows: Callable[[list[dict[str, Any]]], Sequence[tuple[str, ...]]],
|
|
1623
|
+
enrich: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
|
|
1504
1624
|
) -> int:
|
|
1505
1625
|
api_url, api_key = resolve_api_credentials(args)
|
|
1506
1626
|
client = ApiClient(api_url, api_key)
|
|
@@ -1509,16 +1629,20 @@ def _render_list(
|
|
|
1509
1629
|
if not isinstance(items, list):
|
|
1510
1630
|
raise UrunError(f"server returned invalid {key} payload")
|
|
1511
1631
|
|
|
1632
|
+
dict_items = _validate_list_items(items, key)
|
|
1633
|
+
enriched_items = [enrich(item) if enrich else dict(item) for item in dict_items]
|
|
1634
|
+
canonical = dict(response)
|
|
1635
|
+
canonical[key] = enriched_items
|
|
1636
|
+
|
|
1512
1637
|
if getattr(args, "json", False):
|
|
1513
|
-
print(json.dumps(
|
|
1638
|
+
print(json.dumps(canonical, indent=2, sort_keys=True))
|
|
1514
1639
|
return 0
|
|
1515
1640
|
|
|
1516
|
-
if not
|
|
1641
|
+
if not enriched_items:
|
|
1517
1642
|
print(empty_message)
|
|
1518
1643
|
return 0
|
|
1519
1644
|
|
|
1520
|
-
|
|
1521
|
-
_print_table(headers, list(rows(dict_items)))
|
|
1645
|
+
_print_table(headers, list(rows(enriched_items)))
|
|
1522
1646
|
return 0
|
|
1523
1647
|
|
|
1524
1648
|
|
|
@@ -1537,12 +1661,48 @@ def app_command(args: argparse.Namespace) -> int:
|
|
|
1537
1661
|
return app_scale(args)
|
|
1538
1662
|
if command in {"delete", "rm"}:
|
|
1539
1663
|
return app_delete(args)
|
|
1664
|
+
if command == "purge":
|
|
1665
|
+
return app_purge(args)
|
|
1540
1666
|
if command == "activate":
|
|
1541
1667
|
return app_activate(args)
|
|
1542
|
-
print("usage: urun app {status,scale,delete,activate} <app> [options]", file=sys.stderr)
|
|
1668
|
+
print("usage: urun app {status,scale,delete,purge,activate} <app> [options]", file=sys.stderr)
|
|
1543
1669
|
return 2
|
|
1544
1670
|
|
|
1545
1671
|
|
|
1672
|
+
def build_command(args: argparse.Namespace) -> int:
|
|
1673
|
+
command = getattr(args, "build_command", None)
|
|
1674
|
+
if command == "purge":
|
|
1675
|
+
return build_purge(args)
|
|
1676
|
+
print("usage: urun build {purge} <manifest-hash-or-prefix> [options]", file=sys.stderr)
|
|
1677
|
+
return 2
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def build_purge(args: argparse.Namespace) -> int:
|
|
1681
|
+
if len(args.manifest) < 12:
|
|
1682
|
+
raise UrunError(
|
|
1683
|
+
"manifest must be a full 64-character hash or a unique 12+ character prefix"
|
|
1684
|
+
)
|
|
1685
|
+
if not getattr(args, "yes", False) and not _confirm_action(
|
|
1686
|
+
f"Permanently purge failed build artifact '{args.manifest}'? This cannot be undone."
|
|
1687
|
+
):
|
|
1688
|
+
print("Aborted.")
|
|
1689
|
+
return 1
|
|
1690
|
+
response = _client(args).purge_build(args.manifest)
|
|
1691
|
+
if getattr(args, "json", False):
|
|
1692
|
+
_print_json(response)
|
|
1693
|
+
return 0
|
|
1694
|
+
build = response.get("build")
|
|
1695
|
+
if not isinstance(build, dict):
|
|
1696
|
+
raise UrunError("server returned invalid build payload")
|
|
1697
|
+
manifest = _short_hash(build.get("manifest_hash"))
|
|
1698
|
+
app = _coalesce_str(build.get("app_slug"), "-")
|
|
1699
|
+
environment = _coalesce_str(build.get("environment"), "-")
|
|
1700
|
+
print(f"Purged failed build artifact {manifest}.")
|
|
1701
|
+
print(f"App: {app}")
|
|
1702
|
+
print(f"Environment: {environment}")
|
|
1703
|
+
return 0
|
|
1704
|
+
|
|
1705
|
+
|
|
1546
1706
|
def _app_payload(response: dict[str, Any]) -> dict[str, Any]:
|
|
1547
1707
|
app = response.get("app")
|
|
1548
1708
|
if not isinstance(app, dict):
|
|
@@ -1554,21 +1714,19 @@ def app_status(args: argparse.Namespace) -> int:
|
|
|
1554
1714
|
api_url, api_key = resolve_api_credentials(args)
|
|
1555
1715
|
environment = resolve_environment(args)
|
|
1556
1716
|
client = ApiClient(api_url, api_key)
|
|
1557
|
-
response = client
|
|
1717
|
+
response = build_app_status_payload(client, args.app, environment=environment)
|
|
1558
1718
|
if getattr(args, "json", False):
|
|
1559
1719
|
print(json.dumps(response, indent=2, sort_keys=True))
|
|
1560
1720
|
return 0
|
|
1561
1721
|
app = _app_payload(response)
|
|
1562
|
-
display_status = _derive_display_status(app)
|
|
1563
|
-
detail = _derive_detail(app, display_status)
|
|
1564
1722
|
fields = [
|
|
1565
1723
|
("App", _coalesce_str(app.get("app_slug"), args.app)),
|
|
1566
1724
|
("Name", _coalesce_str(app.get("app_name"), "-")),
|
|
1567
1725
|
("Environment", _coalesce_str(app.get("environment"), "-")),
|
|
1568
1726
|
("App status", _coalesce_str(app.get("app_status"), "-")),
|
|
1569
1727
|
("Deployment", _coalesce_str(app.get("deployment_status"), "(not deployed)")),
|
|
1570
|
-
("Status",
|
|
1571
|
-
("Detail", detail),
|
|
1728
|
+
("Status", _coalesce_str(app.get("status"), _DISPLAY_UNKNOWN)),
|
|
1729
|
+
("Detail", _coalesce_str(app.get("detail"), "-")),
|
|
1572
1730
|
("Desired replicas", _int_str(app.get("desired_replicas"))),
|
|
1573
1731
|
("Function", _coalesce_str(app.get("function_name"), app.get("default_function"), "-")),
|
|
1574
1732
|
("Compute", _coalesce_str(app.get("compute_shape"), "-")),
|
|
@@ -1582,6 +1740,139 @@ def app_status(args: argparse.Namespace) -> int:
|
|
|
1582
1740
|
return 0
|
|
1583
1741
|
|
|
1584
1742
|
|
|
1743
|
+
def build_app_status_payload(
|
|
1744
|
+
client: ApiClient, app_slug: str, *, environment: str
|
|
1745
|
+
) -> dict[str, Any]:
|
|
1746
|
+
"""Return the canonical app-status payload used by JSON and human output.
|
|
1747
|
+
|
|
1748
|
+
The app-detail endpoint currently has richer identity/deployment fields than
|
|
1749
|
+
the apps listing, while the apps listing has the readiness/capacity fields
|
|
1750
|
+
needed to decide whether an active deployment is actually serving. Compose
|
|
1751
|
+
those backend responses once, then render every output mode from the result.
|
|
1752
|
+
"""
|
|
1753
|
+
response = client.app_status(app_slug, environment=environment)
|
|
1754
|
+
app = _app_payload(response)
|
|
1755
|
+
warnings = (
|
|
1756
|
+
list(response.get("warnings") or []) if isinstance(response.get("warnings"), list) else []
|
|
1757
|
+
)
|
|
1758
|
+
if not _app_status_has_readiness_detail(app):
|
|
1759
|
+
try:
|
|
1760
|
+
list_row = _matching_app_list_row(client, app, app_slug, environment)
|
|
1761
|
+
except UrunError as exc:
|
|
1762
|
+
warnings.append(
|
|
1763
|
+
{
|
|
1764
|
+
"code": "app_readiness_enrichment_unavailable",
|
|
1765
|
+
"message": str(exc),
|
|
1766
|
+
}
|
|
1767
|
+
)
|
|
1768
|
+
else:
|
|
1769
|
+
if list_row:
|
|
1770
|
+
app = _merge_app_status_row(app, list_row)
|
|
1771
|
+
app = enrich_app_row(app)
|
|
1772
|
+
canonical = dict(response)
|
|
1773
|
+
canonical["app"] = app
|
|
1774
|
+
if warnings:
|
|
1775
|
+
canonical["warnings"] = warnings
|
|
1776
|
+
return canonical
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
def _app_status_has_readiness_detail(app: dict[str, Any]) -> bool:
|
|
1780
|
+
build = _coalesce_str(app.get("build_status"), "").lower()
|
|
1781
|
+
if build in {"queued", "building", "failed"}:
|
|
1782
|
+
return True
|
|
1783
|
+
deployment = _coalesce_str(app.get("deployment_status"), "").lower()
|
|
1784
|
+
if deployment in {"paused", "failed"}:
|
|
1785
|
+
return True
|
|
1786
|
+
if deployment != "active":
|
|
1787
|
+
return False
|
|
1788
|
+
if app.get("function_ready") is True:
|
|
1789
|
+
return _is_non_bool_int(app.get("ready_gpu_units")) and _is_non_bool_int(
|
|
1790
|
+
app.get("allocated_gpu_units")
|
|
1791
|
+
)
|
|
1792
|
+
if app.get("function_ready") is False:
|
|
1793
|
+
return "ready_reason" in app
|
|
1794
|
+
return False
|
|
1795
|
+
|
|
1796
|
+
|
|
1797
|
+
def _matching_app_list_row(
|
|
1798
|
+
client: ApiClient, app: dict[str, Any], app_slug: str, environment: str
|
|
1799
|
+
) -> dict[str, Any] | None:
|
|
1800
|
+
apps_response = client.list_apps()
|
|
1801
|
+
apps = apps_response.get("apps") or []
|
|
1802
|
+
if not isinstance(apps, list):
|
|
1803
|
+
raise UrunError("server returned invalid apps payload")
|
|
1804
|
+
app_id = _coalesce_str(app.get("app_id"))
|
|
1805
|
+
current_release_id = _coalesce_str(app.get("current_release_id"))
|
|
1806
|
+
manifest_hash = _coalesce_str(app.get("manifest_hash"), app.get("release_version"))
|
|
1807
|
+
function_name = _coalesce_str(app.get("function_name"), app.get("default_function"))
|
|
1808
|
+
compute_shape = _coalesce_str(app.get("compute_shape"))
|
|
1809
|
+
slug = _coalesce_str(app.get("app_slug"), app_slug)
|
|
1810
|
+
|
|
1811
|
+
def score(candidate: dict[str, Any]) -> int:
|
|
1812
|
+
candidate_env = _coalesce_str(candidate.get("environment"), DEFAULT_ENVIRONMENT)
|
|
1813
|
+
if candidate_env != environment:
|
|
1814
|
+
return -1
|
|
1815
|
+
candidate_slug = _coalesce_str(candidate.get("app_slug"), candidate.get("app_name"))
|
|
1816
|
+
if candidate_slug and candidate_slug != slug:
|
|
1817
|
+
return -1
|
|
1818
|
+
candidate_hash = _coalesce_str(
|
|
1819
|
+
candidate.get("manifest_hash"), candidate.get("release_version")
|
|
1820
|
+
)
|
|
1821
|
+
if manifest_hash and candidate_hash and candidate_hash != manifest_hash:
|
|
1822
|
+
return -1
|
|
1823
|
+
if function_name and candidate.get("function_name") not in {None, function_name}:
|
|
1824
|
+
return -1
|
|
1825
|
+
if compute_shape and candidate.get("compute_shape") not in {None, compute_shape}:
|
|
1826
|
+
return -1
|
|
1827
|
+
value = 1
|
|
1828
|
+
if app_id and candidate.get("app_id") == app_id:
|
|
1829
|
+
value += 8
|
|
1830
|
+
if current_release_id and candidate.get("release_id") == current_release_id:
|
|
1831
|
+
value += 4
|
|
1832
|
+
if manifest_hash and candidate_hash == manifest_hash:
|
|
1833
|
+
value += 4
|
|
1834
|
+
if function_name and candidate.get("function_name") == function_name:
|
|
1835
|
+
value += 2
|
|
1836
|
+
if compute_shape and candidate.get("compute_shape") == compute_shape:
|
|
1837
|
+
value += 2
|
|
1838
|
+
return value
|
|
1839
|
+
|
|
1840
|
+
best: tuple[int, dict[str, Any]] | None = None
|
|
1841
|
+
for candidate in apps:
|
|
1842
|
+
if not isinstance(candidate, dict):
|
|
1843
|
+
raise UrunError("server returned invalid apps payload")
|
|
1844
|
+
candidate_score = score(candidate)
|
|
1845
|
+
if candidate_score < 0:
|
|
1846
|
+
continue
|
|
1847
|
+
if best is None or candidate_score > best[0]:
|
|
1848
|
+
best = (candidate_score, candidate)
|
|
1849
|
+
return dict(best[1]) if best else None
|
|
1850
|
+
|
|
1851
|
+
|
|
1852
|
+
def _merge_app_status_row(app: dict[str, Any], list_row: dict[str, Any]) -> dict[str, Any]:
|
|
1853
|
+
merged = dict(app)
|
|
1854
|
+
for key, value in list_row.items():
|
|
1855
|
+
if key in {"app_name", "app_slug", "environment"}:
|
|
1856
|
+
merged.setdefault(key, value)
|
|
1857
|
+
elif key not in merged or merged[key] is None:
|
|
1858
|
+
merged[key] = value
|
|
1859
|
+
elif (
|
|
1860
|
+
key
|
|
1861
|
+
in {
|
|
1862
|
+
"build_status",
|
|
1863
|
+
"function_ready",
|
|
1864
|
+
"ready_reason",
|
|
1865
|
+
"ready_gpu_units",
|
|
1866
|
+
"allocated_gpu_units",
|
|
1867
|
+
"queued_requests",
|
|
1868
|
+
"active_sessions",
|
|
1869
|
+
}
|
|
1870
|
+
and value is not None
|
|
1871
|
+
):
|
|
1872
|
+
merged[key] = value
|
|
1873
|
+
return merged
|
|
1874
|
+
|
|
1875
|
+
|
|
1585
1876
|
def app_scale(args: argparse.Namespace) -> int:
|
|
1586
1877
|
if args.replicas < 0:
|
|
1587
1878
|
raise UrunError("--replicas must be >= 0")
|
|
@@ -1621,6 +1912,29 @@ def app_delete(args: argparse.Namespace) -> int:
|
|
|
1621
1912
|
return 0
|
|
1622
1913
|
|
|
1623
1914
|
|
|
1915
|
+
def app_purge(args: argparse.Namespace) -> int:
|
|
1916
|
+
if not getattr(args, "yes", False) and not _confirm_action(
|
|
1917
|
+
f"Permanently purge orphaned app state for '{args.app}'? This cannot be undone."
|
|
1918
|
+
):
|
|
1919
|
+
print("Aborted.")
|
|
1920
|
+
return 1
|
|
1921
|
+
api_url, api_key = resolve_api_credentials(args)
|
|
1922
|
+
client = ApiClient(api_url, api_key)
|
|
1923
|
+
response = client.purge_app(args.app)
|
|
1924
|
+
if getattr(args, "json", False):
|
|
1925
|
+
print(json.dumps(response, indent=2, sort_keys=True))
|
|
1926
|
+
return 0
|
|
1927
|
+
app = _app_payload(response)
|
|
1928
|
+
slug = _coalesce_str(app.get("app_slug"), args.app)
|
|
1929
|
+
print(f"Purged orphaned app state for {slug}.")
|
|
1930
|
+
deleted = app.get("deleted")
|
|
1931
|
+
if not isinstance(deleted, dict):
|
|
1932
|
+
raise UrunError("server returned invalid app purge payload")
|
|
1933
|
+
for key in sorted(deleted):
|
|
1934
|
+
print(f" {key}: {_int_str(deleted.get(key))}")
|
|
1935
|
+
return 0
|
|
1936
|
+
|
|
1937
|
+
|
|
1624
1938
|
def app_activate(args: argparse.Namespace) -> int:
|
|
1625
1939
|
api_url, api_key = resolve_api_credentials(args)
|
|
1626
1940
|
environment = resolve_environment(args)
|
|
@@ -1868,8 +2182,8 @@ def _app_row(d: dict[str, Any]) -> tuple[str, str, str, str, str, str, str]:
|
|
|
1868
2182
|
app, function, compute = _resource_identity(d)
|
|
1869
2183
|
environment = _coalesce_str(d.get("environment"), "-")
|
|
1870
2184
|
release = _short_hash(d.get("manifest_hash") or d.get("release_version"))
|
|
1871
|
-
status =
|
|
1872
|
-
detail =
|
|
2185
|
+
status = _coalesce_str(d.get("status"), _DISPLAY_UNKNOWN)
|
|
2186
|
+
detail = _coalesce_str(d.get("detail"), "-")
|
|
1873
2187
|
return (app, environment, function, compute, status, release, detail)
|
|
1874
2188
|
|
|
1875
2189
|
|
|
@@ -1894,7 +2208,11 @@ def _derive_display_status(d: dict[str, Any]) -> str:
|
|
|
1894
2208
|
if promotion == "failed":
|
|
1895
2209
|
return _DISPLAY_FAILED
|
|
1896
2210
|
if promotion == "active":
|
|
1897
|
-
|
|
2211
|
+
if d.get("function_ready") is True:
|
|
2212
|
+
return _DISPLAY_READY
|
|
2213
|
+
if d.get("function_ready") is False:
|
|
2214
|
+
return _DISPLAY_PENDING
|
|
2215
|
+
return _DISPLAY_UNKNOWN
|
|
1898
2216
|
return _DISPLAY_UNKNOWN
|
|
1899
2217
|
|
|
1900
2218
|
|
|
@@ -1904,7 +2222,7 @@ def _derive_detail(d: dict[str, Any], status: str) -> str:
|
|
|
1904
2222
|
if status == _DISPLAY_FAILED:
|
|
1905
2223
|
return _coalesce_str(_failure_summary(d), d.get("ready_reason"), "Build failed")
|
|
1906
2224
|
if status == _DISPLAY_PENDING:
|
|
1907
|
-
return _coalesce_str(d.get("ready_reason"), "
|
|
2225
|
+
return _coalesce_str(d.get("ready_reason"), "-")
|
|
1908
2226
|
if status == _DISPLAY_READY:
|
|
1909
2227
|
ready = d.get("ready_gpu_units")
|
|
1910
2228
|
allocated = d.get("allocated_gpu_units")
|
|
@@ -1914,6 +2232,14 @@ def _derive_detail(d: dict[str, Any], status: str) -> str:
|
|
|
1914
2232
|
return "-"
|
|
1915
2233
|
|
|
1916
2234
|
|
|
2235
|
+
def enrich_app_row(row: dict[str, Any]) -> dict[str, Any]:
|
|
2236
|
+
enriched = dict(row)
|
|
2237
|
+
status = _derive_display_status(enriched)
|
|
2238
|
+
enriched["status"] = status
|
|
2239
|
+
enriched["detail"] = _derive_detail(enriched, status)
|
|
2240
|
+
return enriched
|
|
2241
|
+
|
|
2242
|
+
|
|
1917
2243
|
def _failure_summary(source: Any) -> str:
|
|
1918
2244
|
if not isinstance(source, dict):
|
|
1919
2245
|
return ""
|
|
@@ -1948,6 +2274,7 @@ def list_sessions(args: argparse.Namespace) -> int:
|
|
|
1948
2274
|
# Server returns newest-first to take the LIMIT cleanly; reverse so the
|
|
1949
2275
|
# newest rows appear at the bottom of the table (tail-friendly).
|
|
1950
2276
|
rows=lambda sessions: [_session_row(s, now) for s in reversed(sessions)],
|
|
2277
|
+
enrich=lambda session: enrich_session_row(session, now),
|
|
1951
2278
|
)
|
|
1952
2279
|
|
|
1953
2280
|
|
|
@@ -1955,13 +2282,23 @@ def _session_row(s: dict[str, Any], now: datetime) -> tuple[str, str, str, str,
|
|
|
1955
2282
|
sid = _short_hash(s.get("id"))
|
|
1956
2283
|
app, function, shape = _resource_identity(s)
|
|
1957
2284
|
started = _format_iso_minutes(s.get("allocated_at") or s.get("created_at"))
|
|
1958
|
-
duration =
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
detail = _coalesce_str(s.get("close_reason"), "-")
|
|
2285
|
+
duration = _coalesce_str(s.get("duration"), "-")
|
|
2286
|
+
state = _coalesce_str(s.get("state"), _DISPLAY_UNKNOWN)
|
|
2287
|
+
detail = _coalesce_str(s.get("detail"), "-")
|
|
1962
2288
|
return (sid, app, function, shape, started, duration, state, detail)
|
|
1963
2289
|
|
|
1964
2290
|
|
|
2291
|
+
def enrich_session_row(s: dict[str, Any], now: datetime) -> dict[str, Any]:
|
|
2292
|
+
enriched = dict(s)
|
|
2293
|
+
duration_seconds = _session_duration_seconds(enriched, now)
|
|
2294
|
+
raw_status = _coalesce_str(enriched.get("status"), "").lower()
|
|
2295
|
+
enriched["state"] = _SESSION_STATE_DISPLAY.get(raw_status, raw_status or _DISPLAY_UNKNOWN)
|
|
2296
|
+
enriched["detail"] = _coalesce_str(enriched.get("close_reason"), "-")
|
|
2297
|
+
enriched["duration_seconds"] = int(duration_seconds) if duration_seconds is not None else None
|
|
2298
|
+
enriched["duration"] = _format_duration(duration_seconds)
|
|
2299
|
+
return enriched
|
|
2300
|
+
|
|
2301
|
+
|
|
1965
2302
|
def _session_duration_seconds(s: dict[str, Any], now: datetime) -> float | None:
|
|
1966
2303
|
start = _parse_iso(s.get("allocated_at")) or _parse_iso(s.get("created_at"))
|
|
1967
2304
|
if start is None:
|
|
@@ -2010,18 +2347,45 @@ def list_compute(args: argparse.Namespace) -> int:
|
|
|
2010
2347
|
key="compute",
|
|
2011
2348
|
fetch=lambda client: client.list_compute(limit=getattr(args, "limit", None)),
|
|
2012
2349
|
empty_message="No compute is currently provisioned.",
|
|
2013
|
-
headers=(
|
|
2350
|
+
headers=(
|
|
2351
|
+
"APP",
|
|
2352
|
+
"FUNCTION",
|
|
2353
|
+
"SHAPE",
|
|
2354
|
+
"INSTANCES",
|
|
2355
|
+
"GPU UNITS",
|
|
2356
|
+
"SESSIONS",
|
|
2357
|
+
"AGE",
|
|
2358
|
+
"REASON",
|
|
2359
|
+
),
|
|
2014
2360
|
rows=lambda slices: [_compute_row(s, now) for s in slices],
|
|
2361
|
+
enrich=lambda compute: enrich_compute_row(compute, now),
|
|
2015
2362
|
)
|
|
2016
2363
|
|
|
2017
2364
|
|
|
2018
|
-
def _compute_row(s: dict[str, Any], now: datetime) -> tuple[str, str, str, str, str, str, str]:
|
|
2365
|
+
def _compute_row(s: dict[str, Any], now: datetime) -> tuple[str, str, str, str, str, str, str, str]:
|
|
2019
2366
|
app, function, shape = _resource_identity(s)
|
|
2020
|
-
instances =
|
|
2021
|
-
units =
|
|
2022
|
-
sessions =
|
|
2023
|
-
age =
|
|
2024
|
-
|
|
2367
|
+
instances = _coalesce_str(s.get("instances"), "-")
|
|
2368
|
+
units = _coalesce_str(s.get("gpu_units"), "-")
|
|
2369
|
+
sessions = _coalesce_str(s.get("sessions"), "-")
|
|
2370
|
+
age = _coalesce_str(s.get("age"), "-")
|
|
2371
|
+
reason = _coalesce_str(s.get("reason"), s.get("ready_reason"), "-")
|
|
2372
|
+
return (app, function, shape, instances, units, sessions, age, reason)
|
|
2373
|
+
|
|
2374
|
+
|
|
2375
|
+
def enrich_compute_row(s: dict[str, Any], now: datetime) -> dict[str, Any]:
|
|
2376
|
+
enriched = dict(s)
|
|
2377
|
+
age_seconds = _age_seconds(enriched.get("snapshot_observed_at"), now)
|
|
2378
|
+
enriched["instances"] = _format_pair(
|
|
2379
|
+
enriched.get("instances_allocated"), enriched.get("instances_ready")
|
|
2380
|
+
)
|
|
2381
|
+
enriched["gpu_units"] = _format_pair(
|
|
2382
|
+
enriched.get("gpu_units_allocated"), enriched.get("gpu_units_ready")
|
|
2383
|
+
)
|
|
2384
|
+
enriched["sessions"] = _format_count(enriched.get("active_sessions"))
|
|
2385
|
+
enriched["age_seconds"] = int(age_seconds) if age_seconds is not None else None
|
|
2386
|
+
enriched["age"] = _format_age_seconds(age_seconds)
|
|
2387
|
+
enriched["reason"] = _coalesce_str(enriched.get("ready_reason"), "-")
|
|
2388
|
+
return enriched
|
|
2025
2389
|
|
|
2026
2390
|
|
|
2027
2391
|
def _format_pair(numerator: Any, denominator: Any) -> str:
|
|
@@ -2053,8 +2417,8 @@ def _age_seconds(observed_at: Any, now: datetime) -> float | None:
|
|
|
2053
2417
|
|
|
2054
2418
|
def _format_age_seconds(seconds: float | None) -> str:
|
|
2055
2419
|
# Compact age string for snapshot freshness. The view-side staleness
|
|
2056
|
-
# threshold (10 min) is reflected in ready_reason
|
|
2057
|
-
#
|
|
2420
|
+
# threshold (10 min) is reflected in ready_reason, which is now surfaced as
|
|
2421
|
+
# the human REASON column; AGE stays a human "how fresh is the data" signal.
|
|
2058
2422
|
if seconds is None:
|
|
2059
2423
|
return "-"
|
|
2060
2424
|
total = int(seconds)
|
|
@@ -65,9 +65,23 @@ def canonical_json(data: dict[str, Any]) -> bytes:
|
|
|
65
65
|
)
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
# Manifest fields that are PURELY informational metadata — they describe WHEN /
|
|
69
|
+
# by WHICH client a deploy happened, not WHAT is being deployed. They MUST be
|
|
70
|
+
# excluded from ``manifest_hash`` so the hash is a true content address: two
|
|
71
|
+
# deploys of an identical source tree produce an identical hash (and therefore
|
|
72
|
+
# the SAME server-side release — no new ``app_releases`` row, no repoint, no pod
|
|
73
|
+
# roll). Folding ``created_at`` (a per-deploy wall-clock timestamp) into the hash
|
|
74
|
+
# was the root cause of no-change redeploys minting a fresh release every time
|
|
75
|
+
# and rolling the runtime pod (~7 min not-ready -> "0 candidates" -> no frames).
|
|
76
|
+
# ``client_version`` is excluded for the same reason: a CLI upgrade must not roll
|
|
77
|
+
# every pod when the deployed content is byte-identical.
|
|
78
|
+
_NON_CONTENT_MANIFEST_FIELDS = ("manifest_hash", "created_at", "client_version")
|
|
79
|
+
|
|
80
|
+
|
|
68
81
|
def manifest_hash(manifest_without_hash: dict[str, Any]) -> str:
|
|
69
82
|
data = dict(manifest_without_hash)
|
|
70
|
-
|
|
83
|
+
for field in _NON_CONTENT_MANIFEST_FIELDS:
|
|
84
|
+
data.pop(field, None)
|
|
71
85
|
return hashlib.sha256(canonical_json(data)).hexdigest()
|
|
72
86
|
|
|
73
87
|
|
|
@@ -25,8 +25,11 @@ deployed by this path (its deletion is a follow-up; other lanes still reference
|
|
|
25
25
|
A user who already has a local ``_serve/app.py`` can point ``--entrypoint`` at it;
|
|
26
26
|
that app is deployed in place (no shim, no per-deploy env).
|
|
27
27
|
|
|
28
|
-
CANONICAL PATH (CLAUDE.md):
|
|
29
|
-
|
|
28
|
+
CANONICAL PATH (CLAUDE.md): the catalog is read over the uRun API's ``/catalog``
|
|
29
|
+
endpoint using ONLY the user's API key (``<api_url>/catalog`` with
|
|
30
|
+
``Authorization: Bearer <api_key>``; the body is ``{"rows": [...]}``). The old
|
|
31
|
+
direct-PostgREST read with a Supabase anon key is a DEPRECATED opt-in fallback
|
|
32
|
+
(``--anon-key`` / ``URUN_CATALOG_ANON_KEY`` / ``--catalog-url``).
|
|
30
33
|
"""
|
|
31
34
|
|
|
32
35
|
from __future__ import annotations
|
|
@@ -125,14 +128,18 @@ def _add_catalog_resolution_args(parser: argparse.ArgumentParser) -> None:
|
|
|
125
128
|
parser.add_argument(
|
|
126
129
|
"--catalog-url",
|
|
127
130
|
help=(
|
|
128
|
-
"PostgREST base for the catalog (
|
|
129
|
-
f"+ {POSTGREST_PREFIX}, or {CATALOG_URL_ENV})"
|
|
131
|
+
"DEPRECATED: PostgREST base for the catalog (e.g. the control-plane API "
|
|
132
|
+
f"origin + {POSTGREST_PREFIX}, or {CATALOG_URL_ENV}). The default reads the "
|
|
133
|
+
"uRun API /catalog endpoint with your API key; only set this to opt into "
|
|
134
|
+
"the legacy direct-PostgREST read."
|
|
130
135
|
),
|
|
131
136
|
)
|
|
132
137
|
parser.add_argument(
|
|
133
138
|
"--anon-key",
|
|
134
139
|
help=(
|
|
135
|
-
|
|
140
|
+
"DEPRECATED: Supabase anon key for the legacy direct-PostgREST catalog read "
|
|
141
|
+
f"(or {' / '.join(ANON_KEY_ENVS)}). The default reads the uRun API /catalog "
|
|
142
|
+
"endpoint with your API key — no anon key needed."
|
|
136
143
|
),
|
|
137
144
|
)
|
|
138
145
|
|
|
@@ -415,28 +422,43 @@ def _render_deps_literal(python_deps: list[str]) -> str:
|
|
|
415
422
|
def resolve_catalog_client(args: argparse.Namespace) -> CatalogClient:
|
|
416
423
|
"""Build the catalog client by the SINGLE canonical resolution.
|
|
417
424
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
``--
|
|
421
|
-
|
|
425
|
+
DEFAULT (needs ONLY the API key): when no anon key (``--anon-key`` /
|
|
426
|
+
``URUN_CATALOG_ANON_KEY`` / ``URUN_SUPABASE_ANON_KEY``) AND no catalog URL
|
|
427
|
+
(``--catalog-url`` / ``URUN_CATALOG_URL``) is given, resolve the API URL +
|
|
428
|
+
API key the rest of the CLI uses and return an API-key client that GETs
|
|
429
|
+
``<api_url>/catalog`` with ``Authorization: Bearer <api_key>``.
|
|
430
|
+
|
|
431
|
+
DEPRECATED opt-in (PostgREST + anon key): if an anon key OR a ``--catalog-url``
|
|
432
|
+
IS explicitly provided, fall back to the legacy direct-PostgREST read. Base:
|
|
433
|
+
``--catalog-url`` -> ``URUN_CATALOG_URL`` -> the control-plane API origin +
|
|
434
|
+
``/rest/v1``. Anon key: ``--anon-key`` -> ``URUN_CATALOG_ANON_KEY`` ->
|
|
435
|
+
``URUN_SUPABASE_ANON_KEY``. A ``--catalog-url`` without an anon key FAILS HARD.
|
|
422
436
|
"""
|
|
423
437
|
from .cli import resolve_api_credentials
|
|
424
438
|
|
|
425
|
-
|
|
426
|
-
if not base:
|
|
427
|
-
api_url, _ = resolve_api_credentials(args)
|
|
428
|
-
base = _postgrest_base_from_api_url(api_url)
|
|
429
|
-
|
|
439
|
+
explicit_url = getattr(args, "catalog_url", None) or os.getenv(CATALOG_URL_ENV)
|
|
430
440
|
anon_key = getattr(args, "anon_key", None)
|
|
431
441
|
if not anon_key:
|
|
432
442
|
for env in ANON_KEY_ENVS:
|
|
433
443
|
anon_key = os.getenv(env)
|
|
434
444
|
if anon_key:
|
|
435
445
|
break
|
|
446
|
+
|
|
447
|
+
# DEFAULT: no explicit PostgREST inputs -> read the API /catalog with the API key.
|
|
448
|
+
if not explicit_url and not anon_key:
|
|
449
|
+
api_url, api_key = resolve_api_credentials(args)
|
|
450
|
+
return CatalogClient.from_api(api_url, api_key)
|
|
451
|
+
|
|
452
|
+
# DEPRECATED opt-in: explicit anon key and/or --catalog-url -> direct PostgREST.
|
|
453
|
+
base = explicit_url
|
|
454
|
+
if not base:
|
|
455
|
+
api_url, _ = resolve_api_credentials(args)
|
|
456
|
+
base = _postgrest_base_from_api_url(api_url)
|
|
436
457
|
if not anon_key:
|
|
437
458
|
raise UrunError(
|
|
438
|
-
"missing Supabase anon key for the catalog read; pass
|
|
439
|
-
+ " / ".join(ANON_KEY_ENVS)
|
|
459
|
+
"missing Supabase anon key for the deprecated PostgREST catalog read; pass "
|
|
460
|
+
"--anon-key or set " + " / ".join(ANON_KEY_ENVS) + " (or omit --catalog-url "
|
|
461
|
+
"to use the default uRun API /catalog read with your API key)"
|
|
440
462
|
)
|
|
441
463
|
return CatalogClient(base, anon_key)
|
|
442
464
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|