@mriekki/project-forge 4.8.1 → 4.9.0
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.
- package/assets/components.json +2 -2
- package/assets/deployment/distribution.json +5 -0
- package/assets/generated/ASSEMBLY.json +28 -3
- package/assets/generated/deployment/docs/CLOUDFLARE_INGRESS.md +226 -0
- package/assets/generated/deployment/src/project_forge_deployment/cloudflare_api.py +260 -0
- package/assets/generated/deployment/src/project_forge_deployment/cloudflare_ingress.py +834 -0
- package/assets/generated/deployment/src/project_forge_deployment/cloudflare_ingress_remote.py +327 -0
- package/assets/generated/deployment/src/project_forge_deployment/entrypoint.py +4 -0
- package/assets/generated/deployment/src/project_forge_deployment/ingress_cli.py +192 -0
- package/package.json +1 -1
package/assets/components.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.9.0",
|
|
5
5
|
"defaultProfile": "greenfield-project",
|
|
6
6
|
"profiles": {
|
|
7
7
|
"greenfield-project": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"canonicalDescriptor": "distributions/greenfield-workspace.json"
|
|
22
22
|
},
|
|
23
23
|
"deployment": {
|
|
24
|
-
"revision": "
|
|
24
|
+
"revision": "fd2cf23fa9e3d4fa27e142ce3a963f51b39cba36",
|
|
25
25
|
"distribution": "greenfield-project",
|
|
26
26
|
"descriptor": "assets/deployment/distribution.json",
|
|
27
27
|
"sourceRoot": "assets/generated/deployment",
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
{"source": "src/project_forge_deployment/box_platform.py", "destination": "tools/project-forge-deployment/project_forge_deployment/box_platform.py"},
|
|
17
17
|
{"source": "src/project_forge_deployment/box_platform_assets.py", "destination": "tools/project-forge-deployment/project_forge_deployment/box_platform_assets.py"},
|
|
18
18
|
{"source": "src/project_forge_deployment/box_platform_remote.py", "destination": "tools/project-forge-deployment/project_forge_deployment/box_platform_remote.py"},
|
|
19
|
+
{"source": "src/project_forge_deployment/cloudflare_api.py", "destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_api.py"},
|
|
20
|
+
{"source": "src/project_forge_deployment/cloudflare_ingress.py", "destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_ingress.py"},
|
|
21
|
+
{"source": "src/project_forge_deployment/cloudflare_ingress_remote.py", "destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_ingress_remote.py"},
|
|
19
22
|
{"source": "src/project_forge_deployment/cli.py", "destination": "tools/project-forge-deployment/project_forge_deployment/cli.py"},
|
|
20
23
|
{"source": "src/project_forge_deployment/doctor.py", "destination": "tools/project-forge-deployment/project_forge_deployment/doctor.py"},
|
|
21
24
|
{"source": "src/project_forge_deployment/entrypoint.py", "destination": "tools/project-forge-deployment/project_forge_deployment/entrypoint.py"},
|
|
@@ -26,6 +29,7 @@
|
|
|
26
29
|
{"source": "src/project_forge_deployment/errors.py", "destination": "tools/project-forge-deployment/project_forge_deployment/errors.py"},
|
|
27
30
|
{"source": "src/project_forge_deployment/home_lab.py", "destination": "tools/project-forge-deployment/project_forge_deployment/home_lab.py"},
|
|
28
31
|
{"source": "src/project_forge_deployment/image_transfer.py", "destination": "tools/project-forge-deployment/project_forge_deployment/image_transfer.py"},
|
|
32
|
+
{"source": "src/project_forge_deployment/ingress_cli.py", "destination": "tools/project-forge-deployment/project_forge_deployment/ingress_cli.py"},
|
|
29
33
|
{"source": "src/project_forge_deployment/keys.py", "destination": "tools/project-forge-deployment/project_forge_deployment/keys.py"},
|
|
30
34
|
{"source": "src/project_forge_deployment/local_cli.py", "destination": "tools/project-forge-deployment/project_forge_deployment/local_cli.py"},
|
|
31
35
|
{"source": "src/project_forge_deployment/local_runtime.py", "destination": "tools/project-forge-deployment/project_forge_deployment/local_runtime.py"},
|
|
@@ -52,6 +56,7 @@
|
|
|
52
56
|
{"source": "src/project_forge_deployment/teardown_remote.py", "destination": "tools/project-forge-deployment/project_forge_deployment/teardown_remote.py"},
|
|
53
57
|
{"source": "src/project_forge_deployment/topology_cli.py", "destination": "tools/project-forge-deployment/project_forge_deployment/topology_cli.py"},
|
|
54
58
|
{"source": "src/project_forge_deployment/trust.py", "destination": "tools/project-forge-deployment/project_forge_deployment/trust.py"},
|
|
59
|
+
{"source": "docs/CLOUDFLARE_INGRESS.md", "destination": "docs/project-forge/CLOUDFLARE_INGRESS.md"},
|
|
55
60
|
{"source": "docs/DEVELOPER_ENVIRONMENTS.md", "destination": "docs/project-forge/DEVELOPER_ENVIRONMENTS.md"},
|
|
56
61
|
{"source": "docs/WORKSPACE_INSTANCES.md", "destination": "docs/project-forge/WORKSPACE_INSTANCES.md"},
|
|
57
62
|
{"source": "docs/ENVIRONMENT_PROOF.md", "destination": "docs/project-forge/ENVIRONMENT_PROOF.md"},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.9.0",
|
|
5
5
|
"components": {
|
|
6
6
|
"core": {
|
|
7
7
|
"revision": "a78c2482f4f559ead8efbab6c25b4b05496db735",
|
|
@@ -326,7 +326,7 @@
|
|
|
326
326
|
]
|
|
327
327
|
},
|
|
328
328
|
"deployment": {
|
|
329
|
-
"revision": "
|
|
329
|
+
"revision": "fd2cf23fa9e3d4fa27e142ce3a963f51b39cba36",
|
|
330
330
|
"distribution": "greenfield-project",
|
|
331
331
|
"files": [
|
|
332
332
|
{
|
|
@@ -394,6 +394,21 @@
|
|
|
394
394
|
"destination": "tools/project-forge-deployment/project_forge_deployment/box_platform_remote.py",
|
|
395
395
|
"sha256": "65de94ac4b6e980d83ce99ea55b9555aa96959c11808cce8c6ed537b27c15533"
|
|
396
396
|
},
|
|
397
|
+
{
|
|
398
|
+
"source": "src/project_forge_deployment/cloudflare_api.py",
|
|
399
|
+
"destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_api.py",
|
|
400
|
+
"sha256": "de383926e04f6ebb3b8fd95948b475a9dc40d8da01ff2499afbe0f3a7305d4df"
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
"source": "src/project_forge_deployment/cloudflare_ingress.py",
|
|
404
|
+
"destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_ingress.py",
|
|
405
|
+
"sha256": "98c77950df1d0bba36da1244001bdd157251238778e7068be0c6c20d2683d2d4"
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
"source": "src/project_forge_deployment/cloudflare_ingress_remote.py",
|
|
409
|
+
"destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_ingress_remote.py",
|
|
410
|
+
"sha256": "38688e85654090dc7f4ab634eb3b5b6aedce9f3edb695f3d62bb09789214c86e"
|
|
411
|
+
},
|
|
397
412
|
{
|
|
398
413
|
"source": "src/project_forge_deployment/cli.py",
|
|
399
414
|
"destination": "tools/project-forge-deployment/project_forge_deployment/cli.py",
|
|
@@ -407,7 +422,7 @@
|
|
|
407
422
|
{
|
|
408
423
|
"source": "src/project_forge_deployment/entrypoint.py",
|
|
409
424
|
"destination": "tools/project-forge-deployment/project_forge_deployment/entrypoint.py",
|
|
410
|
-
"sha256": "
|
|
425
|
+
"sha256": "90d241bbbfb592c0b8e6368e13d3b3b8fb6c1e3f34c5fac9fe593df4e4a5cf1d"
|
|
411
426
|
},
|
|
412
427
|
{
|
|
413
428
|
"source": "src/project_forge_deployment/environment_cli.py",
|
|
@@ -444,6 +459,11 @@
|
|
|
444
459
|
"destination": "tools/project-forge-deployment/project_forge_deployment/image_transfer.py",
|
|
445
460
|
"sha256": "dadd1fe7724372a4ca0e04ab2280769b6c2381f0f9ae75a096f6c5a5f5add755"
|
|
446
461
|
},
|
|
462
|
+
{
|
|
463
|
+
"source": "src/project_forge_deployment/ingress_cli.py",
|
|
464
|
+
"destination": "tools/project-forge-deployment/project_forge_deployment/ingress_cli.py",
|
|
465
|
+
"sha256": "c70b312aacceb9e6761eafa07a534fc41ce728b2b9aef130568b399367b2640c"
|
|
466
|
+
},
|
|
447
467
|
{
|
|
448
468
|
"source": "src/project_forge_deployment/keys.py",
|
|
449
469
|
"destination": "tools/project-forge-deployment/project_forge_deployment/keys.py",
|
|
@@ -574,6 +594,11 @@
|
|
|
574
594
|
"destination": "tools/project-forge-deployment/project_forge_deployment/trust.py",
|
|
575
595
|
"sha256": "b143fa828c3a23df3909dacfd78ae56b9ec1159628eb45261d69c0aa6ddd1a7b"
|
|
576
596
|
},
|
|
597
|
+
{
|
|
598
|
+
"source": "docs/CLOUDFLARE_INGRESS.md",
|
|
599
|
+
"destination": "docs/project-forge/CLOUDFLARE_INGRESS.md",
|
|
600
|
+
"sha256": "e0d95aa8e1c204390a51a7900ef60542537c1a9d2eeb017f34920e8dcb5e8329"
|
|
601
|
+
},
|
|
577
602
|
{
|
|
578
603
|
"source": "docs/DEVELOPER_ENVIRONMENTS.md",
|
|
579
604
|
"destination": "docs/project-forge/DEVELOPER_ENVIRONMENTS.md",
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Cloudflare Tunnel public ingress
|
|
2
|
+
|
|
3
|
+
Status: **PARTIAL — IMPLEMENTED MECHANICS; LIVE DOMAIN/GENERATED-CONSUMER PROOF PENDING**
|
|
4
|
+
|
|
5
|
+
Project Forge models Cloudflare Tunnel as optional **public ingress**, not as a deployment target or remote-management transport.
|
|
6
|
+
|
|
7
|
+
The supported shape is:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
placement: remote-target
|
|
11
|
+
adapter: remote-ssh
|
|
12
|
+
exposure: public
|
|
13
|
+
ingress provider: cloudflare-tunnel
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`remote-ssh` continues to own verified workstation-to-target management. Cloudflare Tunnel owns only internet-facing application ingress.
|
|
17
|
+
|
|
18
|
+
## Architecture
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
Internet
|
|
22
|
+
-> Cloudflare edge / public DNS / HTTPS
|
|
23
|
+
-> remotely managed Cloudflare Tunnel
|
|
24
|
+
-> cloudflared container on the selected Ubuntu target
|
|
25
|
+
-> shared Project Forge caddy-net
|
|
26
|
+
-> project-forge-caddy:80
|
|
27
|
+
-> existing Project Forge application container
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The cloudflared runtime publishes no host ports. It initiates outbound connections to Cloudflare and joins the existing shared Docker network.
|
|
31
|
+
|
|
32
|
+
Public routes do **not** replace the trusted-LAN Route Registry. Each public Cloudflare hostname forwards to the shared Caddy container while setting `originRequest.httpHostHeader` to an already-owned `.local` Project Forge hostname. Caddy therefore reuses the existing Project/environment route and upstream ownership contract.
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
inventory.example.com
|
|
38
|
+
-> Cloudflare Tunnel
|
|
39
|
+
-> http://project-forge-caddy:80
|
|
40
|
+
Host: inventory-production.local
|
|
41
|
+
-> existing Caddy route
|
|
42
|
+
-> inventory Production container
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Shared versus Project-owned state
|
|
46
|
+
|
|
47
|
+
The shared ingress scope owns:
|
|
48
|
+
|
|
49
|
+
- one remotely managed Cloudflare Tunnel identity;
|
|
50
|
+
- one cloudflared container;
|
|
51
|
+
- the host-side tunnel token;
|
|
52
|
+
- the Cloudflare account/zone/tunnel non-secret identifiers;
|
|
53
|
+
- the connector Compose definition;
|
|
54
|
+
- a host-side public-route ownership registry.
|
|
55
|
+
|
|
56
|
+
A Project/environment public route owns:
|
|
57
|
+
|
|
58
|
+
- exact `ownerRepo`, environment, app slug and route ID;
|
|
59
|
+
- one exact public hostname;
|
|
60
|
+
- one exact existing `.local` origin hostname;
|
|
61
|
+
- one exact Cloudflare tunnel ingress rule;
|
|
62
|
+
- one exact proxied Cloudflare CNAME record.
|
|
63
|
+
|
|
64
|
+
Removing a Project route must preserve the shared tunnel, cloudflared runtime, Caddy platform, unrelated DNS records and unrelated Projects.
|
|
65
|
+
|
|
66
|
+
## Secrets boundary
|
|
67
|
+
|
|
68
|
+
The Cloudflare API token is workstation-only. It is read from:
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
PROJECT_FORGE_CLOUDFLARE_API_TOKEN
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The token is never accepted as a CLI argument and is not written to:
|
|
75
|
+
|
|
76
|
+
- Git;
|
|
77
|
+
- `PROJECT_BOOTSTRAP.json`;
|
|
78
|
+
- `DEPLOYMENT_TOPOLOGY.json`;
|
|
79
|
+
- the portable Project Forge developer profile;
|
|
80
|
+
- the Production host;
|
|
81
|
+
- command-line process arguments.
|
|
82
|
+
|
|
83
|
+
The remotely managed Tunnel token is retrieved only when applying shared ingress. It is streamed through the existing verified SSH operation and written beneath the selected root-owned ingress scope. It is not printed or returned in plans/status.
|
|
84
|
+
|
|
85
|
+
Re-applying shared ingress force-recreates the cloudflared container after securely replacing the token file, so refreshed Tunnel credentials are actually consumed by the running connector.
|
|
86
|
+
|
|
87
|
+
## Shared runtime commands
|
|
88
|
+
|
|
89
|
+
Read-only:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
project-forge-deploy ingress cloudflare status <target> --scope production
|
|
93
|
+
project-forge-deploy ingress cloudflare doctor <target> --scope production
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Configure/create/adopt the shared scope after the domain exists:
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
project-forge-deploy ingress cloudflare configure <target> \
|
|
100
|
+
--scope production \
|
|
101
|
+
--zone example.com
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The write requires:
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
[HOST PUBLIC INGRESS WRITE]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The default tunnel name is:
|
|
111
|
+
|
|
112
|
+
```text
|
|
113
|
+
project-forge-<scope>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
An existing tunnel is adopted only when it is remotely managed and has no application ingress rules beyond the final fail-closed `http_status:404` rule. Project Forge never silently takes over a tunnel with existing application configuration.
|
|
117
|
+
|
|
118
|
+
## Project public routes
|
|
119
|
+
|
|
120
|
+
Inventory:
|
|
121
|
+
|
|
122
|
+
```text
|
|
123
|
+
project-forge-deploy ingress cloudflare route inventory <target> --scope production
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Register:
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
project-forge-deploy ingress cloudflare route register <target> \
|
|
130
|
+
--scope production \
|
|
131
|
+
--owner-repo owner/repository \
|
|
132
|
+
--environment production \
|
|
133
|
+
--app-slug inventory \
|
|
134
|
+
--public-hostname inventory.example.com \
|
|
135
|
+
--origin-hostname inventory-production.local
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Registration requires:
|
|
139
|
+
|
|
140
|
+
```text
|
|
141
|
+
[PUBLIC INGRESS ROUTE WRITE]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Remove:
|
|
145
|
+
|
|
146
|
+
```text
|
|
147
|
+
project-forge-deploy ingress cloudflare route remove <target> \
|
|
148
|
+
--scope production \
|
|
149
|
+
--owner-repo owner/repository \
|
|
150
|
+
--environment production \
|
|
151
|
+
--app-slug inventory
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Removal requires:
|
|
155
|
+
|
|
156
|
+
```text
|
|
157
|
+
[PUBLIC INGRESS ROUTE REMOVE]
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Route safety
|
|
161
|
+
|
|
162
|
+
Before publishing a hostname, Deployment Kit verifies:
|
|
163
|
+
|
|
164
|
+
1. the selected shared Cloudflare ingress scope is ready;
|
|
165
|
+
2. the exact Project/environment `.local` route already exists and owns the requested origin hostname;
|
|
166
|
+
3. the public hostname is not owned by another managed Project route;
|
|
167
|
+
4. the Cloudflare tunnel configuration has the expected final fail-closed 404 rule;
|
|
168
|
+
5. no conflicting tunnel ingress rule exists;
|
|
169
|
+
6. no conflicting DNS record exists;
|
|
170
|
+
7. any existing DNS record exactly matches Project Forge's tunnel target and ownership marker.
|
|
171
|
+
|
|
172
|
+
The current partial adapter intentionally supports one first-level subdomain beneath the selected zone, such as `inventory.example.com`. Deeper names such as `api.inventory.example.com` require an explicit certificate/support review rather than being silently accepted.
|
|
173
|
+
|
|
174
|
+
## Domain is deliberately late-bound
|
|
175
|
+
|
|
176
|
+
A Builder does not need a domain to generate or develop a Project.
|
|
177
|
+
|
|
178
|
+
Bootstrap may record:
|
|
179
|
+
|
|
180
|
+
```text
|
|
181
|
+
exposure: public
|
|
182
|
+
ingress.provider: cloudflare-tunnel
|
|
183
|
+
ingress.bindingState: deferred
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
without a zone or public hostname. Once a domain is registered and active in Cloudflare, environment setup/readiness can bind the shared ingress scope and each Project can register its exact public hostname. Regeneration is not required merely to choose the domain later.
|
|
187
|
+
|
|
188
|
+
The shared Cloudflare zone is environment infrastructure and should not be copied into every Project as a credential-like default. The exact application hostname remains Project/environment state.
|
|
189
|
+
|
|
190
|
+
## Current cloudflared runtime
|
|
191
|
+
|
|
192
|
+
The first partial implementation selects Cloudflare release `2026.10.4` and pins the actual Docker pull to the verified multi-platform image index digest:
|
|
193
|
+
|
|
194
|
+
```text
|
|
195
|
+
cloudflare/cloudflared:2026.10.4@sha256:c6c7ae1d5dfdcb094ad344ed68ccffc26635c9c6be80965664c33baf064b0515
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`latest` is never used, and a mutable tag is not the final runtime identity. Promotion from `partial` to `supported` remains blocked until the package/release process and unrelated generated consumers prove this pinned artifact together with the live Cloudflare integration path.
|
|
199
|
+
|
|
200
|
+
## Required live proof before support promotion
|
|
201
|
+
|
|
202
|
+
At minimum:
|
|
203
|
+
|
|
204
|
+
1. create/adopt a real remotely managed Tunnel without manually logging into the Ubuntu host;
|
|
205
|
+
2. install cloudflared through verified Project Forge SSH and prove no inbound host/router port is required;
|
|
206
|
+
3. publish a real first-level hostname and verify external HTTPS reaches the expected Project container;
|
|
207
|
+
4. publish a second unrelated Project through the same shared scope and prove isolation/collision checks;
|
|
208
|
+
5. remove one Project route while preserving the other Project and the shared tunnel;
|
|
209
|
+
6. exercise DNS/provider failure rollback;
|
|
210
|
+
7. exercise changed host/provider state and prove stale plans fail closed;
|
|
211
|
+
8. prove separate logical Production/Staging scopes when both are configured;
|
|
212
|
+
9. prove generated/default-branch CI and human workspace acceptance.
|
|
213
|
+
|
|
214
|
+
Tunnel connector health alone is not sufficient application evidence. Final Production readiness must include an external HTTPS application probe.
|
|
215
|
+
|
|
216
|
+
## Deliberate V1 exclusions
|
|
217
|
+
|
|
218
|
+
- Cloudflare Access / Zero Trust identity policy;
|
|
219
|
+
- wildcard DNS publication for arbitrary applications;
|
|
220
|
+
- direct public router/firewall fallback;
|
|
221
|
+
- automatic domain purchase or registrar mutation;
|
|
222
|
+
- deeper-than-first-level public hostnames;
|
|
223
|
+
- implicit adoption of existing unmanaged Tunnel/DNS rules;
|
|
224
|
+
- deleting a shared Tunnel as part of normal Project tear-down.
|
|
225
|
+
|
|
226
|
+
These remain separate capabilities or future decisions.
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Minimal Cloudflare API client for Project Forge public-ingress orchestration.
|
|
2
|
+
|
|
3
|
+
The API token is supplied by the caller and is never persisted by this module.
|
|
4
|
+
Only the exact zone/tunnel/DNS surfaces needed by the Deployment Kit are exposed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .errors import DeploymentKitError
|
|
16
|
+
|
|
17
|
+
API_BASE = "https://api.cloudflare.com/client/v4"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CloudflareApiError(DeploymentKitError):
|
|
21
|
+
"""Cloudflare API request or response validation failure."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _api_error_message(payload: Any) -> str:
|
|
25
|
+
if isinstance(payload, dict):
|
|
26
|
+
errors = payload.get("errors")
|
|
27
|
+
if isinstance(errors, list):
|
|
28
|
+
parts = []
|
|
29
|
+
for item in errors:
|
|
30
|
+
if isinstance(item, dict):
|
|
31
|
+
code = item.get("code")
|
|
32
|
+
message = item.get("message")
|
|
33
|
+
if message:
|
|
34
|
+
parts.append(f"{code}: {message}" if code is not None else str(message))
|
|
35
|
+
if parts:
|
|
36
|
+
return "; ".join(parts)
|
|
37
|
+
return "Cloudflare API returned an unsuccessful response."
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _request(
|
|
41
|
+
method: str,
|
|
42
|
+
path: str,
|
|
43
|
+
*,
|
|
44
|
+
token: str,
|
|
45
|
+
body: dict[str, Any] | None = None,
|
|
46
|
+
query: dict[str, str | int | bool] | None = None,
|
|
47
|
+
timeout: float = 20.0,
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
if not isinstance(token, str) or not token.strip():
|
|
50
|
+
raise CloudflareApiError("Cloudflare API token is required.")
|
|
51
|
+
url = API_BASE + path
|
|
52
|
+
if query:
|
|
53
|
+
encoded = urllib.parse.urlencode(
|
|
54
|
+
{key: str(value).lower() if isinstance(value, bool) else str(value) for key, value in query.items()}
|
|
55
|
+
)
|
|
56
|
+
url += "?" + encoded
|
|
57
|
+
data = None
|
|
58
|
+
headers = {
|
|
59
|
+
"Authorization": f"Bearer {token.strip()}",
|
|
60
|
+
"Accept": "application/json",
|
|
61
|
+
"User-Agent": "project-forge-deployment-kit/1",
|
|
62
|
+
}
|
|
63
|
+
if body is not None:
|
|
64
|
+
data = json.dumps(body, separators=(",", ":")).encode("utf-8")
|
|
65
|
+
headers["Content-Type"] = "application/json"
|
|
66
|
+
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
67
|
+
try:
|
|
68
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
69
|
+
raw = response.read()
|
|
70
|
+
except urllib.error.HTTPError as error:
|
|
71
|
+
try:
|
|
72
|
+
payload = json.loads(error.read().decode("utf-8", errors="replace"))
|
|
73
|
+
except (json.JSONDecodeError, UnicodeError):
|
|
74
|
+
payload = None
|
|
75
|
+
raise CloudflareApiError(
|
|
76
|
+
f"Cloudflare API {method} {path} failed with HTTP {error.code}: {_api_error_message(payload)}"
|
|
77
|
+
) from error
|
|
78
|
+
except urllib.error.URLError as error:
|
|
79
|
+
raise CloudflareApiError(f"Cloudflare API {method} {path} could not be reached: {error.reason}") from error
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
83
|
+
except (json.JSONDecodeError, UnicodeError) as error:
|
|
84
|
+
raise CloudflareApiError(f"Cloudflare API {method} {path} returned malformed JSON.") from error
|
|
85
|
+
if not isinstance(payload, dict) or payload.get("success") is not True:
|
|
86
|
+
raise CloudflareApiError(_api_error_message(payload))
|
|
87
|
+
return payload
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve_zone(token: str, zone_name: str) -> dict[str, Any]:
|
|
91
|
+
"""Resolve one exact active Cloudflare zone and its owning account."""
|
|
92
|
+
if not isinstance(zone_name, str) or not zone_name.strip():
|
|
93
|
+
raise CloudflareApiError("Cloudflare zone name is required.")
|
|
94
|
+
normalized = zone_name.strip().lower().rstrip(".")
|
|
95
|
+
payload = _request(
|
|
96
|
+
"GET",
|
|
97
|
+
"/zones",
|
|
98
|
+
token=token,
|
|
99
|
+
query={"name": normalized, "status": "active", "per_page": 50},
|
|
100
|
+
)
|
|
101
|
+
result = payload.get("result")
|
|
102
|
+
if not isinstance(result, list):
|
|
103
|
+
raise CloudflareApiError("Cloudflare zone lookup returned an unexpected result.")
|
|
104
|
+
exact = [
|
|
105
|
+
item for item in result
|
|
106
|
+
if isinstance(item, dict) and str(item.get("name", "")).lower().rstrip(".") == normalized
|
|
107
|
+
]
|
|
108
|
+
if len(exact) != 1:
|
|
109
|
+
raise CloudflareApiError(
|
|
110
|
+
f"Expected exactly one active Cloudflare zone named {normalized}; found {len(exact)}."
|
|
111
|
+
)
|
|
112
|
+
zone = exact[0]
|
|
113
|
+
account = zone.get("account")
|
|
114
|
+
if not isinstance(account, dict) or not account.get("id"):
|
|
115
|
+
raise CloudflareApiError("Cloudflare zone response did not include its account ID.")
|
|
116
|
+
if not zone.get("id"):
|
|
117
|
+
raise CloudflareApiError("Cloudflare zone response did not include its zone ID.")
|
|
118
|
+
return {
|
|
119
|
+
"zoneId": str(zone["id"]),
|
|
120
|
+
"zoneName": normalized,
|
|
121
|
+
"accountId": str(account["id"]),
|
|
122
|
+
"status": zone.get("status"),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def list_tunnels(token: str, account_id: str, *, name: str | None = None) -> list[dict[str, Any]]:
|
|
127
|
+
query: dict[str, str | int | bool] = {"is_deleted": False, "per_page": 1000}
|
|
128
|
+
if name is not None:
|
|
129
|
+
query["name"] = name
|
|
130
|
+
payload = _request(
|
|
131
|
+
"GET",
|
|
132
|
+
f"/accounts/{urllib.parse.quote(account_id, safe='')}/cfd_tunnel",
|
|
133
|
+
token=token,
|
|
134
|
+
query=query,
|
|
135
|
+
)
|
|
136
|
+
result = payload.get("result")
|
|
137
|
+
if not isinstance(result, list):
|
|
138
|
+
raise CloudflareApiError("Cloudflare tunnel list returned an unexpected result.")
|
|
139
|
+
return [item for item in result if isinstance(item, dict)]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def find_tunnel(token: str, account_id: str, name: str) -> dict[str, Any] | None:
|
|
143
|
+
matches = [
|
|
144
|
+
item for item in list_tunnels(token, account_id, name=name)
|
|
145
|
+
if item.get("name") == name and item.get("deleted_at") in {None, ""}
|
|
146
|
+
]
|
|
147
|
+
if len(matches) > 1:
|
|
148
|
+
raise CloudflareApiError(f"Multiple active Cloudflare tunnels are named {name}; refusing ambiguous adoption.")
|
|
149
|
+
return matches[0] if matches else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def create_tunnel(token: str, account_id: str, name: str) -> dict[str, Any]:
|
|
153
|
+
payload = _request(
|
|
154
|
+
"POST",
|
|
155
|
+
f"/accounts/{urllib.parse.quote(account_id, safe='')}/cfd_tunnel",
|
|
156
|
+
token=token,
|
|
157
|
+
body={"name": name, "config_src": "cloudflare"},
|
|
158
|
+
)
|
|
159
|
+
result = payload.get("result")
|
|
160
|
+
if not isinstance(result, dict) or not result.get("id"):
|
|
161
|
+
raise CloudflareApiError("Cloudflare tunnel creation did not return a tunnel ID.")
|
|
162
|
+
if result.get("config_src") not in {None, "cloudflare"}:
|
|
163
|
+
raise CloudflareApiError("Cloudflare created a tunnel with an unexpected configuration source.")
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def get_tunnel_token(token: str, account_id: str, tunnel_id: str) -> str:
|
|
168
|
+
payload = _request(
|
|
169
|
+
"GET",
|
|
170
|
+
f"/accounts/{urllib.parse.quote(account_id, safe='')}/cfd_tunnel/{urllib.parse.quote(tunnel_id, safe='')}/token",
|
|
171
|
+
token=token,
|
|
172
|
+
)
|
|
173
|
+
result = payload.get("result")
|
|
174
|
+
if not isinstance(result, str) or not result.strip():
|
|
175
|
+
raise CloudflareApiError("Cloudflare tunnel-token response was empty.")
|
|
176
|
+
return result.strip()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def get_tunnel_configuration(token: str, account_id: str, tunnel_id: str) -> dict[str, Any]:
|
|
180
|
+
payload = _request(
|
|
181
|
+
"GET",
|
|
182
|
+
f"/accounts/{urllib.parse.quote(account_id, safe='')}/cfd_tunnel/{urllib.parse.quote(tunnel_id, safe='')}/configurations",
|
|
183
|
+
token=token,
|
|
184
|
+
)
|
|
185
|
+
result = payload.get("result")
|
|
186
|
+
if not isinstance(result, dict):
|
|
187
|
+
raise CloudflareApiError("Cloudflare tunnel configuration returned an unexpected result.")
|
|
188
|
+
config = result.get("config")
|
|
189
|
+
if config is None:
|
|
190
|
+
return {"ingress": [{"service": "http_status:404"}]}
|
|
191
|
+
if not isinstance(config, dict):
|
|
192
|
+
raise CloudflareApiError("Cloudflare tunnel configuration 'config' must be an object.")
|
|
193
|
+
return config
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def update_tunnel_configuration(
|
|
197
|
+
token: str,
|
|
198
|
+
account_id: str,
|
|
199
|
+
tunnel_id: str,
|
|
200
|
+
config: dict[str, Any],
|
|
201
|
+
) -> dict[str, Any]:
|
|
202
|
+
payload = _request(
|
|
203
|
+
"PUT",
|
|
204
|
+
f"/accounts/{urllib.parse.quote(account_id, safe='')}/cfd_tunnel/{urllib.parse.quote(tunnel_id, safe='')}/configurations",
|
|
205
|
+
token=token,
|
|
206
|
+
body={"config": config},
|
|
207
|
+
)
|
|
208
|
+
result = payload.get("result")
|
|
209
|
+
if not isinstance(result, dict):
|
|
210
|
+
raise CloudflareApiError("Cloudflare tunnel configuration update returned an unexpected result.")
|
|
211
|
+
returned = result.get("config")
|
|
212
|
+
return returned if isinstance(returned, dict) else config
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def list_dns_records(token: str, zone_id: str, *, name: str) -> list[dict[str, Any]]:
|
|
216
|
+
payload = _request(
|
|
217
|
+
"GET",
|
|
218
|
+
f"/zones/{urllib.parse.quote(zone_id, safe='')}/dns_records",
|
|
219
|
+
token=token,
|
|
220
|
+
query={"name": name, "per_page": 100},
|
|
221
|
+
)
|
|
222
|
+
result = payload.get("result")
|
|
223
|
+
if not isinstance(result, list):
|
|
224
|
+
raise CloudflareApiError("Cloudflare DNS lookup returned an unexpected result.")
|
|
225
|
+
return [item for item in result if isinstance(item, dict)]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def create_dns_cname(
|
|
229
|
+
token: str,
|
|
230
|
+
zone_id: str,
|
|
231
|
+
*,
|
|
232
|
+
name: str,
|
|
233
|
+
content: str,
|
|
234
|
+
comment: str,
|
|
235
|
+
) -> dict[str, Any]:
|
|
236
|
+
payload = _request(
|
|
237
|
+
"POST",
|
|
238
|
+
f"/zones/{urllib.parse.quote(zone_id, safe='')}/dns_records",
|
|
239
|
+
token=token,
|
|
240
|
+
body={
|
|
241
|
+
"type": "CNAME",
|
|
242
|
+
"name": name,
|
|
243
|
+
"content": content,
|
|
244
|
+
"proxied": True,
|
|
245
|
+
"ttl": 1,
|
|
246
|
+
"comment": comment,
|
|
247
|
+
},
|
|
248
|
+
)
|
|
249
|
+
result = payload.get("result")
|
|
250
|
+
if not isinstance(result, dict) or not result.get("id"):
|
|
251
|
+
raise CloudflareApiError("Cloudflare DNS creation did not return a record ID.")
|
|
252
|
+
return result
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def delete_dns_record(token: str, zone_id: str, record_id: str) -> None:
|
|
256
|
+
_request(
|
|
257
|
+
"DELETE",
|
|
258
|
+
f"/zones/{urllib.parse.quote(zone_id, safe='')}/dns_records/{urllib.parse.quote(record_id, safe='')}",
|
|
259
|
+
token=token,
|
|
260
|
+
)
|