urun-cli 0.3.1__tar.gz → 0.4.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urun-cli
3
- Version: 0.3.1
3
+ Version: 0.4.1
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "urun-cli"
7
- version = "0.3.1"
7
+ version = "0.4.1"
8
8
  description = "End-user CLI for deploying apps to urun"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -5,6 +5,7 @@ import time
5
5
  import urllib.error
6
6
  import urllib.parse
7
7
  import urllib.request
8
+ from collections.abc import Callable
8
9
  from concurrent.futures import ThreadPoolExecutor, as_completed
9
10
  from typing import Any
10
11
 
@@ -171,13 +172,36 @@ def require_safe_url(raw_url: str, label: str) -> None:
171
172
  raise UrunError(f"{label} must use HTTPS except for localhost development")
172
173
 
173
174
 
175
+ # Terminal build states reported by the control plane's deployment-status document.
176
+ # The build worker transitions a deployment queued -> building -> ready|failed and
177
+ # records the actual build stderr/reason in the `error` field on failure.
178
+ TERMINAL_STATES = {"ready", "failed"}
179
+
180
+ # Human-readable progress lines for non-terminal states, so the CLI streams what the
181
+ # builder is doing instead of blocking silently until ready/failed.
182
+ _STATE_PROGRESS = {
183
+ "queued": "queued (waiting for a build slot)",
184
+ "building": "building (installing deps + materializing app)",
185
+ "not_found": "waiting for the control plane to register the build",
186
+ }
187
+
188
+
174
189
  def poll_until_done(
175
190
  client: ApiClient,
176
191
  manifest_hash: str,
177
192
  interval: float = 2.0,
178
193
  timeout: float = 1800.0,
179
194
  not_found_grace: float = 30.0,
195
+ on_state: Callable[[str], None] | None = None,
180
196
  ) -> dict[str, Any]:
197
+ """Poll the control plane for the real build outcome of a deployment.
198
+
199
+ Streams each new build state via ``on_state`` (so callers can show
200
+ queued -> building -> ... progress) and returns the terminal status
201
+ document (status ``ready`` or ``failed``). The ``failed`` document carries
202
+ the actual build error under ``error`` for the caller to surface. Raises
203
+ ``UrunError`` on timeout.
204
+ """
181
205
  deadline = time.time() + timeout
182
206
  not_found_deadline = time.time() + min(not_found_grace, timeout)
183
207
  last_state = "unknown"
@@ -186,16 +210,25 @@ def poll_until_done(
186
210
  status = client.deployment_status(manifest_hash)
187
211
  except ApiError as exc:
188
212
  if exc.status == 404 and time.time() < not_found_deadline:
213
+ if last_state != "not_found" and on_state is not None:
214
+ on_state("not_found")
189
215
  last_state = "not_found"
190
216
  time.sleep(interval)
191
217
  continue
192
218
  raise
193
- state = status.get("status")
194
- last_state = str(state)
195
- if state in {"ready", "failed"}:
219
+ state = str(status.get("status"))
220
+ if state != last_state and on_state is not None:
221
+ on_state(state)
222
+ last_state = state
223
+ if state in TERMINAL_STATES:
196
224
  return status
197
225
  if time.time() >= deadline:
198
226
  raise UrunError(
199
227
  f"timed out waiting for deployment {manifest_hash} (last status: {last_state})"
200
228
  )
201
229
  time.sleep(interval)
230
+
231
+
232
+ def progress_line(state: str) -> str:
233
+ """Render a human-readable progress line for a build state."""
234
+ return _STATE_PROGRESS.get(state, state)
@@ -11,7 +11,13 @@ from pathlib import Path
11
11
  from typing import Any
12
12
 
13
13
  from . import __version__
14
- from .api import DEFAULT_API_URL, ApiClient, poll_until_done, upload_missing_blobs
14
+ from .api import (
15
+ DEFAULT_API_URL,
16
+ ApiClient,
17
+ poll_until_done,
18
+ progress_line,
19
+ upload_missing_blobs,
20
+ )
15
21
  from .config import load_credentials, save_credentials
16
22
  from .deps import resolve_deps
17
23
  from .discovery import derive_app_name, discover_main_files
@@ -120,9 +126,21 @@ def add_auth_parser(sub: argparse._SubParsersAction) -> None:
120
126
  description=(
121
127
  "Register (or replace) the trusted key source the control plane "
122
128
  "verifies this org's session JWTs against. Provide EXACTLY ONE of "
123
- "--jwks-url (a remote JWK Set endpoint) or --jwks-json (an inline JWK "
124
- "Set from a file or '-' for stdin). Optionally pin the expected token "
125
- "issuer/audience. This registers trust only; it does not mint a JWT."
129
+ "--workos-client-id (templates the WorkOS AuthKit JWKS for you), "
130
+ "--jwks-url (a remote JWK Set endpoint), or --jwks-json (an inline "
131
+ "JWK Set from a file or '-' for stdin). Optionally pin the expected "
132
+ "token issuer/audience. This registers trust only; it does not mint a JWT."
133
+ ),
134
+ )
135
+ set_parser.add_argument(
136
+ "--workos-client-id",
137
+ metavar="CLIENT_ID",
138
+ help=(
139
+ "WorkOS Client ID (client_...). Templates the WorkOS AuthKit JWKS "
140
+ "endpoint and access-token issuer for you, so you do not have to "
141
+ "supply raw JWKS URLs: --jwks-url becomes "
142
+ "https://api.workos.com/sso/jwks/<client_id> and --issuer defaults to "
143
+ "https://api.workos.com. Override either with --issuer/--audience."
126
144
  ),
127
145
  )
128
146
  set_parser.add_argument(
@@ -136,7 +154,11 @@ def add_auth_parser(sub: argparse._SubParsersAction) -> None:
136
154
  )
137
155
  set_parser.add_argument(
138
156
  "--issuer",
139
- help="Optional expected JWT `iss` claim; tokens whose iss differs are rejected",
157
+ help=(
158
+ "Optional expected JWT `iss` claim; tokens whose iss differs are "
159
+ "rejected. With --workos-client-id this defaults to "
160
+ "https://api.workos.com (pass to override, e.g. a custom AuthKit domain)."
161
+ ),
140
162
  )
141
163
  set_parser.add_argument(
142
164
  "--audience",
@@ -223,38 +245,81 @@ def auth(args: argparse.Namespace) -> int:
223
245
  getattr(args, "auth_command", None) != "jwks"
224
246
  or getattr(args, "jwks_command", None) != "set"
225
247
  ):
226
- raise UrunError("usage: urun auth jwks set --jwks-url <url> | --jwks-json <file|->")
248
+ raise UrunError(
249
+ "usage: urun auth jwks set "
250
+ "--workos-client-id <client_...> | --jwks-url <url> | --jwks-json <file|->"
251
+ )
227
252
  return auth_jwks_set(args)
228
253
 
229
254
 
255
+ # WorkOS publishes one JWK Set per OAuth client at this unauthenticated endpoint;
256
+ # it hosts the public key used to verify AuthKit / User Management access tokens.
257
+ WORKOS_JWKS_URL_TEMPLATE = "https://api.workos.com/sso/jwks/{client_id}"
258
+ # A WorkOS AuthKit (User Management) access token's `iss` claim is the static
259
+ # WorkOS API host (NOT a per-client URL). It becomes your custom AuthKit domain
260
+ # only when one is configured, which is why --issuer can override this default.
261
+ WORKOS_ACCESS_TOKEN_ISSUER = "https://api.workos.com"
262
+ # A WorkOS client id looks like `client_<base32-ish>`.
263
+ WORKOS_CLIENT_ID_RE = re.compile(r"^client_[0-9A-Za-z]+$")
264
+
265
+
266
+ def workos_jwks_template(client_id: str) -> dict[str, str]:
267
+ """Template the WorkOS AuthKit trust anchor from just the WorkOS Client ID.
268
+
269
+ Returns the JWKS endpoint URL and the expected access-token issuer for the
270
+ given WorkOS client so callers never have to hand-build raw WorkOS URLs.
271
+
272
+ Note (deliberately omitted): WorkOS AuthKit *access tokens* do NOT carry an
273
+ `aud` claim by default, so no audience is templated — pin one with
274
+ --audience only if your AuthKit config actually sets it (pinning a wrong
275
+ audience would silently 401 every real token).
276
+ """
277
+ if not WORKOS_CLIENT_ID_RE.fullmatch(client_id):
278
+ raise UrunError(
279
+ "invalid WorkOS client id; expected client_<alphanumeric> (e.g. client_01ABC...)"
280
+ )
281
+ return {
282
+ "jwks_url": WORKOS_JWKS_URL_TEMPLATE.format(client_id=client_id),
283
+ "expected_issuer": WORKOS_ACCESS_TOKEN_ISSUER,
284
+ }
285
+
286
+
230
287
  def auth_jwks_set(args: argparse.Namespace) -> int:
231
288
  jwks_url = string_value(args.jwks_url)
232
289
  jwks_json_arg = string_value(args.jwks_json)
233
- if bool(jwks_url) == bool(jwks_json_arg):
234
- raise UrunError("provide exactly one of --jwks-url or --jwks-json")
290
+ workos_client_id = string_value(getattr(args, "workos_client_id", None))
291
+ issuer = string_value(args.issuer)
292
+ audience = string_value(args.audience)
293
+
294
+ sources = [s for s in (workos_client_id, jwks_url, jwks_json_arg) if s]
295
+ if len(sources) != 1:
296
+ raise UrunError("provide exactly one of --workos-client-id, --jwks-url, or --jwks-json")
235
297
 
236
298
  payload: dict[str, Any] = {}
237
- if jwks_url:
299
+ if workos_client_id:
300
+ # Template the WorkOS well-known structure from the client id so the
301
+ # caller never supplies raw URLs. --issuer/--audience still override.
302
+ template = workos_jwks_template(workos_client_id)
303
+ payload["jwks_url"] = template["jwks_url"]
304
+ issuer = issuer or template.get("expected_issuer")
305
+ elif jwks_url:
238
306
  payload["jwks_url"] = jwks_url
239
- issuer = string_value(args.issuer)
240
- audience = string_value(args.audience)
241
- if issuer:
242
- payload["expected_issuer"] = issuer
243
- if audience:
244
- payload["expected_audience"] = audience
245
307
  else:
246
308
  payload["jwks_json"] = read_jwks_json(jwks_json_arg)
247
- issuer = string_value(args.issuer)
248
- audience = string_value(args.audience)
249
- if issuer:
250
- payload["expected_issuer"] = issuer
251
- if audience:
252
- payload["expected_audience"] = audience
309
+
310
+ if issuer:
311
+ payload["expected_issuer"] = issuer
312
+ if audience:
313
+ payload["expected_audience"] = audience
253
314
 
254
315
  api_url, api_key = resolve_api_credentials(args)
255
316
  result = ApiClient(api_url, api_key).register_trusted_jwks(payload)
256
317
  org_id = string_value(result.get("org_id")) or "(unknown)"
257
318
  print(f"Registered trusted JWKS for org {org_id}.")
319
+ if workos_client_id:
320
+ print(f" WorkOS client: {workos_client_id}")
321
+ print(f" JWKS URL: {payload['jwks_url']}")
322
+ print(f" Expected iss: {payload.get('expected_issuer', '(unset)')}")
258
323
  print("This registers a trust relationship only; no JWT was minted or fetched.")
259
324
  return 0
260
325
 
@@ -326,17 +391,43 @@ def deploy(args: argparse.Namespace) -> int:
326
391
  print(f"Status: {status_url}")
327
392
  return 0
328
393
 
329
- status = poll_until_done(client, mh, args.poll_interval, args.timeout)
394
+ print("Waiting for build to complete (--no-wait to skip)")
395
+ status = poll_until_done(
396
+ client,
397
+ mh,
398
+ args.poll_interval,
399
+ args.timeout,
400
+ on_state=lambda state: print(f" build: {progress_line(state)}"),
401
+ )
330
402
  if status.get("status") == "failed":
331
- err = status.get("error") or {}
332
- code = err.get("code")
333
- message = err.get("message")
334
- detail = f"{code}: {message}" if code and message else (message or code or "unknown error")
335
- raise UrunError(f"deployment failed: {detail}")
403
+ print_build_failure(status)
404
+ return 1
336
405
  print_ready(status)
337
406
  return 0
338
407
 
339
408
 
409
+ def print_build_failure(status: dict[str, Any]) -> None:
410
+ """Surface the REAL build failure (e.g. the uv/git/torch error) to stderr.
411
+
412
+ The control plane records the build worker's terminal error under
413
+ ``error`` as ``{code, message}`` where ``message`` is the actual build
414
+ stderr/reason. We print the full message verbatim so callers no longer have
415
+ to kubectl into the cluster to learn why a build failed.
416
+ """
417
+ err = status.get("error") or {}
418
+ code = string_value(err.get("code"))
419
+ message = string_value(err.get("message"))
420
+ print("Deployment failed: build did not succeed", file=sys.stderr)
421
+ if code:
422
+ print(f" reason: {code}", file=sys.stderr)
423
+ if message:
424
+ print(" build error:", file=sys.stderr)
425
+ for line in message.rstrip().splitlines() or [message]:
426
+ print(f" {line}", file=sys.stderr)
427
+ if not code and not message:
428
+ print(" (no error detail recorded by the build worker)", file=sys.stderr)
429
+
430
+
340
431
  def resolve_api_credentials(args: argparse.Namespace) -> tuple[str, str]:
341
432
  api_key = args.api_key or os.getenv("URUN_API_KEY")
342
433
  api_url = args.api_url or os.getenv("URUN_API_URL")
@@ -133,8 +133,21 @@ def resolve_relative_import(node: ast.ImportFrom, source: Path, project_root: Pa
133
133
  def module_path_candidates(base: Path, parts: list[str]) -> set[Path]:
134
134
  if not parts:
135
135
  return set()
136
- path = base.joinpath(*parts)
137
136
  candidates: set[Path] = set()
137
+ # Every INTERMEDIATE package __init__.py along the dotted path must ship
138
+ # too: importing pkg.sub.mod executes pkg/__init__.py and pkg/sub/__init__.py.
139
+ # Resolving only the leaf dropped those when nothing imported the package
140
+ # name bare — in the pod the package degrades to an implicit NAMESPACE
141
+ # package, so the import still "succeeds" while every module-level
142
+ # definition in the dropped __init__ is silently gone (caught in-pod by the
143
+ # spmd-canary bundle-completeness gate, urun-infra run 27303509334).
144
+ prefix = base
145
+ for part in parts[:-1]:
146
+ prefix = prefix / part
147
+ intermediate_init = prefix / "__init__.py"
148
+ if intermediate_init.is_file():
149
+ candidates.add(intermediate_init.resolve())
150
+ path = base.joinpath(*parts)
138
151
  module_file = path.with_suffix(".py")
139
152
  package_init = path / "__init__.py"
140
153
  if module_file.is_file():
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