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.
Files changed (36) hide show
  1. {urun_cli-0.7.6 → urun_cli-0.7.7}/PKG-INFO +1 -1
  2. {urun_cli-0.7.6 → urun_cli-0.7.7}/pyproject.toml +1 -1
  3. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/cli.py +226 -34
  4. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/deps.py +16 -0
  5. urun_cli-0.7.7/src/urun/hub_builds.py +301 -0
  6. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/serve.py +3 -0
  7. {urun_cli-0.7.6 → urun_cli-0.7.7}/.gitignore +0 -0
  8. {urun_cli-0.7.6 → urun_cli-0.7.7}/CHANGELOG.md +0 -0
  9. {urun_cli-0.7.6 → urun_cli-0.7.7}/LICENSE +0 -0
  10. {urun_cli-0.7.6 → urun_cli-0.7.7}/README.md +0 -0
  11. {urun_cli-0.7.6 → urun_cli-0.7.7}/SECURITY.md +0 -0
  12. {urun_cli-0.7.6 → urun_cli-0.7.7}/hatch_build.py +0 -0
  13. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/__init__.py +0 -0
  14. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/_bin/README.md +0 -0
  15. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/api.py +0 -0
  16. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/buildlogs.py +0 -0
  17. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/catalog.py +0 -0
  18. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/client_tokens.py +0 -0
  19. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/config.py +0 -0
  20. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/dev.py +0 -0
  21. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/devconsole.py +0 -0
  22. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/devstream.py +0 -0
  23. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/discovery.py +0 -0
  24. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/errors.py +0 -0
  25. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/fleet.py +0 -0
  26. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/frontend_launch.py +0 -0
  27. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/launch_auth.py +0 -0
  28. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/manifest.py +0 -0
  29. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/nodes.py +0 -0
  30. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/openai_proxy_bin.py +0 -0
  31. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/repo_deps.py +0 -0
  32. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/reserve.py +0 -0
  33. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/retry.py +0 -0
  34. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/runtime_health.py +0 -0
  35. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/rust.py +0 -0
  36. {urun_cli-0.7.6 → urun_cli-0.7.7}/src/urun/scratch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urun-cli
3
- Version: 0.7.6
3
+ Version: 0.7.7
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
@@ -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.6"
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 manifest_hash, unique_blobs
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(r"^urun_(?:[0-9a-f]{32}|sk_[A-Za-z0-9_-]{43})$")
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
- # Transient retry/backoff reporting (owner ruling 2026-07-17): silent at
2563
- # normal verbosity — a retried blip that succeeds is not the user's
2564
- # problem. --verbose shows every internal per-attempt line; a persistent
2565
- # streak gets one calm line so a slow deploy never looks like a hang.
2566
- log_retry = make_retry_logger(getattr(args, "verbose", False))
2567
-
2568
- client = ApiClient(api_url, api_key, on_retry=log_retry)
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
- register = client.register_manifest(manifest)
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, unique_blobs(all_blobs), on_retry=log_retry)
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
- try:
2596
- final = client.finalize(mh, environment=environment, deploy_kind=deploy_kind)
2597
- except ApiError as exc:
2598
- if not (
2599
- deploy_kind == DEV_DEPLOY_KIND and exc.status == 409 and exc.code == "app_disabled"
2600
- ):
2601
- raise
2602
- print(
2603
- f"dev app {app_name} was left disabled by the previous `urun dev` "
2604
- "exit; re-enabling it (dev apps belong to `urun dev`)"
2605
- )
2606
- client.activate_app(app_name, environment=environment)
2607
- final = client.finalize(mh, environment=environment, deploy_kind=deploy_kind)
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
- pause_check_client = ApiClient(
2617
- api_url,
2618
- api_key,
2619
- timeout=DEMO_PREFLIGHT_TIMEOUT_S,
2620
- backoff=BackoffPolicy(max_attempts=1),
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
- warn_paused_functions(pause_check_client, app_name, environment=environment)
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
- if "building" in seen_states and status.get("build_lane") != "inline":
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