make-cloudflare 0.1.0__tar.gz → 0.2.0__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.
- {make_cloudflare-0.1.0 → make_cloudflare-0.2.0}/PKG-INFO +10 -2
- {make_cloudflare-0.1.0 → make_cloudflare-0.2.0}/README.md +9 -1
- {make_cloudflare-0.1.0 → make_cloudflare-0.2.0}/pyproject.toml +1 -1
- {make_cloudflare-0.1.0 → make_cloudflare-0.2.0}/src/make_cloudflare/__init__.py +4 -3
- {make_cloudflare-0.1.0 → make_cloudflare-0.2.0}/src/make_cloudflare/cloudflare.py +12 -2
- {make_cloudflare-0.1.0 → make_cloudflare-0.2.0}/src/make_cloudflare/pages.py +78 -12
- {make_cloudflare-0.1.0 → make_cloudflare-0.2.0}/tests/test_pages.py +78 -0
- {make_cloudflare-0.1.0 → make_cloudflare-0.2.0}/.gitignore +0 -0
- {make_cloudflare-0.1.0 → make_cloudflare-0.2.0}/tests/conftest.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: make-cloudflare
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Cloudflare Pages direct upload for mkrun -- deploy a built site with no Node and no wrangler.
|
|
5
5
|
Project-URL: Homepage, https://make.optersoft.com
|
|
6
6
|
Author-email: "Optersoft, S.L." <david@optersoft.com>
|
|
@@ -31,6 +31,7 @@ $ mk cloudflare.deploy site/dist --project mkrun # production (branch main)
|
|
|
31
31
|
$ mk cloudflare.deploy site/dist --branch try # a preview, on its own URL
|
|
32
32
|
$ mk cloudflare.projects # what the account has
|
|
33
33
|
$ mk cloudflare.deployments --limit 5 # one project's recent ones
|
|
34
|
+
$ mk cloudflare.prune --keep 10 # delete all but the newest ten
|
|
34
35
|
$ mk -n cloudflare.deploy site/dist # the plan; nothing is sent
|
|
35
36
|
```
|
|
36
37
|
|
|
@@ -42,6 +43,13 @@ the environment until a task asks for it, redacted in everything printed, and
|
|
|
42
43
|
readable from the encrypted store. The project can come from `--project` or from
|
|
43
44
|
`CLOUDFLARE_PAGES_PROJECT` in the repo's env layer.
|
|
44
45
|
|
|
46
|
+
**Every deploy prunes the project to its newest 10 deployments** (`--keep N`
|
|
47
|
+
to change it, `--keep 0` to keep them all; `pages.deploy(..., keep=)` from
|
|
48
|
+
Python). Pages never deletes one by itself, and each stays reachable on its own
|
|
49
|
+
`<hash>.<project>.pages.dev` URL forever. The live production deployment is
|
|
50
|
+
never deleted, however old; a pruned preview that was still its branch's alias
|
|
51
|
+
takes that branch URL with it.
|
|
52
|
+
|
|
45
53
|
## Why this exists
|
|
46
54
|
|
|
47
55
|
`wrangler pages deploy` is four HTTPS calls and a hash. Reaching them through
|
|
@@ -85,5 +93,5 @@ than a wrapper nobody reads.
|
|
|
85
93
|
Nothing here is specific to any owner or account. The group name is
|
|
86
94
|
`cloudflare`, and it merges with a repo's own `cloudflare.*` tasks — groups are
|
|
87
95
|
namespaces — so only a same-named task collides; this package claims `deploy`,
|
|
88
|
-
`projects` and `
|
|
96
|
+
`projects`, `deployments` and `prune`, nothing else. `blake3` is the one dependency, and
|
|
89
97
|
it lives here rather than in the runner so `mkrun` itself stays dependency-free.
|
|
@@ -18,6 +18,7 @@ $ mk cloudflare.deploy site/dist --project mkrun # production (branch main)
|
|
|
18
18
|
$ mk cloudflare.deploy site/dist --branch try # a preview, on its own URL
|
|
19
19
|
$ mk cloudflare.projects # what the account has
|
|
20
20
|
$ mk cloudflare.deployments --limit 5 # one project's recent ones
|
|
21
|
+
$ mk cloudflare.prune --keep 10 # delete all but the newest ten
|
|
21
22
|
$ mk -n cloudflare.deploy site/dist # the plan; nothing is sent
|
|
22
23
|
```
|
|
23
24
|
|
|
@@ -29,6 +30,13 @@ the environment until a task asks for it, redacted in everything printed, and
|
|
|
29
30
|
readable from the encrypted store. The project can come from `--project` or from
|
|
30
31
|
`CLOUDFLARE_PAGES_PROJECT` in the repo's env layer.
|
|
31
32
|
|
|
33
|
+
**Every deploy prunes the project to its newest 10 deployments** (`--keep N`
|
|
34
|
+
to change it, `--keep 0` to keep them all; `pages.deploy(..., keep=)` from
|
|
35
|
+
Python). Pages never deletes one by itself, and each stays reachable on its own
|
|
36
|
+
`<hash>.<project>.pages.dev` URL forever. The live production deployment is
|
|
37
|
+
never deleted, however old; a pruned preview that was still its branch's alias
|
|
38
|
+
takes that branch URL with it.
|
|
39
|
+
|
|
32
40
|
## Why this exists
|
|
33
41
|
|
|
34
42
|
`wrangler pages deploy` is four HTTPS calls and a hash. Reaching them through
|
|
@@ -72,5 +80,5 @@ than a wrapper nobody reads.
|
|
|
72
80
|
Nothing here is specific to any owner or account. The group name is
|
|
73
81
|
`cloudflare`, and it merges with a repo's own `cloudflare.*` tasks — groups are
|
|
74
82
|
namespaces — so only a same-named task collides; this package claims `deploy`,
|
|
75
|
-
`projects` and `
|
|
83
|
+
`projects`, `deployments` and `prune`, nothing else. `blake3` is the one dependency, and
|
|
76
84
|
it lives here rather than in the runner so `mkrun` itself stays dependency-free.
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Today it is Pages direct upload: `cloudflare.deploy` publishes a built
|
|
4
4
|
directory, `cloudflare.projects` lists what the account has, and
|
|
5
|
-
`cloudflare.deployments` shows a project's recent ones
|
|
5
|
+
`cloudflare.deployments` shows a project's recent ones, and `cloudflare.prune`
|
|
6
|
+
deletes all but the newest ten (which every deploy also does). No Node, no wrangler --
|
|
6
7
|
four HTTPS calls and a BLAKE3 hash.
|
|
7
8
|
|
|
8
9
|
Importing is what registers it:
|
|
@@ -11,12 +12,12 @@ Importing is what registers it:
|
|
|
11
12
|
from make_cloudflare import cloudflare
|
|
12
13
|
|
|
13
14
|
The group merges with a repo's own `cloudflare.*` tasks -- groups are
|
|
14
|
-
namespaces, and this package claims only the
|
|
15
|
+
namespaces, and this package claims only the four names above.
|
|
15
16
|
"""
|
|
16
17
|
|
|
17
18
|
from __future__ import annotations
|
|
18
19
|
|
|
19
|
-
__version__ = "0.
|
|
20
|
+
__version__ = "0.2.0"
|
|
20
21
|
|
|
21
22
|
__all__ = ["cloudflare", "pages"]
|
|
22
23
|
|
|
@@ -23,14 +23,16 @@ def deploy(
|
|
|
23
23
|
*,
|
|
24
24
|
project: str | None = None,
|
|
25
25
|
branch: Annotated[str, arg(help="the Pages branch; the production one publishes live")] = "main",
|
|
26
|
+
keep: Annotated[int, arg(help="deployments to keep afterwards; 0 keeps every one")] = pages.KEEP,
|
|
26
27
|
) -> None:
|
|
27
28
|
"""Publish a built directory to Cloudflare Pages by direct upload.
|
|
28
29
|
|
|
29
30
|
No Node and no wrangler: four HTTPS calls. `--branch` is what decides
|
|
30
31
|
production -- the project's production branch goes live, every other name
|
|
31
|
-
lands as a preview on its own URL.
|
|
32
|
+
lands as a preview on its own URL. Then the project is pruned to its
|
|
33
|
+
newest `--keep` deployments; the live one always stays.
|
|
32
34
|
"""
|
|
33
|
-
url = pages.deploy(Path(directory), project=pages.project_name(project), branch=branch)
|
|
35
|
+
url = pages.deploy(Path(directory), project=pages.project_name(project), branch=branch, keep=keep)
|
|
34
36
|
note(f"deployed -- {url}")
|
|
35
37
|
|
|
36
38
|
|
|
@@ -50,3 +52,11 @@ def deployments(project: str | None = None, *, limit: int = 10) -> None:
|
|
|
50
52
|
stage = (entry.get("latest_stage") or {}).get("status", "?")
|
|
51
53
|
env_name = entry.get("environment", "?")
|
|
52
54
|
step(f"{entry.get('created_on', '?')} {env_name:<10} {stage:<10} {entry.get('url', '')}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@task(group="cloudflare", name="prune", dangerous=True, secrets=["CLOUDFLARE_API_TOKEN"])
|
|
58
|
+
def prune(project: str | None = None, *, keep: int = pages.KEEP) -> None:
|
|
59
|
+
"""Delete all but a project's newest `--keep` deployments; the live one always stays."""
|
|
60
|
+
name = pages.project_name(project)
|
|
61
|
+
if not pages.prune(name, keep=keep):
|
|
62
|
+
note(f"{name} has {keep} deployments or fewer; nothing to delete")
|
|
@@ -57,6 +57,7 @@ __all__ = [
|
|
|
57
57
|
"manifest_of",
|
|
58
58
|
"project_name",
|
|
59
59
|
"projects",
|
|
60
|
+
"prune",
|
|
60
61
|
]
|
|
61
62
|
|
|
62
63
|
API = "https://api.cloudflare.com/client/v4"
|
|
@@ -72,6 +73,14 @@ MAX_ASSET_COUNT = 20_000
|
|
|
72
73
|
BATCH_FILES = 100
|
|
73
74
|
BATCH_BYTES = 10 * 1024 * 1024
|
|
74
75
|
|
|
76
|
+
#: How many deployments a project keeps after a deploy: the newest ones, plus
|
|
77
|
+
#: the live production deployment wherever it falls. Pages itself keeps every
|
|
78
|
+
#: one forever, each on its own URL.
|
|
79
|
+
KEEP = 10
|
|
80
|
+
|
|
81
|
+
#: One page of the deployments listing.
|
|
82
|
+
PER_PAGE = 25
|
|
83
|
+
|
|
75
84
|
#: Handled by Pages itself, as fields on the deployment -- never as assets.
|
|
76
85
|
SPECIAL_FILES = ("_headers", "_redirects", "_routes.json")
|
|
77
86
|
|
|
@@ -200,7 +209,18 @@ def _credentials(account: str | None, token: str | None) -> tuple[str, str]:
|
|
|
200
209
|
return account, token
|
|
201
210
|
|
|
202
211
|
|
|
203
|
-
def _call(
|
|
212
|
+
def _call(method: str, url: str, **kwargs: Any) -> Any:
|
|
213
|
+
"""One API call, returning `result` -- a dict or a list, as the endpoint says.
|
|
214
|
+
|
|
215
|
+
Under `--dry-run` nothing is sent and this returns `None`, which every
|
|
216
|
+
caller reads as "unknown". A dry run therefore prints the whole plan
|
|
217
|
+
instead of stopping at the first call whose answer it needed.
|
|
218
|
+
"""
|
|
219
|
+
payload = _envelope(method, url, **kwargs)
|
|
220
|
+
return None if payload is None else payload.get("result")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _envelope(
|
|
204
224
|
method: str,
|
|
205
225
|
url: str,
|
|
206
226
|
*,
|
|
@@ -208,13 +228,8 @@ def _call(
|
|
|
208
228
|
body: bytes | None = None,
|
|
209
229
|
content_type: str | None = None,
|
|
210
230
|
timeout: float = 120.0,
|
|
211
|
-
) ->
|
|
212
|
-
"""One API call, returning
|
|
213
|
-
|
|
214
|
-
Under `--dry-run` nothing is sent and this returns `None`, which every
|
|
215
|
-
caller reads as "unknown". A dry run therefore prints the whole plan
|
|
216
|
-
instead of stopping at the first call whose answer it needed.
|
|
217
|
-
"""
|
|
231
|
+
) -> dict | None:
|
|
232
|
+
"""One API call, returning Cloudflare's whole answer -- `result_info` too."""
|
|
218
233
|
headers = {"Authorization": f"Bearer {auth}"}
|
|
219
234
|
if content_type:
|
|
220
235
|
headers["Content-Type"] = content_type
|
|
@@ -236,7 +251,7 @@ def _call(
|
|
|
236
251
|
f"{method} {url} -> {answer.status}" + (f": {detail}" if detail else ""),
|
|
237
252
|
hint=None if detail else (answer.body[:300] or None),
|
|
238
253
|
)
|
|
239
|
-
return payload
|
|
254
|
+
return payload
|
|
240
255
|
|
|
241
256
|
|
|
242
257
|
def _json_call(method: str, url: str, *, auth: str, payload: object, **kwargs: Any) -> Any:
|
|
@@ -292,6 +307,7 @@ def deploy(
|
|
|
292
307
|
*,
|
|
293
308
|
project: str,
|
|
294
309
|
branch: str = "main",
|
|
310
|
+
keep: int = KEEP,
|
|
295
311
|
account: str | None = None,
|
|
296
312
|
token: str | None = None,
|
|
297
313
|
) -> str:
|
|
@@ -301,6 +317,9 @@ def deploy(
|
|
|
301
317
|
branch (`main` for every project here) as the live deployment and every
|
|
302
318
|
other name as a preview on its own URL. There is no `--production` flag to
|
|
303
319
|
forget; there is a branch name to get right.
|
|
320
|
+
|
|
321
|
+
Afterwards the project is pruned to its newest `keep` deployments (see
|
|
322
|
+
`prune`); `keep=0` leaves every one in place.
|
|
304
323
|
"""
|
|
305
324
|
account, token = _credentials(account, token)
|
|
306
325
|
assets, specials = collect(directory)
|
|
@@ -369,6 +388,8 @@ def deploy(
|
|
|
369
388
|
if specials:
|
|
370
389
|
note("as deployment files, not assets: " + ", ".join(sorted(specials)))
|
|
371
390
|
url = created.get("url") if isinstance(created, dict) else None
|
|
391
|
+
if keep:
|
|
392
|
+
prune(project, keep=keep, account=account, token=token)
|
|
372
393
|
return str(url or f"https://{branch}.{project}.pages.dev")
|
|
373
394
|
|
|
374
395
|
|
|
@@ -403,10 +424,55 @@ def create_project(
|
|
|
403
424
|
|
|
404
425
|
|
|
405
426
|
def deployments(project: str, *, account: str | None = None, token: str | None = None) -> list[dict]:
|
|
406
|
-
"""A project's deployments, newest first.
|
|
427
|
+
"""A project's deployments, newest first -- every page of them.
|
|
428
|
+
|
|
429
|
+
The listing is paginated, and one page is not all of a project that has
|
|
430
|
+
been deployed by hand for a month. `[]` under `--dry-run`.
|
|
431
|
+
"""
|
|
407
432
|
account, token = _credentials(account, token)
|
|
408
|
-
|
|
409
|
-
|
|
433
|
+
found: list[dict] = []
|
|
434
|
+
page = 1
|
|
435
|
+
while True:
|
|
436
|
+
payload = _envelope(
|
|
437
|
+
"GET",
|
|
438
|
+
f"{API}/accounts/{account}/pages/projects/{project}/deployments?page={page}&per_page={PER_PAGE}",
|
|
439
|
+
auth=token,
|
|
440
|
+
)
|
|
441
|
+
result = (payload or {}).get("result")
|
|
442
|
+
if not isinstance(result, list) or not result:
|
|
443
|
+
return found
|
|
444
|
+
found.extend(result)
|
|
445
|
+
pages_total = ((payload or {}).get("result_info") or {}).get("total_pages")
|
|
446
|
+
if isinstance(pages_total, int) and page >= pages_total:
|
|
447
|
+
return found
|
|
448
|
+
page += 1
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def prune(
|
|
452
|
+
project: str, *, keep: int = KEEP, account: str | None = None, token: str | None = None
|
|
453
|
+
) -> list[dict]:
|
|
454
|
+
"""Delete all but the newest `keep` deployments of `project`; return the deleted ones.
|
|
455
|
+
|
|
456
|
+
The live production deployment is never deleted, however old -- Pages
|
|
457
|
+
refuses it anyway, and it is the site. A preview that is still its branch's
|
|
458
|
+
alias goes with `force=true`: its branch URL stops serving, which is what
|
|
459
|
+
pruning a preview means.
|
|
460
|
+
"""
|
|
461
|
+
if keep < 1:
|
|
462
|
+
raise MakeError(f"keep={keep} would delete every deployment", hint="keep at least 1")
|
|
463
|
+
account, token = _credentials(account, token)
|
|
464
|
+
base = f"{API}/accounts/{account}/pages/projects/{project}"
|
|
465
|
+
found = deployments(project, account=account, token=token)
|
|
466
|
+
if len(found) <= keep:
|
|
467
|
+
return []
|
|
468
|
+
live = (_call("GET", base, auth=token) or {}).get("canonical_deployment") or {}
|
|
469
|
+
doomed = [d for d in found[keep:] if d.get("id") and d.get("id") != live.get("id")]
|
|
470
|
+
for entry in doomed:
|
|
471
|
+
_call("DELETE", f"{base}/deployments/{entry['id']}?force=true", auth=token)
|
|
472
|
+
if doomed:
|
|
473
|
+
plural = "" if len(doomed) == 1 else "s"
|
|
474
|
+
note(f"pruned {len(doomed)} old deployment{plural} of {project}; kept the newest {keep}")
|
|
475
|
+
return doomed
|
|
410
476
|
|
|
411
477
|
|
|
412
478
|
def project_name(project: str | None) -> str:
|
|
@@ -133,6 +133,7 @@ def test_deploy_makes_the_four_calls_in_order(monkeypatch, tmp_path):
|
|
|
133
133
|
"upload",
|
|
134
134
|
"upsert-hashes",
|
|
135
135
|
"deployments",
|
|
136
|
+
"deployments?page=1&per_page=25", # the prune: one deployment, nothing to delete
|
|
136
137
|
]
|
|
137
138
|
assert url == "https://abc123.example.pages.dev"
|
|
138
139
|
|
|
@@ -282,3 +283,80 @@ def test_a_missing_project_is_created_on_its_production_branch(monkeypatch):
|
|
|
282
283
|
method, url, data = calls[-1]
|
|
283
284
|
assert (method, url.endswith("/accounts/acct/pages/projects")) == ("POST", True)
|
|
284
285
|
assert json.loads(data) == {"name": "demo", "production_branch": "main"}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# -- pruning ---------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def history_api(count: int, *, live: str, per_page: int = 25):
|
|
292
|
+
"""A project with `count` deployments, `d0` newest, served in pages of `per_page`."""
|
|
293
|
+
calls: list[tuple[str, str]] = []
|
|
294
|
+
ids = [f"d{i}" for i in range(count)]
|
|
295
|
+
|
|
296
|
+
def answer(url, *, method="GET", **kwargs):
|
|
297
|
+
calls.append((method, url))
|
|
298
|
+
info = None
|
|
299
|
+
if method == "DELETE":
|
|
300
|
+
result = None
|
|
301
|
+
elif "/deployments?" in url:
|
|
302
|
+
page = int(url.split("page=")[1].split("&")[0])
|
|
303
|
+
chunk = ids[(page - 1) * per_page : page * per_page]
|
|
304
|
+
result = [{"id": i} for i in chunk]
|
|
305
|
+
info = {"page": page, "per_page": per_page, "total_pages": -(-count // per_page)}
|
|
306
|
+
elif url.endswith("/deployments"):
|
|
307
|
+
result = {"url": "https://new.demo.pages.dev"}
|
|
308
|
+
elif url.endswith("/projects/demo"):
|
|
309
|
+
result = {"name": "demo", "canonical_deployment": {"id": live}}
|
|
310
|
+
else:
|
|
311
|
+
result = {"jwt": "jot"} if url.endswith("/upload-token") else []
|
|
312
|
+
body = {"success": True, "errors": [], "result": result}
|
|
313
|
+
if info:
|
|
314
|
+
body["result_info"] = info
|
|
315
|
+
return pages.http.Response(url=url, status=200, body=json.dumps(body))
|
|
316
|
+
|
|
317
|
+
return answer, calls
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def deleted(calls) -> list[str]:
|
|
321
|
+
return [url.split("/deployments/")[1].split("?")[0] for method, url in calls if method == "DELETE"]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_prune_keeps_the_newest_and_reads_every_page(monkeypatch):
|
|
325
|
+
answer, calls = history_api(60, live="d0")
|
|
326
|
+
monkeypatch.setattr(pages.http, "request", answer)
|
|
327
|
+
gone = pages.prune("demo", keep=10, account="acct", token="tok")
|
|
328
|
+
assert [d["id"] for d in gone] == [f"d{i}" for i in range(10, 60)]
|
|
329
|
+
assert deleted(calls) == [f"d{i}" for i in range(10, 60)]
|
|
330
|
+
assert sum("/deployments?page=" in url for _, url in calls) == 3
|
|
331
|
+
assert all(url.endswith("?force=true") for method, url in calls if method == "DELETE")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_prune_never_deletes_the_live_deployment(monkeypatch):
|
|
335
|
+
answer, calls = history_api(15, live="d12") # a run of previews pushed it down
|
|
336
|
+
monkeypatch.setattr(pages.http, "request", answer)
|
|
337
|
+
pages.prune("demo", keep=10, account="acct", token="tok")
|
|
338
|
+
assert deleted(calls) == ["d10", "d11", "d13", "d14"]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_prune_with_little_history_deletes_nothing(monkeypatch):
|
|
342
|
+
answer, calls = history_api(10, live="d0")
|
|
343
|
+
monkeypatch.setattr(pages.http, "request", answer)
|
|
344
|
+
assert pages.prune("demo", keep=10, account="acct", token="tok") == []
|
|
345
|
+
assert deleted(calls) == []
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_prune_refuses_to_keep_nothing():
|
|
349
|
+
with pytest.raises(MakeError, match="every deployment"):
|
|
350
|
+
pages.prune("demo", keep=0, account="acct", token="tok")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_deploy_prunes_unless_told_to_keep_everything(monkeypatch, tmp_path):
|
|
354
|
+
build(tmp_path, {"index.html": "x"})
|
|
355
|
+
answer, calls = history_api(12, live="d0")
|
|
356
|
+
monkeypatch.setattr(pages.http, "request", answer)
|
|
357
|
+
pages.deploy(tmp_path, project="demo", account="acct", token="tok")
|
|
358
|
+
assert deleted(calls) == ["d10", "d11"]
|
|
359
|
+
|
|
360
|
+
calls.clear()
|
|
361
|
+
pages.deploy(tmp_path, project="demo", account="acct", token="tok", keep=0)
|
|
362
|
+
assert deleted(calls) == [] and not any("/deployments?" in url for _, url in calls)
|
|
File without changes
|
|
File without changes
|