urun-cli 0.7.1__tar.gz → 0.7.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.7.1 → urun_cli-0.7.3}/CHANGELOG.md +18 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/PKG-INFO +1 -1
- {urun_cli-0.7.1 → urun_cli-0.7.3}/pyproject.toml +1 -1
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/catalog.py +2 -2
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/cli.py +11 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/client_tokens.py +68 -3
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/dev.py +14 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/launch_auth.py +92 -12
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/scratch.py +66 -3
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/serve.py +87 -6
- {urun_cli-0.7.1 → urun_cli-0.7.3}/.gitignore +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/LICENSE +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/README.md +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/SECURITY.md +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/hatch_build.py +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/__init__.py +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/_bin/README.md +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/api.py +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/buildlogs.py +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/config.py +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/deps.py +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/devconsole.py +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/devstream.py +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/discovery.py +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/errors.py +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/fleet.py +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/frontend_launch.py +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/manifest.py +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/nodes.py +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/openai_proxy_bin.py +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/repo_deps.py +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/retry.py +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/runtime_health.py +0 -0
- {urun_cli-0.7.1 → urun_cli-0.7.3}/src/urun/rust.py +0 -0
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
## 0.7.3
|
|
2
|
+
|
|
3
|
+
- `urun serve --urun-wheel <version>` — pin a deploy to a specific urun wheel
|
|
4
|
+
version (e.g. `2.0.0+1497.g8439ef6b3b77`). The pin renders
|
|
5
|
+
`URUN_WHEEL_VERSION` into the generated serve shim beside the `--warm` pin,
|
|
6
|
+
BEFORE the bundled-app import, and is content-bearing: it changes the
|
|
7
|
+
manifest hash, so a wheel roll is a NEW RELEASE (the shared reconciler's
|
|
8
|
+
wheel-roll path) — never a re-register of an immutable release, which the
|
|
9
|
+
control plane's deploy-register refuses with a 409. Omitted = byte-identical
|
|
10
|
+
shim (no pin line).
|
|
11
|
+
|
|
12
|
+
## 0.7.2
|
|
13
|
+
|
|
14
|
+
- Fix the generated serve shim: the `--warm N` pin assigns `URUN_SERVE_WARM`
|
|
15
|
+
as a STRING. The 0.7.1 shim assigned an int literal, which raises
|
|
16
|
+
`TypeError: str expected, not int` at `os.encode` inside any build/import —
|
|
17
|
+
the exact first-build failure the agents-a1-fp8 shared deploy hit.
|
|
18
|
+
|
|
1
19
|
## 0.7.1
|
|
2
20
|
|
|
3
21
|
- `urun serve --warm N` — an idle replica floor on a serve deploy (the
|
|
@@ -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.3"
|
|
24
24
|
description = "End-user CLI for deploying apps to urun"
|
|
25
25
|
readme = "README.md"
|
|
26
26
|
requires-python = ">=3.11"
|
|
@@ -384,7 +384,7 @@ ENGINE_DEPS: dict[str, list[str]] = {
|
|
|
384
384
|
"pillow>=10.0.0",
|
|
385
385
|
"openimageio",
|
|
386
386
|
"cloudpickle>=3.1",
|
|
387
|
-
"huggingface_hub
|
|
387
|
+
"huggingface_hub>=1.5.0,<2.0",
|
|
388
388
|
"sentencepiece",
|
|
389
389
|
],
|
|
390
390
|
"t2av": [
|
|
@@ -435,7 +435,7 @@ ENGINE_DEPS: dict[str, list[str]] = {
|
|
|
435
435
|
"pillow>=10.0.0",
|
|
436
436
|
"openimageio",
|
|
437
437
|
"cloudpickle>=3.1",
|
|
438
|
-
"huggingface_hub
|
|
438
|
+
"huggingface_hub>=1.5.0,<2.0",
|
|
439
439
|
"sentencepiece",
|
|
440
440
|
"nvidia-cuda-nvcc==13.0.88",
|
|
441
441
|
"nvidia-cuda-crt==13.0.88",
|
|
@@ -2965,6 +2965,17 @@ def _run_demo_frontend(
|
|
|
2965
2965
|
)
|
|
2966
2966
|
if launch.token_expires_at:
|
|
2967
2967
|
print(f"Browser token expires: {launch.token_expires_at}")
|
|
2968
|
+
if launch.principal_actor_id:
|
|
2969
|
+
print(
|
|
2970
|
+
f"Browser token identity: bound to your API key's principal "
|
|
2971
|
+
f"(actor {launch.principal_actor_id}) — this session can re-attach "
|
|
2972
|
+
"across `urun dev --attach` runs for this app"
|
|
2973
|
+
)
|
|
2974
|
+
else:
|
|
2975
|
+
print(
|
|
2976
|
+
"Browser token identity: a fresh per-invocation identity (this API "
|
|
2977
|
+
"key has no resolvable principal)"
|
|
2978
|
+
)
|
|
2968
2979
|
print(
|
|
2969
2980
|
f"Token endpoint: {launch.token_url} (re-mints silently; a re-mint gates NEW "
|
|
2970
2981
|
"session starts only — live sessions keep running)"
|
|
@@ -158,7 +158,8 @@ def build_client_token_request(
|
|
|
158
158
|
allowed_functions: list[str],
|
|
159
159
|
allowed_origins: list[str] | None,
|
|
160
160
|
max_session_s: int | None,
|
|
161
|
-
subject: str,
|
|
161
|
+
subject: str | None = None,
|
|
162
|
+
bind_principal: bool = False,
|
|
162
163
|
dev: bool = False,
|
|
163
164
|
metadata: dict[str, Any] | None = None,
|
|
164
165
|
) -> dict[str, Any]:
|
|
@@ -175,6 +176,15 @@ def build_client_token_request(
|
|
|
175
176
|
the browser can never ask to be resolved. Omitted entirely when false,
|
|
176
177
|
like every other optional field here: every non-dev mint
|
|
177
178
|
send a body that does not mention dev mode at all.
|
|
179
|
+
|
|
180
|
+
``bind_principal`` (ENG-297) asks the server to derive the subject
|
|
181
|
+
itself from the API key's creator (``client-user:<principalActorId>``)
|
|
182
|
+
instead of accepting one from the caller — mutually exclusive with
|
|
183
|
+
``subject`` (the server 400s if both are sent; this mirrors that
|
|
184
|
+
locally, same convention as every other bound here). Omit both to keep
|
|
185
|
+
minting an unscoped ad-hoc subject the old way (nothing in this CLI
|
|
186
|
+
does that anymore, but the parameter combination stays legal for direct
|
|
187
|
+
callers/tests).
|
|
178
188
|
"""
|
|
179
189
|
if not isinstance(expires_in, int) or isinstance(expires_in, bool):
|
|
180
190
|
raise UrunError("client token expires_in must be an integer")
|
|
@@ -214,7 +224,14 @@ def build_client_token_request(
|
|
|
214
224
|
not isinstance(max_session_s, int) or isinstance(max_session_s, bool) or max_session_s < 1
|
|
215
225
|
):
|
|
216
226
|
raise UrunError("client token max_session_s must be a positive integer")
|
|
217
|
-
if not
|
|
227
|
+
if not isinstance(bind_principal, bool):
|
|
228
|
+
raise UrunError("client token bind_principal must be a boolean")
|
|
229
|
+
if bind_principal and subject is not None:
|
|
230
|
+
raise UrunError(
|
|
231
|
+
"client token bind_principal and subject are mutually exclusive — "
|
|
232
|
+
"bind_principal derives the subject server-side"
|
|
233
|
+
)
|
|
234
|
+
if not bind_principal and (not subject or len(subject) > CLIENT_TOKEN_MAX_SUBJECT_LENGTH):
|
|
218
235
|
raise UrunError(
|
|
219
236
|
f"client token subject must be 1..{CLIENT_TOKEN_MAX_SUBJECT_LENGTH} characters"
|
|
220
237
|
)
|
|
@@ -227,8 +244,11 @@ def build_client_token_request(
|
|
|
227
244
|
body: dict[str, Any] = {
|
|
228
245
|
"expires_in": expires_in,
|
|
229
246
|
"allowed_functions": list(allowed_functions),
|
|
230
|
-
"subject": subject,
|
|
231
247
|
}
|
|
248
|
+
if subject is not None:
|
|
249
|
+
body["subject"] = subject
|
|
250
|
+
if bind_principal:
|
|
251
|
+
body["bind_principal"] = True
|
|
232
252
|
if allowed_origins is not None:
|
|
233
253
|
body["allowed_origins"] = list(allowed_origins)
|
|
234
254
|
if max_session_s is not None:
|
|
@@ -245,6 +265,10 @@ class MintedToken:
|
|
|
245
265
|
token: str
|
|
246
266
|
expires_at: str
|
|
247
267
|
org_id: str
|
|
268
|
+
# ENG-297: the claim-verified principal actor id, present only when the
|
|
269
|
+
# mint asked for bind_principal and it was resolved server-side. Absence
|
|
270
|
+
# is the normal unbound case, never an error.
|
|
271
|
+
principal_actor_id: str | None = None
|
|
248
272
|
|
|
249
273
|
|
|
250
274
|
class ClientTokenMinter:
|
|
@@ -294,14 +318,55 @@ class ClientTokenMinter:
|
|
|
294
318
|
"client-token response did not include a positive integer expires_in"
|
|
295
319
|
)
|
|
296
320
|
expires_at = payload.get("expires_at")
|
|
321
|
+
principal_actor_id = payload.get("principal_actor_id")
|
|
322
|
+
# A bind_principal request answered with a PRESENT but
|
|
323
|
+
# malformed principal_actor_id (empty string, wrong type) is a
|
|
324
|
+
# server contract violation, not "unbound" — silently
|
|
325
|
+
# coercing it to None would make the CLI print "no resolvable
|
|
326
|
+
# principal" for a session the server actually DID bind,
|
|
327
|
+
# masking a bug instead of failing loud like every other
|
|
328
|
+
# contract check above. An ABSENT key is different: it is the
|
|
329
|
+
# expected shape from a pre-ENG-297 server that doesn't know
|
|
330
|
+
# the field at all (ignores bind_principal, mints with its own
|
|
331
|
+
# random subject) — the accepted deploy-lag scenario, not a
|
|
332
|
+
# bug — so it degrades to the unbound case exactly as before.
|
|
333
|
+
if (
|
|
334
|
+
self._body.get("bind_principal") is True
|
|
335
|
+
and "principal_actor_id" in payload
|
|
336
|
+
and (not isinstance(principal_actor_id, str) or not principal_actor_id)
|
|
337
|
+
):
|
|
338
|
+
raise UrunError(
|
|
339
|
+
"client-token response requested bind_principal but included a "
|
|
340
|
+
"malformed principal_actor_id"
|
|
341
|
+
)
|
|
297
342
|
self._minted = MintedToken(
|
|
298
343
|
token=token,
|
|
299
344
|
expires_at=expires_at if isinstance(expires_at, str) else "",
|
|
300
345
|
org_id=org_id,
|
|
346
|
+
principal_actor_id=(
|
|
347
|
+
principal_actor_id if isinstance(principal_actor_id, str) else None
|
|
348
|
+
),
|
|
301
349
|
)
|
|
302
350
|
self._remint_at = now + max(expires_in - self._skew, 1)
|
|
303
351
|
return self._minted
|
|
304
352
|
|
|
353
|
+
def replace_body(self, body: dict[str, Any]) -> None:
|
|
354
|
+
"""Swap the mint body for THIS minter's remaining life — every future
|
|
355
|
+
(re-)mint uses it, not just the next one.
|
|
356
|
+
|
|
357
|
+
The ONLY caller is launch_auth.py's bind_principal fallback: the
|
|
358
|
+
first mint discovers this API key has no resolvable principal, so
|
|
359
|
+
every mint for the rest of this process must use the legacy nonce
|
|
360
|
+
subject instead — never retry bind_principal again for this launch.
|
|
361
|
+
Forces an immediate re-mint (clears the cached token) so the swap
|
|
362
|
+
takes effect on the very next :meth:`current`, not after some stale
|
|
363
|
+
remint-skew window.
|
|
364
|
+
"""
|
|
365
|
+
with self._lock:
|
|
366
|
+
self._body = body
|
|
367
|
+
self._minted = None
|
|
368
|
+
self._remint_at = 0.0
|
|
369
|
+
|
|
305
370
|
|
|
306
371
|
class _TokenRequestHandler(BaseHTTPRequestHandler):
|
|
307
372
|
"""Serve scoped tokens and browser diagnostics for a local demo.
|
|
@@ -1442,6 +1442,20 @@ def _maybe_launch_frontend(
|
|
|
1442
1442
|
"origins: unrestricted (dev only); "
|
|
1443
1443
|
f"max session: {f'{launch.max_session_s}s' if launch.max_session_s else 'uncapped (ends with the CLI)'})",
|
|
1444
1444
|
)
|
|
1445
|
+
mux.line(
|
|
1446
|
+
"frontend | ",
|
|
1447
|
+
(
|
|
1448
|
+
f"browser token identity: bound to this API key's principal "
|
|
1449
|
+
f"(actor {launch.principal_actor_id}) — this session can re-attach "
|
|
1450
|
+
"across `urun dev` runs for this app"
|
|
1451
|
+
)
|
|
1452
|
+
if launch.principal_actor_id
|
|
1453
|
+
else (
|
|
1454
|
+
"browser token identity: a fresh per-invocation identity (this API "
|
|
1455
|
+
"key has no resolvable principal — sessions will not re-attach "
|
|
1456
|
+
"across runs; see the warning above)"
|
|
1457
|
+
),
|
|
1458
|
+
)
|
|
1445
1459
|
# Say the quiet part out loud: the scope above names the BASE app while the
|
|
1446
1460
|
# dev loop deploys to `<app>--dev`, which reads like a mismatch to anyone
|
|
1447
1461
|
# watching. It is the contract — the page asks for the base name and the
|
|
@@ -18,6 +18,7 @@ from __future__ import annotations
|
|
|
18
18
|
|
|
19
19
|
import re
|
|
20
20
|
import secrets
|
|
21
|
+
import sys
|
|
21
22
|
import urllib.parse
|
|
22
23
|
from collections.abc import Callable, Sequence
|
|
23
24
|
from dataclasses import dataclass, field
|
|
@@ -46,12 +47,16 @@ EVENTS_URL_ENV = "NEXT_PUBLIC_URUN_EVENTS_URL"
|
|
|
46
47
|
# identity as a pure hash of (v3, orgId, token subject, app, function,
|
|
47
48
|
# concurrency key) — services/session-api-ts/src/logical-id.ts:86 — and the
|
|
48
49
|
# SDK sends no explicit id when the app names no sessionKey
|
|
49
|
-
# (urun-ts packages/core/src/app.ts:162). The mint subject
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
50
|
+
# (urun-ts packages/core/src/app.ts:162). The mint subject is fixed for the
|
|
51
|
+
# whole invocation — and, for a principal-bound mint (the default, ENG-297),
|
|
52
|
+
# fixed ACROSS invocations too — so the CLI's session and the page it
|
|
53
|
+
# launches derive the SAME id and the platform's attach-or-create binds them
|
|
54
|
+
# together; a principal-bound launch additionally reattaches a PRIOR run's
|
|
55
|
+
# still-live session for the same actor and app, not just the one page in
|
|
56
|
+
# this process. There is deliberately NO session-key env var: the launcher
|
|
57
|
+
# has nothing to publish. Limitation, stated not hidden: an app passing its
|
|
58
|
+
# OWN sessionKey derives a different id and lands on the platform's coalesce
|
|
59
|
+
# path, not a clean attach.
|
|
55
60
|
# Next.js's built-in dev port, used when a frontend's dev script names none.
|
|
56
61
|
DEFAULT_FRONTEND_PORT = 3000
|
|
57
62
|
# The loopback host `urun dev` binds its token endpoint and pins the browser
|
|
@@ -62,6 +67,14 @@ DEFAULT_LAUNCH_HOST = "127.0.0.1"
|
|
|
62
67
|
# and fails loud — another launch path must be added HERE deliberately.
|
|
63
68
|
_LAUNCH_COMMANDS = frozenset({"dev"})
|
|
64
69
|
|
|
70
|
+
# ENG-297: the session API's structured error code for "bind_principal was
|
|
71
|
+
# requested but this API key's creator has no resolvable principal" (a
|
|
72
|
+
# legacy key minted before member API keys existed, or an admin-minted key
|
|
73
|
+
# with no creator) — urun-infra services/session-api-ts's
|
|
74
|
+
# handleClientTokenMint. A NAMED constant, not a literal, so the catch below
|
|
75
|
+
# and its test pin ONE string — never message matching.
|
|
76
|
+
PRINCIPAL_UNRESOLVABLE_CODE = "principal_not_available"
|
|
77
|
+
|
|
65
78
|
|
|
66
79
|
def launch_auth_label(command: str) -> str:
|
|
67
80
|
"""Inert SDK-side auth-provider label (the session API ignores
|
|
@@ -160,7 +173,13 @@ def _display_session_url(url: str) -> str:
|
|
|
160
173
|
return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, "", ""))
|
|
161
174
|
|
|
162
175
|
|
|
163
|
-
def mint_launch_client_token(
|
|
176
|
+
def mint_launch_client_token(
|
|
177
|
+
minter: Any,
|
|
178
|
+
session_url: str,
|
|
179
|
+
*,
|
|
180
|
+
command: str | None = None,
|
|
181
|
+
legacy_body_factory: Callable[[], dict[str, Any]] | None = None,
|
|
182
|
+
) -> Any:
|
|
164
183
|
"""Mint the launch's first scoped client token, failing with a clear story.
|
|
165
184
|
|
|
166
185
|
The mint is the first request the launch sends to the SESSION API, and
|
|
@@ -172,6 +191,20 @@ def mint_launch_client_token(minter: Any, session_url: str, *, command: str | No
|
|
|
172
191
|
(Owner ruling 2026-08-03: remedy first, keep it short, and never name
|
|
173
192
|
internal deployment topology — a customer reading this has a support
|
|
174
193
|
issue, and a urun developer knows which host to point the flag at.)
|
|
194
|
+
|
|
195
|
+
When ``legacy_body_factory`` is given (only
|
|
196
|
+
:func:`prepare_frontend_launch_auth` passes one, when the primary mint
|
|
197
|
+
requested ``bind_principal``), a mint that fails with the server's
|
|
198
|
+
structured "no resolvable principal" 400
|
|
199
|
+
(:data:`PRINCIPAL_UNRESOLVABLE_CODE`) is retried EXACTLY ONCE against a
|
|
200
|
+
freshly-built legacy body (the old per-invocation nonce subject) — this
|
|
201
|
+
API key has no resolvable principal (a legacy or admin-minted key), so
|
|
202
|
+
the launch falls back rather than hard-failing a command that used to
|
|
203
|
+
work. The minter's body is swapped IN PLACE
|
|
204
|
+
(:meth:`ClientTokenMinter.replace_body`), so every LATER silent re-mint
|
|
205
|
+
(the TokenEndpoint's) also uses the legacy subject — bind_principal is
|
|
206
|
+
never retried again for this process. A failure on the retry itself
|
|
207
|
+
propagates raw, exactly like any other mint failure.
|
|
175
208
|
"""
|
|
176
209
|
try:
|
|
177
210
|
return minter.current()
|
|
@@ -186,6 +219,20 @@ def mint_launch_client_token(minter: Any, session_url: str, *, command: str | No
|
|
|
186
219
|
f"The URL tried ({_display_session_url(session_url)}) does not "
|
|
187
220
|
"serve POST /api/client-tokens."
|
|
188
221
|
) from exc
|
|
222
|
+
if (
|
|
223
|
+
legacy_body_factory is not None
|
|
224
|
+
and exc.status == 400
|
|
225
|
+
and exc.code == PRINCIPAL_UNRESOLVABLE_CODE
|
|
226
|
+
):
|
|
227
|
+
print(
|
|
228
|
+
"warning: this API key has no resolvable principal — the browser "
|
|
229
|
+
"session falls back to a fresh per-invocation identity (it will not "
|
|
230
|
+
"re-attach to a prior `urun dev` run for this app). Re-create the key "
|
|
231
|
+
"as a member API key to fix this.",
|
|
232
|
+
file=sys.stderr,
|
|
233
|
+
)
|
|
234
|
+
minter.replace_body(legacy_body_factory())
|
|
235
|
+
return minter.current()
|
|
189
236
|
raise
|
|
190
237
|
|
|
191
238
|
|
|
@@ -279,6 +326,10 @@ class FrontendLaunchAuth:
|
|
|
279
326
|
allowed_functions: list[str]
|
|
280
327
|
allowed_origins: list[str]
|
|
281
328
|
max_session_s: int | None
|
|
329
|
+
# ENG-297: the claim-verified principal this launch's token is bound to,
|
|
330
|
+
# or None when this API key had no resolvable principal (the legacy
|
|
331
|
+
# fallback fired — see mint_launch_client_token).
|
|
332
|
+
principal_actor_id: str | None
|
|
282
333
|
endpoint: TokenEndpoint
|
|
283
334
|
# The launch's token minter — the SessionHolder authenticates its HTTP
|
|
284
335
|
# calls with the same silently-re-minting token the page refreshes from.
|
|
@@ -349,20 +400,48 @@ def prepare_frontend_launch_auth(
|
|
|
349
400
|
# Dev frontends may be opened through any loopback hostname or port. The
|
|
350
401
|
# session API treats an absent claim as unrestricted; demo remains pinned.
|
|
351
402
|
token_allowed_origins = None if dev else endpoint_allowed_origins
|
|
403
|
+
|
|
404
|
+
# The DEFAULT (bind_principal=True) asks the platform to derive the
|
|
405
|
+
# subject itself from the API key's creator (`client-user:<principalActorId>`)
|
|
406
|
+
# — stable across BOTH re-mints AND invocations, so the same actor
|
|
407
|
+
# launching the same app twice reattaches the SAME session (ENG-297)
|
|
408
|
+
# instead of minting a fresh identity every process run. `_legacy_nonce_body`
|
|
409
|
+
# below is the ONLY fallback: an API key with no resolvable principal
|
|
410
|
+
# (legacy or admin-minted) still gets a working launch, just back to the
|
|
411
|
+
# old per-invocation nonce — stable across re-mints for THIS launch,
|
|
412
|
+
# unique per invocation, so two such launches never coalesce their
|
|
413
|
+
# sessions.
|
|
352
414
|
mint_body = build_client_token_request(
|
|
353
415
|
expires_in=DEMO_TOKEN_EXPIRES_IN_S,
|
|
354
416
|
allowed_functions=allowed_functions,
|
|
355
417
|
allowed_origins=token_allowed_origins,
|
|
356
418
|
max_session_s=max_session_s,
|
|
357
|
-
|
|
358
|
-
# launch), unique per invocation (two launches never coalesce their
|
|
359
|
-
# sessions).
|
|
360
|
-
subject=f"{command}:{app_slug}:{secrets.token_hex(4)}",
|
|
419
|
+
bind_principal=True,
|
|
361
420
|
dev=dev,
|
|
362
421
|
metadata={"source": "urun-cli", "command": command, "app": app_slug},
|
|
363
422
|
)
|
|
423
|
+
|
|
424
|
+
def _legacy_nonce_body() -> dict[str, Any]:
|
|
425
|
+
# Rebuilt lazily — only if bind_principal actually fails — from the
|
|
426
|
+
# SAME scope/dev/metadata as the bound attempt; only the subject
|
|
427
|
+
# strategy differs. A fresh secrets.token_hex(4) each call: this is
|
|
428
|
+
# only ever invoked once per process (the fallback retries exactly
|
|
429
|
+
# once), so "fresh" here still means "one nonce for the whole
|
|
430
|
+
# invocation" exactly as before.
|
|
431
|
+
return build_client_token_request(
|
|
432
|
+
expires_in=DEMO_TOKEN_EXPIRES_IN_S,
|
|
433
|
+
allowed_functions=allowed_functions,
|
|
434
|
+
allowed_origins=token_allowed_origins,
|
|
435
|
+
max_session_s=max_session_s,
|
|
436
|
+
subject=f"{command}:{app_slug}:{secrets.token_hex(4)}",
|
|
437
|
+
dev=dev,
|
|
438
|
+
metadata={"source": "urun-cli", "command": command, "app": app_slug},
|
|
439
|
+
)
|
|
440
|
+
|
|
364
441
|
minter = ClientTokenMinter(create_client_token, mint_body)
|
|
365
|
-
minted = mint_launch_client_token(
|
|
442
|
+
minted = mint_launch_client_token(
|
|
443
|
+
minter, session_url, command=command, legacy_body_factory=_legacy_nonce_body
|
|
444
|
+
)
|
|
366
445
|
endpoint = TokenEndpoint(
|
|
367
446
|
minter,
|
|
368
447
|
endpoint_allowed_origins,
|
|
@@ -385,6 +464,7 @@ def prepare_frontend_launch_auth(
|
|
|
385
464
|
allowed_functions=allowed_functions,
|
|
386
465
|
allowed_origins=token_allowed_origins or [],
|
|
387
466
|
max_session_s=max_session_s,
|
|
467
|
+
principal_actor_id=minted.principal_actor_id,
|
|
388
468
|
endpoint=endpoint,
|
|
389
469
|
minter=minter,
|
|
390
470
|
)
|
|
@@ -326,14 +326,33 @@ def capacity_verdict(
|
|
|
326
326
|
# ---------------------------------------------------------------------------
|
|
327
327
|
|
|
328
328
|
|
|
329
|
-
|
|
330
|
-
|
|
329
|
+
#: THE WEIGHTS-TIER IDENTITY a clone MUST keep. The vineyard fleet controller
|
|
330
|
+
#: (control-plane ``vineyard_fleet.py``) spawns the per-(node, org) vineyardd
|
|
331
|
+
#: for pods matching ``urun.sh/gpu-type,urun.sh/org-id`` and reads the org
|
|
332
|
+
#: from ``urun.sh/org-id-verbatim``; the runtime's armed boot contract then
|
|
333
|
+
#: waits 120 s for that daemon's socket and fails LOUD without it. A clone
|
|
334
|
+
#: that dropped these booted only by luck - when it landed on a node where
|
|
335
|
+
#: its org's daemon was already up for a real pod (dev-usw2 2026-09-10: the
|
|
336
|
+
#: scratch scheduled onto the node hosting another org's daemon and died 7x
|
|
337
|
+
#: on "vineyard daemon socket did not appear"). These label the weights
|
|
338
|
+
#: tier the pod needs, not the materializer / capacity / routing attachment
|
|
339
|
+
#: the clone is detached from - those stay stripped.
|
|
340
|
+
WEIGHTS_TIER_LABELS = ("urun.sh/gpu-type", "urun.sh/org-id", "urun.sh/org-id-verbatim")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _scratch_labels(name: str, source_name: str, source_labels: dict[str, str]) -> dict[str, str]:
|
|
344
|
+
labels = {
|
|
331
345
|
"app.kubernetes.io/component": SCRATCH_COMPONENT,
|
|
332
346
|
"app.kubernetes.io/managed-by": SCRATCH_MANAGED_BY,
|
|
333
347
|
"app.kubernetes.io/name": name,
|
|
334
348
|
SCRATCH_LABEL: "true",
|
|
335
349
|
SCRATCH_SOURCE_LABEL: source_name,
|
|
336
350
|
}
|
|
351
|
+
for key in WEIGHTS_TIER_LABELS:
|
|
352
|
+
value = source_labels.get(key)
|
|
353
|
+
if value:
|
|
354
|
+
labels[key] = value
|
|
355
|
+
return labels
|
|
337
356
|
|
|
338
357
|
|
|
339
358
|
def _clean_annotations(annotations: dict[str, str]) -> dict[str, str]:
|
|
@@ -529,6 +548,25 @@ def _invalidate_wheel_metadata() -> None:
|
|
|
529
548
|
)
|
|
530
549
|
|
|
531
550
|
|
|
551
|
+
def _pip_accepts_break_system_packages() -> bool:
|
|
552
|
+
"""Does the copied venv's OWN pip understand ``--break-system-packages``?
|
|
553
|
+
|
|
554
|
+
The option (and PEP 668 enforcement itself) landed in pip 23.0: on pip
|
|
555
|
+
22.x the flag is an unknown option and ``python -m pip install`` exits
|
|
556
|
+
BEFORE resolving ("no such option"), breaking previously-working
|
|
557
|
+
overlays. ``derive_scratch_sts`` neither constrains nor upgrades the
|
|
558
|
+
source venv's pip, so the flag is passed ONLY when this interpreter's
|
|
559
|
+
pip supports it — where it is needed, it is pip's own PEP 668 opt-out.
|
|
560
|
+
"""
|
|
561
|
+
try:
|
|
562
|
+
import pip
|
|
563
|
+
|
|
564
|
+
major, minor = (int(part) for part in str(pip.__version__).split(".")[:2])
|
|
565
|
+
except (ImportError, ValueError, TypeError):
|
|
566
|
+
return False
|
|
567
|
+
return (major, minor) >= (23, 0)
|
|
568
|
+
|
|
569
|
+
|
|
532
570
|
def _install_and_resolve() -> dict:
|
|
533
571
|
"""Install the candidate ONCE and read the EXACT artifact from pip's report.
|
|
534
572
|
|
|
@@ -553,6 +591,12 @@ def _install_and_resolve() -> dict:
|
|
|
553
591
|
"install",
|
|
554
592
|
"--no-deps",
|
|
555
593
|
"--force-reinstall",
|
|
594
|
+
# The copied deps venv is a uv-managed interpreter carrying the
|
|
595
|
+
# PEP 668 EXTERNALLY-MANAGED marker; the overlay is a private
|
|
596
|
+
# per-pod copy, so pip's own opt-out applies (without it: rc=1,
|
|
597
|
+
# no readiness). Only offered to pips that HAVE the option —
|
|
598
|
+
# pip 22.x rejects it before resolving ("no such option").
|
|
599
|
+
*(("--break-system-packages",) if _pip_accepts_break_system_packages() else ()),
|
|
556
600
|
"--disable-pip-version-check",
|
|
557
601
|
"--report",
|
|
558
602
|
str(REPORT),
|
|
@@ -825,7 +869,26 @@ def derive_scratch_sts(
|
|
|
825
869
|
)
|
|
826
870
|
|
|
827
871
|
expires_at = (now.astimezone(UTC) + timedelta(hours=ttl_hours)).isoformat(timespec="seconds")
|
|
828
|
-
|
|
872
|
+
source_pod_labels = dict(
|
|
873
|
+
(sts.get("spec", {}).get("template", {}).get("metadata", {}) or {}).get("labels") or {}
|
|
874
|
+
)
|
|
875
|
+
# Fail-closed BEFORE any create: the org identity is what the vineyard
|
|
876
|
+
# fleet controller routes the per-(node, org) daemon by (it reads the
|
|
877
|
+
# org from ``urun.sh/org-id-verbatim``) and the runtime's armed boot
|
|
878
|
+
# contract fails loud 120 s in without its socket. ``gpu-type`` stays
|
|
879
|
+
# optional (a CPU source carries none and needs no vineyard daemon);
|
|
880
|
+
# a template missing the org pair is NOT a cloneable source — a clone
|
|
881
|
+
# that silently omits it boots only by luck, then dies.
|
|
882
|
+
missing_identity = [
|
|
883
|
+
key for key in (ORG_ID_LABEL, "urun.sh/org-id-verbatim") if not source_pod_labels.get(key)
|
|
884
|
+
]
|
|
885
|
+
if missing_identity:
|
|
886
|
+
raise UrunError(
|
|
887
|
+
"the source pod template is missing required org identity "
|
|
888
|
+
"label(s) — the clone would not be routed to its org's "
|
|
889
|
+
"vineyard daemon: " + ", ".join(missing_identity)
|
|
890
|
+
)
|
|
891
|
+
labels = _scratch_labels(name, source_name, source_pod_labels)
|
|
829
892
|
|
|
830
893
|
meta = sts["metadata"]
|
|
831
894
|
for key in _SERVER_METADATA_KEYS:
|
|
@@ -63,6 +63,22 @@ CATALOG_URL_ENV = "URUN_CATALOG_URL"
|
|
|
63
63
|
ANON_KEY_ENVS = ("URUN_CATALOG_ANON_KEY", "URUN_SUPABASE_ANON_KEY")
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
def _urun_wheel_version_type(value: str) -> str:
|
|
67
|
+
"""argparse type for ``--urun-wheel``: a non-empty string, no whitespace.
|
|
68
|
+
|
|
69
|
+
The wheel version is embedded verbatim in the generated shim (and compared
|
|
70
|
+
by the control plane against the recorded release pin), so anything blank
|
|
71
|
+
or whitespace-carrying is a typo, not a version. Raises argparse's
|
|
72
|
+
ArgumentTypeError so `urun serve --urun-wheel ""` fails at parse time with
|
|
73
|
+
a usable message.
|
|
74
|
+
"""
|
|
75
|
+
if not value or value.split() != [value]:
|
|
76
|
+
raise argparse.ArgumentTypeError(
|
|
77
|
+
f"--urun-wheel must be a non-empty version string with no whitespace, got {value!r}"
|
|
78
|
+
)
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
|
|
66
82
|
def add_serve_parser(sub: argparse._SubParsersAction) -> None:
|
|
67
83
|
serve_parser = sub.add_parser(
|
|
68
84
|
"serve",
|
|
@@ -133,6 +149,20 @@ def _add_serve_model_args(parser: argparse.ArgumentParser) -> None:
|
|
|
133
149
|
"deploys pass the catalog row's warm (default 0)."
|
|
134
150
|
),
|
|
135
151
|
)
|
|
152
|
+
parser.add_argument(
|
|
153
|
+
"--urun-wheel",
|
|
154
|
+
dest="urun_wheel",
|
|
155
|
+
type=_urun_wheel_version_type,
|
|
156
|
+
default=None,
|
|
157
|
+
metavar="VERSION",
|
|
158
|
+
help=(
|
|
159
|
+
"Pin THIS deploy to a specific urun wheel version (e.g. "
|
|
160
|
+
"2.0.0+1497.g8439ef6b3b77). Content-bearing: it changes the deploy's "
|
|
161
|
+
"manifest hash, so a wheel roll is a NEW RELEASE, not a rebuild. "
|
|
162
|
+
"Shared-endpoint deploys pass the platform's current wheel version. "
|
|
163
|
+
"Omit = the platform's default wheel resolution."
|
|
164
|
+
),
|
|
165
|
+
)
|
|
136
166
|
parser.add_argument(
|
|
137
167
|
"--entrypoint",
|
|
138
168
|
help=(
|
|
@@ -316,6 +346,23 @@ def _validated_exo_pin(args: argparse.Namespace) -> tuple[str | None, str | None
|
|
|
316
346
|
return exo_class, exo_image
|
|
317
347
|
|
|
318
348
|
|
|
349
|
+
def _validated_urun_wheel(value: str | None) -> str | None:
|
|
350
|
+
"""Re-validate the ``--urun-wheel`` pin for Namespace-built call paths.
|
|
351
|
+
|
|
352
|
+
argparse already refuses empty/whitespace values (``_urun_wheel_version_type``);
|
|
353
|
+
a Namespace built in code (tests, future callers) skips argparse, so the pin
|
|
354
|
+
is checked again here with the SAME rule. The wheel version is CONTENT in
|
|
355
|
+
the generated shim — a blank value is a config error the deployer must see,
|
|
356
|
+
never a silently unpinned manifest that fails to change the release hash.
|
|
357
|
+
"""
|
|
358
|
+
if value is None:
|
|
359
|
+
return None
|
|
360
|
+
try:
|
|
361
|
+
return _urun_wheel_version_type(value)
|
|
362
|
+
except argparse.ArgumentTypeError as exc:
|
|
363
|
+
raise UrunError(str(exc)) from None
|
|
364
|
+
|
|
365
|
+
|
|
319
366
|
def _build_deploy_args(args: argparse.Namespace, row: CatalogRow) -> argparse.Namespace:
|
|
320
367
|
"""Prepare the deploy() Namespace + project root for the resolved row.
|
|
321
368
|
|
|
@@ -330,9 +377,13 @@ def _build_deploy_args(args: argparse.Namespace, row: CatalogRow) -> argparse.Na
|
|
|
330
377
|
``urun.serve.app:app``. When ``--warm`` is given, the idle replica floor rides
|
|
331
378
|
the same channel (``URUN_SERVE_WARM``, JSON int) so the bundled app's
|
|
332
379
|
``@app.function(warm=...)`` declares it at import; an omitted ``--warm``
|
|
333
|
-
renders nothing (byte-identical shim, legacy always-on NULL).
|
|
334
|
-
|
|
335
|
-
|
|
380
|
+
renders nothing (byte-identical shim, legacy always-on NULL). When
|
|
381
|
+
``--urun-wheel`` is given, the wheel version rides ``URUN_WHEEL_VERSION`` the
|
|
382
|
+
same way — content-bearing, so a wheel roll produces a new manifest hash (a
|
|
383
|
+
NEW release, never a re-register of an immutable one); an omitted
|
|
384
|
+
``--urun-wheel`` renders nothing. Those land scoped to THIS deploy artifact
|
|
385
|
+
(NOT a shared ``URUN_SERVE_CONFIG`` materializer env, the retired coupling
|
|
386
|
+
bug). The bundled app resolves engine
|
|
336
387
|
ARGS by-string at boot; the engine NAME is pinned here only to select the
|
|
337
388
|
build-time dependency group (vllm / llama-cpp-python / ...) so the deps venv
|
|
338
389
|
carries the engine the adapter imports at load.
|
|
@@ -351,6 +402,16 @@ def _build_deploy_args(args: argparse.Namespace, row: CatalogRow) -> argparse.Na
|
|
|
351
402
|
"--warm only applies to the bundled serve app (it is pinned in the "
|
|
352
403
|
"generated shim); it cannot be combined with --entrypoint."
|
|
353
404
|
)
|
|
405
|
+
# --urun-wheel re-validation (argparse already refuses empty/whitespace):
|
|
406
|
+
# a Namespace built in code skips argparse, so the pin is checked again
|
|
407
|
+
# here. The wheel version rides the shim env, so --entrypoint (the user's
|
|
408
|
+
# own app) + --urun-wheel cannot combine either.
|
|
409
|
+
urun_wheel = _validated_urun_wheel(getattr(args, "urun_wheel", None))
|
|
410
|
+
if urun_wheel is not None and getattr(args, "entrypoint", None):
|
|
411
|
+
raise UrunError(
|
|
412
|
+
"--urun-wheel only applies to the bundled serve app (it is pinned in "
|
|
413
|
+
"the generated shim); it cannot be combined with --entrypoint."
|
|
414
|
+
)
|
|
354
415
|
|
|
355
416
|
entrypoint = getattr(args, "entrypoint", None)
|
|
356
417
|
if entrypoint:
|
|
@@ -363,7 +424,7 @@ def _build_deploy_args(args: argparse.Namespace, row: CatalogRow) -> argparse.Na
|
|
|
363
424
|
shim_root = None
|
|
364
425
|
else:
|
|
365
426
|
project_root, rel_entrypoint = _write_serve_shim(
|
|
366
|
-
row, exo_class=exo_class, exo_image=exo_image, warm=warm
|
|
427
|
+
row, exo_class=exo_class, exo_image=exo_image, warm=warm, urun_wheel=urun_wheel
|
|
367
428
|
)
|
|
368
429
|
# PLATFORM RELEASE SCOPE (catalog prebuilt instant serve). The shim is
|
|
369
430
|
# deterministic given the resolved row + gpu, so its manifest hash is an
|
|
@@ -429,13 +490,20 @@ def _write_serve_shim(
|
|
|
429
490
|
exo_class: str | None = None,
|
|
430
491
|
exo_image: str | None = None,
|
|
431
492
|
warm: int | None = None,
|
|
493
|
+
urun_wheel: str | None = None,
|
|
432
494
|
) -> tuple[Path, str]:
|
|
433
495
|
"""Write the bundled-app shim ``app.py`` pinned to ``row`` into a temp root."""
|
|
434
496
|
project_root = Path(tempfile.mkdtemp(prefix="urun-serve-"))
|
|
435
497
|
try:
|
|
436
498
|
app_path = project_root / "app.py"
|
|
437
499
|
app_path.write_text(
|
|
438
|
-
render_serve_shim_py(
|
|
500
|
+
render_serve_shim_py(
|
|
501
|
+
row,
|
|
502
|
+
exo_class=exo_class,
|
|
503
|
+
exo_image=exo_image,
|
|
504
|
+
warm=warm,
|
|
505
|
+
urun_wheel=urun_wheel,
|
|
506
|
+
),
|
|
439
507
|
encoding="utf-8",
|
|
440
508
|
)
|
|
441
509
|
# Function-engine map for the launcher's PRE-IMPORT topology decision
|
|
@@ -554,6 +622,7 @@ def render_serve_shim_py(
|
|
|
554
622
|
exo_class: str | None = None,
|
|
555
623
|
exo_image: str | None = None,
|
|
556
624
|
warm: int | None = None,
|
|
625
|
+
urun_wheel: str | None = None,
|
|
557
626
|
) -> str:
|
|
558
627
|
"""Render the tiny shim that deploys the BUNDLED urun-python serve app.
|
|
559
628
|
|
|
@@ -575,6 +644,11 @@ def render_serve_shim_py(
|
|
|
575
644
|
the bundled app's ``@app.function(warm=...)`` declares the idle replica
|
|
576
645
|
floor (app_functions.warm_replicas) at import. Unset renders NOTHING —
|
|
577
646
|
byte-identical shim, legacy always-on.
|
|
647
|
+
* When ``urun_wheel`` is not None, ``URUN_WHEEL_VERSION`` is set before the
|
|
648
|
+
import so the platform's wheel resolver pins THAT urun wheel. The pin is
|
|
649
|
+
CONTENT-BEARING: it changes the shim (hence the manifest hash), so a
|
|
650
|
+
wheel roll is a NEW RELEASE, never a re-register of the immutable one.
|
|
651
|
+
Unset renders NOTHING — byte-identical shim.
|
|
578
652
|
|
|
579
653
|
Values are embedded as JSON (NOT interpolated Python) so a weird model string,
|
|
580
654
|
gpu spec, or engine name can never break the module. The catalog row's
|
|
@@ -641,6 +715,7 @@ os.environ["URUN_SERVE_GPUS"] = %(gpus)s
|
|
|
641
715
|
os.environ["URUN_SERVE_ENGINE"] = %(engine)s
|
|
642
716
|
%(exo_env)s
|
|
643
717
|
%(warm_env)s
|
|
718
|
+
%(urun_wheel_env)s
|
|
644
719
|
%(serve_deps_env)s
|
|
645
720
|
|
|
646
721
|
from urun.serve.app import app # noqa: E402 (bundled urun-python serve app)
|
|
@@ -663,10 +738,15 @@ __all__ = ["app"]
|
|
|
663
738
|
'''
|
|
664
739
|
exo_env = render_exo_pin_env(exo_class, exo_image)
|
|
665
740
|
warm_env = (
|
|
666
|
-
'os.environ["URUN_SERVE_WARM"] = ' + json.dumps(int(warm)) + "\n"
|
|
741
|
+
'os.environ["URUN_SERVE_WARM"] = ' + json.dumps(str(int(warm))) + "\n"
|
|
667
742
|
if warm is not None
|
|
668
743
|
else ""
|
|
669
744
|
)
|
|
745
|
+
urun_wheel_env = (
|
|
746
|
+
'os.environ["URUN_WHEEL_VERSION"] = ' + json.dumps(urun_wheel) + "\n"
|
|
747
|
+
if urun_wheel is not None
|
|
748
|
+
else ""
|
|
749
|
+
)
|
|
670
750
|
return template % {
|
|
671
751
|
"entrypoint": BUNDLED_SERVE_ENTRYPOINT,
|
|
672
752
|
"model": model,
|
|
@@ -676,6 +756,7 @@ __all__ = ["app"]
|
|
|
676
756
|
"serve_deps_env": serve_deps_env,
|
|
677
757
|
"exo_env": exo_env,
|
|
678
758
|
"warm_env": warm_env,
|
|
759
|
+
"urun_wheel_env": urun_wheel_env,
|
|
679
760
|
}
|
|
680
761
|
|
|
681
762
|
|
|
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
|