@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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.8.1",
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": "aaf5d24c897b3d89b3d1791c79bed4979414ca40",
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.8.1",
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": "aaf5d24c897b3d89b3d1791c79bed4979414ca40",
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": "6b767380f502acf0763b71f46aff6efd62674e63a526ed270097efa68c320a1e"
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
+ )