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.
- tasklease/__init__.py +7 -0
- tasklease/__main__.py +5 -0
- tasklease/cli/__init__.py +53 -0
- tasklease/cli/common.py +87 -0
- tasklease/cli/identity.py +106 -0
- tasklease/cli/inbox.py +37 -0
- tasklease/cli/monitor.py +57 -0
- tasklease/cli/requester.py +197 -0
- tasklease/cli/service.py +169 -0
- tasklease/cli/tasks.py +175 -0
- tasklease/cli/topics.py +65 -0
- tasklease/cli/worker.py +186 -0
- tasklease/client.py +283 -0
- tasklease/cloudflare/__init__.py +6 -0
- tasklease/cloudflare/bundle.py +78 -0
- tasklease/cloudflare/deployer.py +714 -0
- tasklease/cloudflare/wrangler.py +300 -0
- tasklease/config.py +206 -0
- tasklease/errors.py +89 -0
- tasklease/fsio.py +275 -0
- tasklease/generated/__init__.py +1 -0
- tasklease/generated/protocol.py +478 -0
- tasklease/monitor/__init__.py +1 -0
- tasklease/monitor/server.py +325 -0
- tasklease/monitor/static/THIRD_PARTY_NOTICES.md +25 -0
- tasklease/monitor/static/assets/index-ClHk7fvq.css +1 -0
- tasklease/monitor/static/assets/index-CmFr3mRG.js +6 -0
- tasklease/monitor/static/index.html +13 -0
- tasklease/names.py +60 -0
- tasklease/py.typed +0 -0
- tasklease/service_bundle/README.md +1 -0
- tasklease/service_bundle/THIRD_PARTY_NOTICES.md +2163 -0
- tasklease/service_bundle/index.js +18533 -0
- tasklease/service_bundle/index.js.map +8 -0
- tasklease/service_bundle/manifest.json +4 -0
- tasklease/service_bundle/wrangler.template.jsonc +53 -0
- tasklease/store.py +669 -0
- tasklease/worker.py +444 -0
- tasklease/worker_control.py +114 -0
- tasklease/worker_templates/launchd.plist +22 -0
- tasklease/worker_templates/systemd.service +15 -0
- tasklease/worker_templates/windows-stop.ps1 +4 -0
- tasklease/worker_templates/windows.xml +27 -0
- tasklease-0.1.0.dist-info/METADATA +495 -0
- tasklease-0.1.0.dist-info/RECORD +48 -0
- tasklease-0.1.0.dist-info/WHEEL +4 -0
- tasklease-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|