make-cloudflare 0.1.0__tar.gz → 0.3.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.3.0}/PKG-INFO +25 -5
- {make_cloudflare-0.1.0 → make_cloudflare-0.3.0}/README.md +24 -4
- {make_cloudflare-0.1.0 → make_cloudflare-0.3.0}/pyproject.toml +1 -1
- {make_cloudflare-0.1.0 → make_cloudflare-0.3.0}/src/make_cloudflare/__init__.py +4 -3
- {make_cloudflare-0.1.0 → make_cloudflare-0.3.0}/src/make_cloudflare/cloudflare.py +17 -3
- {make_cloudflare-0.1.0 → make_cloudflare-0.3.0}/src/make_cloudflare/pages.py +160 -23
- {make_cloudflare-0.1.0 → make_cloudflare-0.3.0}/tests/test_pages.py +141 -0
- {make_cloudflare-0.1.0 → make_cloudflare-0.3.0}/.gitignore +0 -0
- {make_cloudflare-0.1.0 → make_cloudflare-0.3.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.3.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>
|
|
@@ -29,8 +29,10 @@ from make_cloudflare import cloudflare # importing is what registers the group
|
|
|
29
29
|
```console
|
|
30
30
|
$ 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
|
+
$ mk cloudflare.deploy dist --functions functions # with Pages Functions
|
|
32
33
|
$ mk cloudflare.projects # what the account has
|
|
33
34
|
$ mk cloudflare.deployments --limit 5 # one project's recent ones
|
|
35
|
+
$ mk cloudflare.prune --keep 10 # delete all but the newest ten
|
|
34
36
|
$ mk -n cloudflare.deploy site/dist # the plan; nothing is sent
|
|
35
37
|
```
|
|
36
38
|
|
|
@@ -42,6 +44,24 @@ the environment until a task asks for it, redacted in everything printed, and
|
|
|
42
44
|
readable from the encrypted store. The project can come from `--project` or from
|
|
43
45
|
`CLOUDFLARE_PAGES_PROJECT` in the repo's env layer.
|
|
44
46
|
|
|
47
|
+
**Every deploy prunes the project to its newest 10 deployments** (`--keep N`
|
|
48
|
+
to change it, `--keep 0` to keep them all; `pages.deploy(..., keep=)` from
|
|
49
|
+
Python). Pages never deletes one by itself, and each stays reachable on its own
|
|
50
|
+
`<hash>.<project>.pages.dev` URL forever. The live production deployment is
|
|
51
|
+
never deleted, however old; a pruned preview that was still its branch's alias
|
|
52
|
+
takes that branch URL with it.
|
|
53
|
+
|
|
54
|
+
**Pages Functions go up with the deployment.** `--functions functions` (or
|
|
55
|
+
`pages.deploy(..., functions=)`) names the tree. It sits **beside** the built
|
|
56
|
+
directory, as wrangler expects, and `/api/...` answers from the same project and
|
|
57
|
+
origin. Bundling it is a build step, not an upload, so that one step is
|
|
58
|
+
`wrangler pages functions build --outfile` (from PATH, or through `npx`). It
|
|
59
|
+
writes exactly the `_worker.bundle` the deployment takes, plus
|
|
60
|
+
`functions-filepath-routing-config.json` and a generated `_routes.json`; the
|
|
61
|
+
site's own `_routes.json` wins. Everything else is still the four calls below.
|
|
62
|
+
Bindings (D1, KV) and secrets are settings on the Pages project, never part of
|
|
63
|
+
the upload.
|
|
64
|
+
|
|
45
65
|
## Why this exists
|
|
46
66
|
|
|
47
67
|
`wrangler pages deploy` is four HTTPS calls and a hash. Reaching them through
|
|
@@ -72,9 +92,9 @@ call returns 200 and the site serves the old bytes, or none:
|
|
|
72
92
|
|
|
73
93
|
## What it does not do
|
|
74
94
|
|
|
75
|
-
**
|
|
76
|
-
refused by name
|
|
77
|
-
|
|
95
|
+
**Advanced-mode `_worker.js`.** A `_worker.js` (or a `functions/`) *inside*
|
|
96
|
+
the built directory is refused by name: it would otherwise be served as a
|
|
97
|
+
static file. Pages Functions go beside it, through `--functions`.
|
|
78
98
|
|
|
79
99
|
**Steps 2 and 3 are undocumented.** They exist because wrangler uses them, and
|
|
80
100
|
Cloudflare can change them without a changelog; the documented `deployments`
|
|
@@ -85,5 +105,5 @@ than a wrapper nobody reads.
|
|
|
85
105
|
Nothing here is specific to any owner or account. The group name is
|
|
86
106
|
`cloudflare`, and it merges with a repo's own `cloudflare.*` tasks — groups are
|
|
87
107
|
namespaces — so only a same-named task collides; this package claims `deploy`,
|
|
88
|
-
`projects` and `
|
|
108
|
+
`projects`, `deployments` and `prune`, nothing else. `blake3` is the one dependency, and
|
|
89
109
|
it lives here rather than in the runner so `mkrun` itself stays dependency-free.
|
|
@@ -16,8 +16,10 @@ from make_cloudflare import cloudflare # importing is what registers the group
|
|
|
16
16
|
```console
|
|
17
17
|
$ 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
|
+
$ mk cloudflare.deploy dist --functions functions # with Pages Functions
|
|
19
20
|
$ mk cloudflare.projects # what the account has
|
|
20
21
|
$ mk cloudflare.deployments --limit 5 # one project's recent ones
|
|
22
|
+
$ mk cloudflare.prune --keep 10 # delete all but the newest ten
|
|
21
23
|
$ mk -n cloudflare.deploy site/dist # the plan; nothing is sent
|
|
22
24
|
```
|
|
23
25
|
|
|
@@ -29,6 +31,24 @@ the environment until a task asks for it, redacted in everything printed, and
|
|
|
29
31
|
readable from the encrypted store. The project can come from `--project` or from
|
|
30
32
|
`CLOUDFLARE_PAGES_PROJECT` in the repo's env layer.
|
|
31
33
|
|
|
34
|
+
**Every deploy prunes the project to its newest 10 deployments** (`--keep N`
|
|
35
|
+
to change it, `--keep 0` to keep them all; `pages.deploy(..., keep=)` from
|
|
36
|
+
Python). Pages never deletes one by itself, and each stays reachable on its own
|
|
37
|
+
`<hash>.<project>.pages.dev` URL forever. The live production deployment is
|
|
38
|
+
never deleted, however old; a pruned preview that was still its branch's alias
|
|
39
|
+
takes that branch URL with it.
|
|
40
|
+
|
|
41
|
+
**Pages Functions go up with the deployment.** `--functions functions` (or
|
|
42
|
+
`pages.deploy(..., functions=)`) names the tree. It sits **beside** the built
|
|
43
|
+
directory, as wrangler expects, and `/api/...` answers from the same project and
|
|
44
|
+
origin. Bundling it is a build step, not an upload, so that one step is
|
|
45
|
+
`wrangler pages functions build --outfile` (from PATH, or through `npx`). It
|
|
46
|
+
writes exactly the `_worker.bundle` the deployment takes, plus
|
|
47
|
+
`functions-filepath-routing-config.json` and a generated `_routes.json`; the
|
|
48
|
+
site's own `_routes.json` wins. Everything else is still the four calls below.
|
|
49
|
+
Bindings (D1, KV) and secrets are settings on the Pages project, never part of
|
|
50
|
+
the upload.
|
|
51
|
+
|
|
32
52
|
## Why this exists
|
|
33
53
|
|
|
34
54
|
`wrangler pages deploy` is four HTTPS calls and a hash. Reaching them through
|
|
@@ -59,9 +79,9 @@ call returns 200 and the site serves the old bytes, or none:
|
|
|
59
79
|
|
|
60
80
|
## What it does not do
|
|
61
81
|
|
|
62
|
-
**
|
|
63
|
-
refused by name
|
|
64
|
-
|
|
82
|
+
**Advanced-mode `_worker.js`.** A `_worker.js` (or a `functions/`) *inside*
|
|
83
|
+
the built directory is refused by name: it would otherwise be served as a
|
|
84
|
+
static file. Pages Functions go beside it, through `--functions`.
|
|
65
85
|
|
|
66
86
|
**Steps 2 and 3 are undocumented.** They exist because wrangler uses them, and
|
|
67
87
|
Cloudflare can change them without a changelog; the documented `deployments`
|
|
@@ -72,5 +92,5 @@ than a wrapper nobody reads.
|
|
|
72
92
|
Nothing here is specific to any owner or account. The group name is
|
|
73
93
|
`cloudflare`, and it merges with a repo's own `cloudflare.*` tasks — groups are
|
|
74
94
|
namespaces — so only a same-named task collides; this package claims `deploy`,
|
|
75
|
-
`projects` and `
|
|
95
|
+
`projects`, `deployments` and `prune`, nothing else. `blake3` is the one dependency, and
|
|
76
96
|
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.3.0"
|
|
20
21
|
|
|
21
22
|
__all__ = ["cloudflare", "pages"]
|
|
22
23
|
|
|
@@ -23,14 +23,20 @@ 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,
|
|
27
|
+
functions: Annotated[str | None, arg(help="a Pages Functions tree to bundle and deploy with it")] = None,
|
|
26
28
|
) -> None:
|
|
27
29
|
"""Publish a built directory to Cloudflare Pages by direct upload.
|
|
28
30
|
|
|
29
|
-
No Node and no wrangler: four HTTPS calls. `--
|
|
31
|
+
No Node and no wrangler: four HTTPS calls. (`--functions` is the exception:
|
|
32
|
+
wrangler bundles that tree, and only bundles it.) `--branch` is what decides
|
|
30
33
|
production -- the project's production branch goes live, every other name
|
|
31
|
-
lands as a preview on its own URL.
|
|
34
|
+
lands as a preview on its own URL. Then the project is pruned to its
|
|
35
|
+
newest `--keep` deployments; the live one always stays.
|
|
32
36
|
"""
|
|
33
|
-
url = pages.deploy(
|
|
37
|
+
url = pages.deploy(
|
|
38
|
+
Path(directory), project=pages.project_name(project), branch=branch, keep=keep, functions=functions
|
|
39
|
+
)
|
|
34
40
|
note(f"deployed -- {url}")
|
|
35
41
|
|
|
36
42
|
|
|
@@ -50,3 +56,11 @@ def deployments(project: str | None = None, *, limit: int = 10) -> None:
|
|
|
50
56
|
stage = (entry.get("latest_stage") or {}).get("status", "?")
|
|
51
57
|
env_name = entry.get("environment", "?")
|
|
52
58
|
step(f"{entry.get('created_on', '?')} {env_name:<10} {stage:<10} {entry.get('url', '')}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@task(group="cloudflare", name="prune", dangerous=True, secrets=["CLOUDFLARE_API_TOKEN"])
|
|
62
|
+
def prune(project: str | None = None, *, keep: int = pages.KEEP) -> None:
|
|
63
|
+
"""Delete all but a project's newest `--keep` deployments; the live one always stays."""
|
|
64
|
+
name = pages.project_name(project)
|
|
65
|
+
if not pages.prune(name, keep=keep):
|
|
66
|
+
note(f"{name} has {keep} deployments or fewer; nothing to delete")
|
|
@@ -8,6 +8,7 @@ a built directory without installing a JavaScript toolchain to do it.
|
|
|
8
8
|
|
|
9
9
|
pages.deploy("site/dist", project="mkrun") # production
|
|
10
10
|
pages.deploy("site/dist", project="mkrun", branch="x") # a preview
|
|
11
|
+
pages.deploy("dist", project="name", functions="functions") # with Pages Functions
|
|
11
12
|
|
|
12
13
|
The flow, and the three things a naive port gets wrong:
|
|
13
14
|
|
|
@@ -30,6 +31,13 @@ reports success while the site's CSP quietly stops applying.
|
|
|
30
31
|
**Steps 2 and 3 are undocumented.** They exist because wrangler uses them;
|
|
31
32
|
Cloudflare can change them without a changelog. The documented `deployments`
|
|
32
33
|
endpoint alone cannot upload a file.
|
|
34
|
+
|
|
35
|
+
**Pages Functions are built by wrangler, and only built.** Bundling a
|
|
36
|
+
`functions/` tree into a Worker is esbuild plus Cloudflare's routing glue, and
|
|
37
|
+
`wrangler pages functions build --outfile` writes exactly the `_worker.bundle`
|
|
38
|
+
the deployment takes. That one build step is the only thing wrangler does here;
|
|
39
|
+
the upload is still these calls. Bindings (D1, KV) and secrets are settings on
|
|
40
|
+
the Pages project, never part of the upload.
|
|
33
41
|
"""
|
|
34
42
|
|
|
35
43
|
from __future__ import annotations
|
|
@@ -39,17 +47,20 @@ import json
|
|
|
39
47
|
import mimetypes
|
|
40
48
|
import os
|
|
41
49
|
import secrets as _random
|
|
50
|
+
import shutil
|
|
51
|
+
import tempfile
|
|
42
52
|
from collections.abc import Iterable, Iterator, Mapping
|
|
43
53
|
from dataclasses import dataclass
|
|
44
54
|
from pathlib import Path
|
|
45
55
|
from typing import Any
|
|
46
56
|
|
|
47
|
-
from make import env, http, note, step
|
|
57
|
+
from make import env, http, note, sh, step
|
|
48
58
|
from make.context import mark_sensitive
|
|
49
59
|
from make.errors import ConfigError, MakeError
|
|
50
60
|
|
|
51
61
|
__all__ = [
|
|
52
62
|
"Asset",
|
|
63
|
+
"build_functions",
|
|
53
64
|
"collect",
|
|
54
65
|
"deploy",
|
|
55
66
|
"deployments",
|
|
@@ -57,6 +68,7 @@ __all__ = [
|
|
|
57
68
|
"manifest_of",
|
|
58
69
|
"project_name",
|
|
59
70
|
"projects",
|
|
71
|
+
"prune",
|
|
60
72
|
]
|
|
61
73
|
|
|
62
74
|
API = "https://api.cloudflare.com/client/v4"
|
|
@@ -72,12 +84,21 @@ MAX_ASSET_COUNT = 20_000
|
|
|
72
84
|
BATCH_FILES = 100
|
|
73
85
|
BATCH_BYTES = 10 * 1024 * 1024
|
|
74
86
|
|
|
87
|
+
#: How many deployments a project keeps after a deploy: the newest ones, plus
|
|
88
|
+
#: the live production deployment wherever it falls. Pages itself keeps every
|
|
89
|
+
#: one forever, each on its own URL.
|
|
90
|
+
KEEP = 10
|
|
91
|
+
|
|
92
|
+
#: One page of the deployments listing.
|
|
93
|
+
PER_PAGE = 25
|
|
94
|
+
|
|
75
95
|
#: Handled by Pages itself, as fields on the deployment -- never as assets.
|
|
76
96
|
SPECIAL_FILES = ("_headers", "_redirects", "_routes.json")
|
|
77
97
|
|
|
78
|
-
#: Never uploaded.
|
|
79
|
-
#:
|
|
80
|
-
#:
|
|
98
|
+
#: Never uploaded. Pages Functions live *beside* the built directory and are
|
|
99
|
+
#: handed to `deploy(functions=...)`; inside it they would be served as static
|
|
100
|
+
#: files -- the source of the API, readable by anyone. An advanced-mode
|
|
101
|
+
#: `_worker.js` is not built here at all.
|
|
81
102
|
SKIP_NAMES = {".DS_Store", "Thumbs.db", ".gitkeep", *SPECIAL_FILES}
|
|
82
103
|
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".wrangler"}
|
|
83
104
|
FUNCTIONS = ("_worker.js", "_worker.js.map", "_worker.bundle", "functions")
|
|
@@ -137,8 +158,9 @@ def collect(directory: str | Path) -> tuple[list[Asset], dict[str, str]]:
|
|
|
137
158
|
for name in FUNCTIONS:
|
|
138
159
|
if (root / name).exists():
|
|
139
160
|
raise MakeError(
|
|
140
|
-
f"{root / name} is
|
|
141
|
-
hint="
|
|
161
|
+
f"{root / name} is Pages Functions code inside the built directory",
|
|
162
|
+
hint="keep `functions/` beside the built directory and pass `functions=`; "
|
|
163
|
+
"an advanced-mode `_worker.js` is not supported",
|
|
142
164
|
)
|
|
143
165
|
|
|
144
166
|
assets: list[Asset] = []
|
|
@@ -200,7 +222,18 @@ def _credentials(account: str | None, token: str | None) -> tuple[str, str]:
|
|
|
200
222
|
return account, token
|
|
201
223
|
|
|
202
224
|
|
|
203
|
-
def _call(
|
|
225
|
+
def _call(method: str, url: str, **kwargs: Any) -> Any:
|
|
226
|
+
"""One API call, returning `result` -- a dict or a list, as the endpoint says.
|
|
227
|
+
|
|
228
|
+
Under `--dry-run` nothing is sent and this returns `None`, which every
|
|
229
|
+
caller reads as "unknown". A dry run therefore prints the whole plan
|
|
230
|
+
instead of stopping at the first call whose answer it needed.
|
|
231
|
+
"""
|
|
232
|
+
payload = _envelope(method, url, **kwargs)
|
|
233
|
+
return None if payload is None else payload.get("result")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _envelope(
|
|
204
237
|
method: str,
|
|
205
238
|
url: str,
|
|
206
239
|
*,
|
|
@@ -208,13 +241,8 @@ def _call(
|
|
|
208
241
|
body: bytes | None = None,
|
|
209
242
|
content_type: str | None = None,
|
|
210
243
|
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
|
-
"""
|
|
244
|
+
) -> dict | None:
|
|
245
|
+
"""One API call, returning Cloudflare's whole answer -- `result_info` too."""
|
|
218
246
|
headers = {"Authorization": f"Bearer {auth}"}
|
|
219
247
|
if content_type:
|
|
220
248
|
headers["Content-Type"] = content_type
|
|
@@ -236,7 +264,7 @@ def _call(
|
|
|
236
264
|
f"{method} {url} -> {answer.status}" + (f": {detail}" if detail else ""),
|
|
237
265
|
hint=None if detail else (answer.body[:300] or None),
|
|
238
266
|
)
|
|
239
|
-
return payload
|
|
267
|
+
return payload
|
|
240
268
|
|
|
241
269
|
|
|
242
270
|
def _json_call(method: str, url: str, *, auth: str, payload: object, **kwargs: Any) -> Any:
|
|
@@ -245,7 +273,9 @@ def _json_call(method: str, url: str, *, auth: str, payload: object, **kwargs: A
|
|
|
245
273
|
)
|
|
246
274
|
|
|
247
275
|
|
|
248
|
-
def _multipart(
|
|
276
|
+
def _multipart(
|
|
277
|
+
fields: Mapping[str, str], files: Mapping[str, str | bytes] | None = None
|
|
278
|
+
) -> tuple[bytes, str]:
|
|
249
279
|
"""Encode a deployment's form: plain fields, then the special files as file parts.
|
|
250
280
|
|
|
251
281
|
⚠ `_headers` and `_redirects` must be FILE parts (a `filename=` on the part),
|
|
@@ -264,7 +294,7 @@ def _multipart(fields: Mapping[str, str], files: Mapping[str, str] | None = None
|
|
|
264
294
|
chunks.append(f"--{boundary}\r\n".encode())
|
|
265
295
|
chunks.append(f'Content-Disposition: form-data; name="{name}"; filename="{name}"\r\n'.encode())
|
|
266
296
|
chunks.append(b"Content-Type: application/octet-stream\r\n\r\n")
|
|
267
|
-
chunks.append(value.encode())
|
|
297
|
+
chunks.append(value if isinstance(value, bytes) else value.encode())
|
|
268
298
|
chunks.append(b"\r\n")
|
|
269
299
|
chunks.append(f"--{boundary}--\r\n".encode())
|
|
270
300
|
return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
|
|
@@ -284,6 +314,55 @@ def _batches(assets: list[Asset]) -> Iterator[list[Asset]]:
|
|
|
284
314
|
yield batch
|
|
285
315
|
|
|
286
316
|
|
|
317
|
+
# -- Pages Functions -------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _wrangler() -> list[str]:
|
|
321
|
+
found = shutil.which("wrangler")
|
|
322
|
+
return [found] if found else ["npx", "--yes", "wrangler"]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def build_functions(functions: str | Path, directory: str | Path, out: str | Path) -> dict[str, bytes]:
|
|
326
|
+
"""Bundle a `functions/` tree; return the deployment's file parts.
|
|
327
|
+
|
|
328
|
+
`_worker.bundle` (the Worker, already in the upload form Pages takes),
|
|
329
|
+
`functions-filepath-routing-config.json`, and `_routes.json` unless the
|
|
330
|
+
built directory brings its own -- the same three `wrangler pages deploy`
|
|
331
|
+
sends. Empty under `--dry-run`, where nothing was built.
|
|
332
|
+
"""
|
|
333
|
+
source = Path(functions).resolve()
|
|
334
|
+
root = Path(directory).resolve()
|
|
335
|
+
if not source.is_dir():
|
|
336
|
+
raise MakeError(f"{source} is not a directory", hint="`functions=` names the Pages Functions tree")
|
|
337
|
+
out = Path(out)
|
|
338
|
+
bundle, config, routes = out / "_worker.bundle", out / "routing-config.json", out / "_routes.json"
|
|
339
|
+
sh(
|
|
340
|
+
*_wrangler(),
|
|
341
|
+
"pages",
|
|
342
|
+
"functions",
|
|
343
|
+
"build",
|
|
344
|
+
source,
|
|
345
|
+
"--outfile",
|
|
346
|
+
bundle,
|
|
347
|
+
"--output-config-path",
|
|
348
|
+
config,
|
|
349
|
+
"--output-routes-path",
|
|
350
|
+
routes,
|
|
351
|
+
"--build-output-directory",
|
|
352
|
+
root,
|
|
353
|
+
cwd=source.parent,
|
|
354
|
+
)
|
|
355
|
+
if not bundle.is_file():
|
|
356
|
+
return {}
|
|
357
|
+
parts = {
|
|
358
|
+
"_worker.bundle": bundle.read_bytes(),
|
|
359
|
+
"functions-filepath-routing-config.json": config.read_bytes(),
|
|
360
|
+
}
|
|
361
|
+
if not (root / "_routes.json").is_file() and routes.is_file():
|
|
362
|
+
parts["_routes.json"] = routes.read_bytes()
|
|
363
|
+
return parts
|
|
364
|
+
|
|
365
|
+
|
|
287
366
|
# -- the tasks' behaviour --------------------------------------------------
|
|
288
367
|
|
|
289
368
|
|
|
@@ -292,6 +371,8 @@ def deploy(
|
|
|
292
371
|
*,
|
|
293
372
|
project: str,
|
|
294
373
|
branch: str = "main",
|
|
374
|
+
keep: int = KEEP,
|
|
375
|
+
functions: str | Path | None = None,
|
|
295
376
|
account: str | None = None,
|
|
296
377
|
token: str | None = None,
|
|
297
378
|
) -> str:
|
|
@@ -301,9 +382,18 @@ def deploy(
|
|
|
301
382
|
branch (`main` for every project here) as the live deployment and every
|
|
302
383
|
other name as a preview on its own URL. There is no `--production` flag to
|
|
303
384
|
forget; there is a branch name to get right.
|
|
385
|
+
|
|
386
|
+
`functions` names a Pages Functions tree (`functions/`, beside the built
|
|
387
|
+
directory): it is bundled by `build_functions` and goes up with the
|
|
388
|
+
deployment, so `/api/...` answers from the same project.
|
|
389
|
+
|
|
390
|
+
Afterwards the project is pruned to its newest `keep` deployments (see
|
|
391
|
+
`prune`); `keep=0` leaves every one in place.
|
|
304
392
|
"""
|
|
305
393
|
account, token = _credentials(account, token)
|
|
306
394
|
assets, specials = collect(directory)
|
|
395
|
+
with tempfile.TemporaryDirectory(prefix="mk-pages-") as scratch:
|
|
396
|
+
worker = build_functions(functions, directory, scratch) if functions is not None else {}
|
|
307
397
|
plural = "" if len(assets) == 1 else "s"
|
|
308
398
|
step(f"{len(assets)} file{plural}, {sum(a.size for a in assets) / 1024:.0f} KiB -> {project} ({branch})")
|
|
309
399
|
|
|
@@ -358,7 +448,7 @@ def deploy(
|
|
|
358
448
|
)
|
|
359
449
|
|
|
360
450
|
fields = {"manifest": json.dumps(manifest_of(assets)), "branch": branch}
|
|
361
|
-
body, content_type = _multipart(fields, specials)
|
|
451
|
+
body, content_type = _multipart(fields, {**specials, **worker})
|
|
362
452
|
created = _call(
|
|
363
453
|
"POST",
|
|
364
454
|
f"{API}/accounts/{account}/pages/projects/{project}/deployments",
|
|
@@ -366,9 +456,11 @@ def deploy(
|
|
|
366
456
|
body=body,
|
|
367
457
|
content_type=content_type,
|
|
368
458
|
)
|
|
369
|
-
if specials:
|
|
370
|
-
note("as deployment files, not assets: " + ", ".join(sorted(specials)))
|
|
459
|
+
if specials or worker:
|
|
460
|
+
note("as deployment files, not assets: " + ", ".join(sorted({**specials, **worker})))
|
|
371
461
|
url = created.get("url") if isinstance(created, dict) else None
|
|
462
|
+
if keep:
|
|
463
|
+
prune(project, keep=keep, account=account, token=token)
|
|
372
464
|
return str(url or f"https://{branch}.{project}.pages.dev")
|
|
373
465
|
|
|
374
466
|
|
|
@@ -403,10 +495,55 @@ def create_project(
|
|
|
403
495
|
|
|
404
496
|
|
|
405
497
|
def deployments(project: str, *, account: str | None = None, token: str | None = None) -> list[dict]:
|
|
406
|
-
"""A project's deployments, newest first.
|
|
498
|
+
"""A project's deployments, newest first -- every page of them.
|
|
499
|
+
|
|
500
|
+
The listing is paginated, and one page is not all of a project that has
|
|
501
|
+
been deployed by hand for a month. `[]` under `--dry-run`.
|
|
502
|
+
"""
|
|
407
503
|
account, token = _credentials(account, token)
|
|
408
|
-
|
|
409
|
-
|
|
504
|
+
found: list[dict] = []
|
|
505
|
+
page = 1
|
|
506
|
+
while True:
|
|
507
|
+
payload = _envelope(
|
|
508
|
+
"GET",
|
|
509
|
+
f"{API}/accounts/{account}/pages/projects/{project}/deployments?page={page}&per_page={PER_PAGE}",
|
|
510
|
+
auth=token,
|
|
511
|
+
)
|
|
512
|
+
result = (payload or {}).get("result")
|
|
513
|
+
if not isinstance(result, list) or not result:
|
|
514
|
+
return found
|
|
515
|
+
found.extend(result)
|
|
516
|
+
pages_total = ((payload or {}).get("result_info") or {}).get("total_pages")
|
|
517
|
+
if isinstance(pages_total, int) and page >= pages_total:
|
|
518
|
+
return found
|
|
519
|
+
page += 1
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def prune(
|
|
523
|
+
project: str, *, keep: int = KEEP, account: str | None = None, token: str | None = None
|
|
524
|
+
) -> list[dict]:
|
|
525
|
+
"""Delete all but the newest `keep` deployments of `project`; return the deleted ones.
|
|
526
|
+
|
|
527
|
+
The live production deployment is never deleted, however old -- Pages
|
|
528
|
+
refuses it anyway, and it is the site. A preview that is still its branch's
|
|
529
|
+
alias goes with `force=true`: its branch URL stops serving, which is what
|
|
530
|
+
pruning a preview means.
|
|
531
|
+
"""
|
|
532
|
+
if keep < 1:
|
|
533
|
+
raise MakeError(f"keep={keep} would delete every deployment", hint="keep at least 1")
|
|
534
|
+
account, token = _credentials(account, token)
|
|
535
|
+
base = f"{API}/accounts/{account}/pages/projects/{project}"
|
|
536
|
+
found = deployments(project, account=account, token=token)
|
|
537
|
+
if len(found) <= keep:
|
|
538
|
+
return []
|
|
539
|
+
live = (_call("GET", base, auth=token) or {}).get("canonical_deployment") or {}
|
|
540
|
+
doomed = [d for d in found[keep:] if d.get("id") and d.get("id") != live.get("id")]
|
|
541
|
+
for entry in doomed:
|
|
542
|
+
_call("DELETE", f"{base}/deployments/{entry['id']}?force=true", auth=token)
|
|
543
|
+
if doomed:
|
|
544
|
+
plural = "" if len(doomed) == 1 else "s"
|
|
545
|
+
note(f"pruned {len(doomed)} old deployment{plural} of {project}; kept the newest {keep}")
|
|
546
|
+
return doomed
|
|
410
547
|
|
|
411
548
|
|
|
412
549
|
def project_name(project: str | None) -> str:
|
|
@@ -109,6 +109,13 @@ def test_a_functions_project_is_refused(tmp_path):
|
|
|
109
109
|
pages.collect(tmp_path)
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
def test_functions_inside_the_built_directory_are_refused(tmp_path):
|
|
113
|
+
"""Served from there, the API's source would be a static file anyone can read."""
|
|
114
|
+
build(tmp_path, {"index.html": "x", "functions/api/x.ts": "export const onRequest = 1"})
|
|
115
|
+
with pytest.raises(MakeError, match="inside the built directory"):
|
|
116
|
+
pages.collect(tmp_path)
|
|
117
|
+
|
|
118
|
+
|
|
112
119
|
def test_an_oversized_asset_is_named(tmp_path):
|
|
113
120
|
(tmp_path / "big.bin").write_bytes(b"x" * (pages.MAX_ASSET_SIZE + 1))
|
|
114
121
|
with pytest.raises(MakeError, match=r"big\.bin"):
|
|
@@ -133,6 +140,7 @@ def test_deploy_makes_the_four_calls_in_order(monkeypatch, tmp_path):
|
|
|
133
140
|
"upload",
|
|
134
141
|
"upsert-hashes",
|
|
135
142
|
"deployments",
|
|
143
|
+
"deployments?page=1&per_page=25", # the prune: one deployment, nothing to delete
|
|
136
144
|
]
|
|
137
145
|
assert url == "https://abc123.example.pages.dev"
|
|
138
146
|
|
|
@@ -282,3 +290,136 @@ def test_a_missing_project_is_created_on_its_production_branch(monkeypatch):
|
|
|
282
290
|
method, url, data = calls[-1]
|
|
283
291
|
assert (method, url.endswith("/accounts/acct/pages/projects")) == ("POST", True)
|
|
284
292
|
assert json.loads(data) == {"name": "demo", "production_branch": "main"}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# -- pruning ---------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def history_api(count: int, *, live: str, per_page: int = 25):
|
|
299
|
+
"""A project with `count` deployments, `d0` newest, served in pages of `per_page`."""
|
|
300
|
+
calls: list[tuple[str, str]] = []
|
|
301
|
+
ids = [f"d{i}" for i in range(count)]
|
|
302
|
+
|
|
303
|
+
def answer(url, *, method="GET", **kwargs):
|
|
304
|
+
calls.append((method, url))
|
|
305
|
+
info = None
|
|
306
|
+
if method == "DELETE":
|
|
307
|
+
result = None
|
|
308
|
+
elif "/deployments?" in url:
|
|
309
|
+
page = int(url.split("page=")[1].split("&")[0])
|
|
310
|
+
chunk = ids[(page - 1) * per_page : page * per_page]
|
|
311
|
+
result = [{"id": i} for i in chunk]
|
|
312
|
+
info = {"page": page, "per_page": per_page, "total_pages": -(-count // per_page)}
|
|
313
|
+
elif url.endswith("/deployments"):
|
|
314
|
+
result = {"url": "https://new.demo.pages.dev"}
|
|
315
|
+
elif url.endswith("/projects/demo"):
|
|
316
|
+
result = {"name": "demo", "canonical_deployment": {"id": live}}
|
|
317
|
+
else:
|
|
318
|
+
result = {"jwt": "jot"} if url.endswith("/upload-token") else []
|
|
319
|
+
body = {"success": True, "errors": [], "result": result}
|
|
320
|
+
if info:
|
|
321
|
+
body["result_info"] = info
|
|
322
|
+
return pages.http.Response(url=url, status=200, body=json.dumps(body))
|
|
323
|
+
|
|
324
|
+
return answer, calls
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def deleted(calls) -> list[str]:
|
|
328
|
+
return [url.split("/deployments/")[1].split("?")[0] for method, url in calls if method == "DELETE"]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_prune_keeps_the_newest_and_reads_every_page(monkeypatch):
|
|
332
|
+
answer, calls = history_api(60, live="d0")
|
|
333
|
+
monkeypatch.setattr(pages.http, "request", answer)
|
|
334
|
+
gone = pages.prune("demo", keep=10, account="acct", token="tok")
|
|
335
|
+
assert [d["id"] for d in gone] == [f"d{i}" for i in range(10, 60)]
|
|
336
|
+
assert deleted(calls) == [f"d{i}" for i in range(10, 60)]
|
|
337
|
+
assert sum("/deployments?page=" in url for _, url in calls) == 3
|
|
338
|
+
assert all(url.endswith("?force=true") for method, url in calls if method == "DELETE")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_prune_never_deletes_the_live_deployment(monkeypatch):
|
|
342
|
+
answer, calls = history_api(15, live="d12") # a run of previews pushed it down
|
|
343
|
+
monkeypatch.setattr(pages.http, "request", answer)
|
|
344
|
+
pages.prune("demo", keep=10, account="acct", token="tok")
|
|
345
|
+
assert deleted(calls) == ["d10", "d11", "d13", "d14"]
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_prune_with_little_history_deletes_nothing(monkeypatch):
|
|
349
|
+
answer, calls = history_api(10, live="d0")
|
|
350
|
+
monkeypatch.setattr(pages.http, "request", answer)
|
|
351
|
+
assert pages.prune("demo", keep=10, account="acct", token="tok") == []
|
|
352
|
+
assert deleted(calls) == []
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_prune_refuses_to_keep_nothing():
|
|
356
|
+
with pytest.raises(MakeError, match="every deployment"):
|
|
357
|
+
pages.prune("demo", keep=0, account="acct", token="tok")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_deploy_prunes_unless_told_to_keep_everything(monkeypatch, tmp_path):
|
|
361
|
+
build(tmp_path, {"index.html": "x"})
|
|
362
|
+
answer, calls = history_api(12, live="d0")
|
|
363
|
+
monkeypatch.setattr(pages.http, "request", answer)
|
|
364
|
+
pages.deploy(tmp_path, project="demo", account="acct", token="tok")
|
|
365
|
+
assert deleted(calls) == ["d10", "d11"]
|
|
366
|
+
|
|
367
|
+
calls.clear()
|
|
368
|
+
pages.deploy(tmp_path, project="demo", account="acct", token="tok", keep=0)
|
|
369
|
+
assert deleted(calls) == [] and not any("/deployments?" in url for _, url in calls)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# -- Pages Functions -------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def fake_wrangler(monkeypatch):
|
|
376
|
+
"""Stands in for `wrangler pages functions build`, writing the three outputs."""
|
|
377
|
+
seen = []
|
|
378
|
+
|
|
379
|
+
def run(*argv, cwd=None, **kwargs):
|
|
380
|
+
argv = [str(a) for a in argv]
|
|
381
|
+
seen.append(argv)
|
|
382
|
+
out = {
|
|
383
|
+
flag: argv[argv.index(flag) + 1]
|
|
384
|
+
for flag in ("--outfile", "--output-config-path", "--output-routes-path")
|
|
385
|
+
}
|
|
386
|
+
pages.Path(out["--outfile"]).write_bytes(b"--bundle--")
|
|
387
|
+
pages.Path(out["--output-config-path"]).write_text('{"routes": []}')
|
|
388
|
+
pages.Path(out["--output-routes-path"]).write_text('{"include": ["/api/*"]}')
|
|
389
|
+
|
|
390
|
+
monkeypatch.setattr(pages, "sh", run)
|
|
391
|
+
return seen
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def test_functions_go_up_as_file_parts_of_the_deployment(monkeypatch, tmp_path):
|
|
395
|
+
site = build(tmp_path / "dist", {"index.html": "x"})
|
|
396
|
+
functions = build(tmp_path / "functions", {"api/[[path]].ts": "export const onRequest = 1"})
|
|
397
|
+
seen = fake_wrangler(monkeypatch)
|
|
398
|
+
fake = FakeHTTP()
|
|
399
|
+
deploy(monkeypatch, site, fake, functions=functions)
|
|
400
|
+
|
|
401
|
+
assert seen[0][1:4] == ["pages", "functions", "build"] or seen[0][3:6] == ["pages", "functions", "build"]
|
|
402
|
+
body = fake.body("/deployments").decode()
|
|
403
|
+
assert 'name="_worker.bundle"; filename="_worker.bundle"' in body and "--bundle--" in body
|
|
404
|
+
assert 'name="functions-filepath-routing-config.json"; filename=' in body
|
|
405
|
+
assert 'name="_routes.json"; filename="_routes.json"' in body and "/api/*" in body
|
|
406
|
+
# The functions are never assets.
|
|
407
|
+
assert "[[path]]" not in fake.body("/check-missing").decode()
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def test_the_sites_own_routes_beat_the_generated_ones(monkeypatch, tmp_path):
|
|
411
|
+
site = build(tmp_path / "dist", {"index.html": "x", "_routes.json": '{"include": ["/mine/*"]}'})
|
|
412
|
+
functions = build(tmp_path / "functions", {"api/x.ts": "export const onRequest = 1"})
|
|
413
|
+
fake_wrangler(monkeypatch)
|
|
414
|
+
fake = FakeHTTP()
|
|
415
|
+
deploy(monkeypatch, site, fake, functions=functions)
|
|
416
|
+
|
|
417
|
+
body = fake.body("/deployments").decode()
|
|
418
|
+
assert body.count('filename="_routes.json"') == 1
|
|
419
|
+
assert "/mine/*" in body and "/api/*" not in body
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def test_a_missing_functions_tree_is_named(monkeypatch, tmp_path):
|
|
423
|
+
site = build(tmp_path / "dist", {"index.html": "x"})
|
|
424
|
+
with pytest.raises(MakeError, match="not a directory"):
|
|
425
|
+
deploy(monkeypatch, site, FakeHTTP(), functions=tmp_path / "nope")
|
|
File without changes
|
|
File without changes
|