make-cloudflare 0.1.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/.gitignore +19 -0
- make_cloudflare-0.1.0/PKG-INFO +89 -0
- make_cloudflare-0.1.0/README.md +76 -0
- make_cloudflare-0.1.0/pyproject.toml +43 -0
- make_cloudflare-0.1.0/src/make_cloudflare/__init__.py +35 -0
- make_cloudflare-0.1.0/src/make_cloudflare/cloudflare.py +52 -0
- make_cloudflare-0.1.0/src/make_cloudflare/pages.py +422 -0
- make_cloudflare-0.1.0/tests/conftest.py +19 -0
- make_cloudflare-0.1.0/tests/test_pages.py +284 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
.venv/
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
.ruff_cache/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
*.egg-info/
|
|
9
|
+
.env
|
|
10
|
+
|
|
11
|
+
# wrangler caches its Pages upload state here (site/ is deployed by direct upload).
|
|
12
|
+
.wrangler/
|
|
13
|
+
|
|
14
|
+
# The VS Code extension in editors/vscode: `dist/` is above, and these two are what
|
|
15
|
+
# building a .vsix leaves behind. `npm install` is never run there -- the extension has
|
|
16
|
+
# no runtime dependency and both CLIs come from `npx --yes` -- so node_modules/ is
|
|
17
|
+
# listed to catch the day somebody tries.
|
|
18
|
+
node_modules/
|
|
19
|
+
*.vsix
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: make-cloudflare
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cloudflare Pages direct upload for mkrun -- deploy a built site with no Node and no wrangler.
|
|
5
|
+
Project-URL: Homepage, https://make.optersoft.com
|
|
6
|
+
Author-email: "Optersoft, S.L." <david@optersoft.com>
|
|
7
|
+
License-Expression: MIT OR Apache-2.0
|
|
8
|
+
Keywords: cloudflare,deploy,direct upload,mk,mkrun,pages
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: blake3>=1.0
|
|
11
|
+
Requires-Dist: mkrun>=0.4.1
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# make-cloudflare
|
|
15
|
+
|
|
16
|
+
Cloudflare Pages **direct upload** for [`mkrun`](https://pypi.org/project/mkrun/), in
|
|
17
|
+
Python: publish a built directory with no Node on the machine and no `wrangler`
|
|
18
|
+
in the pipeline.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
# Makefile.py in a consuming repo
|
|
22
|
+
# /// script
|
|
23
|
+
# requires-python = ">=3.11"
|
|
24
|
+
# dependencies = ["mkrun>=0.4", "make-cloudflare>=0.1"]
|
|
25
|
+
# ///
|
|
26
|
+
from make_cloudflare import cloudflare # importing is what registers the group
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```console
|
|
30
|
+
$ mk cloudflare.deploy site/dist --project mkrun # production (branch main)
|
|
31
|
+
$ mk cloudflare.deploy site/dist --branch try # a preview, on its own URL
|
|
32
|
+
$ mk cloudflare.projects # what the account has
|
|
33
|
+
$ mk cloudflare.deployments --limit 5 # one project's recent ones
|
|
34
|
+
$ mk -n cloudflare.deploy site/dist # the plan; nothing is sent
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Credentials are the two variables wrangler already reads, so a repository that
|
|
38
|
+
deploys from CI needs no new secret: `CLOUDFLARE_ACCOUNT_ID` and
|
|
39
|
+
`CLOUDFLARE_API_TOKEN` (a token with **Cloudflare Pages: Edit**). The token's
|
|
40
|
+
name ends in `TOKEN`, so mkrun treats it as a credential — it is withheld from
|
|
41
|
+
the environment until a task asks for it, redacted in everything printed, and
|
|
42
|
+
readable from the encrypted store. The project can come from `--project` or from
|
|
43
|
+
`CLOUDFLARE_PAGES_PROJECT` in the repo's env layer.
|
|
44
|
+
|
|
45
|
+
## Why this exists
|
|
46
|
+
|
|
47
|
+
`wrangler pages deploy` is four HTTPS calls and a hash. Reaching them through
|
|
48
|
+
`npx` costs a Node toolchain in repositories that otherwise have none — a Rust
|
|
49
|
+
one, a [frontage](https://github.com/optersoft/frontage) one — and a
|
|
50
|
+
`node_modules` in the deploy image of every one of them.
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
POST /accounts/<acct>/pages/projects/<p>/upload-token -> a short-lived JWT
|
|
54
|
+
POST /pages/assets/check-missing -> what is not stored yet
|
|
55
|
+
POST /pages/assets/upload -> the files, batched
|
|
56
|
+
POST /pages/assets/upsert-hashes
|
|
57
|
+
POST /accounts/<acct>/pages/projects/<p>/deployments -> the manifest
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Three things make a naive port wrong, and all three fail **silently** — every
|
|
61
|
+
call returns 200 and the site serves the old bytes, or none:
|
|
62
|
+
|
|
63
|
+
- **The hash is BLAKE3 of a strange input**: the base64 *text* of the contents
|
|
64
|
+
with the extension (no dot) appended, first 32 hex characters. Not of the
|
|
65
|
+
bytes, and not SHA-256. `tests/test_pages.py` locks it with a golden value.
|
|
66
|
+
- **`_headers`, `_redirects` and `_routes.json` are not assets.** They are
|
|
67
|
+
fields on the deployment. Walk them in as ordinary files and the deploy
|
|
68
|
+
reports success while the site's CSP stops applying.
|
|
69
|
+
- **`check-missing`, `upload` and `upsert-hashes` take the JWT, not the account
|
|
70
|
+
token, and carry no `/accounts/<id>` prefix** — the JWT already names the
|
|
71
|
+
account.
|
|
72
|
+
|
|
73
|
+
## What it does not do
|
|
74
|
+
|
|
75
|
+
**Pages Functions.** A directory holding `_worker.js` or `functions/` is
|
|
76
|
+
refused by name rather than half-deployed: bundling a Worker is a build step,
|
|
77
|
+
not an upload, and wrangler should keep doing it.
|
|
78
|
+
|
|
79
|
+
**Steps 2 and 3 are undocumented.** They exist because wrangler uses them, and
|
|
80
|
+
Cloudflare can change them without a changelog; the documented `deployments`
|
|
81
|
+
endpoint alone cannot upload a file. That is the real cost of dropping
|
|
82
|
+
wrangler, and it is the reason this is a small module with a golden test rather
|
|
83
|
+
than a wrapper nobody reads.
|
|
84
|
+
|
|
85
|
+
Nothing here is specific to any owner or account. The group name is
|
|
86
|
+
`cloudflare`, and it merges with a repo's own `cloudflare.*` tasks — groups are
|
|
87
|
+
namespaces — so only a same-named task collides; this package claims `deploy`,
|
|
88
|
+
`projects` and `deployments`, nothing else. `blake3` is the one dependency, and
|
|
89
|
+
it lives here rather than in the runner so `mkrun` itself stays dependency-free.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# make-cloudflare
|
|
2
|
+
|
|
3
|
+
Cloudflare Pages **direct upload** for [`mkrun`](https://pypi.org/project/mkrun/), in
|
|
4
|
+
Python: publish a built directory with no Node on the machine and no `wrangler`
|
|
5
|
+
in the pipeline.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
# Makefile.py in a consuming repo
|
|
9
|
+
# /// script
|
|
10
|
+
# requires-python = ">=3.11"
|
|
11
|
+
# dependencies = ["mkrun>=0.4", "make-cloudflare>=0.1"]
|
|
12
|
+
# ///
|
|
13
|
+
from make_cloudflare import cloudflare # importing is what registers the group
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```console
|
|
17
|
+
$ mk cloudflare.deploy site/dist --project mkrun # production (branch main)
|
|
18
|
+
$ mk cloudflare.deploy site/dist --branch try # a preview, on its own URL
|
|
19
|
+
$ mk cloudflare.projects # what the account has
|
|
20
|
+
$ mk cloudflare.deployments --limit 5 # one project's recent ones
|
|
21
|
+
$ mk -n cloudflare.deploy site/dist # the plan; nothing is sent
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Credentials are the two variables wrangler already reads, so a repository that
|
|
25
|
+
deploys from CI needs no new secret: `CLOUDFLARE_ACCOUNT_ID` and
|
|
26
|
+
`CLOUDFLARE_API_TOKEN` (a token with **Cloudflare Pages: Edit**). The token's
|
|
27
|
+
name ends in `TOKEN`, so mkrun treats it as a credential — it is withheld from
|
|
28
|
+
the environment until a task asks for it, redacted in everything printed, and
|
|
29
|
+
readable from the encrypted store. The project can come from `--project` or from
|
|
30
|
+
`CLOUDFLARE_PAGES_PROJECT` in the repo's env layer.
|
|
31
|
+
|
|
32
|
+
## Why this exists
|
|
33
|
+
|
|
34
|
+
`wrangler pages deploy` is four HTTPS calls and a hash. Reaching them through
|
|
35
|
+
`npx` costs a Node toolchain in repositories that otherwise have none — a Rust
|
|
36
|
+
one, a [frontage](https://github.com/optersoft/frontage) one — and a
|
|
37
|
+
`node_modules` in the deploy image of every one of them.
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
POST /accounts/<acct>/pages/projects/<p>/upload-token -> a short-lived JWT
|
|
41
|
+
POST /pages/assets/check-missing -> what is not stored yet
|
|
42
|
+
POST /pages/assets/upload -> the files, batched
|
|
43
|
+
POST /pages/assets/upsert-hashes
|
|
44
|
+
POST /accounts/<acct>/pages/projects/<p>/deployments -> the manifest
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Three things make a naive port wrong, and all three fail **silently** — every
|
|
48
|
+
call returns 200 and the site serves the old bytes, or none:
|
|
49
|
+
|
|
50
|
+
- **The hash is BLAKE3 of a strange input**: the base64 *text* of the contents
|
|
51
|
+
with the extension (no dot) appended, first 32 hex characters. Not of the
|
|
52
|
+
bytes, and not SHA-256. `tests/test_pages.py` locks it with a golden value.
|
|
53
|
+
- **`_headers`, `_redirects` and `_routes.json` are not assets.** They are
|
|
54
|
+
fields on the deployment. Walk them in as ordinary files and the deploy
|
|
55
|
+
reports success while the site's CSP stops applying.
|
|
56
|
+
- **`check-missing`, `upload` and `upsert-hashes` take the JWT, not the account
|
|
57
|
+
token, and carry no `/accounts/<id>` prefix** — the JWT already names the
|
|
58
|
+
account.
|
|
59
|
+
|
|
60
|
+
## What it does not do
|
|
61
|
+
|
|
62
|
+
**Pages Functions.** A directory holding `_worker.js` or `functions/` is
|
|
63
|
+
refused by name rather than half-deployed: bundling a Worker is a build step,
|
|
64
|
+
not an upload, and wrangler should keep doing it.
|
|
65
|
+
|
|
66
|
+
**Steps 2 and 3 are undocumented.** They exist because wrangler uses them, and
|
|
67
|
+
Cloudflare can change them without a changelog; the documented `deployments`
|
|
68
|
+
endpoint alone cannot upload a file. That is the real cost of dropping
|
|
69
|
+
wrangler, and it is the reason this is a small module with a golden test rather
|
|
70
|
+
than a wrapper nobody reads.
|
|
71
|
+
|
|
72
|
+
Nothing here is specific to any owner or account. The group name is
|
|
73
|
+
`cloudflare`, and it merges with a repo's own `cloudflare.*` tasks — groups are
|
|
74
|
+
namespaces — so only a same-named task collides; this package claims `deploy`,
|
|
75
|
+
`projects` and `deployments`, nothing else. `blake3` is the one dependency, and
|
|
76
|
+
it lives here rather than in the runner so `mkrun` itself stays dependency-free.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "make-cloudflare"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Cloudflare Pages direct upload for mkrun -- deploy a built site with no Node and no wrangler."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
# Same dual licence as mkrun, for the same reason: this member is generic and
|
|
8
|
+
# publishable. The licence texts live at the repository root; a built wheel
|
|
9
|
+
# carries the SPDX expression, which is what PEP 639 asks for.
|
|
10
|
+
license = "MIT OR Apache-2.0"
|
|
11
|
+
authors = [{ name = "Optersoft, S.L.", email = "david@optersoft.com" }]
|
|
12
|
+
keywords = ["cloudflare", "pages", "deploy", "direct upload", "mk", "mkrun"]
|
|
13
|
+
# blake3 is not optional and not replaceable: Cloudflare's asset store is keyed
|
|
14
|
+
# on it, so a deploy that hashes with anything else uploads files nothing will
|
|
15
|
+
# ever ask for. It lives here rather than in the runner precisely so `mkrun`
|
|
16
|
+
# itself stays dependency-free -- see this repo's CLAUDE.md, rule 4.
|
|
17
|
+
dependencies = ["mkrun>=0.4.1", "blake3>=1.0"]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/make_cloudflare"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://make.optersoft.com"
|
|
28
|
+
|
|
29
|
+
# The runner is the other half of this repository, so resolve it from the
|
|
30
|
+
# working tree instead of PyPI, exactly as rust/ does: a change to `make.http`
|
|
31
|
+
# is testable against a real consumer in the same commit that makes it.
|
|
32
|
+
[tool.uv.sources]
|
|
33
|
+
mkrun = { workspace = true }
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = ["pytest>=8", "ruff>=0.6"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
addopts = "-q"
|
|
41
|
+
|
|
42
|
+
# No [tool.ruff] here on purpose: every member shares the root config, so they
|
|
43
|
+
# cannot drift apart by an isort option.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Cloudflare tasks for mkrun -- the `cloudflare` group.
|
|
2
|
+
|
|
3
|
+
Today it is Pages direct upload: `cloudflare.deploy` publishes a built
|
|
4
|
+
directory, `cloudflare.projects` lists what the account has, and
|
|
5
|
+
`cloudflare.deployments` shows a project's recent ones. No Node, no wrangler --
|
|
6
|
+
four HTTPS calls and a BLAKE3 hash.
|
|
7
|
+
|
|
8
|
+
Importing is what registers it:
|
|
9
|
+
|
|
10
|
+
# Makefile.py
|
|
11
|
+
from make_cloudflare import cloudflare
|
|
12
|
+
|
|
13
|
+
The group merges with a repo's own `cloudflare.*` tasks -- groups are
|
|
14
|
+
namespaces, and this package claims only the three names above.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = ["cloudflare", "pages"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def __getattr__(name: str):
|
|
25
|
+
"""Import on first access, keeping module scope import-free.
|
|
26
|
+
|
|
27
|
+
The same shape as the other task packages: startup latency is a feature of
|
|
28
|
+
the tool this plugs into, so nothing heavy -- `blake3`, here -- may load
|
|
29
|
+
before it is asked for.
|
|
30
|
+
"""
|
|
31
|
+
if name in __all__:
|
|
32
|
+
import importlib
|
|
33
|
+
|
|
34
|
+
return importlib.import_module(f".{name}", __name__)
|
|
35
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The `cloudflare` group: deploy to Pages, and see what is there.
|
|
2
|
+
|
|
3
|
+
Nothing here knows whose account it runs in. The project name comes from the
|
|
4
|
+
task line or from `CLOUDFLARE_PAGES_PROJECT` in the repo's env layer, and the
|
|
5
|
+
credentials come from `CLOUDFLARE_ACCOUNT_ID` + `CLOUDFLARE_API_TOKEN` -- the
|
|
6
|
+
same two names wrangler reads, so a repository that already deploys through CI
|
|
7
|
+
needs no new secret.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
|
|
15
|
+
from make import arg, note, step, task
|
|
16
|
+
|
|
17
|
+
from . import pages
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@task(group="cloudflare", name="deploy", dangerous=True, secrets=["CLOUDFLARE_API_TOKEN"])
|
|
21
|
+
def deploy(
|
|
22
|
+
directory: str = "dist",
|
|
23
|
+
*,
|
|
24
|
+
project: str | None = None,
|
|
25
|
+
branch: Annotated[str, arg(help="the Pages branch; the production one publishes live")] = "main",
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Publish a built directory to Cloudflare Pages by direct upload.
|
|
28
|
+
|
|
29
|
+
No Node and no wrangler: four HTTPS calls. `--branch` is what decides
|
|
30
|
+
production -- the project's production branch goes live, every other name
|
|
31
|
+
lands as a preview on its own URL.
|
|
32
|
+
"""
|
|
33
|
+
url = pages.deploy(Path(directory), project=pages.project_name(project), branch=branch)
|
|
34
|
+
note(f"deployed -- {url}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@task(group="cloudflare", name="projects", secrets=["CLOUDFLARE_API_TOKEN"])
|
|
38
|
+
def projects() -> None:
|
|
39
|
+
"""List the account's Pages projects, and where each one deploys from."""
|
|
40
|
+
for project in pages.projects():
|
|
41
|
+
source = (project.get("source") or {}).get("type") or "direct upload"
|
|
42
|
+
domains = ", ".join(project.get("domains") or []) or "--"
|
|
43
|
+
step(f"{project.get('name', '?')} [{source}] {domains}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@task(group="cloudflare", name="deployments", secrets=["CLOUDFLARE_API_TOKEN"])
|
|
47
|
+
def deployments(project: str | None = None, *, limit: int = 10) -> None:
|
|
48
|
+
"""The most recent deployments of one project, newest first."""
|
|
49
|
+
for entry in pages.deployments(pages.project_name(project))[:limit]:
|
|
50
|
+
stage = (entry.get("latest_stage") or {}).get("status", "?")
|
|
51
|
+
env_name = entry.get("environment", "?")
|
|
52
|
+
step(f"{entry.get('created_on', '?')} {env_name:<10} {stage:<10} {entry.get('url', '')}")
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Cloudflare Pages direct upload, in Python.
|
|
2
|
+
|
|
3
|
+
`wrangler pages deploy` is four HTTPS calls and a hash. This is those four
|
|
4
|
+
calls, so a repository with no Node -- a Rust one, a frontage one -- can publish
|
|
5
|
+
a built directory without installing a JavaScript toolchain to do it.
|
|
6
|
+
|
|
7
|
+
from make_cloudflare import pages
|
|
8
|
+
|
|
9
|
+
pages.deploy("site/dist", project="mkrun") # production
|
|
10
|
+
pages.deploy("site/dist", project="mkrun", branch="x") # a preview
|
|
11
|
+
|
|
12
|
+
The flow, and the three things a naive port gets wrong:
|
|
13
|
+
|
|
14
|
+
1. `POST /accounts/<acct>/pages/projects/<p>/upload-token` -> a short-lived JWT.
|
|
15
|
+
2. `POST /pages/assets/check-missing` (JWT) -> the hashes not already stored.
|
|
16
|
+
Note the missing `/accounts/<acct>` prefix on this call and the next: the
|
|
17
|
+
JWT carries the account, and adding the prefix 404s.
|
|
18
|
+
3. `POST /pages/assets/upload` (JWT), then `upsert-hashes` -- in batches.
|
|
19
|
+
4. `POST /accounts/<acct>/pages/projects/<p>/deployments` -> the manifest.
|
|
20
|
+
|
|
21
|
+
**The hash is BLAKE3 of a strange input**: the base64 *text* of the contents
|
|
22
|
+
with the extension (no dot) appended, first 32 hex characters. Not of the bytes,
|
|
23
|
+
not SHA-256. Get it wrong and every call still succeeds while the deployment
|
|
24
|
+
serves nothing.
|
|
25
|
+
|
|
26
|
+
**`_headers`, `_redirects` and `_routes.json` are not assets.** They are form
|
|
27
|
+
fields on the deployment, and a port that walks them in as ordinary files
|
|
28
|
+
reports success while the site's CSP quietly stops applying.
|
|
29
|
+
|
|
30
|
+
**Steps 2 and 3 are undocumented.** They exist because wrangler uses them;
|
|
31
|
+
Cloudflare can change them without a changelog. The documented `deployments`
|
|
32
|
+
endpoint alone cannot upload a file.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import base64
|
|
38
|
+
import json
|
|
39
|
+
import mimetypes
|
|
40
|
+
import os
|
|
41
|
+
import secrets as _random
|
|
42
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
43
|
+
from dataclasses import dataclass
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Any
|
|
46
|
+
|
|
47
|
+
from make import env, http, note, step
|
|
48
|
+
from make.context import mark_sensitive
|
|
49
|
+
from make.errors import ConfigError, MakeError
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"Asset",
|
|
53
|
+
"collect",
|
|
54
|
+
"deploy",
|
|
55
|
+
"deployments",
|
|
56
|
+
"file_hash",
|
|
57
|
+
"manifest_of",
|
|
58
|
+
"project_name",
|
|
59
|
+
"projects",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
API = "https://api.cloudflare.com/client/v4"
|
|
63
|
+
|
|
64
|
+
#: Pages' own limits. Both are refused by the API anyway; refusing here names
|
|
65
|
+
#: the offending file instead of failing the batch that happened to carry it.
|
|
66
|
+
MAX_ASSET_SIZE = 25 * 1024 * 1024
|
|
67
|
+
MAX_ASSET_COUNT = 20_000
|
|
68
|
+
|
|
69
|
+
#: One upload request. Cloudflare accepts more; these keep a single request
|
|
70
|
+
#: small enough to retry cheaply on a bad connection, which matters more than
|
|
71
|
+
#: shaving round trips off a deploy that happens by hand.
|
|
72
|
+
BATCH_FILES = 100
|
|
73
|
+
BATCH_BYTES = 10 * 1024 * 1024
|
|
74
|
+
|
|
75
|
+
#: Handled by Pages itself, as fields on the deployment -- never as assets.
|
|
76
|
+
SPECIAL_FILES = ("_headers", "_redirects", "_routes.json")
|
|
77
|
+
|
|
78
|
+
#: Never uploaded. `_worker.js` and `functions/` are Pages Functions, which this
|
|
79
|
+
#: module does not build: a directory that has them is a Functions project and
|
|
80
|
+
#: belongs on wrangler until someone needs it here.
|
|
81
|
+
SKIP_NAMES = {".DS_Store", "Thumbs.db", ".gitkeep", *SPECIAL_FILES}
|
|
82
|
+
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".wrangler"}
|
|
83
|
+
FUNCTIONS = ("_worker.js", "_worker.js.map", "_worker.bundle", "functions")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class Asset:
|
|
88
|
+
"""One file, as the manifest and the upload endpoint each want it."""
|
|
89
|
+
|
|
90
|
+
key: str
|
|
91
|
+
"""Manifest path, with the leading slash Cloudflare requires."""
|
|
92
|
+
file: Path
|
|
93
|
+
hash: str
|
|
94
|
+
size: int
|
|
95
|
+
content_type: str
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# -- hashing ---------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def file_hash(data: bytes, extension: str = "") -> str:
|
|
102
|
+
"""Cloudflare's asset key: `blake3(base64(data) + ext)` , first 32 hex chars.
|
|
103
|
+
|
|
104
|
+
`extension` is the suffix without its dot, and the empty string for a file
|
|
105
|
+
that has none. The base64 text is hashed, not the bytes -- that is not a
|
|
106
|
+
mistake in the reading of wrangler, it is what wrangler does, and the store
|
|
107
|
+
is keyed on the result.
|
|
108
|
+
"""
|
|
109
|
+
from blake3 import blake3
|
|
110
|
+
|
|
111
|
+
payload = base64.b64encode(data) + extension.encode()
|
|
112
|
+
return blake3(payload).hexdigest()[:32]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# -- walking a built directory --------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _walk(directory: Path) -> Iterator[Path]:
|
|
119
|
+
for parent, dirs, names in os.walk(directory):
|
|
120
|
+
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
|
|
121
|
+
for name in sorted(names):
|
|
122
|
+
if name not in SKIP_NAMES:
|
|
123
|
+
yield Path(parent) / name
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def collect(directory: str | Path) -> tuple[list[Asset], dict[str, str]]:
|
|
127
|
+
"""Every uploadable file under `directory`, plus the special files' contents.
|
|
128
|
+
|
|
129
|
+
Returns `(assets, specials)`, where `specials` maps `_headers` /
|
|
130
|
+
`_redirects` / `_routes.json` to their text. They are deliberately not
|
|
131
|
+
assets: Pages takes them as fields on the deployment.
|
|
132
|
+
"""
|
|
133
|
+
root = Path(directory).resolve()
|
|
134
|
+
if not root.is_dir():
|
|
135
|
+
raise MakeError(f"{root} is not a directory", hint="build the site first")
|
|
136
|
+
|
|
137
|
+
for name in FUNCTIONS:
|
|
138
|
+
if (root / name).exists():
|
|
139
|
+
raise MakeError(
|
|
140
|
+
f"{root / name} is a Pages Functions project",
|
|
141
|
+
hint="this module uploads static assets only -- deploy it with wrangler",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
assets: list[Asset] = []
|
|
145
|
+
for file in _walk(root):
|
|
146
|
+
data = file.read_bytes()
|
|
147
|
+
if len(data) > MAX_ASSET_SIZE:
|
|
148
|
+
raise MakeError(
|
|
149
|
+
f"{file.relative_to(root)} is {len(data) / 1_048_576:.1f} MiB",
|
|
150
|
+
hint=f"Cloudflare Pages refuses any asset over {MAX_ASSET_SIZE // 1_048_576} MiB",
|
|
151
|
+
)
|
|
152
|
+
guessed, _ = mimetypes.guess_type(file.name)
|
|
153
|
+
assets.append(
|
|
154
|
+
Asset(
|
|
155
|
+
key="/" + file.relative_to(root).as_posix(),
|
|
156
|
+
file=file,
|
|
157
|
+
hash=file_hash(data, file.suffix.lstrip(".")),
|
|
158
|
+
size=len(data),
|
|
159
|
+
content_type=guessed or "application/octet-stream",
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if not assets:
|
|
164
|
+
raise MakeError(f"{root} holds no files to upload")
|
|
165
|
+
if len(assets) > MAX_ASSET_COUNT:
|
|
166
|
+
raise MakeError(
|
|
167
|
+
f"{len(assets)} files under {root}",
|
|
168
|
+
hint=f"Cloudflare Pages takes at most {MAX_ASSET_COUNT} per deployment",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
specials = {name: (root / name).read_text() for name in SPECIAL_FILES if (root / name).is_file()}
|
|
172
|
+
return assets, specials
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def manifest_of(assets: Iterable[Asset]) -> dict[str, str]:
|
|
176
|
+
"""The deployment manifest: `/path` -> hash."""
|
|
177
|
+
return {asset.key: asset.hash for asset in assets}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# -- the API ---------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _credentials(account: str | None, token: str | None) -> tuple[str, str]:
|
|
184
|
+
# The layers first, as every task package that needs a credential does: without
|
|
185
|
+
# this, `require` sees only the process environment, and a machine whose
|
|
186
|
+
# `~/.make/secrets.env` already holds these two is told to set what it has set.
|
|
187
|
+
# The secret itself is still withheld from child processes until asked for --
|
|
188
|
+
# `layered()` holds sensitive values back by design.
|
|
189
|
+
if account is None or token is None:
|
|
190
|
+
env.layered()
|
|
191
|
+
account = account or env.require(
|
|
192
|
+
"CLOUDFLARE_ACCOUNT_ID", hint="the account id from any zone's overview page"
|
|
193
|
+
)
|
|
194
|
+
token = token or env.require(
|
|
195
|
+
"CLOUDFLARE_API_TOKEN",
|
|
196
|
+
hint="an API token with the `Cloudflare Pages: Edit` permission, "
|
|
197
|
+
"from https://dash.cloudflare.com/profile/api-tokens",
|
|
198
|
+
)
|
|
199
|
+
mark_sensitive(token)
|
|
200
|
+
return account, token
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _call(
|
|
204
|
+
method: str,
|
|
205
|
+
url: str,
|
|
206
|
+
*,
|
|
207
|
+
auth: str,
|
|
208
|
+
body: bytes | None = None,
|
|
209
|
+
content_type: str | None = None,
|
|
210
|
+
timeout: float = 120.0,
|
|
211
|
+
) -> Any:
|
|
212
|
+
"""One API call, returning `result` -- a dict or a list, as the endpoint says.
|
|
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
|
+
"""
|
|
218
|
+
headers = {"Authorization": f"Bearer {auth}"}
|
|
219
|
+
if content_type:
|
|
220
|
+
headers["Content-Type"] = content_type
|
|
221
|
+
answer = http.request(url, method=method, headers=headers, data=body, timeout=timeout)
|
|
222
|
+
if answer.skipped:
|
|
223
|
+
return None
|
|
224
|
+
if answer.status == 0:
|
|
225
|
+
raise MakeError(f"{method} {url}: no answer from Cloudflare")
|
|
226
|
+
try:
|
|
227
|
+
payload = json.loads(answer.body)
|
|
228
|
+
except ValueError:
|
|
229
|
+
payload = {}
|
|
230
|
+
if not answer.ok or not payload.get("success", False):
|
|
231
|
+
detail = "; ".join(f"{e.get('code', '?')}: {e.get('message', '')}" for e in payload.get("errors", []))
|
|
232
|
+
# Cloudflare's own text goes in the message, not the hint: a 403 saying
|
|
233
|
+
# which permission the token lacks is the thing to read, and a hint is
|
|
234
|
+
# printed as advice rather than as the failure.
|
|
235
|
+
raise MakeError(
|
|
236
|
+
f"{method} {url} -> {answer.status}" + (f": {detail}" if detail else ""),
|
|
237
|
+
hint=None if detail else (answer.body[:300] or None),
|
|
238
|
+
)
|
|
239
|
+
return payload.get("result")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _json_call(method: str, url: str, *, auth: str, payload: object, **kwargs: Any) -> Any:
|
|
243
|
+
return _call(
|
|
244
|
+
method, url, auth=auth, body=json.dumps(payload).encode(), content_type="application/json", **kwargs
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _multipart(fields: Mapping[str, str], files: Mapping[str, str] | None = None) -> tuple[bytes, str]:
|
|
249
|
+
"""Encode a deployment's form: plain fields, then the special files as file parts.
|
|
250
|
+
|
|
251
|
+
⚠ `_headers` and `_redirects` must be FILE parts (a `filename=` on the part),
|
|
252
|
+
the way wrangler appends them: `formData.append("_headers", new File(...))`.
|
|
253
|
+
Sent as plain text fields, the deployment succeeds and Pages silently ignores
|
|
254
|
+
them -- every header rule, the CSP included, simply stops applying.
|
|
255
|
+
"""
|
|
256
|
+
boundary = "----mk" + _random.token_hex(16)
|
|
257
|
+
chunks: list[bytes] = []
|
|
258
|
+
for name, value in fields.items():
|
|
259
|
+
chunks.append(f"--{boundary}\r\n".encode())
|
|
260
|
+
chunks.append(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
|
|
261
|
+
chunks.append(value.encode())
|
|
262
|
+
chunks.append(b"\r\n")
|
|
263
|
+
for name, value in (files or {}).items():
|
|
264
|
+
chunks.append(f"--{boundary}\r\n".encode())
|
|
265
|
+
chunks.append(f'Content-Disposition: form-data; name="{name}"; filename="{name}"\r\n'.encode())
|
|
266
|
+
chunks.append(b"Content-Type: application/octet-stream\r\n\r\n")
|
|
267
|
+
chunks.append(value.encode())
|
|
268
|
+
chunks.append(b"\r\n")
|
|
269
|
+
chunks.append(f"--{boundary}--\r\n".encode())
|
|
270
|
+
return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _batches(assets: list[Asset]) -> Iterator[list[Asset]]:
|
|
274
|
+
batch: list[Asset] = []
|
|
275
|
+
total = 0
|
|
276
|
+
for asset in assets:
|
|
277
|
+
encoded = asset.size * 4 // 3
|
|
278
|
+
if batch and (len(batch) >= BATCH_FILES or total + encoded > BATCH_BYTES):
|
|
279
|
+
yield batch
|
|
280
|
+
batch, total = [], 0
|
|
281
|
+
batch.append(asset)
|
|
282
|
+
total += encoded
|
|
283
|
+
if batch:
|
|
284
|
+
yield batch
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# -- the tasks' behaviour --------------------------------------------------
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def deploy(
|
|
291
|
+
directory: str | Path,
|
|
292
|
+
*,
|
|
293
|
+
project: str,
|
|
294
|
+
branch: str = "main",
|
|
295
|
+
account: str | None = None,
|
|
296
|
+
token: str | None = None,
|
|
297
|
+
) -> str:
|
|
298
|
+
"""Publish a built directory and return the deployment's URL.
|
|
299
|
+
|
|
300
|
+
`branch` is what decides production: Pages treats the project's production
|
|
301
|
+
branch (`main` for every project here) as the live deployment and every
|
|
302
|
+
other name as a preview on its own URL. There is no `--production` flag to
|
|
303
|
+
forget; there is a branch name to get right.
|
|
304
|
+
"""
|
|
305
|
+
account, token = _credentials(account, token)
|
|
306
|
+
assets, specials = collect(directory)
|
|
307
|
+
plural = "" if len(assets) == 1 else "s"
|
|
308
|
+
step(f"{len(assets)} file{plural}, {sum(a.size for a in assets) / 1024:.0f} KiB -> {project} ({branch})")
|
|
309
|
+
|
|
310
|
+
granted = _call("GET", f"{API}/accounts/{account}/pages/projects/{project}/upload-token", auth=token)
|
|
311
|
+
jwt = str(granted.get("jwt", "")) if isinstance(granted, dict) else ""
|
|
312
|
+
if jwt:
|
|
313
|
+
mark_sensitive(jwt)
|
|
314
|
+
# The JWT is what the two asset endpoints want; the account token is not
|
|
315
|
+
# accepted there. Falling back to it keeps a dry run legible rather than
|
|
316
|
+
# correct -- there is nothing to authenticate when nothing is sent.
|
|
317
|
+
upload_auth = jwt or token
|
|
318
|
+
|
|
319
|
+
# `check-missing` answers with the hashes Cloudflare does NOT already hold:
|
|
320
|
+
# a repeat deploy of an unchanged site uploads nothing. `None` is a dry run,
|
|
321
|
+
# where the honest plan is "all of them".
|
|
322
|
+
missing = _json_call(
|
|
323
|
+
"POST",
|
|
324
|
+
f"{API}/pages/assets/check-missing",
|
|
325
|
+
auth=upload_auth,
|
|
326
|
+
payload={"hashes": [a.hash for a in assets]},
|
|
327
|
+
)
|
|
328
|
+
pending = assets if missing is None else [a for a in assets if a.hash in set(missing)]
|
|
329
|
+
|
|
330
|
+
if pending:
|
|
331
|
+
for batch in _batches(pending):
|
|
332
|
+
_json_call(
|
|
333
|
+
"POST",
|
|
334
|
+
f"{API}/pages/assets/upload",
|
|
335
|
+
auth=upload_auth,
|
|
336
|
+
payload=[
|
|
337
|
+
{
|
|
338
|
+
"key": asset.hash,
|
|
339
|
+
"value": base64.b64encode(asset.file.read_bytes()).decode(),
|
|
340
|
+
"metadata": {"contentType": asset.content_type},
|
|
341
|
+
"base64": True,
|
|
342
|
+
}
|
|
343
|
+
for asset in batch
|
|
344
|
+
],
|
|
345
|
+
)
|
|
346
|
+
if len(pending) == len(assets):
|
|
347
|
+
note(f"uploading all {len(assets)} file{plural}")
|
|
348
|
+
else:
|
|
349
|
+
note(f"uploading {len(pending)} of {len(assets)}; Cloudflare already holds the rest")
|
|
350
|
+
else:
|
|
351
|
+
note("every file was already stored; only the manifest changes")
|
|
352
|
+
|
|
353
|
+
_json_call(
|
|
354
|
+
"POST",
|
|
355
|
+
f"{API}/pages/assets/upsert-hashes",
|
|
356
|
+
auth=upload_auth,
|
|
357
|
+
payload={"hashes": [a.hash for a in assets]},
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
fields = {"manifest": json.dumps(manifest_of(assets)), "branch": branch}
|
|
361
|
+
body, content_type = _multipart(fields, specials)
|
|
362
|
+
created = _call(
|
|
363
|
+
"POST",
|
|
364
|
+
f"{API}/accounts/{account}/pages/projects/{project}/deployments",
|
|
365
|
+
auth=token,
|
|
366
|
+
body=body,
|
|
367
|
+
content_type=content_type,
|
|
368
|
+
)
|
|
369
|
+
if specials:
|
|
370
|
+
note("as deployment files, not assets: " + ", ".join(sorted(specials)))
|
|
371
|
+
url = created.get("url") if isinstance(created, dict) else None
|
|
372
|
+
return str(url or f"https://{branch}.{project}.pages.dev")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def projects(*, account: str | None = None, token: str | None = None) -> list[dict]:
|
|
376
|
+
"""Every Pages project on the account."""
|
|
377
|
+
account, token = _credentials(account, token)
|
|
378
|
+
result = _call("GET", f"{API}/accounts/{account}/pages/projects", auth=token)
|
|
379
|
+
return list(result) if isinstance(result, list) else []
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def create_project(
|
|
383
|
+
project: str, *, production_branch: str = "main", account: str | None = None, token: str | None = None
|
|
384
|
+
) -> dict:
|
|
385
|
+
"""The project named `project`, created for direct upload if the account has none.
|
|
386
|
+
|
|
387
|
+
Idempotent: an existing project is returned as it is, whatever its branch or source.
|
|
388
|
+
Its `subdomain` is where it serves -- `<project>.pages.dev` only if no other account
|
|
389
|
+
holds that name; Cloudflare appends a suffix otherwise, so read it, never assume it.
|
|
390
|
+
"""
|
|
391
|
+
account, token = _credentials(account, token)
|
|
392
|
+
for found in _call("GET", f"{API}/accounts/{account}/pages/projects", auth=token) or []:
|
|
393
|
+
if found.get("name") == project:
|
|
394
|
+
return found
|
|
395
|
+
step(f"creating the Pages project {project} (production branch {production_branch})")
|
|
396
|
+
created = _json_call(
|
|
397
|
+
"POST",
|
|
398
|
+
f"{API}/accounts/{account}/pages/projects",
|
|
399
|
+
auth=token,
|
|
400
|
+
payload={"name": project, "production_branch": production_branch},
|
|
401
|
+
)
|
|
402
|
+
return created if isinstance(created, dict) else {"name": project}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def deployments(project: str, *, account: str | None = None, token: str | None = None) -> list[dict]:
|
|
406
|
+
"""A project's deployments, newest first."""
|
|
407
|
+
account, token = _credentials(account, token)
|
|
408
|
+
result = _call("GET", f"{API}/accounts/{account}/pages/projects/{project}/deployments", auth=token)
|
|
409
|
+
return list(result) if isinstance(result, list) else []
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def project_name(project: str | None) -> str:
|
|
413
|
+
"""The project to act on: the one given, else the repo's env layer."""
|
|
414
|
+
if project:
|
|
415
|
+
return project
|
|
416
|
+
found = env.get("CLOUDFLARE_PAGES_PROJECT")
|
|
417
|
+
if found:
|
|
418
|
+
return found
|
|
419
|
+
raise ConfigError(
|
|
420
|
+
"no Pages project given",
|
|
421
|
+
hint="pass --project, or set CLOUDFLARE_PAGES_PROJECT in this repo's env layer",
|
|
422
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Test setup for make-cloudflare.
|
|
2
|
+
|
|
3
|
+
Importing the group is what registers it, and a module is imported once per
|
|
4
|
+
process -- so the registry is populated here, once, rather than relying on
|
|
5
|
+
whichever test file happened to import first.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from make.tasks import registry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture(scope="session", autouse=True)
|
|
16
|
+
def register_groups():
|
|
17
|
+
from make_cloudflare import cloudflare # noqa: F401
|
|
18
|
+
|
|
19
|
+
registry.finalize()
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""make-cloudflare: the hash is the contract, and the special files are not assets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from make.errors import MakeError
|
|
11
|
+
from make.testing import context
|
|
12
|
+
from make_cloudflare import pages
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build(root, files: dict[str, str]):
|
|
16
|
+
for name, text in files.items():
|
|
17
|
+
target = root / name
|
|
18
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
target.write_text(text)
|
|
20
|
+
return root
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FakeHTTP:
|
|
24
|
+
"""Stands in for `make.http.request`, answering each endpoint in turn."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, *, missing: list[str] | None = None):
|
|
27
|
+
self.calls: list[tuple[str, str, dict]] = []
|
|
28
|
+
self.missing = missing
|
|
29
|
+
|
|
30
|
+
def __call__(self, url, *, method="GET", headers=None, data=None, **kwargs):
|
|
31
|
+
self.calls.append((method, url, {"headers": headers or {}, "data": data}))
|
|
32
|
+
if url.endswith("/upload-token"):
|
|
33
|
+
result = {"jwt": "jot"}
|
|
34
|
+
elif url.endswith("/check-missing"):
|
|
35
|
+
sent = json.loads(data)["hashes"]
|
|
36
|
+
result = sent if self.missing is None else self.missing
|
|
37
|
+
elif url.endswith("/deployments"):
|
|
38
|
+
result = {"url": "https://abc123.example.pages.dev"}
|
|
39
|
+
else:
|
|
40
|
+
result = {}
|
|
41
|
+
return pages.http.Response(
|
|
42
|
+
url=url, status=200, body=json.dumps({"success": True, "errors": [], "result": result})
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def urls(self, fragment: str) -> list[str]:
|
|
46
|
+
return [url for _, url, _ in self.calls if fragment in url]
|
|
47
|
+
|
|
48
|
+
def body(self, fragment: str):
|
|
49
|
+
return next(call["data"] for _, url, call in self.calls if fragment in url)
|
|
50
|
+
|
|
51
|
+
def auth(self, fragment: str) -> str:
|
|
52
|
+
call = next(c for _, url, c in self.calls if fragment in url)
|
|
53
|
+
return call["headers"]["Authorization"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def deploy(monkeypatch, root, fake, **kwargs):
|
|
57
|
+
monkeypatch.setattr(pages.http, "request", fake)
|
|
58
|
+
return pages.deploy(root, project="demo", account="acct", token="tok", **kwargs)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# -- the hash --------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_the_hash_is_blake3_of_the_base64_text_plus_the_extension():
|
|
65
|
+
# Locked deliberately. Cloudflare's asset store is keyed on this value, so
|
|
66
|
+
# a "simplification" to hashing the raw bytes -- the obvious reading -- would
|
|
67
|
+
# upload files that nothing ever asks for, with every call still returning
|
|
68
|
+
# 200. If this test fails, the deploy is broken, not the test.
|
|
69
|
+
assert pages.file_hash(b"hello", "html") == "a2b82584e50075886b08927390f2f573"
|
|
70
|
+
assert len(pages.file_hash(b"hello", "html")) == 32
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_the_extension_is_part_of_the_hash():
|
|
74
|
+
assert pages.file_hash(b"same", "html") != pages.file_hash(b"same", "css")
|
|
75
|
+
assert pages.file_hash(b"same", "") != pages.file_hash(b"same", "html")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# -- walking the directory -------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_collect_keys_with_a_leading_slash_and_skips_the_noise(tmp_path):
|
|
82
|
+
build(
|
|
83
|
+
tmp_path,
|
|
84
|
+
{
|
|
85
|
+
"index.html": "<h1>hi</h1>",
|
|
86
|
+
"assets/app.css": "body{}",
|
|
87
|
+
".DS_Store": "junk",
|
|
88
|
+
"node_modules/dep/index.js": "junk",
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
assets, specials = pages.collect(tmp_path)
|
|
92
|
+
assert pages.manifest_of(assets).keys() == {"/index.html", "/assets/app.css"}
|
|
93
|
+
assert specials == {}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_the_special_files_are_fields_not_assets(tmp_path):
|
|
97
|
+
build(
|
|
98
|
+
tmp_path,
|
|
99
|
+
{"index.html": "x", "_headers": "/*\n X-Frame-Options: DENY\n", "_redirects": "/a /b 301\n"},
|
|
100
|
+
)
|
|
101
|
+
assets, specials = pages.collect(tmp_path)
|
|
102
|
+
assert pages.manifest_of(assets).keys() == {"/index.html"}
|
|
103
|
+
assert set(specials) == {"_headers", "_redirects"}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_a_functions_project_is_refused(tmp_path):
|
|
107
|
+
build(tmp_path, {"index.html": "x", "_worker.js": "export default {}"})
|
|
108
|
+
with pytest.raises(MakeError, match="Functions"):
|
|
109
|
+
pages.collect(tmp_path)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_an_oversized_asset_is_named(tmp_path):
|
|
113
|
+
(tmp_path / "big.bin").write_bytes(b"x" * (pages.MAX_ASSET_SIZE + 1))
|
|
114
|
+
with pytest.raises(MakeError, match=r"big\.bin"):
|
|
115
|
+
pages.collect(tmp_path)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_an_empty_directory_is_refused(tmp_path):
|
|
119
|
+
with pytest.raises(MakeError, match="no files"):
|
|
120
|
+
pages.collect(tmp_path)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# -- the four calls --------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_deploy_makes_the_four_calls_in_order(monkeypatch, tmp_path):
|
|
127
|
+
build(tmp_path, {"index.html": "x"})
|
|
128
|
+
fake = FakeHTTP()
|
|
129
|
+
url = deploy(monkeypatch, tmp_path, fake)
|
|
130
|
+
assert [u.rsplit("/", 1)[-1] for _, u, _ in fake.calls] == [
|
|
131
|
+
"upload-token",
|
|
132
|
+
"check-missing",
|
|
133
|
+
"upload",
|
|
134
|
+
"upsert-hashes",
|
|
135
|
+
"deployments",
|
|
136
|
+
]
|
|
137
|
+
assert url == "https://abc123.example.pages.dev"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_the_asset_endpoints_use_the_jwt_and_omit_the_account(monkeypatch, tmp_path):
|
|
141
|
+
build(tmp_path, {"index.html": "x"})
|
|
142
|
+
fake = FakeHTTP()
|
|
143
|
+
deploy(monkeypatch, tmp_path, fake)
|
|
144
|
+
for endpoint in ("/assets/check-missing", "/assets/upload", "/assets/upsert-hashes"):
|
|
145
|
+
assert fake.auth(endpoint) == "Bearer jot"
|
|
146
|
+
assert "/accounts/" not in fake.urls(endpoint)[0]
|
|
147
|
+
assert fake.auth("/deployments") == "Bearer tok"
|
|
148
|
+
assert "/accounts/acct/" in fake.urls("/deployments")[0]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_only_the_missing_hashes_are_uploaded(monkeypatch, tmp_path):
|
|
152
|
+
build(tmp_path, {"index.html": "x", "second.html": "y"})
|
|
153
|
+
wanted = pages.file_hash(b"y", "html")
|
|
154
|
+
fake = FakeHTTP(missing=[wanted])
|
|
155
|
+
deploy(monkeypatch, tmp_path, fake)
|
|
156
|
+
uploaded = json.loads(fake.body("/assets/upload"))
|
|
157
|
+
assert [entry["key"] for entry in uploaded] == [wanted]
|
|
158
|
+
assert base64.b64decode(uploaded[0]["value"]) == b"y"
|
|
159
|
+
assert uploaded[0]["metadata"]["contentType"].startswith("text/html")
|
|
160
|
+
# ... and the manifest still names both, or the unchanged one would 404.
|
|
161
|
+
assert (
|
|
162
|
+
len(
|
|
163
|
+
json.loads(
|
|
164
|
+
fake.body("/deployments")
|
|
165
|
+
.decode()
|
|
166
|
+
.split('name="manifest"')[1]
|
|
167
|
+
.split("\r\n\r\n")[1]
|
|
168
|
+
.split("\r\n")[0]
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
== 2
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_nothing_uploads_when_cloudflare_already_has_it_all(monkeypatch, tmp_path):
|
|
176
|
+
build(tmp_path, {"index.html": "x"})
|
|
177
|
+
fake = FakeHTTP(missing=[])
|
|
178
|
+
deploy(monkeypatch, tmp_path, fake)
|
|
179
|
+
assert fake.urls("/assets/upload") == []
|
|
180
|
+
assert fake.urls("/deployments") # the deployment is still created
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_the_deployment_carries_the_manifest_the_branch_and_the_headers(monkeypatch, tmp_path):
|
|
184
|
+
build(tmp_path, {"index.html": "x", "_headers": "/*\n X-Frame-Options: DENY\n"})
|
|
185
|
+
fake = FakeHTTP()
|
|
186
|
+
deploy(monkeypatch, tmp_path, fake, branch="preview")
|
|
187
|
+
body = fake.body("/deployments").decode()
|
|
188
|
+
assert 'name="manifest"' in body and "/index.html" in body
|
|
189
|
+
assert 'name="branch"\r\n\r\npreview' in body
|
|
190
|
+
assert 'name="_headers"' in body and "X-Frame-Options" in body
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_a_cloudflare_error_names_its_own_message(monkeypatch, tmp_path):
|
|
194
|
+
build(tmp_path, {"index.html": "x"})
|
|
195
|
+
|
|
196
|
+
def angry(url, **kwargs):
|
|
197
|
+
return pages.http.Response(
|
|
198
|
+
url=url,
|
|
199
|
+
status=403,
|
|
200
|
+
body=json.dumps({"success": False, "errors": [{"code": 10000, "message": "no permission"}]}),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
monkeypatch.setattr(pages.http, "request", angry)
|
|
204
|
+
with pytest.raises(MakeError, match="no permission"):
|
|
205
|
+
pages.deploy(tmp_path, project="demo", account="acct", token="tok")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_dry_run_sends_nothing(tmp_path):
|
|
209
|
+
build(tmp_path, {"index.html": "x"})
|
|
210
|
+
with context(tmp_path, dry_run=True):
|
|
211
|
+
# The real http module, which refuses to make a request under --dry-run:
|
|
212
|
+
# a plan is printed and the fallback URL comes back.
|
|
213
|
+
url = pages.deploy(tmp_path, project="demo", account="acct", token="tok")
|
|
214
|
+
assert url == "https://main.demo.pages.dev"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# -- batching --------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_uploads_are_batched_by_count_and_by_size(tmp_path):
|
|
221
|
+
assets = [
|
|
222
|
+
pages.Asset(
|
|
223
|
+
key=f"/{i}.txt", file=tmp_path / f"{i}.txt", hash=f"h{i}", size=1, content_type="text/plain"
|
|
224
|
+
)
|
|
225
|
+
for i in range(pages.BATCH_FILES * 2 + 3)
|
|
226
|
+
]
|
|
227
|
+
assert [len(b) for b in pages._batches(assets)] == [pages.BATCH_FILES, pages.BATCH_FILES, 3]
|
|
228
|
+
|
|
229
|
+
fat = [
|
|
230
|
+
pages.Asset(key="/a", file=tmp_path / "a", hash="a", size=pages.BATCH_BYTES, content_type="x"),
|
|
231
|
+
pages.Asset(key="/b", file=tmp_path / "b", hash="b", size=pages.BATCH_BYTES, content_type="x"),
|
|
232
|
+
]
|
|
233
|
+
assert [len(b) for b in pages._batches(fat)] == [1, 1]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_the_special_files_are_file_parts_of_the_deployment():
|
|
237
|
+
"""Pages reads `_headers` only as a FILE part, as wrangler sends it.
|
|
238
|
+
|
|
239
|
+
As a plain field the deployment still succeeds and the site serves with no
|
|
240
|
+
header rules at all -- isard.pages.dev lost its CSP that way on 2026-10-03.
|
|
241
|
+
"""
|
|
242
|
+
body, content_type = pages._multipart({"branch": "main"}, {"_headers": "/*\n X-Test: 1\n"})
|
|
243
|
+
text = body.decode()
|
|
244
|
+
assert 'name="_headers"; filename="_headers"' in text
|
|
245
|
+
assert 'name="branch"\r\n\r\nmain' in text # an ordinary field stays one
|
|
246
|
+
assert "X-Test: 1" in text
|
|
247
|
+
assert content_type.startswith("multipart/form-data; boundary=")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# -- creating a project ----------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def projects_api(existing):
|
|
254
|
+
calls = []
|
|
255
|
+
|
|
256
|
+
def answer(url, *, method="GET", headers=None, data=None, **kwargs):
|
|
257
|
+
calls.append((method, url, data))
|
|
258
|
+
if method == "GET":
|
|
259
|
+
result = [{"name": name, "subdomain": f"{name}.pages.dev"} for name in existing]
|
|
260
|
+
else:
|
|
261
|
+
sent = json.loads(data)
|
|
262
|
+
result = {"name": sent["name"], "subdomain": f"{sent['name']}-x1y.pages.dev"}
|
|
263
|
+
return pages.http.Response(
|
|
264
|
+
url=url, status=200, body=json.dumps({"success": True, "errors": [], "result": result})
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return answer, calls
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_an_existing_project_is_returned_and_nothing_is_created(monkeypatch):
|
|
271
|
+
answer, calls = projects_api(["demo"])
|
|
272
|
+
monkeypatch.setattr(pages.http, "request", answer)
|
|
273
|
+
assert pages.create_project("demo", account="acct", token="tok")["subdomain"] == "demo.pages.dev"
|
|
274
|
+
assert [method for method, _, _ in calls] == ["GET"]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_a_missing_project_is_created_on_its_production_branch(monkeypatch):
|
|
278
|
+
answer, calls = projects_api(["other"])
|
|
279
|
+
monkeypatch.setattr(pages.http, "request", answer)
|
|
280
|
+
made = pages.create_project("demo", account="acct", token="tok")
|
|
281
|
+
assert made["subdomain"] == "demo-x1y.pages.dev"
|
|
282
|
+
method, url, data = calls[-1]
|
|
283
|
+
assert (method, url.endswith("/accounts/acct/pages/projects")) == ("POST", True)
|
|
284
|
+
assert json.loads(data) == {"name": "demo", "production_branch": "main"}
|