tasklease 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. tasklease/__init__.py +7 -0
  2. tasklease/__main__.py +5 -0
  3. tasklease/cli/__init__.py +53 -0
  4. tasklease/cli/common.py +87 -0
  5. tasklease/cli/identity.py +106 -0
  6. tasklease/cli/inbox.py +37 -0
  7. tasklease/cli/monitor.py +57 -0
  8. tasklease/cli/requester.py +197 -0
  9. tasklease/cli/service.py +169 -0
  10. tasklease/cli/tasks.py +175 -0
  11. tasklease/cli/topics.py +65 -0
  12. tasklease/cli/worker.py +186 -0
  13. tasklease/client.py +283 -0
  14. tasklease/cloudflare/__init__.py +6 -0
  15. tasklease/cloudflare/bundle.py +78 -0
  16. tasklease/cloudflare/deployer.py +714 -0
  17. tasklease/cloudflare/wrangler.py +300 -0
  18. tasklease/config.py +206 -0
  19. tasklease/errors.py +89 -0
  20. tasklease/fsio.py +275 -0
  21. tasklease/generated/__init__.py +1 -0
  22. tasklease/generated/protocol.py +478 -0
  23. tasklease/monitor/__init__.py +1 -0
  24. tasklease/monitor/server.py +325 -0
  25. tasklease/monitor/static/THIRD_PARTY_NOTICES.md +25 -0
  26. tasklease/monitor/static/assets/index-ClHk7fvq.css +1 -0
  27. tasklease/monitor/static/assets/index-CmFr3mRG.js +6 -0
  28. tasklease/monitor/static/index.html +13 -0
  29. tasklease/names.py +60 -0
  30. tasklease/py.typed +0 -0
  31. tasklease/service_bundle/README.md +1 -0
  32. tasklease/service_bundle/THIRD_PARTY_NOTICES.md +2163 -0
  33. tasklease/service_bundle/index.js +18533 -0
  34. tasklease/service_bundle/index.js.map +8 -0
  35. tasklease/service_bundle/manifest.json +4 -0
  36. tasklease/service_bundle/wrangler.template.jsonc +53 -0
  37. tasklease/store.py +669 -0
  38. tasklease/worker.py +444 -0
  39. tasklease/worker_control.py +114 -0
  40. tasklease/worker_templates/launchd.plist +22 -0
  41. tasklease/worker_templates/systemd.service +15 -0
  42. tasklease/worker_templates/windows-stop.ps1 +4 -0
  43. tasklease/worker_templates/windows.xml +27 -0
  44. tasklease-0.1.0.dist-info/METADATA +495 -0
  45. tasklease-0.1.0.dist-info/RECORD +48 -0
  46. tasklease-0.1.0.dist-info/WHEEL +4 -0
  47. tasklease-0.1.0.dist-info/entry_points.txt +2 -0
  48. tasklease-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,714 @@
1
+ """Idempotent provisioning, verification and read-only service status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import re
8
+ import secrets
9
+ import sys
10
+ import tempfile
11
+ import time
12
+ from collections.abc import Callable, Iterable, Iterator, Mapping
13
+ from contextlib import contextmanager
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ import httpx
18
+
19
+ from tasklease import __version__
20
+ from tasklease.client import Client
21
+ from tasklease.cloudflare.bundle import configure_bundle, stage_bundle
22
+ from tasklease.cloudflare.wrangler import (
23
+ DeployError,
24
+ Wrangler,
25
+ bucket_names,
26
+ deployment_url,
27
+ json_value,
28
+ lifecycle_rules,
29
+ object_value,
30
+ secret_names,
31
+ worker_url,
32
+ )
33
+ from tasklease.config import Config, save_deployment
34
+ from tasklease.errors import TaskleaseError
35
+
36
+ type Reporter = Callable[[str], None]
37
+
38
+ _ACCOUNT_ID = re.compile(r"[a-fA-F0-9]{32}")
39
+ _EXPIRE_RULE = "tasklease-expire"
40
+ _EXACT_RULE = {
41
+ "name": _EXPIRE_RULE,
42
+ "enabled": "Yes",
43
+ "prefix": "(all prefixes)",
44
+ "action": "Expire objects after 7 days",
45
+ }
46
+ _R2_SECRETS = {"R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY"}
47
+
48
+ R2_HELP = (
49
+ "Open Cloudflare dashboard > R2 Object Storage > Overview > Account Details "
50
+ "> Manage API Tokens > Create Account API token. Choose Object Read & Write "
51
+ "and scope it to bucket {bucket}. Export both TL_R2_ACCESS_KEY_ID and "
52
+ "TL_R2_SECRET_ACCESS_KEY, then rerun. Keep the admin token and set "
53
+ "TL_ADMIN_TOKEN to it so the next run can verify the service."
54
+ )
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class DeployOptions:
59
+ account_id: str | None = None
60
+ name: str | None = None
61
+ bucket: str | None = None
62
+ r2_access_key_id: str | None = None
63
+ r2_secret_access_key: str | None = None
64
+ url: str | None = None
65
+ rotate_admin_token: bool = False
66
+ dry_run: bool = False
67
+
68
+
69
+ def _report(message: str) -> None:
70
+ print(message, file=sys.stderr)
71
+
72
+
73
+ def _saved(config: Config) -> dict[str, object]:
74
+ try:
75
+ return object_value(config.values.get("cloudflare", {}), "[cloudflare] config")
76
+ except DeployError as exc:
77
+ raise DeployError(str(exc), 4) from exc
78
+
79
+
80
+ def _optional_string(value: object, name: str) -> str | None:
81
+ if value is None:
82
+ return None
83
+ if not isinstance(value, str) or not value:
84
+ raise DeployError(f"{name} must be a nonempty string.", 4)
85
+ return value
86
+
87
+
88
+ def _account(wrangler: Wrangler, requested: str | None) -> str:
89
+ if requested:
90
+ if _ACCOUNT_ID.fullmatch(requested) is None:
91
+ raise DeployError("Cloudflare account ID must be 32 hex digits.", 4)
92
+ wrangler.environ["CLOUDFLARE_ACCOUNT_ID"] = requested
93
+ try:
94
+ who = object_value(
95
+ json_value(wrangler.run(["whoami", "--json"]), "Wrangler whoami"),
96
+ "Wrangler whoami",
97
+ )
98
+ except DeployError as exc:
99
+ raise DeployError(
100
+ f"{exc}\nSign in with npx --yes wrangler@{wrangler.version} login, "
101
+ "or set CLOUDFLARE_API_TOKEN and pass --account-id."
102
+ ) from exc
103
+ if who.get("loggedIn") is not True:
104
+ raise DeployError("Wrangler is not signed in; run its login command.", 4)
105
+ if requested is None:
106
+ accounts = who.get("accounts")
107
+ if not isinstance(accounts, list) or len(accounts) != 1:
108
+ raise DeployError(
109
+ "Pass --account-id when more than one account is visible.", 4
110
+ )
111
+ requested = _optional_string(
112
+ object_value(accounts[0], "Cloudflare account").get("id"), "account ID"
113
+ )
114
+ if requested is None or _ACCOUNT_ID.fullmatch(requested) is None:
115
+ raise DeployError(
116
+ "Cloudflare account ID must contain 32 hexadecimal digits.", 4
117
+ )
118
+ wrangler.environ["CLOUDFLARE_ACCOUNT_ID"] = requested
119
+ return requested
120
+
121
+
122
+ def _version(http: httpx.Client, url: str) -> str:
123
+ response = http.get(f"{url.rstrip('/')}/v1/version")
124
+ response.raise_for_status()
125
+ value = object_value(
126
+ json_value(response.text, "service version"), "service version"
127
+ )
128
+ version = value.get("service_version")
129
+ if value.get("protocol") != "v1" or not isinstance(version, str):
130
+ raise DeployError(
131
+ "Service version response has an unsupported protocol or shape."
132
+ )
133
+ return version
134
+
135
+
136
+ def _confirm_admin(
137
+ http: httpx.Client,
138
+ url: str,
139
+ token: str,
140
+ retry_seconds: float,
141
+ ) -> bool:
142
+ deadline = time.monotonic() + retry_seconds
143
+ while True:
144
+ try:
145
+ # Authorization precedes this endpoint's bounded streaming response.
146
+ # Reading only headers avoids buffering an unbounded identity list.
147
+ with http.stream(
148
+ "POST",
149
+ f"{url}/v1/identity.list",
150
+ json={},
151
+ headers={"Authorization": f"Bearer {token}"},
152
+ ) as response:
153
+ if response.status_code == 200:
154
+ return True
155
+ except httpx.HTTPError:
156
+ pass
157
+ remaining = deadline - time.monotonic()
158
+ if remaining <= 0:
159
+ return False
160
+ time.sleep(min(1.0, remaining))
161
+
162
+
163
+ def _check_version(http: httpx.Client, url: str, retry_seconds: float) -> None:
164
+ deadline = time.monotonic() + retry_seconds
165
+ while True:
166
+ try:
167
+ deployed = _version(http, url)
168
+ if deployed == __version__:
169
+ return
170
+ problem = f"Service version is {deployed}; expected {__version__}."
171
+ except (httpx.HTTPError, DeployError) as exc:
172
+ problem = str(exc)
173
+ remaining = deadline - time.monotonic()
174
+ if remaining <= 0:
175
+ raise DeployError(f"Version check failed: {problem}")
176
+ time.sleep(min(1.0, remaining))
177
+
178
+
179
+ def _probe(
180
+ url: str,
181
+ token: str,
182
+ transport: httpx.BaseTransport | None,
183
+ report: Reporter,
184
+ ) -> None:
185
+ name = f"deploy-check-{secrets.token_hex(8)}"
186
+ domain = "tasklease.deploy-check"
187
+ stage = "temporary identity"
188
+ original: BaseException | None = None
189
+ with Client(url, token, transport=transport) as admin:
190
+ try:
191
+ identity = admin.identity_add(
192
+ {
193
+ "name": name,
194
+ "domains": [domain],
195
+ "operator": False,
196
+ "scopes": {
197
+ "create": ["check"],
198
+ "own": ["check"],
199
+ "read": ["check"],
200
+ },
201
+ }
202
+ )
203
+ with Client(url, identity["token"], transport=transport) as client:
204
+ stage = "Durable Objects task flow"
205
+ client.create(
206
+ {"domain": domain, "topic": "check", "data": {"probe": True}}
207
+ )
208
+ owned = client.own(
209
+ {"domain": domain, "topic": "check", "hold_seconds": 30}
210
+ )
211
+ client.consume(
212
+ {
213
+ "domain": domain,
214
+ "topic": "check",
215
+ "id": owned["task"]["id"],
216
+ "tasks": [],
217
+ }
218
+ )
219
+ payload = secrets.token_bytes(16)
220
+ stage = "R2 upload"
221
+ put = client.blob_put_url(
222
+ {
223
+ "domain": domain,
224
+ "name": "probe.bin",
225
+ "size": len(payload),
226
+ "sha256": hashlib.sha256(payload).hexdigest(),
227
+ }
228
+ )
229
+ # A separate HTTP client prevents bearer tokens reaching R2 URLs.
230
+ with httpx.Client(transport=transport, timeout=30) as blob_http:
231
+ blob_http.put(put["url"], content=payload).raise_for_status()
232
+ stage = "R2 download"
233
+ get = client.blob_get_url({"domain": domain, "key": put["key"]})
234
+ result = blob_http.get(get["url"])
235
+ result.raise_for_status()
236
+ if result.content != payload:
237
+ raise DeployError(
238
+ "Downloaded R2 probe bytes differ from the upload."
239
+ )
240
+ except (TaskleaseError, httpx.HTTPError, DeployError) as exc:
241
+ original = exc
242
+ detail = (
243
+ f"HTTP {exc.response.status_code}"
244
+ if isinstance(exc, httpx.HTTPStatusError)
245
+ else "HTTP connection failed"
246
+ if isinstance(exc, httpx.HTTPError)
247
+ else str(exc)
248
+ )
249
+ raise DeployError(f"End-to-end check failed ({stage}): {detail}") from exc
250
+ finally:
251
+ # Also try after a lost identity.add response: it may have committed.
252
+ try:
253
+ admin.identity_revoke({"name": name})
254
+ except (TaskleaseError, httpx.HTTPError) as exc:
255
+ if original is None:
256
+ raise DeployError(f"Probe identity cleanup failed: {exc}") from exc
257
+ report(f"Probe identity cleanup also failed: {exc}")
258
+
259
+
260
+ @dataclass(frozen=True)
261
+ class _Target:
262
+ """The requested deployment; the account is resolved after sign-in."""
263
+
264
+ account: str | None
265
+ name: str
266
+ bucket: str
267
+ key_id: str | None
268
+ key_secret: str | None
269
+
270
+ def r2_secrets(self) -> dict[str, str]:
271
+ if self.key_id and self.key_secret:
272
+ return {
273
+ "R2_ACCESS_KEY_ID": self.key_id,
274
+ "R2_SECRET_ACCESS_KEY": self.key_secret,
275
+ }
276
+ return {}
277
+
278
+
279
+ @dataclass(frozen=True)
280
+ class _Workspace:
281
+ project: Path
282
+ wrangler: Wrangler
283
+ account: str
284
+ rendered: dict[str, object]
285
+
286
+
287
+ def _choose(
288
+ chosen: str | None, saved: Mapping[str, object], key: str, label: str
289
+ ) -> str | None:
290
+ """An explicit value, else the saved one, which is checked only when used."""
291
+ return chosen or _optional_string(saved.get(key), label)
292
+
293
+
294
+ def _target(
295
+ options: DeployOptions, saved: Mapping[str, object], environment: Mapping[str, str]
296
+ ) -> _Target:
297
+ """Resolve options, environment and saved settings, then validate them."""
298
+ target = _Target(
299
+ account=_choose(
300
+ options.account_id or environment.get("CLOUDFLARE_ACCOUNT_ID"),
301
+ saved,
302
+ "account_id",
303
+ "saved account ID",
304
+ ),
305
+ name=_choose(options.name, saved, "name", "saved Worker name") or "tasklease",
306
+ bucket=_choose(options.bucket, saved, "bucket", "saved bucket")
307
+ or "tasklease-blobs",
308
+ key_id=options.r2_access_key_id or environment.get("TL_R2_ACCESS_KEY_ID"),
309
+ key_secret=options.r2_secret_access_key
310
+ or environment.get("TL_R2_SECRET_ACCESS_KEY"),
311
+ )
312
+ _check_names(target)
313
+ _check_r2_keys(target, saved)
314
+ if environment.get("CLOUDFLARE_API_TOKEN") and not target.account:
315
+ raise DeployError("Pass --account-id with CLOUDFLARE_API_TOKEN.", 4)
316
+ if options.url and worker_url(options.url, target.name) is None:
317
+ raise DeployError("--url must be this Worker's HTTPS workers.dev URL.", 4)
318
+ return target
319
+
320
+
321
+ def _check_names(target: _Target) -> None:
322
+ if re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", target.name) is None:
323
+ raise DeployError(
324
+ "Worker name must be 1–63 lowercase letters, digits or hyphens.", 4
325
+ )
326
+ if re.fullmatch(r"[a-z0-9][a-z0-9-]{1,61}[a-z0-9]", target.bucket) is None:
327
+ raise DeployError(
328
+ "Bucket must be 3–63 lowercase letters, digits or hyphens.", 4
329
+ )
330
+
331
+
332
+ def _check_r2_keys(target: _Target, saved: Mapping[str, object]) -> None:
333
+ if bool(target.key_id) != bool(target.key_secret):
334
+ raise DeployError(
335
+ "Give both R2 keys together: TL_R2_ACCESS_KEY_ID and "
336
+ "TL_R2_SECRET_ACCESS_KEY.",
337
+ 4,
338
+ )
339
+ pending = saved.get("r2_credentials_pending", False)
340
+ if not isinstance(pending, bool):
341
+ raise DeployError("cloudflare.r2_credentials_pending must be a boolean.", 4)
342
+ if target.key_id:
343
+ return
344
+ if pending:
345
+ raise DeployError(
346
+ "An R2 credential update is pending; supply a fresh pair of R2 keys "
347
+ "before retrying. Secret names do not confirm their values.",
348
+ 4,
349
+ )
350
+ if saved.get("account_id") not in (None, target.account) or saved.get(
351
+ "bucket"
352
+ ) not in (None, target.bucket):
353
+ raise DeployError(
354
+ "Changing account or bucket requires a new pair of R2 keys.", 4
355
+ )
356
+
357
+
358
+ @contextmanager
359
+ def _workspace(
360
+ prefix: str,
361
+ environment: Mapping[str, str],
362
+ *,
363
+ account: str | None,
364
+ name: str,
365
+ bucket: str,
366
+ redactions: Iterable[str | None] = (),
367
+ ) -> Iterator[_Workspace]:
368
+ """Stage the bundle, check Node and sign-in, and render wrangler.jsonc."""
369
+ with tempfile.TemporaryDirectory(prefix=prefix) as temporary:
370
+ project = Path(temporary)
371
+ pin, template = stage_bundle(project)
372
+ wrangler = Wrangler(pin, project, environment)
373
+ wrangler.redactions.extend(value for value in redactions if value)
374
+ wrangler.check_node()
375
+ resolved = _account(wrangler, account)
376
+ rendered = configure_bundle(
377
+ project, template, account_id=resolved, name=name, bucket=bucket
378
+ )
379
+ yield _Workspace(project, wrangler, resolved, rendered)
380
+
381
+
382
+ def _bucket_state(wrangler: Wrangler, bucket: str) -> tuple[bool, list[dict[str, str]]]:
383
+ """Whether the bucket exists, and its lifecycle rules."""
384
+ exists = bucket in bucket_names(wrangler.run(["r2", "bucket", "list"]))
385
+ if not exists:
386
+ return False, []
387
+ return True, lifecycle_rules(
388
+ wrangler.run(["r2", "bucket", "lifecycle", "list", bucket])
389
+ )
390
+
391
+
392
+ def _our_rule(rules: Iterable[Mapping[str, str]]) -> Mapping[str, str] | None:
393
+ ours = [rule for rule in rules if rule["name"] == _EXPIRE_RULE]
394
+ if len(ours) > 1:
395
+ raise DeployError(
396
+ "Multiple tasklease-expire lifecycle rules exist; "
397
+ "repair them before deploying."
398
+ )
399
+ return ours[0] if ours else None
400
+
401
+
402
+ def _secret_names(wrangler: Wrangler, name: str) -> set[str]:
403
+ return secret_names(
404
+ wrangler.run(["secret", "list", "--name", name, "--format", "json"])
405
+ )
406
+
407
+
408
+ def _known_secrets(
409
+ http: httpx.Client,
410
+ config: Config,
411
+ saved: Mapping[str, object],
412
+ target: _Target,
413
+ workspace: _Workspace,
414
+ report: Reporter,
415
+ ) -> list[str] | None:
416
+ """Secret names of the saved Worker, if it demonstrably exists."""
417
+ url = _saved_worker_url(config, saved, target.name, workspace.account)
418
+ if url is not None:
419
+ try:
420
+ _version(http, url)
421
+ except (httpx.HTTPError, DeployError):
422
+ pass
423
+ else:
424
+ return sorted(_secret_names(workspace.wrangler, target.name))
425
+ report("Worker existence is unknown; secret inspection skipped.")
426
+ return None
427
+
428
+
429
+ def _saved_worker_url(
430
+ config: Config, saved: Mapping[str, object], name: str, account: str
431
+ ) -> str | None:
432
+ """The configured URL, if it belongs to the Worker being deployed."""
433
+ if (
434
+ config.url
435
+ and saved.get("name") == name
436
+ and saved.get("account_id") == account
437
+ and worker_url(config.url, name) is not None
438
+ ):
439
+ return config.url
440
+ return None
441
+
442
+
443
+ def _dry_run(
444
+ known_secrets: list[str] | None,
445
+ target: _Target,
446
+ workspace: _Workspace,
447
+ bucket_exists: bool,
448
+ exact_rule: bool,
449
+ report: Reporter,
450
+ ) -> dict[str, object]:
451
+ report("Dry run: no resources, secrets or configuration were changed.")
452
+ return {
453
+ "dry_run": True,
454
+ "config": workspace.rendered,
455
+ "account_id": workspace.account,
456
+ "name": target.name,
457
+ "bucket": target.bucket,
458
+ "bucket_exists": bucket_exists,
459
+ "lifecycle": "unchanged" if exact_rule else "would update",
460
+ "secrets": known_secrets,
461
+ "plan": [
462
+ "deploy Worker",
463
+ "reconcile secrets",
464
+ "verify version and task/blob flow",
465
+ "save configuration",
466
+ ],
467
+ }
468
+
469
+
470
+ def _reconcile_bucket(
471
+ wrangler: Wrangler,
472
+ bucket: str,
473
+ exists: bool,
474
+ rule: Mapping[str, str] | None,
475
+ report: Reporter,
476
+ ) -> None:
477
+ if not exists:
478
+ wrangler.run(["r2", "bucket", "create", bucket])
479
+ report(f"Bucket {bucket}: {'unchanged' if exists else 'created'}")
480
+ if rule == _EXACT_RULE:
481
+ report(f"Lifecycle {_EXPIRE_RULE}: unchanged")
482
+ return
483
+ if rule is not None:
484
+ wrangler.run(
485
+ ["r2", "bucket", "lifecycle", "remove", bucket, "--name", _EXPIRE_RULE]
486
+ )
487
+ wrangler.run(
488
+ [
489
+ "r2",
490
+ "bucket",
491
+ "lifecycle",
492
+ "add",
493
+ bucket,
494
+ _EXPIRE_RULE,
495
+ "--expire-days",
496
+ "7",
497
+ "--force",
498
+ ]
499
+ )
500
+ report(f"Lifecycle {_EXPIRE_RULE}: {'updated' if rule else 'created'}")
501
+
502
+
503
+ def _upload(workspace: _Workspace, name: str, requested_url: str | None) -> str:
504
+ """Deploy the Worker and return its workers.dev URL."""
505
+ output = workspace.project / "deploy.ndjson"
506
+ workspace.wrangler.run(
507
+ ["deploy", "--config", str(workspace.project / "wrangler.jsonc")],
508
+ output_file=output,
509
+ )
510
+ return deployment_url(output, name, requested_url)
511
+
512
+
513
+ def _save(
514
+ config: Config,
515
+ workspace: _Workspace,
516
+ target: _Target,
517
+ url: str,
518
+ r2_credentials_pending: bool | None = None,
519
+ ) -> None:
520
+ save_deployment(
521
+ config,
522
+ url=url,
523
+ account_id=workspace.account,
524
+ name=target.name,
525
+ bucket=target.bucket,
526
+ r2_credentials_pending=r2_credentials_pending,
527
+ )
528
+
529
+
530
+ def _admin_candidate(existing: set[str], rotate: bool, report: Reporter) -> str | None:
531
+ """A new admin token when none exists or rotation was requested."""
532
+ if "ADMIN_TOKEN" in existing and not rotate:
533
+ return None
534
+ candidate = "tla_" + secrets.token_urlsafe(32)
535
+ report(f"ADMIN TOKEN (not yet confirmed; store now, never saved): {candidate}")
536
+ return candidate
537
+
538
+
539
+ def _install_secrets(
540
+ wrangler: Wrangler, name: str, values: Mapping[str, str]
541
+ ) -> DeployError | None:
542
+ """Upload secrets in one call; the failure is returned so the admin
543
+ candidate can still be confirmed before it is raised."""
544
+ if not values:
545
+ return None
546
+ try:
547
+ wrangler.run(["secret", "bulk", "--name", name], secrets=dict(values))
548
+ except DeployError as exc:
549
+ return exc
550
+ return None
551
+
552
+
553
+ def _settle_admin(
554
+ http: httpx.Client,
555
+ url: str,
556
+ candidate: str | None,
557
+ bulk_error: DeployError | None,
558
+ retry_seconds: float,
559
+ report: Reporter,
560
+ ) -> None:
561
+ """Confirm a new admin token, then raise any secret upload failure."""
562
+ if candidate and _confirm_admin(http, url, candidate, retry_seconds):
563
+ report("Admin token confirmed.")
564
+ elif candidate:
565
+ report(
566
+ "Admin token remains unconfirmed; retain it. "
567
+ "Set TL_ADMIN_TOKEN to the candidate and rerun, or rotate "
568
+ "again. No conclusion about the previous token is possible."
569
+ )
570
+ if bulk_error:
571
+ raise DeployError(
572
+ f"{bulk_error}\nAdmin token status is unconfirmed."
573
+ ) from bulk_error
574
+ raise DeployError(
575
+ "Admin token remains unconfirmed after waiting for propagation."
576
+ )
577
+ if bulk_error:
578
+ raise bulk_error
579
+
580
+
581
+ def _verify(
582
+ url: str,
583
+ admin_token: str | None,
584
+ transport: httpx.BaseTransport | None,
585
+ report: Reporter,
586
+ ) -> bool:
587
+ """Run the end-to-end probe when an admin token is available."""
588
+ if not admin_token:
589
+ report(
590
+ "Deployment is unverified: set TL_ADMIN_TOKEN and rerun "
591
+ "for the task and R2 checks."
592
+ )
593
+ return False
594
+ _probe(url, admin_token, transport, report)
595
+ report("Task and R2 upload/download checks passed.")
596
+ return True
597
+
598
+
599
+ def deploy(
600
+ options: DeployOptions,
601
+ config: Config,
602
+ *,
603
+ environ: Mapping[str, str] | None = None,
604
+ transport: httpx.BaseTransport | None = None,
605
+ report: Reporter = _report,
606
+ retry_seconds: float = 15,
607
+ ) -> dict[str, object]:
608
+ """Deploy an installed bundle, retaining recoverable partial state on failure."""
609
+ environment = dict(os.environ if environ is None else environ)
610
+ saved = _saved(config)
611
+ target = _target(options, saved, environment)
612
+ with (
613
+ _workspace(
614
+ "tasklease-deploy-",
615
+ environment,
616
+ account=target.account,
617
+ name=target.name,
618
+ bucket=target.bucket,
619
+ redactions=(target.key_id, target.key_secret),
620
+ ) as workspace,
621
+ httpx.Client(transport=transport, timeout=10) as http,
622
+ ):
623
+ wrangler = workspace.wrangler
624
+ exists, rules = _bucket_state(wrangler, target.bucket)
625
+ rule = _our_rule(rules)
626
+ if options.dry_run:
627
+ known = _known_secrets(http, config, saved, target, workspace, report)
628
+ return _dry_run(
629
+ known, target, workspace, exists, rule == _EXACT_RULE, report
630
+ )
631
+ _reconcile_bucket(wrangler, target.bucket, exists, rule, report)
632
+ url = _upload(workspace, target.name, options.url)
633
+ _save(config, workspace, target, url, True if target.key_id else None)
634
+ report(
635
+ "Worker uploaded; deployment coordinates saved. Verification is pending."
636
+ )
637
+ existing = _secret_names(wrangler, target.name)
638
+ candidate = _admin_candidate(existing, options.rotate_admin_token, report)
639
+ values = ({"ADMIN_TOKEN": candidate} if candidate else {}) | target.r2_secrets()
640
+ bulk_error = _install_secrets(wrangler, target.name, values)
641
+ if target.key_id and bulk_error is None:
642
+ _save(config, workspace, target, url, r2_credentials_pending=False)
643
+ _settle_admin(http, url, candidate, bulk_error, retry_seconds, report)
644
+ if not _R2_SECRETS <= existing | values.keys():
645
+ raise DeployError(
646
+ "R2 signing credentials are missing. "
647
+ + R2_HELP.format(bucket=target.bucket),
648
+ 4,
649
+ )
650
+ _check_version(http, url, retry_seconds)
651
+ verified = _verify(
652
+ url, candidate or environment.get("TL_ADMIN_TOKEN"), transport, report
653
+ )
654
+ _save(config, workspace, target, url)
655
+ report(
656
+ "Next: tl identity add for each machine; "
657
+ "tl topic declare for well-known topics."
658
+ )
659
+ return {
660
+ "url": url,
661
+ "account_id": workspace.account,
662
+ "name": target.name,
663
+ "bucket": target.bucket,
664
+ "service_version": __version__,
665
+ "verified": verified,
666
+ }
667
+
668
+
669
+ def status(
670
+ config: Config,
671
+ *,
672
+ environ: Mapping[str, str] | None = None,
673
+ transport: httpx.BaseTransport | None = None,
674
+ report: Reporter = _report,
675
+ ) -> dict[str, object]:
676
+ """Compare versions even on machines without Node or Cloudflare sign-in."""
677
+ if not config.url:
678
+ raise DeployError("No service URL configured; run tl service deploy first.", 4)
679
+ with httpx.Client(transport=transport, timeout=10) as http:
680
+ try:
681
+ deployed = _version(http, config.url)
682
+ except (httpx.HTTPError, DeployError) as exc:
683
+ raise DeployError(f"Cannot read service version: {exc}") from exc
684
+ result: dict[str, object] = {
685
+ "url": config.url,
686
+ "service_version": deployed,
687
+ "installed_version": __version__,
688
+ "upgrade_needed": deployed != __version__,
689
+ }
690
+ configured_url = config.values.get("url")
691
+ if isinstance(configured_url, str) and config.url != configured_url.rstrip("/"):
692
+ report("Cloudflare inspection skipped: the URL overrides the saved deployment.")
693
+ return result
694
+ saved = _saved(config)
695
+ account = _optional_string(saved.get("account_id"), "saved account ID")
696
+ name = _optional_string(saved.get("name"), "saved Worker name")
697
+ bucket = _optional_string(saved.get("bucket"), "saved bucket")
698
+ if not (account and name and bucket):
699
+ return result
700
+ environment = dict(os.environ if environ is None else environ)
701
+ try:
702
+ with _workspace(
703
+ "tasklease-status-", environment, account=account, name=name, bucket=bucket
704
+ ) as workspace:
705
+ exists, rules = _bucket_state(workspace.wrangler, bucket)
706
+ result["cloudflare"] = {
707
+ "bucket_exists": exists,
708
+ "lifecycle": rules,
709
+ "secrets": sorted(_secret_names(workspace.wrangler, name)),
710
+ }
711
+ except DeployError as exc:
712
+ report(f"Cloudflare inspection unavailable: {exc}")
713
+ result["cloudflare"] = {"available": False}
714
+ return result