urun-cli 0.7.6__tar.gz → 0.7.7__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.7.6 → urun_cli-0.7.7}/PKG-INFO +1 -1
- {urun_cli-0.7.6 → urun_cli-0.7.7}/pyproject.toml +1 -1
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/cli.py +226 -34
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/deps.py +16 -0
- urun_cli-0.7.7/src/urun/hub_builds.py +301 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/serve.py +3 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/.gitignore +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/CHANGELOG.md +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/LICENSE +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/README.md +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/SECURITY.md +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/hatch_build.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/__init__.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/_bin/README.md +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/api.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/buildlogs.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/catalog.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/client_tokens.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/config.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/dev.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/devconsole.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/devstream.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/discovery.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/errors.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/fleet.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/frontend_launch.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/launch_auth.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/manifest.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/nodes.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/openai_proxy_bin.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/repo_deps.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/reserve.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/retry.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/runtime_health.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/rust.py +0 -0
- {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/scratch.py +0 -0
|
@@ -20,7 +20,7 @@ name = "urun-cli"
|
|
|
20
20
|
# so every merge remains on the already-published version until this value is
|
|
21
21
|
# advanced. Wiring semantic-release into CI remains separate work; this patch
|
|
22
22
|
# bump is the minimal release unblock (post-v0.6.3 fixes + the Rust feature minor).
|
|
23
|
-
version = "0.7.
|
|
23
|
+
version = "0.7.7"
|
|
24
24
|
description = "End-user CLI for deploying apps to urun"
|
|
25
25
|
readme = "README.md"
|
|
26
26
|
requires-python = ">=3.11"
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import getpass
|
|
5
|
+
import hashlib
|
|
5
6
|
import http.client
|
|
6
7
|
import json
|
|
7
8
|
import os
|
|
@@ -9,6 +10,7 @@ import re
|
|
|
9
10
|
import shlex
|
|
10
11
|
import shutil
|
|
11
12
|
import sys
|
|
13
|
+
import tempfile
|
|
12
14
|
import time
|
|
13
15
|
import urllib.parse
|
|
14
16
|
import webbrowser
|
|
@@ -90,12 +92,19 @@ from .frontend_launch import (
|
|
|
90
92
|
resolve_app_functions,
|
|
91
93
|
resolve_demo_function,
|
|
92
94
|
)
|
|
95
|
+
from .hub_builds import HubBuildClient
|
|
93
96
|
from .launch_auth import (
|
|
94
97
|
FrontendLaunchAuth,
|
|
95
98
|
frontend_dev_port,
|
|
96
99
|
prepare_frontend_launch_auth,
|
|
97
100
|
)
|
|
98
|
-
from .manifest import
|
|
101
|
+
from .manifest import (
|
|
102
|
+
_NON_CONTENT_MANIFEST_FIELDS,
|
|
103
|
+
FileBlob,
|
|
104
|
+
canonical_json,
|
|
105
|
+
manifest_hash,
|
|
106
|
+
unique_blobs,
|
|
107
|
+
)
|
|
99
108
|
from .nodes import (
|
|
100
109
|
NODE_GPU_TYPES,
|
|
101
110
|
looks_like_node_id,
|
|
@@ -106,7 +115,13 @@ from .nodes import (
|
|
|
106
115
|
from .retry import BackoffPolicy, is_transient_error
|
|
107
116
|
from .runtime_health import RuntimeHealth, normalize_runtime_health, runtime_state, runtime_summary
|
|
108
117
|
|
|
109
|
-
API_KEY_RE = re.compile(
|
|
118
|
+
API_KEY_RE = re.compile(
|
|
119
|
+
# A JWT (hub client principal) is accepted alongside org API keys: the hub's
|
|
120
|
+
# client auth IS an asymmetric JWT (jwt-auth.ts); three dot-separated
|
|
121
|
+
# base64url segments is the cheap shape check, signature verified server-side.
|
|
122
|
+
r"^urun_(?:[0-9a-f]{32}|sk_[A-Za-z0-9_-]{43})$"
|
|
123
|
+
r"|^eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}$"
|
|
124
|
+
)
|
|
110
125
|
CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
|
|
111
126
|
MAX_TABLE_CELL_WIDTH = 80
|
|
112
127
|
MAX_DETAIL_CELL_WIDTH = 96
|
|
@@ -1727,6 +1742,13 @@ def add_login_args(parser: argparse.ArgumentParser) -> None:
|
|
|
1727
1742
|
"and prompts interactively"
|
|
1728
1743
|
),
|
|
1729
1744
|
)
|
|
1745
|
+
parser.add_argument(
|
|
1746
|
+
"--hub-url",
|
|
1747
|
+
help=(
|
|
1748
|
+
"Regional hub base URL; saved into the login so `urun deploy` targets the "
|
|
1749
|
+
"hub's /v1/builds flow. Defaults to URUN_HUB_URL; unset = the legacy path."
|
|
1750
|
+
),
|
|
1751
|
+
)
|
|
1730
1752
|
parser.add_argument(
|
|
1731
1753
|
"--no-browser",
|
|
1732
1754
|
action="store_true",
|
|
@@ -1759,6 +1781,16 @@ def add_api_args(parser: argparse.ArgumentParser, *, defer_to_parent: bool = Fal
|
|
|
1759
1781
|
),
|
|
1760
1782
|
)
|
|
1761
1783
|
|
|
1784
|
+
parser.add_argument(
|
|
1785
|
+
"--hub-url",
|
|
1786
|
+
**_deferred_default(defer_to_parent, None),
|
|
1787
|
+
help=(
|
|
1788
|
+
"Regional hub base URL (http(s)://host[:port]); routes `urun deploy`'s "
|
|
1789
|
+
"build through the hub's /v1/builds flow instead of the control-plane "
|
|
1790
|
+
"edge functions. Defaults to URUN_HUB_URL; unset = the legacy path."
|
|
1791
|
+
),
|
|
1792
|
+
)
|
|
1793
|
+
|
|
1762
1794
|
|
|
1763
1795
|
# A transient retry that resolves on the next attempt is noise; a RUN of them is
|
|
1764
1796
|
# an incident. At normal verbosity the first ``RETRY_NOTICE_AFTER - 1`` retries
|
|
@@ -1891,6 +1923,12 @@ def add_auth_trust_jwk_args(parser: argparse.ArgumentParser) -> None:
|
|
|
1891
1923
|
def login(args: argparse.Namespace) -> int:
|
|
1892
1924
|
api_url = args.api_url or os.getenv("URUN_API_URL") or DEFAULT_API_URL
|
|
1893
1925
|
api_key = args.api_key or os.getenv("URUN_API_KEY")
|
|
1926
|
+
# The hub region is part of the SAVED LOGIN, not an engineer-only flag: a
|
|
1927
|
+
# user who logs into a hub region deploys to it with their normal
|
|
1928
|
+
# credential (the hub resolves the synced key/JWT to the right tenant).
|
|
1929
|
+
# Precedence matches deploy-time resolution: --hub-url > URUN_HUB_URL;
|
|
1930
|
+
# unset = the legacy control-plane target, byte-for-byte unchanged.
|
|
1931
|
+
hub_url = getattr(args, "hub_url", None) or os.getenv("URUN_HUB_URL")
|
|
1894
1932
|
if not api_key:
|
|
1895
1933
|
if not sys.stdin.isatty():
|
|
1896
1934
|
raise UrunError(
|
|
@@ -1927,6 +1965,9 @@ def login(args: argparse.Namespace) -> int:
|
|
|
1927
1965
|
"api_key": api_key,
|
|
1928
1966
|
"org_id": org_id,
|
|
1929
1967
|
"key_id": key_id,
|
|
1968
|
+
# Written ONLY when a hub was chosen: a legacy login keeps the
|
|
1969
|
+
# file free of hub fields (byte-identical to today's save).
|
|
1970
|
+
**({"hub_url": hub_url} if hub_url else {}),
|
|
1930
1971
|
}
|
|
1931
1972
|
)
|
|
1932
1973
|
|
|
@@ -1935,6 +1976,8 @@ def login(args: argparse.Namespace) -> int:
|
|
|
1935
1976
|
print(f"Org: {org_name} ({org_slug})")
|
|
1936
1977
|
print(f"Org ID: {org_id}")
|
|
1937
1978
|
print(f"Key ID: {key_id}")
|
|
1979
|
+
if hub_url:
|
|
1980
|
+
print(f"Hub URL: {hub_url}")
|
|
1938
1981
|
print(f"Credentials: {path}")
|
|
1939
1982
|
return 0
|
|
1940
1983
|
|
|
@@ -2181,9 +2224,80 @@ def deploy(args: argparse.Namespace) -> int:
|
|
|
2181
2224
|
return deploy_once(args).exit_code
|
|
2182
2225
|
|
|
2183
2226
|
|
|
2227
|
+
def _hub_client_for_deploy(
|
|
2228
|
+
args: argparse.Namespace, api_key: str, on_retry: Callable[[str], None] | None = None
|
|
2229
|
+
) -> HubBuildClient | None:
|
|
2230
|
+
"""The hub build client when hub mode is selected, else None.
|
|
2231
|
+
|
|
2232
|
+
Precedence: ``--hub-url`` flag > ``URUN_HUB_URL`` env > the SAVED LOGIN's
|
|
2233
|
+
``hub_url`` (written by ``urun login --hub-url`` — the hub region is part
|
|
2234
|
+
of the login, not an engineer-only flag). Unset = the legacy
|
|
2235
|
+
control-plane path, byte-for-byte unchanged.
|
|
2236
|
+
"""
|
|
2237
|
+
hub_url = getattr(args, "hub_url", None) or os.getenv("URUN_HUB_URL")
|
|
2238
|
+
if not hub_url:
|
|
2239
|
+
saved = load_credentials().get("hub_url")
|
|
2240
|
+
hub_url = saved if isinstance(saved, str) and saved else None
|
|
2241
|
+
if not hub_url:
|
|
2242
|
+
return None
|
|
2243
|
+
return HubBuildClient(hub_url, api_key, on_retry=on_retry)
|
|
2244
|
+
|
|
2245
|
+
|
|
2246
|
+
def hub_deployment_doc(
|
|
2247
|
+
hub: HubBuildClient, register: dict[str, Any], manifest_hash: str
|
|
2248
|
+
) -> dict[str, Any]:
|
|
2249
|
+
"""Commit the registered build; the reply as the legacy deploy doc.
|
|
2250
|
+
|
|
2251
|
+
Commit is the finalize hop, deleted: it creates + queues the build
|
|
2252
|
+
session and answers the build view. ``poll_until_done`` (via the hub
|
|
2253
|
+
client's ``deployment_status``) keeps watching from the same shape —
|
|
2254
|
+
and a same-manifest redeploy whose session is already ``completed``
|
|
2255
|
+
answers ready here, the legacy ``reused`` fast path's honest analogue
|
|
2256
|
+
(the CLI's reuse messaging keys off ``reused is True``; the hub path
|
|
2257
|
+
reports the status straight).
|
|
2258
|
+
"""
|
|
2259
|
+
build_id = register.get("build_id")
|
|
2260
|
+
if not isinstance(build_id, str) or not build_id:
|
|
2261
|
+
raise UrunError("hub register reply carried no build_id")
|
|
2262
|
+
hub.remember_build(manifest_hash, build_id)
|
|
2263
|
+
view = hub.commit(build_id, manifest_hash, _hub_blobs_from_register(register))
|
|
2264
|
+
doc = _legacy_status_doc_from_view(view)
|
|
2265
|
+
if doc.get("status") == "ready":
|
|
2266
|
+
doc["reused"] = True
|
|
2267
|
+
return doc
|
|
2268
|
+
|
|
2269
|
+
|
|
2270
|
+
def _hub_blobs_from_register(register: dict[str, Any]) -> list[dict[str, Any]]:
|
|
2271
|
+
blobs: list[dict[str, Any]] = []
|
|
2272
|
+
for group in ("missing_blobs", "present_blobs"):
|
|
2273
|
+
items = register.get(group)
|
|
2274
|
+
if isinstance(items, list):
|
|
2275
|
+
blobs.extend(
|
|
2276
|
+
{"sha256": b["sha256"]} for b in items if isinstance(b, dict) and b.get("sha256")
|
|
2277
|
+
)
|
|
2278
|
+
return blobs
|
|
2279
|
+
|
|
2280
|
+
|
|
2281
|
+
def _legacy_status_doc_from_view(view: dict[str, Any]) -> dict[str, Any]:
|
|
2282
|
+
"""A hub build view as the legacy deploy doc (imported shape, no cycle)."""
|
|
2283
|
+
from .hub_builds import _legacy_status_doc
|
|
2284
|
+
|
|
2285
|
+
return _legacy_status_doc(view)
|
|
2286
|
+
|
|
2287
|
+
|
|
2184
2288
|
def deploy_once(args: argparse.Namespace) -> DeployOutcome:
|
|
2185
2289
|
api_url, api_key = resolve_api_credentials(args)
|
|
2186
2290
|
environment = resolve_environment(args)
|
|
2291
|
+
# Hub mode resolved UP FRONT (not at the register hop): every pre-register
|
|
2292
|
+
# decision — the credentials channel, the reused-release pause check — must
|
|
2293
|
+
# know whether this deploy targets the hub, or it silently touches the
|
|
2294
|
+
# legacy control plane behind a --hub-url deploy.
|
|
2295
|
+
# Transient retry/backoff reporting (owner ruling 2026-07-17): silent at
|
|
2296
|
+
# normal verbosity — a retried blip that succeeds is not the user's
|
|
2297
|
+
# problem. --verbose shows every internal per-attempt line; a persistent
|
|
2298
|
+
# streak gets one calm line so a slow deploy never looks like a hang.
|
|
2299
|
+
log_retry = make_retry_logger(getattr(args, "verbose", False))
|
|
2300
|
+
hub = _hub_client_for_deploy(args, api_key, on_retry=log_retry)
|
|
2187
2301
|
if args.poll_interval <= 0:
|
|
2188
2302
|
raise UrunError("--poll-interval must be greater than 0")
|
|
2189
2303
|
if args.timeout <= 0:
|
|
@@ -2382,6 +2496,18 @@ def deploy_once(args: argparse.Namespace) -> DeployOutcome:
|
|
|
2382
2496
|
creds_from_store: list[str] = []
|
|
2383
2497
|
declared_credential_names: list[str] = []
|
|
2384
2498
|
secret_client: ApiClient | None = None
|
|
2499
|
+
if (required_env or optional_env) and hub is not None:
|
|
2500
|
+
# HUB ISOLATION (owner ruling 2026-10-27): a hub deploy must never touch
|
|
2501
|
+
# the legacy control plane — silently reading or pushing secrets to
|
|
2502
|
+
# api_url is exactly the bug that created prod rows from hub-mode
|
|
2503
|
+
# deploys. The hub has no secret surface yet, so fail loud; declaring
|
|
2504
|
+
# creds on a hub deploy needs that surface first.
|
|
2505
|
+
raise UrunError(
|
|
2506
|
+
"deploy --hub-url cannot deliver declared Credentials(...) yet: the "
|
|
2507
|
+
"hub has no secret surface, and pushing them to the legacy control "
|
|
2508
|
+
"plane would silently touch prod. Remove the Credentials(...) "
|
|
2509
|
+
"declaration, or deploy without --hub-url."
|
|
2510
|
+
)
|
|
2385
2511
|
if required_env or optional_env:
|
|
2386
2512
|
secret_client = ApiClient(api_url, api_key)
|
|
2387
2513
|
# Org-level secrets (``app_id`` null) plus this app's app-level secrets —
|
|
@@ -2559,23 +2685,67 @@ def deploy_once(args: argparse.Namespace) -> DeployOutcome:
|
|
|
2559
2685
|
f"secret(s) (app-scoped): {', '.join(delivered)}"
|
|
2560
2686
|
)
|
|
2561
2687
|
|
|
2562
|
-
#
|
|
2563
|
-
#
|
|
2564
|
-
#
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2688
|
+
# HUB ISOLATION: in hub mode the legacy client is never even constructed —
|
|
2689
|
+
# register/finalize/poll/log-tail all ride the hub client, and any stray
|
|
2690
|
+
# legacy use below would be a real prod touch, not just a construction.
|
|
2691
|
+
client: ApiClient | None = (
|
|
2692
|
+
None if hub is not None else ApiClient(api_url, api_key, on_retry=log_retry)
|
|
2693
|
+
)
|
|
2694
|
+
# Hub mode (lane 42, docs/design/builds.md §1 row 15): the SAME client
|
|
2695
|
+
# flow on the hub's /v1/builds wire — register/commit replace the two
|
|
2696
|
+
# edge functions; uploads, poll, and the log tail keep their shape.
|
|
2697
|
+
# HUB MODE — the manifest IS a content-addressed blob (coordinator ruling
|
|
2698
|
+
# 2026-10-25): its sha256 is the manifest_hash, so the builder reads it
|
|
2699
|
+
# from blobs/<tenant>/<manifest_hash> and the hub stays stateless (refs
|
|
2700
|
+
# on the session, bytes in the store). The canonical JSON bytes are
|
|
2701
|
+
# hashed here once; the same bytes go to the store, so the builder's
|
|
2702
|
+
# re-hash of the fetched blob converges on the same manifest_hash.
|
|
2703
|
+
hub_manifest_blob: FileBlob | None = None
|
|
2704
|
+
if hub is not None:
|
|
2705
|
+
# The blob's sha256 must BE the manifest_hash, so hash the SAME way
|
|
2706
|
+
# manifest_hash does: canonical JSON of the manifest minus the
|
|
2707
|
+
# non-content fields (created_at/client_version describe the
|
|
2708
|
+
# deployer, not the content — they never reach the hash domain).
|
|
2709
|
+
content = dict(manifest)
|
|
2710
|
+
for field in _NON_CONTENT_MANIFEST_FIELDS:
|
|
2711
|
+
content.pop(field, None)
|
|
2712
|
+
manifest_bytes = canonical_json(content)
|
|
2713
|
+
manifest_sha = hashlib.sha256(manifest_bytes).hexdigest()
|
|
2714
|
+
if manifest_sha != mh:
|
|
2715
|
+
raise UrunError(
|
|
2716
|
+
f"manifest blob hash {manifest_sha} != manifest_hash {mh}; "
|
|
2717
|
+
"the content-addressed manifest blob must converge on the "
|
|
2718
|
+
"manifest hash"
|
|
2719
|
+
)
|
|
2720
|
+
tmp = tempfile.NamedTemporaryFile(prefix="urun-manifest-", suffix=".json", delete=False)
|
|
2721
|
+
tmp.write(manifest_bytes)
|
|
2722
|
+
tmp.close()
|
|
2723
|
+
hub_manifest_blob = FileBlob(
|
|
2724
|
+
path=f"manifests/{mh}.json",
|
|
2725
|
+
source=Path(tmp.name),
|
|
2726
|
+
sha256=mh,
|
|
2727
|
+
size=len(manifest_bytes),
|
|
2728
|
+
mode=0o100644,
|
|
2729
|
+
)
|
|
2730
|
+
hub_blobs = unique_blobs(all_blobs)
|
|
2731
|
+
if hub_manifest_blob is not None:
|
|
2732
|
+
hub_blobs.setdefault(hub_manifest_blob.sha256, hub_manifest_blob)
|
|
2733
|
+
hub_blob_list = [{"sha256": b.sha256, "size": b.size} for b in hub_blobs.values()]
|
|
2569
2734
|
mark_phase("collect") # discovery + deps resolve + credentials + manifest hash
|
|
2570
|
-
|
|
2735
|
+
if hub is not None:
|
|
2736
|
+
register = hub.register(mh, hub_blob_list)
|
|
2737
|
+
else:
|
|
2738
|
+
register = client.register_manifest(manifest)
|
|
2571
2739
|
server_hash = register.get("manifest_hash")
|
|
2572
2740
|
if server_hash and server_hash != mh:
|
|
2573
2741
|
raise UrunError(f"server manifest hash mismatch: local {mh}, server {server_hash}")
|
|
2574
2742
|
mark_phase("register")
|
|
2575
2743
|
missing = register.get("missing_blobs", [])
|
|
2576
2744
|
print(f"Uploading {len(missing)} missing blob(s)")
|
|
2577
|
-
upload_missing_blobs(missing,
|
|
2745
|
+
upload_missing_blobs(missing, hub_blobs, on_retry=log_retry)
|
|
2578
2746
|
mark_phase("upload")
|
|
2747
|
+
if hub_manifest_blob is not None:
|
|
2748
|
+
Path(hub_manifest_blob.source).unlink(missing_ok=True)
|
|
2579
2749
|
|
|
2580
2750
|
# ``deploy_kind`` is set ONLY by `urun dev` (resolved above, where it also
|
|
2581
2751
|
# rewrote the identity to the dev app); the deploy verb never defines it,
|
|
@@ -2592,19 +2762,26 @@ def deploy_once(args: argparse.Namespace) -> DeployOutcome:
|
|
|
2592
2762
|
# it re-activates its own app and retries ONCE, loudly. A standard deploy
|
|
2593
2763
|
# never does this: there a disabled app is an operator ruling and the 409
|
|
2594
2764
|
# stays a hard error.
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
)
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2765
|
+
if hub is not None:
|
|
2766
|
+
# The finalize hop, deleted (docs/design/builds.md §2): commit
|
|
2767
|
+
# creates + queues the build session; the reply IS the status doc
|
|
2768
|
+
# (state translated by the client). No app_disabled dev dance —
|
|
2769
|
+
# the hub owns no app rows.
|
|
2770
|
+
final = hub_deployment_doc(hub, register, mh)
|
|
2771
|
+
else:
|
|
2772
|
+
try:
|
|
2773
|
+
final = client.finalize(mh, environment=environment, deploy_kind=deploy_kind)
|
|
2774
|
+
except ApiError as exc:
|
|
2775
|
+
if not (
|
|
2776
|
+
deploy_kind == DEV_DEPLOY_KIND and exc.status == 409 and exc.code == "app_disabled"
|
|
2777
|
+
):
|
|
2778
|
+
raise
|
|
2779
|
+
print(
|
|
2780
|
+
f"dev app {app_name} was left disabled by the previous `urun dev` "
|
|
2781
|
+
"exit; re-enabling it (dev apps belong to `urun dev`)"
|
|
2782
|
+
)
|
|
2783
|
+
client.activate_app(app_name, environment=environment)
|
|
2784
|
+
final = client.finalize(mh, environment=environment, deploy_kind=deploy_kind)
|
|
2608
2785
|
mark_phase("finalize")
|
|
2609
2786
|
if final.get("reused") is True:
|
|
2610
2787
|
# Same-manifest redeploy of an already-built release: the control
|
|
@@ -2613,13 +2790,21 @@ def deploy_once(args: argparse.Namespace) -> DeployOutcome:
|
|
|
2613
2790
|
# cannot silently clear an operational safety decision. Surface that
|
|
2614
2791
|
# state and its explicit recovery command before returning success.
|
|
2615
2792
|
print(f"unchanged — reusing release {mh[:12]}")
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2793
|
+
# HUB ISOLATION: the pause check reads the legacy control plane's
|
|
2794
|
+
# function rows; the hub owns no app rows, so skip it entirely in hub
|
|
2795
|
+
# mode rather than probing prod behind a --hub-url deploy.
|
|
2796
|
+
pause_check_client = (
|
|
2797
|
+
None
|
|
2798
|
+
if hub is not None
|
|
2799
|
+
else ApiClient(
|
|
2800
|
+
api_url,
|
|
2801
|
+
api_key,
|
|
2802
|
+
timeout=DEMO_PREFLIGHT_TIMEOUT_S,
|
|
2803
|
+
backoff=BackoffPolicy(max_attempts=1),
|
|
2804
|
+
)
|
|
2621
2805
|
)
|
|
2622
|
-
|
|
2806
|
+
if pause_check_client is not None:
|
|
2807
|
+
warn_paused_functions(pause_check_client, app_name, environment=environment)
|
|
2623
2808
|
print(format_phase_timing(phases))
|
|
2624
2809
|
return DeployOutcome(exit_code=0, manifest_hash=mh, deps=deps, status=dict(final))
|
|
2625
2810
|
print(f"Deployment {final.get('status', 'queued')}: {mh}")
|
|
@@ -2635,7 +2820,7 @@ def deploy_once(args: argparse.Namespace) -> DeployOutcome:
|
|
|
2635
2820
|
# lines are printed as their own `build | ` lane, riding the SAME status
|
|
2636
2821
|
# poll (see urun.buildlogs). Degrades honestly: a control plane that
|
|
2637
2822
|
# serves no logs for this deploy streams nothing — never fake output.
|
|
2638
|
-
tailer = BuildLogTailer(client, mh)
|
|
2823
|
+
tailer = BuildLogTailer(hub if hub is not None else client, mh)
|
|
2639
2824
|
seen_states: set[str] = set()
|
|
2640
2825
|
queued_reason_shown = False
|
|
2641
2826
|
|
|
@@ -2659,7 +2844,9 @@ def deploy_once(args: argparse.Namespace) -> DeployOutcome:
|
|
|
2659
2844
|
|
|
2660
2845
|
def renew_dev_lease_on_poll() -> None:
|
|
2661
2846
|
nonlocal dev_lease_last_renew, dev_lease_warned
|
|
2662
|
-
if deploy_kind != DEV_DEPLOY_KIND:
|
|
2847
|
+
if deploy_kind != DEV_DEPLOY_KIND or hub is not None:
|
|
2848
|
+
# HUB ISOLATION: renew_dev_lease targets the legacy control plane;
|
|
2849
|
+
# a hub deploy must never renew (or probe) it.
|
|
2663
2850
|
return
|
|
2664
2851
|
now = time.monotonic()
|
|
2665
2852
|
if dev_lease_last_renew is not None and now - dev_lease_last_renew < (
|
|
@@ -2715,7 +2902,7 @@ def deploy_once(args: argparse.Namespace) -> DeployOutcome:
|
|
|
2715
2902
|
|
|
2716
2903
|
try:
|
|
2717
2904
|
status = poll_until_done(
|
|
2718
|
-
client,
|
|
2905
|
+
hub if hub is not None else client,
|
|
2719
2906
|
mh,
|
|
2720
2907
|
args.poll_interval,
|
|
2721
2908
|
args.timeout,
|
|
@@ -2747,7 +2934,9 @@ def deploy_once(args: argparse.Namespace) -> DeployOutcome:
|
|
|
2747
2934
|
# inline fast lane) rolls the serving pod: watch that rollout honestly
|
|
2748
2935
|
# (rolling -> booting -> ready) instead of declaring victory the moment the
|
|
2749
2936
|
# artifact exists. Inline/fast deploys skip this — instant and quiet.
|
|
2750
|
-
|
|
2937
|
+
# (Hub mode: the serving rollout is lane 09's cutover world — the hub
|
|
2938
|
+
# owns the build only; the legacy presence API is not wired to it yet.)
|
|
2939
|
+
if "building" in seen_states and status.get("build_lane") != "inline" and hub is None:
|
|
2751
2940
|
# Honest absence: a real build whose tailer streamed nothing gets ONE
|
|
2752
2941
|
# calm note — silence would be indistinguishable from "nothing worth
|
|
2753
2942
|
# showing" and hides a broken build-log path (owner, 2026-07-17).
|
|
@@ -3253,6 +3442,9 @@ def print_build_failure(status: dict[str, Any]) -> None:
|
|
|
3253
3442
|
print(line, file=sys.stderr)
|
|
3254
3443
|
return
|
|
3255
3444
|
err = status.get("error") or {}
|
|
3445
|
+
if not isinstance(err, dict):
|
|
3446
|
+
# The hub's build view carries the failure as a plain reason string.
|
|
3447
|
+
err = {"message": str(err)}
|
|
3256
3448
|
code = string_value(err.get("code"))
|
|
3257
3449
|
summary = _failure_summary(status) or _failure_summary(err) or "Build failed"
|
|
3258
3450
|
print("Deployment failed: build did not succeed", file=sys.stderr)
|
|
@@ -311,6 +311,22 @@ class _DepsEvaluator:
|
|
|
311
311
|
deps = self._extract_deps_from_call(dec)
|
|
312
312
|
if deps is not None:
|
|
313
313
|
calls.append(deps)
|
|
314
|
+
if calls:
|
|
315
|
+
return calls
|
|
316
|
+
# MODULE-LEVEL FALLBACK: the entrypoint declares Dependencies(...) but
|
|
317
|
+
# attaches it to NO ``@app.function`` in THIS file (the sense 2026-10-27
|
|
318
|
+
# live miss: SENSE_DEPS at module level while the App() registration
|
|
319
|
+
# lives in a sibling module). The decorator scan above finds nothing and
|
|
320
|
+
# the manifest used to ship deps={kind, python_version} with every
|
|
321
|
+
# declared package/torch_backend/file silently dropped — the builder then
|
|
322
|
+
# built a deps-less release. The declared Dependencies IS the app's
|
|
323
|
+
# declared dep info; collect it from module-level assignments. Only fires
|
|
324
|
+
# when NO function carries deps (an attached declaration always wins), so
|
|
325
|
+
# every previously-working app is byte-identical.
|
|
326
|
+
for value in self.assignments.values():
|
|
327
|
+
call = self._as_dependencies_call(value)
|
|
328
|
+
if call is not None:
|
|
329
|
+
calls.append(call)
|
|
314
330
|
return calls
|
|
315
331
|
|
|
316
332
|
@staticmethod
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Hub build-flow client: `urun deploy` against the regional hub's /v1/builds.
|
|
2
|
+
|
|
3
|
+
The hub path replaces the two Supabase edge functions the legacy path dials
|
|
4
|
+
(`register-manifest` + `finalize`) with the hub's session-shaped verbs
|
|
5
|
+
(docs/design/builds.md §2 in urun-sh/hub):
|
|
6
|
+
|
|
7
|
+
* ``POST /v1/builds`` — register: probe + presign MISSING blobs only,
|
|
8
|
+
derive the deterministic ``build_id``.
|
|
9
|
+
* presigned PUTs — the SAME ``upload_missing_blobs`` the legacy path uses;
|
|
10
|
+
the URLs are the only thing that changed.
|
|
11
|
+
* ``POST /v1/builds/{id}:commit`` — the finalize hop, deleted: creates and
|
|
12
|
+
queues the build session.
|
|
13
|
+
* ``GET /v1/builds/{id}`` — status; states map 1:1 onto the hub states.
|
|
14
|
+
* ``GET /v1/builds/{id}/logs`` — the builder's fenced ``:logs`` lines.
|
|
15
|
+
|
|
16
|
+
Wire classes are translated exactly once, here: the hub answers hub-state
|
|
17
|
+
names (``queued``/``claimed``/``starting``/``running``/``terminating``/
|
|
18
|
+
``completed``/``failed``), while the CLI's poll loop, tailer, and failure
|
|
19
|
+
printer speak the legacy status doc (``status``: ready|failed|cancelled,
|
|
20
|
+
``build_id``, ``logs``). No call-site changes on the deploy path.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.request
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from . import __version__
|
|
32
|
+
from .errors import ApiError, UrunError, sanitize_error_text
|
|
33
|
+
from .retry import DEPLOY_BACKOFF, BackoffPolicy, retry_call
|
|
34
|
+
|
|
35
|
+
# Same read cap as ApiClient (a misbehaving server must not force an
|
|
36
|
+
# unbounded allocation just to build an error message).
|
|
37
|
+
_MAX_ERROR_BODY_READ_BYTES = 64 * 1024
|
|
38
|
+
|
|
39
|
+
# Hub -> legacy status mapping (docs/design/builds.md §2). The build-phase
|
|
40
|
+
# narrative (queued -> building -> ready) is preserved; `cancelled` is its own
|
|
41
|
+
# terminal class the CLI already handles separately from `failed`.
|
|
42
|
+
_HUB_STATE_TO_STATUS = {
|
|
43
|
+
"queued": "queued",
|
|
44
|
+
"claimed": "building",
|
|
45
|
+
"starting": "building",
|
|
46
|
+
"running": "building",
|
|
47
|
+
"terminating": "cancelled",
|
|
48
|
+
"completed": "ready",
|
|
49
|
+
"failed": "failed",
|
|
50
|
+
"retry_pending": "queued",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HubBuildClient:
|
|
55
|
+
"""The /v1/builds wire on the hub's client principal (org API key).
|
|
56
|
+
|
|
57
|
+
The request/retry/error shape mirrors :class:`urun.api.ApiClient`
|
|
58
|
+
(Bearer auth, ApiError by status, transient retry under the same
|
|
59
|
+
policy) so the deploy path's error handling carries over unchanged.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
base_url: str,
|
|
65
|
+
api_key: str,
|
|
66
|
+
timeout: float = 30.0,
|
|
67
|
+
backoff: BackoffPolicy = DEPLOY_BACKOFF,
|
|
68
|
+
on_retry: Callable[[str], None] | None = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
# Same hygiene as ApiClient.require_safe_url: fail loud at
|
|
71
|
+
# construction, not at first dial.
|
|
72
|
+
base = base_url.rstrip("/")
|
|
73
|
+
if "://" not in base or " " in base:
|
|
74
|
+
raise UrunError(f"invalid hub URL: {base_url!r} (expected http(s)://host[:port])")
|
|
75
|
+
self.base_url = base
|
|
76
|
+
self.api_key = api_key
|
|
77
|
+
self.timeout = timeout
|
|
78
|
+
self.backoff = backoff
|
|
79
|
+
self.on_retry = on_retry
|
|
80
|
+
# manifest_hash -> build_id bindings made by register(); the poll
|
|
81
|
+
# loop resolves through this instead of a builds-list call.
|
|
82
|
+
self._build_ids: dict[str, str] = {}
|
|
83
|
+
|
|
84
|
+
def register(
|
|
85
|
+
self, manifest_hash: str, blobs: list[dict[str, Any]], wheel_identity: str | None = None
|
|
86
|
+
) -> dict[str, Any]:
|
|
87
|
+
body: dict[str, Any] = {"manifest_hash": manifest_hash, "blobs": blobs}
|
|
88
|
+
if wheel_identity:
|
|
89
|
+
body["wheel_identity"] = wheel_identity
|
|
90
|
+
reply = self._json("POST", "/v1/builds", body)
|
|
91
|
+
build_id = reply.get("build_id")
|
|
92
|
+
if isinstance(build_id, str) and build_id:
|
|
93
|
+
self.remember_build(manifest_hash, build_id)
|
|
94
|
+
return reply
|
|
95
|
+
|
|
96
|
+
def commit(
|
|
97
|
+
self,
|
|
98
|
+
build_id: str,
|
|
99
|
+
manifest_hash: str,
|
|
100
|
+
blobs: list[dict[str, Any]],
|
|
101
|
+
wheel_identity: str | None = None,
|
|
102
|
+
) -> dict[str, Any]:
|
|
103
|
+
body: dict[str, Any] = {"manifest_hash": manifest_hash, "blobs": blobs}
|
|
104
|
+
if wheel_identity:
|
|
105
|
+
body["wheel_identity"] = wheel_identity
|
|
106
|
+
return self._json("POST", f"/v1/builds/{build_id}:commit", body)
|
|
107
|
+
|
|
108
|
+
def status(self, build_id: str) -> dict[str, Any]:
|
|
109
|
+
"""The legacy status doc for one hub build.
|
|
110
|
+
|
|
111
|
+
ONE HTTP attempt, raw errors — the poll loop owns retry/backoff
|
|
112
|
+
(mirrors ``ApiClient.deployment_status``'s contract, for the same
|
|
113
|
+
reason: a nested retry ladder turns a front-door blip into a
|
|
114
|
+
false-fatal poll failure).
|
|
115
|
+
"""
|
|
116
|
+
view = self._request("GET", f"/v1/builds/{build_id}", None, timeout=10.0)
|
|
117
|
+
return _legacy_status_doc(view)
|
|
118
|
+
|
|
119
|
+
def logs(self, build_id: str) -> dict[str, Any]:
|
|
120
|
+
"""The hub's structured log lines as the legacy ``{logs: text}`` doc.
|
|
121
|
+
|
|
122
|
+
The tailer's incremental logic (emit only NEW complete lines, re-sync
|
|
123
|
+
on server-side tail trims) works on text; the hub's {ts, line}
|
|
124
|
+
entries joined with newlines preserve both properties, and the
|
|
125
|
+
5000-line oldest-dropped cap re-syncs through the same overlap
|
|
126
|
+
heuristic the legacy tail already has.
|
|
127
|
+
"""
|
|
128
|
+
payload = self._request("GET", f"/v1/builds/{build_id}/logs", None, timeout=10.0)
|
|
129
|
+
entries = payload.get("lines") if isinstance(payload, dict) else None
|
|
130
|
+
text = (
|
|
131
|
+
"\n".join(str(e.get("line", "")) for e in entries if isinstance(e, dict))
|
|
132
|
+
if isinstance(entries, list)
|
|
133
|
+
else ""
|
|
134
|
+
)
|
|
135
|
+
return {"logs": text}
|
|
136
|
+
|
|
137
|
+
def cancel(self, build_id: str) -> dict[str, Any]:
|
|
138
|
+
return self._json("POST", f"/v1/builds/{build_id}:cancel", {})
|
|
139
|
+
|
|
140
|
+
# -- legacy-shape adapter (poll loop + tailer, unchanged call sites) ------
|
|
141
|
+
|
|
142
|
+
def deployment_status(self, manifest_hash: str) -> dict[str, Any]:
|
|
143
|
+
"""``poll_until_done``'s read: the build registered for this deploy.
|
|
144
|
+
|
|
145
|
+
The hub's build_id is the session id DERIVED from (region, tenant,
|
|
146
|
+
(manifest_hash, wheel_identity)) — the register reply carries it, so
|
|
147
|
+
this is one GET, no resolve-by-hash list call. Pre-commit (or a
|
|
148
|
+
foreign hash) answers 404: the same not-yet-registered state the
|
|
149
|
+
legacy poller tolerates through its not_found grace window.
|
|
150
|
+
"""
|
|
151
|
+
build_id = self._build_ids.get(manifest_hash)
|
|
152
|
+
if build_id is None:
|
|
153
|
+
raise ApiError(
|
|
154
|
+
404,
|
|
155
|
+
"not_found",
|
|
156
|
+
"build not registered on this hub",
|
|
157
|
+
method="GET",
|
|
158
|
+
url=f"{self.base_url}/v1/builds",
|
|
159
|
+
)
|
|
160
|
+
return self.status(build_id)
|
|
161
|
+
|
|
162
|
+
def logs_build(self, build_id: str, tail: int | None = None) -> dict[str, Any]:
|
|
163
|
+
"""``BuildLogTailer``'s read — the tail is client-side anyway."""
|
|
164
|
+
return self.logs(build_id)
|
|
165
|
+
|
|
166
|
+
def remember_build(self, manifest_hash: str, build_id: str) -> None:
|
|
167
|
+
"""Bind the register reply's build_id to this deploy's manifest hash.
|
|
168
|
+
|
|
169
|
+
``register`` calls this itself (single writer); the binding is what
|
|
170
|
+
lets ``deployment_status(manifest_hash)`` resolve with no list
|
|
171
|
+
round-trip. Re-registering the same hash rebinds — a changed wheel
|
|
172
|
+
identity derives a different id, which is the propagation hole being
|
|
173
|
+
closed upstream.
|
|
174
|
+
"""
|
|
175
|
+
self._build_ids[manifest_hash] = build_id
|
|
176
|
+
|
|
177
|
+
# -- transport ----------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def _json(
|
|
180
|
+
self,
|
|
181
|
+
method: str,
|
|
182
|
+
path: str,
|
|
183
|
+
body: dict[str, Any] | None,
|
|
184
|
+
timeout: float | None = None,
|
|
185
|
+
) -> dict[str, Any]:
|
|
186
|
+
return self._request(method, path, body, timeout=timeout, retry=True)
|
|
187
|
+
|
|
188
|
+
def _request(
|
|
189
|
+
self,
|
|
190
|
+
method: str,
|
|
191
|
+
path: str,
|
|
192
|
+
body: dict[str, Any] | None,
|
|
193
|
+
timeout: float | None = None,
|
|
194
|
+
retry: bool = False,
|
|
195
|
+
) -> dict[str, Any]:
|
|
196
|
+
url = self.base_url + path
|
|
197
|
+
data = (
|
|
198
|
+
None
|
|
199
|
+
if body is None
|
|
200
|
+
else json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def attempt() -> bytes:
|
|
204
|
+
req = urllib.request.Request(
|
|
205
|
+
url,
|
|
206
|
+
data=data,
|
|
207
|
+
method=method,
|
|
208
|
+
headers={
|
|
209
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
210
|
+
"Accept": "application/json",
|
|
211
|
+
**({"Content-Type": "application/json"} if data else {}),
|
|
212
|
+
"User-Agent": f"urun-cli/{__version__}",
|
|
213
|
+
},
|
|
214
|
+
)
|
|
215
|
+
with urllib.request.urlopen(req, timeout=timeout or self.timeout) as resp:
|
|
216
|
+
payload = resp.read()
|
|
217
|
+
if resp.status >= 300:
|
|
218
|
+
raise urllib.error.HTTPError(
|
|
219
|
+
url, resp.status, "request rejected", resp.headers, None
|
|
220
|
+
)
|
|
221
|
+
return payload
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
if retry:
|
|
225
|
+
payload = retry_call(
|
|
226
|
+
attempt,
|
|
227
|
+
policy=self.backoff,
|
|
228
|
+
on_retry=self.on_retry,
|
|
229
|
+
description=f"{method} {path}",
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
payload = attempt()
|
|
233
|
+
except urllib.error.HTTPError as exc:
|
|
234
|
+
raise _api_error(exc, method, url) from exc
|
|
235
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
236
|
+
reason = getattr(exc, "reason", exc)
|
|
237
|
+
raise UrunError(f"network error: {reason} ({method} {url})") from exc
|
|
238
|
+
if not payload:
|
|
239
|
+
return {}
|
|
240
|
+
try:
|
|
241
|
+
return json.loads(payload.decode("utf-8"))
|
|
242
|
+
except json.JSONDecodeError as exc:
|
|
243
|
+
snippet = sanitize_error_text(payload.decode("utf-8", "replace"), limit=200)
|
|
244
|
+
raise UrunError(
|
|
245
|
+
f"invalid JSON response from hub ({method} {url}): {snippet!r}"
|
|
246
|
+
) from exc
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _api_error(exc: urllib.error.HTTPError, method: str, url: str) -> ApiError:
|
|
250
|
+
"""HTTP errors keep ApiClient's structured rendering (by status)."""
|
|
251
|
+
payload = exc.read(_MAX_ERROR_BODY_READ_BYTES)
|
|
252
|
+
code = "http_error"
|
|
253
|
+
message = sanitize_error_text(payload.decode("utf-8", "replace")) or str(exc.reason)
|
|
254
|
+
try:
|
|
255
|
+
parsed = json.loads(payload.decode("utf-8"))
|
|
256
|
+
err = parsed.get("error")
|
|
257
|
+
if isinstance(err, str):
|
|
258
|
+
code = err
|
|
259
|
+
elif isinstance(err, dict):
|
|
260
|
+
code = err.get("code") or code
|
|
261
|
+
if err.get("message"):
|
|
262
|
+
message = sanitize_error_text(str(err["message"]))
|
|
263
|
+
except Exception: # noqa: BLE001 - best-effort structured parse
|
|
264
|
+
pass
|
|
265
|
+
return ApiError(exc.code, code, message, method=method, url=url)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _legacy_status_doc(view: dict[str, Any]) -> dict[str, Any]:
|
|
269
|
+
"""Translate the hub build view into the legacy status-doc shape.
|
|
270
|
+
|
|
271
|
+
``build_id`` reads ``build_id`` then ``session_id`` — the hub's build
|
|
272
|
+
view carries both spellings across its revisions (register replies
|
|
273
|
+
``build_id``; the commit view is the raw session view).
|
|
274
|
+
"""
|
|
275
|
+
state = str(view.get("state") or "")
|
|
276
|
+
status = _HUB_STATE_TO_STATUS.get(state, state)
|
|
277
|
+
doc: dict[str, Any] = {
|
|
278
|
+
"status": status,
|
|
279
|
+
"build_id": view.get("build_id") or view.get("session_id"),
|
|
280
|
+
"manifest_hash": view.get("manifest_hash"),
|
|
281
|
+
"state": state,
|
|
282
|
+
"attempt": view.get("attempt"),
|
|
283
|
+
"max_attempts": view.get("max_attempts"),
|
|
284
|
+
}
|
|
285
|
+
if status == "failed":
|
|
286
|
+
doc["error"] = view.get("terminal_reason") or view.get("last_error") or "build failed"
|
|
287
|
+
if status == "ready":
|
|
288
|
+
doc["release_ref"] = view.get("release_ref")
|
|
289
|
+
return doc
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def hub_base_url(api_url: str | None, hub_url: str | None = None) -> str | None:
|
|
293
|
+
"""The hub to deploy through, or None for the legacy control-plane path.
|
|
294
|
+
|
|
295
|
+
EXPLICIT ONLY, never inferred: ``hub_url`` (``--hub-url`` /
|
|
296
|
+
``URUN_HUB_URL``) selects hub mode; nothing else does. A host-shape
|
|
297
|
+
heuristic (``hub.`` prefix, localhost ports) would silently hijack
|
|
298
|
+
control-plane e2e runs that point ``--api-url`` at a hub-shaped host —
|
|
299
|
+
cutover (lane 09) flips the default once the hub IS the control plane.
|
|
300
|
+
"""
|
|
301
|
+
return hub_url or None
|
|
@@ -579,6 +579,9 @@ def _build_deploy_args(
|
|
|
579
579
|
environment=getattr(args, "environment", None),
|
|
580
580
|
api_url=getattr(args, "api_url", None),
|
|
581
581
|
api_key=getattr(args, "api_key", None),
|
|
582
|
+
# The hub region (--hub-url): deploy() routes the build through the hub's
|
|
583
|
+
# /v1/builds flow only when this reaches it.
|
|
584
|
+
hub_url=getattr(args, "hub_url", None),
|
|
582
585
|
no_wait=getattr(args, "no_wait", False),
|
|
583
586
|
poll_interval=getattr(args, "poll_interval", 2.0),
|
|
584
587
|
timeout=getattr(args, "timeout", 1800.0),
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|