urun-cli 0.7.5__tar.gz → 0.7.6__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.5 → urun_cli-0.7.6}/CHANGELOG.md +21 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/PKG-INFO +1 -1
- {urun_cli-0.7.5 → urun_cli-0.7.6}/pyproject.toml +1 -1
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/cli.py +173 -7
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/client_tokens.py +45 -13
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/dev.py +1 -1
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/openai_proxy_bin.py +51 -6
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/repo_deps.py +64 -0
- urun_cli-0.7.6/src/urun/reserve.py +156 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/serve.py +68 -1
- {urun_cli-0.7.5 → urun_cli-0.7.6}/.gitignore +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/LICENSE +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/README.md +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/SECURITY.md +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/hatch_build.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/__init__.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/_bin/README.md +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/api.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/buildlogs.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/catalog.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/config.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/deps.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/devconsole.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/devstream.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/discovery.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/errors.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/fleet.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/frontend_launch.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/launch_auth.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/manifest.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/nodes.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/retry.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/runtime_health.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/rust.py +0 -0
- {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/scratch.py +0 -0
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
## Unreleased
|
|
2
|
+
|
|
3
|
+
- Security (ENG-570 / pentest urun-cli-01, mirrored in urun-python
|
|
4
|
+
`urun/core/store/repo.py`): `store.repo(...)` specs parsed from untrusted
|
|
5
|
+
app source are validated before any git call. The `@<ref>` pin must be a
|
|
6
|
+
full 40-hex SHA or a plain refname over `[A-Za-z0-9._/-]` (never a leading
|
|
7
|
+
`-`, never `=`), and the spec head may not start with `-`. A crafted
|
|
8
|
+
`@--upload-pack=<payload>` tail on a local-path spec made git EXECUTE the
|
|
9
|
+
payload on the deployer's machine.
|
|
10
|
+
- Security (ENG-570 / pentest urun-cli-02): the `urun dev --attach` loopback
|
|
11
|
+
token endpoint reflects `Access-Control-Allow-Origin` only for loopback
|
|
12
|
+
browser origins (any port) in dev mode; a remote page's Origin gets 403
|
|
13
|
+
and no CORS headers.
|
|
14
|
+
- Security (ENG-570 / pentest urun-cli-03): `urun compat` no longer hands the
|
|
15
|
+
raw org API key plus the developer's full exported environment to a
|
|
16
|
+
PATH-resolved `urun-openai`. The compat child env is the frontend scrub
|
|
17
|
+
allowlist plus the compat credential keys (explicit env wins per key are
|
|
18
|
+
forwarded through the credentials dict), and the PATH lane is refused
|
|
19
|
+
while credentials are handed over — explicit `URUN_OPENAI_PROXY_BIN` or
|
|
20
|
+
the vendored binary only.
|
|
21
|
+
|
|
1
22
|
## 0.7.3
|
|
2
23
|
|
|
3
24
|
- `urun serve --urun-wheel <version>` — pin a deploy to a specific urun wheel
|
|
@@ -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.6"
|
|
24
24
|
description = "End-user CLI for deploying apps to urun"
|
|
25
25
|
readme = "README.md"
|
|
26
26
|
requires-python = ">=3.11"
|
|
@@ -361,6 +361,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
361
361
|
return fleet_command(args)
|
|
362
362
|
if args.command == "scratch":
|
|
363
363
|
return scratch_command(args)
|
|
364
|
+
if args.command == "reserve":
|
|
365
|
+
return reserve_command(args)
|
|
364
366
|
parser.print_help()
|
|
365
367
|
return 2
|
|
366
368
|
except UrunError as exc:
|
|
@@ -623,6 +625,61 @@ def add_inverted_noun_parsers(sub: argparse._SubParsersAction) -> None:
|
|
|
623
625
|
add_activity_parsers(sub)
|
|
624
626
|
add_failure_group_parsers(sub)
|
|
625
627
|
add_git_installation_parsers(sub)
|
|
628
|
+
add_reserve_parser(sub)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def add_reserve_parser(sub: argparse._SubParsersAction) -> None:
|
|
632
|
+
parser = sub.add_parser(
|
|
633
|
+
"reserve",
|
|
634
|
+
help="Reserve a GPU node for one app (operator; app-exclusive nodes)",
|
|
635
|
+
description=(
|
|
636
|
+
"App-exclusive nodes (operator verb): reserve a GPUWorkerClaim "
|
|
637
|
+
"for ONE app so the node serves only it — foreign serving "
|
|
638
|
+
"residents drain, foreign scratch residents are reaped, and "
|
|
639
|
+
"fresh demand for other apps mints fresh capacity instead of "
|
|
640
|
+
"squatting the reserved node. Writes the SAME "
|
|
641
|
+
"urun.sh/reserved-for (+ urun.sh/pin-until with --until) "
|
|
642
|
+
"annotations the control plane's reservation mechanism reads, "
|
|
643
|
+
"through its sanctioned /node-reservations surface.\n\n"
|
|
644
|
+
"Usage: urun reserve <claim> --app <app_id>[/<fn>] "
|
|
645
|
+
"[--until <RFC3339>] --context <ctx> | urun reserve release <claim> "
|
|
646
|
+
"--context <ctx> | urun reserve list [--context <ctx>]\n\n"
|
|
647
|
+
"--context is REQUIRED for reserve/release (a reservation is a "
|
|
648
|
+
"live cluster write; be looking at the cluster you write to). "
|
|
649
|
+
"A bare reservation holds at most 8h; pass --until for an "
|
|
650
|
+
"explicit bounded TTL (also capped at 8h, the pin's clamp)."
|
|
651
|
+
),
|
|
652
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
653
|
+
)
|
|
654
|
+
reserve_sub = parser.add_subparsers(dest="reserve_command")
|
|
655
|
+
|
|
656
|
+
set_p = reserve_sub.add_parser(
|
|
657
|
+
"set", help="Reserve a claim for an app (implicit: urun reserve <claim> --app …)"
|
|
658
|
+
)
|
|
659
|
+
set_p.add_argument(
|
|
660
|
+
"claim", help="GPUWorkerClaim name (e.g. node-autoscaler-rtx6000-ba6262d689)"
|
|
661
|
+
)
|
|
662
|
+
set_p.add_argument(
|
|
663
|
+
"--app", required=True, help="<app_id>[/<fn>] the node will serve exclusively"
|
|
664
|
+
)
|
|
665
|
+
set_p.add_argument("--until", default=None, help="RFC3339 deadline (bounded by the 8h pin cap)")
|
|
666
|
+
_add_reserve_cluster_args(set_p)
|
|
667
|
+
|
|
668
|
+
release_p = reserve_sub.add_parser("release", help="Release a claim's reservation (idempotent)")
|
|
669
|
+
release_p.add_argument("claim")
|
|
670
|
+
_add_reserve_cluster_args(release_p)
|
|
671
|
+
|
|
672
|
+
list_p = reserve_sub.add_parser("list", help="List every reserved claim")
|
|
673
|
+
_add_reserve_cluster_args(list_p)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _add_reserve_cluster_args(parser: argparse.ArgumentParser) -> None:
|
|
677
|
+
parser.add_argument(
|
|
678
|
+
"--context",
|
|
679
|
+
default=None,
|
|
680
|
+
help="kubectl context (REQUIRED for set/release; the active context for list)",
|
|
681
|
+
)
|
|
682
|
+
parser.add_argument("--namespace", default="urun", help="Namespace (default: urun)")
|
|
626
683
|
|
|
627
684
|
|
|
628
685
|
def add_session_parsers(sub: argparse._SubParsersAction) -> None:
|
|
@@ -1414,7 +1471,7 @@ def add_org_parser(sub: argparse._SubParsersAction) -> None:
|
|
|
1414
1471
|
"cap back to unlimited."
|
|
1415
1472
|
),
|
|
1416
1473
|
)
|
|
1417
|
-
limits_parser.add_argument("--monthly-
|
|
1474
|
+
limits_parser.add_argument("--monthly-credit-cap", dest="monthly_credit_cap", type=int)
|
|
1418
1475
|
limits_parser.add_argument(
|
|
1419
1476
|
"--max-concurrent-sessions", dest="max_concurrent_sessions", type=int
|
|
1420
1477
|
)
|
|
@@ -1423,7 +1480,7 @@ def add_org_parser(sub: argparse._SubParsersAction) -> None:
|
|
|
1423
1480
|
limits_parser.add_argument(
|
|
1424
1481
|
"--clear",
|
|
1425
1482
|
action="append",
|
|
1426
|
-
choices=("
|
|
1483
|
+
choices=("monthly_credit_cap", "max_concurrent_sessions", "max_gpu_units"),
|
|
1427
1484
|
help="Cap(s) to reset to unlimited (repeatable)",
|
|
1428
1485
|
)
|
|
1429
1486
|
# defer_to_parent: `org` defines these same flags, so this child must not
|
|
@@ -1956,7 +2013,7 @@ def org_identity_lines(config: dict[str, Any], resolved: ResolvedCredentials) ->
|
|
|
1956
2013
|
def org_set_limits(args: argparse.Namespace) -> int:
|
|
1957
2014
|
payload: dict[str, Any] = {}
|
|
1958
2015
|
for key in (
|
|
1959
|
-
"
|
|
2016
|
+
"monthly_credit_cap",
|
|
1960
2017
|
"max_concurrent_sessions",
|
|
1961
2018
|
"max_gpu_units",
|
|
1962
2019
|
"alert_threshold_pct",
|
|
@@ -1968,7 +2025,7 @@ def org_set_limits(args: argparse.Namespace) -> int:
|
|
|
1968
2025
|
payload["clear"] = args.clear
|
|
1969
2026
|
if not payload:
|
|
1970
2027
|
raise UrunError(
|
|
1971
|
-
"nothing to set; pass at least one of --monthly-
|
|
2028
|
+
"nothing to set; pass at least one of --monthly-credit-cap, "
|
|
1972
2029
|
"--max-concurrent-sessions, --max-gpu-units, --alert-threshold-pct, --clear"
|
|
1973
2030
|
)
|
|
1974
2031
|
return print_or_table(_client(args).set_org_limits(payload), args, "org limits")
|
|
@@ -4107,6 +4164,40 @@ def _builders_resolve_command(args: argparse.Namespace, client: ApiClient) -> in
|
|
|
4107
4164
|
return print_or_table(response, args, "builder")
|
|
4108
4165
|
|
|
4109
4166
|
|
|
4167
|
+
def reserve_command(args: argparse.Namespace) -> int:
|
|
4168
|
+
from .reserve import list_reservations, release, reserve
|
|
4169
|
+
|
|
4170
|
+
command = getattr(args, "reserve_command", None)
|
|
4171
|
+
context = getattr(args, "context", None)
|
|
4172
|
+
namespace = getattr(args, "namespace", None) or "urun"
|
|
4173
|
+
if command == "list":
|
|
4174
|
+
rows = list_reservations(context, namespace)
|
|
4175
|
+
reservations = rows.get("reservations") or []
|
|
4176
|
+
if not reservations:
|
|
4177
|
+
print("no reserved claims")
|
|
4178
|
+
return 0
|
|
4179
|
+
for row in reservations:
|
|
4180
|
+
until = (
|
|
4181
|
+
row.get("pin_until") or f"bare (≤{int((row.get('max_hold_s') or 0) / 3600)}h hold)"
|
|
4182
|
+
)
|
|
4183
|
+
print(
|
|
4184
|
+
f"{row.get('claim')} reserved-for={row.get('reserved_for')} until={until} phase={row.get('phase') or '?'}"
|
|
4185
|
+
)
|
|
4186
|
+
return 0
|
|
4187
|
+
if command == "release":
|
|
4188
|
+
state = release(context, namespace, args.claim)
|
|
4189
|
+
print(f"released {args.claim} (was_reserved={state.get('was_reserved')})")
|
|
4190
|
+
return 0
|
|
4191
|
+
if command == "set":
|
|
4192
|
+
state = reserve(context, namespace, args.claim, args.app, getattr(args, "until", None))
|
|
4193
|
+
until = state.get("pin_until") or "bare (bounded at the 8h hold cap)"
|
|
4194
|
+
print(f"reserved {state.get('claim')} for {state.get('reserved_for')} (until={until})")
|
|
4195
|
+
print("the node's scheduler applies it on its next poll tick (~15s)")
|
|
4196
|
+
return 0
|
|
4197
|
+
print("usage: urun reserve {set|release|list}", file=sys.stderr)
|
|
4198
|
+
return 2
|
|
4199
|
+
|
|
4200
|
+
|
|
4110
4201
|
def node_command(args: argparse.Namespace) -> int:
|
|
4111
4202
|
target = getattr(args, "node_command", None)
|
|
4112
4203
|
if target == "ls":
|
|
@@ -4584,6 +4675,39 @@ def _app_payload(response: dict[str, Any]) -> dict[str, Any]:
|
|
|
4584
4675
|
return app
|
|
4585
4676
|
|
|
4586
4677
|
|
|
4678
|
+
def _actual_release_entries(app: dict[str, Any]) -> list[dict[str, Any]]:
|
|
4679
|
+
"""Presence-derived actual-release rollup entries, oldest planes tolerated.
|
|
4680
|
+
|
|
4681
|
+
The plane (migration 20270319000000) reports `actual_release_ids` as a list
|
|
4682
|
+
of {release_id, manifest_hash, pods} for the fresh ready pods actually
|
|
4683
|
+
serving the function. Older planes omit the field entirely and scale-to-zero
|
|
4684
|
+
reports JSON null; both degrade to an empty list so the rendered output is
|
|
4685
|
+
exactly what it is today.
|
|
4686
|
+
"""
|
|
4687
|
+
entries = app.get("actual_release_ids")
|
|
4688
|
+
if entries is None:
|
|
4689
|
+
return []
|
|
4690
|
+
if not isinstance(entries, list) or any(not isinstance(entry, dict) for entry in entries):
|
|
4691
|
+
raise UrunError("server returned invalid actual_release_ids payload")
|
|
4692
|
+
return entries
|
|
4693
|
+
|
|
4694
|
+
|
|
4695
|
+
def _actual_release_label(entry: dict[str, Any]) -> str:
|
|
4696
|
+
return _short_hash(entry.get("manifest_hash") or entry.get("release_id"))
|
|
4697
|
+
|
|
4698
|
+
|
|
4699
|
+
def _pods_str(count: Any) -> str:
|
|
4700
|
+
if _is_non_bool_int(count):
|
|
4701
|
+
return f"{count} pod" if count == 1 else f"{count} pods"
|
|
4702
|
+
return "? pods"
|
|
4703
|
+
|
|
4704
|
+
|
|
4705
|
+
def _actual_release_summary(entries: list[dict[str, Any]]) -> str:
|
|
4706
|
+
return ", ".join(
|
|
4707
|
+
f"{_actual_release_label(entry)} ({_pods_str(entry.get('pods'))})" for entry in entries
|
|
4708
|
+
)
|
|
4709
|
+
|
|
4710
|
+
|
|
4587
4711
|
def app_status(args: argparse.Namespace) -> int:
|
|
4588
4712
|
api_url, api_key = resolve_api_credentials(args)
|
|
4589
4713
|
environment = resolve_environment(args)
|
|
@@ -4618,8 +4742,15 @@ def app_status(args: argparse.Namespace) -> int:
|
|
|
4618
4742
|
("Compute", _coalesce_str(app.get("compute_shape"), "-")),
|
|
4619
4743
|
("GPU", _gpu_str(app)),
|
|
4620
4744
|
("Release", _short_hash(app.get("manifest_hash") or app.get("release_version"))),
|
|
4621
|
-
("Live sessions", _int_str(app.get("active_sessions"))),
|
|
4622
4745
|
]
|
|
4746
|
+
# Desired vs actual: `Release` above is the DESIRED (promoted) release; the
|
|
4747
|
+
# plane additionally reports which release the fresh ready pods are ACTUALLY
|
|
4748
|
+
# serving (runtime_presence rollup). Absent on older planes and on
|
|
4749
|
+
# scale-to-zero — in both cases the output stays exactly what it is today.
|
|
4750
|
+
actual_entries = _actual_release_entries(app)
|
|
4751
|
+
if actual_entries:
|
|
4752
|
+
fields.append(("Release (actual)", _actual_release_summary(actual_entries)))
|
|
4753
|
+
fields.append(("Live sessions", _int_str(app.get("active_sessions"))))
|
|
4623
4754
|
health_rows = response.get("runtime_health")
|
|
4624
4755
|
if isinstance(health_rows, list):
|
|
4625
4756
|
for health_value in health_rows:
|
|
@@ -4643,6 +4774,22 @@ def app_status(args: argparse.Namespace) -> int:
|
|
|
4643
4774
|
width = max(len(label) for label, _ in fields)
|
|
4644
4775
|
for label, value in fields:
|
|
4645
4776
|
print(f"{label.rjust(width)}: {value}")
|
|
4777
|
+
# The displayed `Status` value stays untouched (scripted consumers gate on
|
|
4778
|
+
# it); the mismatch is surfaced as an explicit warning line instead.
|
|
4779
|
+
if app.get("release_mismatch") is True:
|
|
4780
|
+
current_release_id = app.get("current_release_id")
|
|
4781
|
+
stale_entries = [
|
|
4782
|
+
entry
|
|
4783
|
+
for entry in actual_entries
|
|
4784
|
+
if entry.get("release_id") is not None and entry.get("release_id") != current_release_id
|
|
4785
|
+
]
|
|
4786
|
+
stale_summary = _actual_release_summary(stale_entries) or "unknown"
|
|
4787
|
+
explanation = (
|
|
4788
|
+
'status "ready" reflects warm capacity, not materialization of the desired release'
|
|
4789
|
+
if _coalesce_str(app.get("status"), "") == _DISPLAY_READY
|
|
4790
|
+
else "the desired release is not yet materialized on these pods"
|
|
4791
|
+
)
|
|
4792
|
+
print(f"WARNING: pods still running previous release {stale_summary} — {explanation}")
|
|
4646
4793
|
return 0
|
|
4647
4794
|
|
|
4648
4795
|
|
|
@@ -5989,14 +6136,33 @@ def resolve_compat_credentials(app_flag: str | None = None) -> dict[str, str]:
|
|
|
5989
6136
|
only DERIVED (via :func:`sdk_base_url`) when the caller did not set it:
|
|
5990
6137
|
deriving eagerly would raise on a custom-path api_url even when the caller
|
|
5991
6138
|
already supplied the exact override :func:`sdk_base_url`'s error recommends.
|
|
6139
|
+
|
|
6140
|
+
SECURITY (pentest urun-cli-03): the compat child env is the frontend scrub
|
|
6141
|
+
allowlist (:func:`urun.frontend_launch.frontend_child_env`) plus these
|
|
6142
|
+
credential keys — NEVER the developer's full exported
|
|
6143
|
+
environment, and the PATH binary lane is refused. The env-wins-per-key
|
|
6144
|
+
contract is preserved by forwarding explicit URUN_BASE_URL / URUN_ORG_ID
|
|
6145
|
+
through this dict.
|
|
5992
6146
|
"""
|
|
5993
6147
|
args = argparse.Namespace(api_key=None, api_url=None, gateway_url=None)
|
|
5994
6148
|
api_url, api_key = resolve_api_credentials(args)
|
|
5995
6149
|
credentials = {"URUN_API_KEY": api_key}
|
|
5996
|
-
|
|
6150
|
+
explicit_base_url = string_value(os.environ.get("URUN_BASE_URL"))
|
|
6151
|
+
if explicit_base_url:
|
|
6152
|
+
# SECURITY (pentest urun-cli-03): the compat child env is SCRUBBED
|
|
6153
|
+
# (run_proxy builds it from frontend_child_env), so an explicit
|
|
6154
|
+
# URUN_BASE_URL rides to the child through the credentials dict —
|
|
6155
|
+
# the same env-wins-per-key contract as before the scrub.
|
|
6156
|
+
credentials["URUN_BASE_URL"] = explicit_base_url
|
|
6157
|
+
else:
|
|
5997
6158
|
credentials["URUN_BASE_URL"] = sdk_base_url(api_url)
|
|
5998
6159
|
saved = load_credentials()
|
|
5999
|
-
|
|
6160
|
+
explicit_org_id = string_value(os.environ.get("URUN_ORG_ID"))
|
|
6161
|
+
if explicit_org_id:
|
|
6162
|
+
# Same env-wins-per-key contract as URUN_BASE_URL above: the scrubbed
|
|
6163
|
+
# child env would otherwise swallow an explicit URUN_ORG_ID.
|
|
6164
|
+
credentials["URUN_ORG_ID"] = explicit_org_id
|
|
6165
|
+
elif api_key == string_value(saved.get("api_key")):
|
|
6000
6166
|
org_id = string_value(saved.get("org_id"))
|
|
6001
6167
|
if org_id:
|
|
6002
6168
|
credentials["URUN_ORG_ID"] = org_id
|
|
@@ -29,11 +29,13 @@ treats a CLI launch and a hosted token route identically).
|
|
|
29
29
|
|
|
30
30
|
from __future__ import annotations
|
|
31
31
|
|
|
32
|
+
import ipaddress
|
|
32
33
|
import json
|
|
33
34
|
import re
|
|
34
35
|
import sys
|
|
35
36
|
import threading
|
|
36
37
|
import time
|
|
38
|
+
import urllib.parse
|
|
37
39
|
from collections.abc import Callable
|
|
38
40
|
from dataclasses import dataclass
|
|
39
41
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
@@ -91,6 +93,31 @@ def is_loopback_host(host: str) -> bool:
|
|
|
91
93
|
return canonical_demo_host(host) in _LOOPBACK_HOSTS
|
|
92
94
|
|
|
93
95
|
|
|
96
|
+
def browser_origin_is_loopback(origin: str) -> bool:
|
|
97
|
+
"""Whether a browser ``Origin`` names THIS machine (loopback host, any port).
|
|
98
|
+
|
|
99
|
+
``http://127.0.0.1:5173``, ``http://localhost:3000``, any ``127.x.x.x``
|
|
100
|
+
address and ``*.localhost`` subdomains (browsers resolve those to loopback,
|
|
101
|
+
RFC 6761) are pages on this machine; ``https://proxy.example`` or a LAN
|
|
102
|
+
address is not. Dev-mode CORS reflection accepts ONLY these (pentest
|
|
103
|
+
urun-cli-02): reflecting an arbitrary Origin handed the live, silently
|
|
104
|
+
re-minting session token to any website open in the developer's browser,
|
|
105
|
+
which needs only a loopback port scan to read it cross-origin.
|
|
106
|
+
"""
|
|
107
|
+
try:
|
|
108
|
+
host = (urllib.parse.urlsplit(origin).hostname or "").lower().strip("[]")
|
|
109
|
+
except ValueError:
|
|
110
|
+
return False
|
|
111
|
+
if not host:
|
|
112
|
+
return False
|
|
113
|
+
if host == "localhost" or host.endswith(".localhost"):
|
|
114
|
+
return True
|
|
115
|
+
try:
|
|
116
|
+
return ipaddress.ip_address(host).is_loopback
|
|
117
|
+
except ValueError:
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
94
121
|
def require_loopback_demo_host(host: str) -> str:
|
|
95
122
|
"""The CANONICAL demo host, or a LOUD refusal for anything routable.
|
|
96
123
|
|
|
@@ -379,19 +406,20 @@ class _TokenRequestHandler(BaseHTTPRequestHandler):
|
|
|
379
406
|
does and reads as a stronger guarantee than exists:
|
|
380
407
|
|
|
381
408
|
* ``/token`` — demo callers must present an allowed Origin. Dev callers
|
|
382
|
-
may present any Origin
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
409
|
+
may present any LOOPBACK Origin (any port): a developer may attach
|
|
410
|
+
through any page served on this machine. Reflecting an arbitrary
|
|
411
|
+
non-loopback Origin is refused (403) — pentest urun-cli-02: an
|
|
412
|
+
arbitrary website open in the browser could otherwise read the live,
|
|
413
|
+
silently re-minting token cross-origin with a plain loopback port
|
|
414
|
+
scan. A caller that sends NO Origin is also served, because the
|
|
415
|
+
hosted token route this mirrors is fetched by server-side code, which
|
|
416
|
+
sends none. Any local process can therefore read a token, which is
|
|
417
|
+
why the socket is bound to loopback and only loopback (see
|
|
418
|
+
``require_loopback_demo_host``).
|
|
388
419
|
* ``/events`` — demo callers must present an allowed Origin, while dev
|
|
389
|
-
callers may present any Origin for the same reason as
|
|
390
|
-
absent Origin is always refused because this route is
|
|
391
|
-
|
|
392
|
-
The Origin check is a browser-context control and nothing more: outside a
|
|
393
|
-
browser the header is caller-controlled, and the permitted origins are a
|
|
394
|
-
readable claim inside the token itself. What actually bounds this token is
|
|
420
|
+
callers may present any LOOPBACK Origin for the same reason as
|
|
421
|
+
``/token``. An absent Origin is always refused because this route is
|
|
422
|
+
browser-only.
|
|
395
423
|
``allowed_functions``, its expiry, and ``max_session_s``.
|
|
396
424
|
"""
|
|
397
425
|
|
|
@@ -403,7 +431,11 @@ class _TokenRequestHandler(BaseHTTPRequestHandler):
|
|
|
403
431
|
if origin is None:
|
|
404
432
|
return None
|
|
405
433
|
if self.server.unrestricted_browser_origins:
|
|
406
|
-
|
|
434
|
+
# SECURITY (pentest urun-cli-02): dev reflects ONLY loopback
|
|
435
|
+
# browser origins (any port) — pages on THIS machine. Reflecting
|
|
436
|
+
# an arbitrary Origin handed the live, silently re-minting token
|
|
437
|
+
# to any website in the browser; a remote page gets 403.
|
|
438
|
+
return origin if browser_origin_is_loopback(origin) else ""
|
|
407
439
|
return origin if origin in self.server.allowed_origins else ""
|
|
408
440
|
|
|
409
441
|
def _reject_disallowed_origin(self, origin: str | None) -> bool:
|
|
@@ -1439,7 +1439,7 @@ def _maybe_launch_frontend(
|
|
|
1439
1439
|
"frontend | ",
|
|
1440
1440
|
"browser auth: scoped client token "
|
|
1441
1441
|
f"(functions: {', '.join(launch.allowed_functions)}; "
|
|
1442
|
-
"origins:
|
|
1442
|
+
"origins: loopback browser origins only (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
1445
|
mux.line(
|
|
@@ -30,11 +30,14 @@ Resolution is fail-loud, one canonical order, no silent fallback:
|
|
|
30
30
|
1. ``URUN_OPENAI_PROXY_BIN`` (explicit path — always wins, must exist);
|
|
31
31
|
2. the vendored platform binary shipped inside this package
|
|
32
32
|
(``src/urun/_bin/urun-openai``);
|
|
33
|
-
3. ``urun-openai`` on ``PATH`` (dev installs)
|
|
33
|
+
3. ``urun-openai`` on ``PATH`` (dev installs) — refused whenever org
|
|
34
|
+
credentials are handed to the child (pentest urun-cli-03);
|
|
34
35
|
4. a loud error naming all three lanes.
|
|
35
36
|
|
|
36
37
|
A missing vendored binary falls through to PATH by design — that fall-through
|
|
37
|
-
is part of the documented canonical order above, not a silent fallback
|
|
38
|
+
is part of the documented canonical order above, not a silent fallback (the
|
|
39
|
+
credential-carrying call is the exception: there the missing vendored binary
|
|
40
|
+
fails LOUD rather than hand the org key to a PATH-resolved binary). The
|
|
38
41
|
proxy's own session config (``URUN_BASE_URL`` / ``URUN_ORG_ID`` / ``URUN_APP``
|
|
39
42
|
/ ``URUN_JWT``) passes through the environment untouched.
|
|
40
43
|
"""
|
|
@@ -73,8 +76,20 @@ def vendored_proxy_binary() -> str | None:
|
|
|
73
76
|
return str(path)
|
|
74
77
|
|
|
75
78
|
|
|
76
|
-
def resolve_proxy_binary(
|
|
77
|
-
|
|
79
|
+
def resolve_proxy_binary(
|
|
80
|
+
env: Mapping[str, str] | None = None, *, allow_path_lane: bool = True
|
|
81
|
+
) -> str:
|
|
82
|
+
"""The proxy binary path — explicit env override, else vendored, else PATH.
|
|
83
|
+
|
|
84
|
+
``allow_path_lane=False`` (used whenever credentials are handed to the
|
|
85
|
+
child) refuses the PATH lane: a repo-run dev script can plant a
|
|
86
|
+
``urun-openai`` executable on the user's PATH (an ordinary file write
|
|
87
|
+
within its scrubbed powers) and capture the org API key the CLI injects —
|
|
88
|
+
exactly the capability the frontend env scrub removed from
|
|
89
|
+
repository-run processes (pentest urun-cli-03). The explicit
|
|
90
|
+
``URUN_OPENAI_PROXY_BIN`` override and the package-vendored binary stay
|
|
91
|
+
allowed: those are operator-controlled, not attacker-placeable.
|
|
92
|
+
"""
|
|
78
93
|
environ = os.environ if env is None else env
|
|
79
94
|
override = (environ.get(BIN_ENV) or "").strip()
|
|
80
95
|
if override:
|
|
@@ -88,6 +103,14 @@ def resolve_proxy_binary(env: Mapping[str, str] | None = None) -> str:
|
|
|
88
103
|
vendored = vendored_proxy_binary()
|
|
89
104
|
if vendored:
|
|
90
105
|
return vendored
|
|
106
|
+
if not allow_path_lane:
|
|
107
|
+
raise FileNotFoundError(
|
|
108
|
+
f"the {BIN_NAME!r} proxy binary cannot be resolved from PATH while "
|
|
109
|
+
"org credentials are handed to the child — a repo-run dev script "
|
|
110
|
+
"can plant a PATH entry and capture the key (pentest urun-cli-03). "
|
|
111
|
+
f"Set {BIN_ENV} to an explicit path or use a urun-cli build that "
|
|
112
|
+
f"vendors it at {VENDOR_DIR / BIN_NAME}"
|
|
113
|
+
)
|
|
91
114
|
# Search the SUPPLIED environment's PATH — the same env the child launches
|
|
92
115
|
# with. An env mapping without PATH has no PATH lane at all (loud below).
|
|
93
116
|
found = shutil.which(BIN_NAME, path=environ.get("PATH", ""))
|
|
@@ -120,12 +143,34 @@ def run_proxy(
|
|
|
120
143
|
them HERE, so they reach the child PROCESS-SCOPED — never shell-exported,
|
|
121
144
|
never written to disk by this seam. Explicit env vars win over the store
|
|
122
145
|
(the override lane); the store fills what the user did not set.
|
|
146
|
+
|
|
147
|
+
SECURITY (pentest urun-cli-03): when ``credentials`` are handed over, the
|
|
148
|
+
child env is NEVER the developer's full exported environment — it is the
|
|
149
|
+
frontend scrub allowlist (:func:`urun.frontend_launch.frontend_child_env`)
|
|
150
|
+
plus the credential keys only, and the PATH resolution lane is refused
|
|
151
|
+
(explicit ``URUN_OPENAI_PROXY_BIN`` path or vendored binary only). A
|
|
152
|
+
repo-run dev script could otherwise plant a ``urun-openai`` on PATH and
|
|
153
|
+
re-capture the raw org key and every exported secret (AWS_*,
|
|
154
|
+
GITHUB_TOKEN, ...) the scrub removed from repository-run processes.
|
|
123
155
|
"""
|
|
124
|
-
binary = resolve_proxy_binary(env)
|
|
125
|
-
base = dict(env) if env is not None else dict(os.environ)
|
|
126
156
|
if credentials:
|
|
157
|
+
# Lazy import: frontend_launch pulls the API client surface; this
|
|
158
|
+
# module must stay importable standalone.
|
|
159
|
+
from .frontend_launch import frontend_child_env
|
|
160
|
+
|
|
161
|
+
base = frontend_child_env()
|
|
162
|
+
if env is not None:
|
|
163
|
+
# The CALLER env may be a full process environment — copy only the
|
|
164
|
+
# credential keys it sets (per-key env-wins), never the whole map.
|
|
165
|
+
for key in credentials:
|
|
166
|
+
if key in env:
|
|
167
|
+
base[key] = env[key]
|
|
168
|
+
binary = resolve_proxy_binary(env, allow_path_lane=False)
|
|
127
169
|
for key, value in credentials.items():
|
|
128
170
|
base.setdefault(key, value) # env overrides store, store fills gaps
|
|
171
|
+
else:
|
|
172
|
+
binary = resolve_proxy_binary(env)
|
|
173
|
+
base = dict(env) if env is not None else dict(os.environ)
|
|
129
174
|
completed = subprocess.run( # noqa: S603 — the resolved binary IS the product
|
|
130
175
|
[binary, *args],
|
|
131
176
|
env=base,
|
|
@@ -48,6 +48,62 @@ class RepoDepsError(Exception):
|
|
|
48
48
|
|
|
49
49
|
_FULL_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
|
50
50
|
|
|
51
|
+
#: A fetchable ref tail: a plain refname (branch / tag — a full 40-hex SHA is a
|
|
52
|
+
#: subset of this charset). The FIRST character is alphanumeric so the ref can
|
|
53
|
+
#: never start with ``-`` (git's parse-options would read it as an option) and
|
|
54
|
+
#: the charset excludes ``=``, ``:``, ``@``, whitespace and every shell
|
|
55
|
+
#: metacharacter. The ref rides as ONE argv element into
|
|
56
|
+
#: ``git fetch --depth 1 origin <ref>``; for a local-path spec
|
|
57
|
+
#: (``/abs/path`` / ``file://``) a ``--upload-pack=<payload>`` tail made git
|
|
58
|
+
#: EXECUTE the payload on the deployer's machine (pentest urun-cli-01), so a
|
|
59
|
+
#: ref that is not a plain refname is refused before any git call.
|
|
60
|
+
_REFNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _require_fetchable_ref(ref: str, spec: str) -> None:
|
|
64
|
+
"""Refuse a ref git parse-options could read as an OPTION (ENG-570).
|
|
65
|
+
|
|
66
|
+
The spec is a string literal in UNTRUSTED app source and the ref tail
|
|
67
|
+
reaches git argv verbatim. A full 40-char commit SHA or a plain refname
|
|
68
|
+
over ``[A-Za-z0-9._/-]`` (never leading ``-``, never ``=``) is fetchable;
|
|
69
|
+
anything else — ``--upload-pack=<payload>`` above all — is option
|
|
70
|
+
injection and aborts the splice loudly.
|
|
71
|
+
"""
|
|
72
|
+
if _FULL_SHA_RE.fullmatch(ref):
|
|
73
|
+
return
|
|
74
|
+
if _REFNAME_RE.fullmatch(ref):
|
|
75
|
+
# git-check-ref-format rules beyond the charset: no empty components
|
|
76
|
+
# (the ref tail can never contain "/", so ".." is the empty-
|
|
77
|
+
# component shape here), no trailing "." or ".lock". These would
|
|
78
|
+
# die at fetch time anyway; rejecting at parse time gives the bad
|
|
79
|
+
# pin a named, actionable error.
|
|
80
|
+
if ".." in ref or "//" in ref or "/." in ref or ref.endswith(".") or ref.endswith(".lock"):
|
|
81
|
+
raise RepoDepsError(
|
|
82
|
+
f"store.repo spec {spec!r}: invalid ref {ref!r} — not a valid "
|
|
83
|
+
"git refname (empty/dot-leading components or a trailing "
|
|
84
|
+
"'.lock'); fix the pin"
|
|
85
|
+
)
|
|
86
|
+
return
|
|
87
|
+
raise RepoDepsError(
|
|
88
|
+
f"store.repo spec {spec!r}: invalid ref {ref!r} — the pin must be a full "
|
|
89
|
+
"40-char commit SHA or a refname over [A-Za-z0-9._/-] not starting with "
|
|
90
|
+
"'-' (refuses git option injection)"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _require_fetchable_head(head: str, spec: str) -> None:
|
|
95
|
+
"""Refuse a spec head (URL / local path) git would parse as an OPTION.
|
|
96
|
+
|
|
97
|
+
The head reaches ``git remote add origin <head>`` verbatim; a leading ``-``
|
|
98
|
+
turns the address into a git option instead of a remote.
|
|
99
|
+
"""
|
|
100
|
+
if head.startswith("-"):
|
|
101
|
+
raise RepoDepsError(
|
|
102
|
+
f"store.repo spec {spec!r}: the repo URL/path may not start with '-' "
|
|
103
|
+
"(refuses git option injection)"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
51
107
|
#: Metadata files scanned for the upstream's own python deps, in priority order
|
|
52
108
|
#: (mirrors urun-python ``repo._DEP_FILES``).
|
|
53
109
|
_DEP_FILES = ("requirements.txt", "pyproject.toml", "setup.py")
|
|
@@ -92,10 +148,18 @@ def parse_repo_spec(spec: str) -> RepoSpec:
|
|
|
92
148
|
# path separator and no scheme.
|
|
93
149
|
if "/" not in maybe_ref and ":" not in maybe_ref:
|
|
94
150
|
raw, ref = head, maybe_ref
|
|
151
|
+
# SECURITY (pentest urun-cli-01 / ENG-570): the spec is an attacker-
|
|
152
|
+
# controlled string literal in app source and BOTH halves reach git argv
|
|
153
|
+
# verbatim (``git remote add origin <head>`` / ``git fetch ... <ref>``).
|
|
154
|
+
# Refuse option-injection shapes before any git call.
|
|
155
|
+
if ref is not None:
|
|
156
|
+
_require_fetchable_ref(ref, spec)
|
|
157
|
+
_require_fetchable_head(raw, spec)
|
|
95
158
|
|
|
96
159
|
# Local filesystem repo (absolute path or file:// URL).
|
|
97
160
|
if raw.startswith("file://"):
|
|
98
161
|
path = raw[len("file://") :]
|
|
162
|
+
_require_fetchable_head(path, spec)
|
|
99
163
|
return RepoSpec(
|
|
100
164
|
clone_url=path,
|
|
101
165
|
ref=ref,
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""``urun reserve`` — the operator verb for app-exclusive nodes (lane 10).
|
|
2
|
+
|
|
3
|
+
Reserves a GPUWorkerClaim for ONE app (`<app_id>[/<fn>]`), optionally
|
|
4
|
+
bounded by a deadline, through the control plane's SANCTIONED surface
|
|
5
|
+
(``POST``/``DELETE``/``GET /node-reservations`` on the urun-control-plane
|
|
6
|
+
health port) — the same annotations the hand-rolled
|
|
7
|
+
``kubectl annotate gpuworkerclaims … urun.sh/reserved-for=…`` path stamped,
|
|
8
|
+
never a second mechanism.
|
|
9
|
+
|
|
10
|
+
Dev/prod posture: unlike ``urun scratch`` (dev-only by context naming),
|
|
11
|
+
reserving is FOR prod too — the whole point is guaranteeing a serving or
|
|
12
|
+
training node to one app. The guard is therefore inverted: the operator's
|
|
13
|
+
ACTIVE kubectl context is refused as a silent default for a WRITE; the
|
|
14
|
+
context must be passed explicitly (``--context``), so a reservation can
|
|
15
|
+
never land on a cluster the operator is not looking at. Listing takes the
|
|
16
|
+
active context freely (a read).
|
|
17
|
+
|
|
18
|
+
The verb shells kubectl port-forward to the control plane's health port —
|
|
19
|
+
the same operator posture ``urun scratch`` already established (explicit
|
|
20
|
+
context/namespace, kubectl-driven, no API-key surface involved).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import subprocess
|
|
27
|
+
import time
|
|
28
|
+
import urllib.error
|
|
29
|
+
import urllib.request
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from .errors import UrunError
|
|
33
|
+
|
|
34
|
+
#: The control-plane deployment whose health port carries the routes.
|
|
35
|
+
CONTROL_PLANE_DEPLOYMENT = "urun-control-plane"
|
|
36
|
+
#: The health server's port (URUN_CONTROL_HEALTH_PORT default).
|
|
37
|
+
CONTROL_PLANE_PORT = 8080
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _free_port() -> int:
|
|
41
|
+
import socket
|
|
42
|
+
|
|
43
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
44
|
+
s.bind(("127.0.0.1", 0))
|
|
45
|
+
return s.getsockname()[1]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _PortForward:
|
|
49
|
+
"""A bounded kubectl port-forward to the control plane's health port."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, context: str | None, namespace: str) -> None:
|
|
52
|
+
port = _free_port()
|
|
53
|
+
self.url = f"http://127.0.0.1:{port}"
|
|
54
|
+
cmd = ["kubectl"]
|
|
55
|
+
if context:
|
|
56
|
+
cmd += ["--context", context]
|
|
57
|
+
cmd += [
|
|
58
|
+
"-n",
|
|
59
|
+
namespace,
|
|
60
|
+
"port-forward",
|
|
61
|
+
f"deploy/{CONTROL_PLANE_DEPLOYMENT}",
|
|
62
|
+
f"{port}:{CONTROL_PLANE_PORT}",
|
|
63
|
+
]
|
|
64
|
+
self.proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
65
|
+
# port-forward needs a moment; the request below retries transiently.
|
|
66
|
+
self._ready_deadline = time.monotonic() + 10.0
|
|
67
|
+
|
|
68
|
+
def request(self, method: str, path: str, body: dict[str, Any] | None = None) -> Any:
|
|
69
|
+
payload = json.dumps(body).encode("utf-8") if body is not None else None
|
|
70
|
+
last_exc: Exception | None = None
|
|
71
|
+
while time.monotonic() < self._ready_deadline + 20.0:
|
|
72
|
+
req = urllib.request.Request(
|
|
73
|
+
self.url + path,
|
|
74
|
+
data=payload,
|
|
75
|
+
method=method,
|
|
76
|
+
headers={"content-type": "application/json"} if payload else {},
|
|
77
|
+
)
|
|
78
|
+
try:
|
|
79
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
80
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
81
|
+
except urllib.error.HTTPError as exc:
|
|
82
|
+
# A defined API answer (404/409/4xx) is the SERVER speaking —
|
|
83
|
+
# relay its JSON body, never retry it away.
|
|
84
|
+
try:
|
|
85
|
+
detail = json.loads(exc.read().decode("utf-8"))
|
|
86
|
+
except (ValueError, UnicodeDecodeError):
|
|
87
|
+
detail = {}
|
|
88
|
+
message = str(detail.get("error") or detail.get("reason") or exc)
|
|
89
|
+
raise UrunError(message) from exc
|
|
90
|
+
except urllib.error.URLError as exc:
|
|
91
|
+
last_exc = exc
|
|
92
|
+
if self.proc.poll() is not None:
|
|
93
|
+
raise UrunError("kubectl port-forward exited early") from exc
|
|
94
|
+
time.sleep(0.3)
|
|
95
|
+
raise UrunError(f"control-plane health port unreachable: {last_exc}")
|
|
96
|
+
|
|
97
|
+
def close(self) -> None:
|
|
98
|
+
self.proc.terminate()
|
|
99
|
+
try:
|
|
100
|
+
self.proc.wait(timeout=5)
|
|
101
|
+
except subprocess.TimeoutExpired:
|
|
102
|
+
self.proc.kill()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _connect(context: str | None, namespace: str) -> _PortForward:
|
|
106
|
+
# Refuse the ACTIVE context as a silent default for any WRITE; a read
|
|
107
|
+
# (list) takes it. main() passes context=None for reads only when the
|
|
108
|
+
# operator did not name one — the write paths resolve it themselves.
|
|
109
|
+
return _PortForward(context, namespace)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def list_reservations(context: str | None, namespace: str) -> dict[str, Any]:
|
|
113
|
+
"""GET /node-reservations — every reserved claim (scope, pin, phase)."""
|
|
114
|
+
pf = _connect(context, namespace)
|
|
115
|
+
try:
|
|
116
|
+
return pf.request("GET", "/node-reservations")
|
|
117
|
+
finally:
|
|
118
|
+
pf.close()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def reserve(
|
|
122
|
+
context: str | None,
|
|
123
|
+
namespace: str,
|
|
124
|
+
claim: str,
|
|
125
|
+
app: str,
|
|
126
|
+
until: str | None,
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
"""POST /node-reservations/{claim} — stamp urun.sh/reserved-for (+ pin)."""
|
|
129
|
+
if context is None:
|
|
130
|
+
raise UrunError(
|
|
131
|
+
"refusing to reserve against the ACTIVE kubectl context as a silent "
|
|
132
|
+
"default — pass --context explicitly (a reservation is a live "
|
|
133
|
+
"cluster write; be looking at the cluster you write to)"
|
|
134
|
+
)
|
|
135
|
+
pf = _connect(context, namespace)
|
|
136
|
+
try:
|
|
137
|
+
body: dict[str, Any] = {"app": app}
|
|
138
|
+
if until:
|
|
139
|
+
body["until"] = until
|
|
140
|
+
return pf.request("POST", f"/node-reservations/{claim}", body)
|
|
141
|
+
finally:
|
|
142
|
+
pf.close()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def release(context: str | None, namespace: str, claim: str) -> dict[str, Any]:
|
|
146
|
+
"""DELETE /node-reservations/{claim} — idempotent release."""
|
|
147
|
+
if context is None:
|
|
148
|
+
raise UrunError(
|
|
149
|
+
"refusing to release against the ACTIVE kubectl context as a silent "
|
|
150
|
+
"default — pass --context explicitly"
|
|
151
|
+
)
|
|
152
|
+
pf = _connect(context, namespace)
|
|
153
|
+
try:
|
|
154
|
+
return pf.request("DELETE", f"/node-reservations/{claim}")
|
|
155
|
+
finally:
|
|
156
|
+
pf.close()
|
|
@@ -122,6 +122,25 @@ def _add_serve_model_args(parser: argparse.ArgumentParser) -> None:
|
|
|
122
122
|
"to the spheron-pinned queue. Unpinned is the default and unchanged."
|
|
123
123
|
),
|
|
124
124
|
)
|
|
125
|
+
# THE CPU DECLARATION'S PARSER HALF (owner design 2026-09-30, #353):
|
|
126
|
+
# the handler/shim/app halves shipped in #353 but this flag never did —
|
|
127
|
+
# argparse rejected `--cpu` before serve_model could honor it, so every
|
|
128
|
+
# shared-reconciler CPU row (phonon-2) died with "unrecognized
|
|
129
|
+
# arguments" (ENG-599, observed live on dev's ENG-584 sweep). Mirrors
|
|
130
|
+
# `urun scratch run`'s --cpu shape (cli.py); the handler validates the
|
|
131
|
+
# placement rules (CPU-only rows; --gpu <SKU> + --cpu is refused there).
|
|
132
|
+
parser.add_argument(
|
|
133
|
+
"--cpu",
|
|
134
|
+
dest="cpu",
|
|
135
|
+
type=int,
|
|
136
|
+
default=None,
|
|
137
|
+
metavar="N",
|
|
138
|
+
help=(
|
|
139
|
+
"CPU declaration for a gpu-less (CPU) placement: N cores "
|
|
140
|
+
"(shape cpu:N). Only valid with `--gpu none` or a CPU variant "
|
|
141
|
+
"— the shared reconciler's CPU rows pass it with --gpu none."
|
|
142
|
+
),
|
|
143
|
+
)
|
|
125
144
|
parser.add_argument(
|
|
126
145
|
"--exo-class",
|
|
127
146
|
dest="exo_class",
|
|
@@ -265,10 +284,32 @@ def serve_model(args: argparse.Namespace) -> int:
|
|
|
265
284
|
"--cpu is the CPU declaration and only applies to a CPU (gpu-less) row; "
|
|
266
285
|
"drop --cpu or resolve a CPU variant."
|
|
267
286
|
)
|
|
287
|
+
# --cpu rides the generated shim's URUN_SERVE_CPUS channel, exactly like
|
|
288
|
+
# every other shim-transported pin (--warm, --urun-wheel, the provider pin,
|
|
289
|
+
# the EXO pin) — so it cannot be combined with --entrypoint: a local app is
|
|
290
|
+
# deployed as-is and nothing would carry the declaration (the pin would
|
|
291
|
+
# be a silent no-op; review finding round 1).
|
|
292
|
+
if requested_cpu is not None and getattr(args, "entrypoint", None):
|
|
293
|
+
raise UrunError(
|
|
294
|
+
"--cpu cannot be combined with --entrypoint. The CPU declaration "
|
|
295
|
+
"is set by the generated serve shim; a local --entrypoint app is "
|
|
296
|
+
"deployed as-is, so the declaration would silently do nothing. "
|
|
297
|
+
"Set cpus in your own app's @app.function(cpus=...) instead."
|
|
298
|
+
)
|
|
299
|
+
# `--cpu 0` / `--cpu -1` must fail from the args alone, before any catalog
|
|
300
|
+
# fetch — the shim's late rejection names the CATALOG ROW ("has no
|
|
301
|
+
# positive 'cpu' cores"), blaming the wrong input; the USER supplied the
|
|
302
|
+
# value (CodeRabbit finding).
|
|
303
|
+
if requested_cpu is not None and requested_cpu < 1:
|
|
304
|
+
raise UrunError(
|
|
305
|
+
f"--cpu must declare >= 1 core, got {requested_cpu}; "
|
|
306
|
+
"drop --cpu or pass a positive core count."
|
|
307
|
+
)
|
|
268
308
|
client = resolve_catalog_client(args)
|
|
269
309
|
rows = client.select_rows()
|
|
270
310
|
requested_gpu = requested_gpu_raw
|
|
271
|
-
|
|
311
|
+
explicit_none_gpu = requested_gpu == "none"
|
|
312
|
+
if explicit_none_gpu:
|
|
272
313
|
requested_gpu = None
|
|
273
314
|
# PARSE THE PIN, THEN VALIDATE THE GPU, THEN THE REST FLOWS THROUGH (review:
|
|
274
315
|
# Keegan, 2026-09-15). The provider rides IN the --gpu class because the
|
|
@@ -287,6 +328,11 @@ def serve_model(args: argparse.Namespace) -> int:
|
|
|
287
328
|
# non-enumerated-but-sufficient SKU is ALLOWED (with a warn) instead of rejected.
|
|
288
329
|
# "error" -> fail before deploy; "warn" -> proceed untuned (synthesize a deploy
|
|
289
330
|
# row pinned to the requested gpu_spec); enumerated SKU -> clean (resolve normally).
|
|
331
|
+
# - EXPLICIT `--gpu none` (CodeRabbit finding): the caller named the CPU
|
|
332
|
+
# placement, so select the variant's gpu_spec-NULL row DIRECTLY — collapsing
|
|
333
|
+
# it to an omitted --gpu would hand resolve_row "first placement", which
|
|
334
|
+
# selects the GPU row first whenever a variant carries BOTH placements.
|
|
335
|
+
# An omitted --gpu keeps the first-placement path untouched.
|
|
290
336
|
if requested_gpu:
|
|
291
337
|
level, message = validate_gpu_against_row(rows, model_id, variant, requested_gpu)
|
|
292
338
|
if level == "error":
|
|
@@ -299,6 +345,27 @@ def serve_model(args: argparse.Namespace) -> int:
|
|
|
299
345
|
row = dataclasses.replace(base, gpu_spec=requested_gpu)
|
|
300
346
|
else:
|
|
301
347
|
row = resolve_row(rows, model_id, variant, requested_gpu, searched=client.base_url)
|
|
348
|
+
elif explicit_none_gpu:
|
|
349
|
+
# Resolve the DEFAULT VARIANT first (CodeRabbit finding): `urun serve
|
|
350
|
+
# <id> --gpu none` arrives with variant=None, and filtering rows on
|
|
351
|
+
# `variant == None` matches nothing — resolve_row owns the
|
|
352
|
+
# first-listed-variant default AND the unknown-model/unknown-variant
|
|
353
|
+
# errors, so resolve through it, then filter the resolved variant's
|
|
354
|
+
# placements for the CPU row.
|
|
355
|
+
default_row = resolve_row(rows, model_id, variant, None, searched=client.base_url)
|
|
356
|
+
for_variant = [
|
|
357
|
+
r
|
|
358
|
+
for r in rows
|
|
359
|
+
if r.model_id == default_row.model_id and r.variant == default_row.variant
|
|
360
|
+
]
|
|
361
|
+
cpu_rows = [r for r in for_variant if r.gpu_spec is None]
|
|
362
|
+
if not cpu_rows:
|
|
363
|
+
known = ", ".join(sorted(str(g) for g in {r.gpu_spec for r in for_variant})) or "(none)"
|
|
364
|
+
raise UrunError(
|
|
365
|
+
f"--gpu none names the CPU placement, but {default_row.model_id}:{default_row.variant} has "
|
|
366
|
+
f"no gpu_spec-NULL row. Available placements: {known}"
|
|
367
|
+
)
|
|
368
|
+
row = cpu_rows[0]
|
|
302
369
|
else:
|
|
303
370
|
row = resolve_row(rows, model_id, variant, None, searched=client.base_url)
|
|
304
371
|
|
|
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
|