@mriekki/project-forge 4.8.0 → 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/README.md +7 -18
- 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/src/cli.mjs +7 -2
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
"""Optional Cloudflare Tunnel public ingress for remote Project Forge targets.
|
|
2
|
+
|
|
3
|
+
Cloudflare is an ingress provider, not a deployment transport. The existing
|
|
4
|
+
remote-ssh target and trusted-LAN Caddy route remain authoritative. This
|
|
5
|
+
module owns an optional shared cloudflared runtime plus Project-scoped public
|
|
6
|
+
hostname/DNS registrations that forward to those existing Caddy routes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import shlex
|
|
16
|
+
import subprocess
|
|
17
|
+
import urllib.parse
|
|
18
|
+
from copy import deepcopy
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from . import cloudflare_api
|
|
22
|
+
from .box_platform import platform_status
|
|
23
|
+
from .box_platform_assets import CADDY_CONTAINER, NETWORK_NAME, PLATFORM_ROOT
|
|
24
|
+
from .cloudflare_ingress_remote import REMOTE_SCRIPT
|
|
25
|
+
from .errors import DeploymentKitError
|
|
26
|
+
from .profiles import load_profile
|
|
27
|
+
from .route_registry import route_inventory
|
|
28
|
+
from .ssh import ssh_args, verify_access
|
|
29
|
+
|
|
30
|
+
INGRESS_SCHEMA_VERSION = 1
|
|
31
|
+
INGRESS_VERSION = "1"
|
|
32
|
+
INGRESS_DISTRIBUTION = "cloudflare-remote-managed-tunnel"
|
|
33
|
+
CLOUDFLARED_VERSION = "2026.10.4"
|
|
34
|
+
CLOUDFLARED_IMAGE = f"cloudflare/cloudflared:{CLOUDFLARED_VERSION}"
|
|
35
|
+
HOST_PUBLIC_INGRESS_WRITE_PHRASE = "[HOST PUBLIC INGRESS WRITE]"
|
|
36
|
+
PUBLIC_INGRESS_ROUTE_WRITE_PHRASE = "[PUBLIC INGRESS ROUTE WRITE]"
|
|
37
|
+
PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE = "[PUBLIC INGRESS ROUTE REMOVE]"
|
|
38
|
+
CLOUDFLARE_API_TOKEN_ENV = "PROJECT_FORGE_CLOUDFLARE_API_TOKEN"
|
|
39
|
+
|
|
40
|
+
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
|
41
|
+
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
42
|
+
_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
|
43
|
+
_BOOL_STATUS_KEYS = {"markerPresent", "platformReady", "tokenPresent", "containerRunning", "networkAttached"}
|
|
44
|
+
_STATUS_KEYS = {
|
|
45
|
+
"state", "scope", "markerPresent", "markerSchemaVersion", "markerIngressVersion",
|
|
46
|
+
"markerDistribution", "platformReady", "tokenPresent", "containerRunning",
|
|
47
|
+
"networkAttached", "containerName", "cloudflaredImage", "accountId", "zoneId",
|
|
48
|
+
"zoneName", "tunnelId", "tunnelName",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _slug(value: str, field: str) -> str:
|
|
53
|
+
if not isinstance(value, str) or _SLUG_RE.fullmatch(value) is None:
|
|
54
|
+
raise DeploymentKitError(f"{field} must use lowercase letters, digits, and internal hyphens.")
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _owner_repo(value: str) -> str:
|
|
59
|
+
if not isinstance(value, str) or _REPO_RE.fullmatch(value) is None:
|
|
60
|
+
raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _hostname(value: str, field: str) -> str:
|
|
65
|
+
if not isinstance(value, str) or not value or len(value) > 253:
|
|
66
|
+
raise DeploymentKitError(f"{field} must be a non-empty DNS hostname.")
|
|
67
|
+
normalized = value.lower().rstrip(".")
|
|
68
|
+
labels = normalized.split(".")
|
|
69
|
+
if len(labels) < 2 or any(_LABEL_RE.fullmatch(label) is None for label in labels):
|
|
70
|
+
raise DeploymentKitError(f"Invalid {field}: {normalized}")
|
|
71
|
+
return normalized
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _first_level_public_hostname(value: str, zone_name: str) -> str:
|
|
75
|
+
host = _hostname(value, "public hostname")
|
|
76
|
+
zone = _hostname(zone_name, "Cloudflare zone")
|
|
77
|
+
suffix = "." + zone
|
|
78
|
+
if not host.endswith(suffix):
|
|
79
|
+
raise DeploymentKitError(f"Public hostname {host} must belong to Cloudflare zone {zone}.")
|
|
80
|
+
relative = host[: -len(suffix)]
|
|
81
|
+
if "." in relative or not relative:
|
|
82
|
+
raise DeploymentKitError(
|
|
83
|
+
"The current partial Cloudflare adapter supports one first-level subdomain per application "
|
|
84
|
+
"(for example app.example.com). Deeper names require an explicit certificate/support review."
|
|
85
|
+
)
|
|
86
|
+
return host
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _origin_hostname(value: str) -> str:
|
|
90
|
+
host = _hostname(value, "origin hostname")
|
|
91
|
+
if not host.endswith(".local"):
|
|
92
|
+
raise DeploymentKitError("Cloudflare origin hostname must be an existing trusted-LAN .local route.")
|
|
93
|
+
return host
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def tunnel_name_for_scope(scope: str) -> str:
|
|
97
|
+
return f"project-forge-{_slug(scope, 'scope')}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_remote_script(*, tunnel_token: str | None = None) -> str:
|
|
101
|
+
token_b64 = ""
|
|
102
|
+
if tunnel_token is not None:
|
|
103
|
+
if not isinstance(tunnel_token, str) or not tunnel_token.strip():
|
|
104
|
+
raise DeploymentKitError("Cloudflare tunnel token is empty.")
|
|
105
|
+
token_b64 = base64.b64encode(tunnel_token.strip().encode("utf-8")).decode("ascii")
|
|
106
|
+
replacements = {
|
|
107
|
+
"__PLATFORM_ROOT__": PLATFORM_ROOT,
|
|
108
|
+
"__NETWORK_NAME__": NETWORK_NAME,
|
|
109
|
+
"__CADDY_CONTAINER__": CADDY_CONTAINER,
|
|
110
|
+
"__CLOUDFLARED_IMAGE__": CLOUDFLARED_IMAGE,
|
|
111
|
+
"__SCHEMA_VERSION__": str(INGRESS_SCHEMA_VERSION),
|
|
112
|
+
"__INGRESS_VERSION__": INGRESS_VERSION,
|
|
113
|
+
"__DISTRIBUTION__": INGRESS_DISTRIBUTION,
|
|
114
|
+
"__TOKEN_B64__": token_b64,
|
|
115
|
+
}
|
|
116
|
+
script = REMOTE_SCRIPT
|
|
117
|
+
for placeholder, replacement in replacements.items():
|
|
118
|
+
script = script.replace(placeholder, replacement)
|
|
119
|
+
if "__" in script:
|
|
120
|
+
raise DeploymentKitError("Internal Cloudflare ingress script contains an unresolved placeholder.")
|
|
121
|
+
return script
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_status(output: str) -> dict[str, Any]:
|
|
125
|
+
result: dict[str, Any] = {}
|
|
126
|
+
for raw in output.splitlines():
|
|
127
|
+
if not raw.strip():
|
|
128
|
+
continue
|
|
129
|
+
if "=" not in raw:
|
|
130
|
+
raise DeploymentKitError(f"Malformed Cloudflare ingress status line: {raw!r}")
|
|
131
|
+
key, value = raw.split("=", 1)
|
|
132
|
+
if key not in _STATUS_KEYS or key in result:
|
|
133
|
+
raise DeploymentKitError(f"Unexpected or duplicate Cloudflare ingress status key: {key!r}")
|
|
134
|
+
if key in _BOOL_STATUS_KEYS:
|
|
135
|
+
if value not in {"true", "false"}:
|
|
136
|
+
raise DeploymentKitError(f"Invalid boolean Cloudflare ingress status value for {key}.")
|
|
137
|
+
result[key] = value == "true"
|
|
138
|
+
else:
|
|
139
|
+
result[key] = value
|
|
140
|
+
missing = _STATUS_KEYS - result.keys()
|
|
141
|
+
if missing:
|
|
142
|
+
raise DeploymentKitError("Cloudflare ingress status omitted keys: " + ", ".join(sorted(missing)))
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _metadata_tsv(values: dict[str, Any]) -> str:
|
|
147
|
+
return "\t".join(
|
|
148
|
+
str(values[key])
|
|
149
|
+
for key in ("accountId", "zoneId", "zoneName", "tunnelId", "tunnelName")
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _route_spec(
|
|
154
|
+
*,
|
|
155
|
+
owner_repo: str,
|
|
156
|
+
environment: str,
|
|
157
|
+
app_slug: str,
|
|
158
|
+
public_hostname: str,
|
|
159
|
+
origin_hostname: str,
|
|
160
|
+
) -> dict[str, Any]:
|
|
161
|
+
owner_repo = _owner_repo(owner_repo)
|
|
162
|
+
environment = _slug(environment, "environment")
|
|
163
|
+
app_slug = _slug(app_slug, "appSlug")
|
|
164
|
+
public_hostname = _hostname(public_hostname, "public hostname")
|
|
165
|
+
if public_hostname.endswith(".local"):
|
|
166
|
+
raise DeploymentKitError("Public hostname must not use the .local suffix.")
|
|
167
|
+
origin_hostname = _origin_hostname(origin_hostname)
|
|
168
|
+
return {
|
|
169
|
+
"schemaVersion": INGRESS_SCHEMA_VERSION,
|
|
170
|
+
"ownerRepo": owner_repo,
|
|
171
|
+
"environment": environment,
|
|
172
|
+
"appSlug": app_slug,
|
|
173
|
+
"routeId": f"{app_slug}--{environment}",
|
|
174
|
+
"publicHostname": public_hostname,
|
|
175
|
+
"originHostname": origin_hostname,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _route_tsv(spec: dict[str, Any]) -> str:
|
|
180
|
+
return "\t".join(
|
|
181
|
+
str(spec[key])
|
|
182
|
+
for key in (
|
|
183
|
+
"schemaVersion", "ownerRepo", "environment", "appSlug", "routeId",
|
|
184
|
+
"publicHostname", "originHostname",
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _run_remote(
|
|
190
|
+
profile: dict[str, Any],
|
|
191
|
+
address: str,
|
|
192
|
+
action: str,
|
|
193
|
+
*,
|
|
194
|
+
scope: str,
|
|
195
|
+
metadata: dict[str, Any] | None = None,
|
|
196
|
+
spec: dict[str, Any] | None = None,
|
|
197
|
+
expected: dict[str, Any] | None = None,
|
|
198
|
+
tunnel_token: str | None = None,
|
|
199
|
+
) -> str:
|
|
200
|
+
if action not in {"status", "install", "upgrade", "route-inventory", "route-register", "route-remove"}:
|
|
201
|
+
raise DeploymentKitError(f"Unsupported Cloudflare ingress action: {action}")
|
|
202
|
+
args = [action, _slug(scope, "scope")]
|
|
203
|
+
args.append(_metadata_tsv(metadata) if metadata is not None else "")
|
|
204
|
+
args.append(_route_tsv(spec) if spec is not None else "")
|
|
205
|
+
if action == "route-register":
|
|
206
|
+
args.append(_route_tsv(expected) if expected is not None else "NONE")
|
|
207
|
+
elif action == "route-remove":
|
|
208
|
+
args.append("")
|
|
209
|
+
remote_command = "sudo -n bash -s -- " + " ".join(shlex.quote(item) for item in args)
|
|
210
|
+
completed = subprocess.run(
|
|
211
|
+
ssh_args(profile, address, command=remote_command),
|
|
212
|
+
input=build_remote_script(tunnel_token=tunnel_token).encode("utf-8"),
|
|
213
|
+
check=False,
|
|
214
|
+
capture_output=True,
|
|
215
|
+
)
|
|
216
|
+
stdout = completed.stdout.decode("utf-8", errors="replace")
|
|
217
|
+
stderr = completed.stderr.decode("utf-8", errors="replace")
|
|
218
|
+
if completed.returncode != 0:
|
|
219
|
+
detail = stderr.strip() or stdout.strip() or f"exit {completed.returncode}"
|
|
220
|
+
raise DeploymentKitError(f"Remote Cloudflare ingress {action} failed: {detail}")
|
|
221
|
+
return stdout
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def cloudflare_ingress_status(name: str, scope: str) -> dict[str, Any]:
|
|
225
|
+
scope = _slug(scope, "scope")
|
|
226
|
+
profile = load_profile(name)
|
|
227
|
+
access = verify_access(profile)
|
|
228
|
+
status = _parse_status(
|
|
229
|
+
_run_remote(profile, access["address"], "status", scope=scope)
|
|
230
|
+
)
|
|
231
|
+
status["targetName"] = name
|
|
232
|
+
status["verifiedAddress"] = access["address"]
|
|
233
|
+
status["mutated"] = False
|
|
234
|
+
return status
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def cloudflare_ingress_doctor(name: str, scope: str) -> dict[str, Any]:
|
|
238
|
+
status = cloudflare_ingress_status(name, scope)
|
|
239
|
+
status["nextAction"] = {
|
|
240
|
+
"installable": "configure-cloudflare-ingress",
|
|
241
|
+
"upgrade-required": "upgrade-cloudflare-ingress",
|
|
242
|
+
"drifted": "inspect-cloudflare-ingress-drift",
|
|
243
|
+
"blocked-platform": "prepare-shared-host-platform",
|
|
244
|
+
"incompatible": "manual-cloudflare-ingress-recovery",
|
|
245
|
+
"ready": "none",
|
|
246
|
+
}.get(status["state"], "manual-review")
|
|
247
|
+
return status
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _status_snapshot(status: dict[str, Any]) -> str:
|
|
251
|
+
selected = {key: status[key] for key in sorted(_STATUS_KEYS)}
|
|
252
|
+
return hashlib.sha256(json.dumps(selected, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _config_snapshot(config: dict[str, Any]) -> str:
|
|
256
|
+
return hashlib.sha256(json.dumps(config, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _tunnel_summary(tunnel: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
260
|
+
if tunnel is None:
|
|
261
|
+
return None
|
|
262
|
+
return {
|
|
263
|
+
"id": tunnel.get("id"),
|
|
264
|
+
"name": tunnel.get("name"),
|
|
265
|
+
"configSrc": tunnel.get("config_src"),
|
|
266
|
+
"status": tunnel.get("status"),
|
|
267
|
+
"deletedAt": tunnel.get("deleted_at"),
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _empty_tunnel_config(config: dict[str, Any]) -> bool:
|
|
272
|
+
ingress = config.get("ingress")
|
|
273
|
+
return isinstance(ingress, list) and ingress == [{"service": "http_status:404"}]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def plan_cloudflare_ingress(
|
|
277
|
+
name: str,
|
|
278
|
+
*,
|
|
279
|
+
scope: str,
|
|
280
|
+
zone_name: str,
|
|
281
|
+
api_token: str,
|
|
282
|
+
tunnel_name: str | None = None,
|
|
283
|
+
) -> dict[str, Any]:
|
|
284
|
+
"""Plan creation/adoption of one shared remotely managed Cloudflare tunnel."""
|
|
285
|
+
scope = _slug(scope, "scope")
|
|
286
|
+
zone = cloudflare_api.resolve_zone(api_token, zone_name)
|
|
287
|
+
expected_tunnel_name = tunnel_name or tunnel_name_for_scope(scope)
|
|
288
|
+
if not isinstance(expected_tunnel_name, str) or not expected_tunnel_name.strip():
|
|
289
|
+
raise DeploymentKitError("Cloudflare tunnel name must be non-empty.")
|
|
290
|
+
expected_tunnel_name = expected_tunnel_name.strip()
|
|
291
|
+
|
|
292
|
+
platform = platform_status(name)
|
|
293
|
+
if platform["state"] != "ready":
|
|
294
|
+
raise DeploymentKitError(
|
|
295
|
+
f"Cloudflare ingress requires a ready shared Project Forge platform; target {name} is {platform['state']}."
|
|
296
|
+
)
|
|
297
|
+
host = cloudflare_ingress_status(name, scope)
|
|
298
|
+
if host["state"] not in {"installable", "upgrade-required", "ready"}:
|
|
299
|
+
raise DeploymentKitError(
|
|
300
|
+
f"Cloudflare ingress cannot be configured while host scope {scope} is {host['state']}."
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
tunnel = cloudflare_api.find_tunnel(api_token, zone["accountId"], expected_tunnel_name)
|
|
304
|
+
tunnel_config: dict[str, Any] | None = None
|
|
305
|
+
if tunnel is not None:
|
|
306
|
+
if tunnel.get("config_src") not in {None, "cloudflare"}:
|
|
307
|
+
raise DeploymentKitError("Project Forge supports adoption only of remotely managed Cloudflare tunnels.")
|
|
308
|
+
tunnel_config = cloudflare_api.get_tunnel_configuration(api_token, zone["accountId"], str(tunnel["id"]))
|
|
309
|
+
|
|
310
|
+
if host["state"] in {"ready", "upgrade-required"}:
|
|
311
|
+
expected = {
|
|
312
|
+
"accountId": zone["accountId"],
|
|
313
|
+
"zoneId": zone["zoneId"],
|
|
314
|
+
"zoneName": zone["zoneName"],
|
|
315
|
+
"tunnelName": expected_tunnel_name,
|
|
316
|
+
}
|
|
317
|
+
for key, value in expected.items():
|
|
318
|
+
if host[key] != value:
|
|
319
|
+
raise DeploymentKitError(
|
|
320
|
+
f"Host Cloudflare ingress scope {scope} is already bound to different {key}; explicit migration is required."
|
|
321
|
+
)
|
|
322
|
+
if tunnel is None or host["tunnelId"] != str(tunnel.get("id")):
|
|
323
|
+
raise DeploymentKitError("Host Cloudflare tunnel identity no longer matches the provider; refusing implicit replacement.")
|
|
324
|
+
action = "upgrade" if host["state"] == "upgrade-required" else "refresh-token"
|
|
325
|
+
else:
|
|
326
|
+
action = "adopt-and-install" if tunnel is not None else "create-and-install"
|
|
327
|
+
if tunnel_config is not None and not _empty_tunnel_config(tunnel_config):
|
|
328
|
+
raise DeploymentKitError(
|
|
329
|
+
"An existing tunnel with the desired name already has ingress configuration. "
|
|
330
|
+
"Project Forge will not implicitly adopt or overwrite it."
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
"targetName": name,
|
|
335
|
+
"scope": scope,
|
|
336
|
+
"action": action,
|
|
337
|
+
"zone": zone,
|
|
338
|
+
"tunnelName": expected_tunnel_name,
|
|
339
|
+
"existingTunnel": _tunnel_summary(tunnel),
|
|
340
|
+
"existingTunnelConfigSha256": _config_snapshot(tunnel_config) if tunnel_config is not None else None,
|
|
341
|
+
"hostState": host["state"],
|
|
342
|
+
"hostStatusSnapshot": _status_snapshot(host),
|
|
343
|
+
"cloudflaredImage": CLOUDFLARED_IMAGE,
|
|
344
|
+
"requiredAuthorization": HOST_PUBLIC_INGRESS_WRITE_PHRASE,
|
|
345
|
+
"secrets": {
|
|
346
|
+
"apiToken": "workstation-only; not persisted by Deployment Kit",
|
|
347
|
+
"tunnelToken": "written only to the selected host scope through verified SSH",
|
|
348
|
+
},
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _delete_tunnel(api_token: str, account_id: str, tunnel_id: str) -> None:
|
|
353
|
+
cloudflare_api._request( # package-internal bounded rollback helper
|
|
354
|
+
"DELETE",
|
|
355
|
+
f"/accounts/{urllib.parse.quote(account_id, safe='')}/cfd_tunnel/{urllib.parse.quote(tunnel_id, safe='')}",
|
|
356
|
+
token=api_token,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def apply_cloudflare_ingress(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
|
|
361
|
+
"""Apply exactly one reviewed shared Cloudflare ingress plan."""
|
|
362
|
+
required = {
|
|
363
|
+
"targetName", "scope", "action", "zone", "tunnelName", "existingTunnel",
|
|
364
|
+
"existingTunnelConfigSha256", "hostState", "hostStatusSnapshot", "cloudflaredImage",
|
|
365
|
+
"requiredAuthorization", "secrets",
|
|
366
|
+
}
|
|
367
|
+
if not isinstance(plan, dict) or set(plan) != required:
|
|
368
|
+
raise DeploymentKitError("Malformed Cloudflare ingress plan.")
|
|
369
|
+
if plan["cloudflaredImage"] != CLOUDFLARED_IMAGE:
|
|
370
|
+
raise DeploymentKitError("Cloudflare ingress plan uses a different cloudflared image; re-plan with the current package.")
|
|
371
|
+
|
|
372
|
+
name = plan["targetName"]
|
|
373
|
+
scope = plan["scope"]
|
|
374
|
+
zone = cloudflare_api.resolve_zone(api_token, plan["zone"]["zoneName"])
|
|
375
|
+
if zone != plan["zone"]:
|
|
376
|
+
raise DeploymentKitError("Cloudflare zone identity changed after planning; create a fresh plan.")
|
|
377
|
+
current_host = cloudflare_ingress_status(name, scope)
|
|
378
|
+
if _status_snapshot(current_host) != plan["hostStatusSnapshot"]:
|
|
379
|
+
raise DeploymentKitError("Host Cloudflare ingress state changed after planning; create a fresh plan.")
|
|
380
|
+
|
|
381
|
+
current_tunnel = cloudflare_api.find_tunnel(api_token, zone["accountId"], plan["tunnelName"])
|
|
382
|
+
if _tunnel_summary(current_tunnel) != plan["existingTunnel"]:
|
|
383
|
+
raise DeploymentKitError("Cloudflare tunnel state changed after planning; create a fresh plan.")
|
|
384
|
+
if current_tunnel is not None:
|
|
385
|
+
current_config = cloudflare_api.get_tunnel_configuration(api_token, zone["accountId"], str(current_tunnel["id"]))
|
|
386
|
+
if _config_snapshot(current_config) != plan["existingTunnelConfigSha256"]:
|
|
387
|
+
raise DeploymentKitError("Cloudflare tunnel configuration changed after planning; create a fresh plan.")
|
|
388
|
+
|
|
389
|
+
created = False
|
|
390
|
+
tunnel = current_tunnel
|
|
391
|
+
if tunnel is None:
|
|
392
|
+
tunnel = cloudflare_api.create_tunnel(api_token, zone["accountId"], plan["tunnelName"])
|
|
393
|
+
created = True
|
|
394
|
+
tunnel_id = str(tunnel["id"])
|
|
395
|
+
metadata = {
|
|
396
|
+
**zone,
|
|
397
|
+
"tunnelId": tunnel_id,
|
|
398
|
+
"tunnelName": plan["tunnelName"],
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
config = cloudflare_api.get_tunnel_configuration(api_token, zone["accountId"], tunnel_id)
|
|
403
|
+
if not _empty_tunnel_config(config):
|
|
404
|
+
raise DeploymentKitError(
|
|
405
|
+
"Cloudflare tunnel acquired non-empty ingress configuration during apply; refusing overwrite."
|
|
406
|
+
)
|
|
407
|
+
token = cloudflare_api.get_tunnel_token(api_token, zone["accountId"], tunnel_id)
|
|
408
|
+
profile = load_profile(name)
|
|
409
|
+
access = verify_access(profile)
|
|
410
|
+
remote_action = "upgrade" if plan["hostState"] == "upgrade-required" else (
|
|
411
|
+
"upgrade" if plan["hostState"] == "ready" else "install"
|
|
412
|
+
)
|
|
413
|
+
_run_remote(
|
|
414
|
+
profile,
|
|
415
|
+
access["address"],
|
|
416
|
+
remote_action,
|
|
417
|
+
scope=scope,
|
|
418
|
+
metadata=metadata,
|
|
419
|
+
tunnel_token=token,
|
|
420
|
+
)
|
|
421
|
+
final = cloudflare_ingress_status(name, scope)
|
|
422
|
+
if final["state"] != "ready":
|
|
423
|
+
raise DeploymentKitError(f"Cloudflare ingress apply completed but host readiness is {final['state']}.")
|
|
424
|
+
for key in ("accountId", "zoneId", "zoneName", "tunnelId", "tunnelName"):
|
|
425
|
+
if final[key] != str(metadata[key]):
|
|
426
|
+
raise DeploymentKitError(f"Host Cloudflare ingress returned mismatched {key} after apply.")
|
|
427
|
+
final["mutated"] = True
|
|
428
|
+
final["providerTunnel"] = _tunnel_summary(tunnel)
|
|
429
|
+
return final
|
|
430
|
+
except Exception:
|
|
431
|
+
if created:
|
|
432
|
+
try:
|
|
433
|
+
_delete_tunnel(api_token, zone["accountId"], tunnel_id)
|
|
434
|
+
except Exception:
|
|
435
|
+
pass
|
|
436
|
+
raise
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _parse_route_inventory(output: str) -> dict[str, Any]:
|
|
440
|
+
summary: dict[str, str] = {}
|
|
441
|
+
routes: list[dict[str, Any]] = []
|
|
442
|
+
for raw in output.splitlines():
|
|
443
|
+
if not raw:
|
|
444
|
+
continue
|
|
445
|
+
parts = raw.split("\t")
|
|
446
|
+
if parts[0] == "SUMMARY":
|
|
447
|
+
if len(parts) != 3 or parts[1] in summary:
|
|
448
|
+
raise DeploymentKitError("Malformed Cloudflare public-route inventory summary.")
|
|
449
|
+
summary[parts[1]] = parts[2]
|
|
450
|
+
elif parts[0] == "ROUTE":
|
|
451
|
+
if len(parts) != 8:
|
|
452
|
+
raise DeploymentKitError("Malformed Cloudflare public-route inventory record.")
|
|
453
|
+
try:
|
|
454
|
+
schema = int(parts[1])
|
|
455
|
+
except ValueError as error:
|
|
456
|
+
raise DeploymentKitError("Malformed Cloudflare public-route schema.") from error
|
|
457
|
+
spec = _route_spec(
|
|
458
|
+
owner_repo=parts[2],
|
|
459
|
+
environment=parts[3],
|
|
460
|
+
app_slug=parts[4],
|
|
461
|
+
public_hostname=parts[6],
|
|
462
|
+
origin_hostname=parts[7],
|
|
463
|
+
)
|
|
464
|
+
if schema != INGRESS_SCHEMA_VERSION or spec["routeId"] != parts[5]:
|
|
465
|
+
raise DeploymentKitError("Cloudflare public-route schema or route ID mismatch.")
|
|
466
|
+
routes.append(spec)
|
|
467
|
+
else:
|
|
468
|
+
raise DeploymentKitError("Unexpected Cloudflare public-route inventory record.")
|
|
469
|
+
if set(summary) != {"scope", "managedRouteCount"}:
|
|
470
|
+
raise DeploymentKitError("Cloudflare public-route inventory omitted summary fields.")
|
|
471
|
+
try:
|
|
472
|
+
count = int(summary["managedRouteCount"])
|
|
473
|
+
except ValueError as error:
|
|
474
|
+
raise DeploymentKitError("Malformed Cloudflare managed route count.") from error
|
|
475
|
+
if count != len(routes):
|
|
476
|
+
raise DeploymentKitError("Cloudflare managed route count does not match inventory.")
|
|
477
|
+
return {"scope": summary["scope"], "managedRoutes": sorted(routes, key=lambda item: item["routeId"])}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def cloudflare_route_inventory(name: str, scope: str) -> dict[str, Any]:
|
|
481
|
+
profile = load_profile(name)
|
|
482
|
+
access = verify_access(profile)
|
|
483
|
+
result = _parse_route_inventory(
|
|
484
|
+
_run_remote(profile, access["address"], "route-inventory", scope=scope)
|
|
485
|
+
)
|
|
486
|
+
result["targetName"] = name
|
|
487
|
+
result["verifiedAddress"] = access["address"]
|
|
488
|
+
result["mutated"] = False
|
|
489
|
+
return result
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _route_inventory_snapshot(value: dict[str, Any]) -> str:
|
|
493
|
+
selected = {"scope": value["scope"], "managedRoutes": value["managedRoutes"]}
|
|
494
|
+
return hashlib.sha256(json.dumps(selected, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _find_route(inventory: dict[str, Any], route_id: str) -> dict[str, Any] | None:
|
|
498
|
+
return next((item for item in inventory["managedRoutes"] if item["routeId"] == route_id), None)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _lan_route(name: str, spec: dict[str, Any]) -> dict[str, Any]:
|
|
502
|
+
inventory = route_inventory(name)
|
|
503
|
+
route = next((item for item in inventory["managedRoutes"] if item["routeId"] == spec["routeId"]), None)
|
|
504
|
+
if route is None:
|
|
505
|
+
raise DeploymentKitError(
|
|
506
|
+
f"Trusted-LAN Caddy route {spec['routeId']} must exist before public ingress can be registered."
|
|
507
|
+
)
|
|
508
|
+
for key in ("ownerRepo", "environment", "appSlug"):
|
|
509
|
+
if route[key] != spec[key]:
|
|
510
|
+
raise DeploymentKitError("Trusted-LAN route ownership does not match the requested public-ingress route.")
|
|
511
|
+
if spec["originHostname"] not in route["hostnames"]:
|
|
512
|
+
raise DeploymentKitError(
|
|
513
|
+
f"Origin hostname {spec['originHostname']} is not owned by trusted-LAN route {spec['routeId']}."
|
|
514
|
+
)
|
|
515
|
+
return route
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _cloudflare_rule(spec: dict[str, Any]) -> dict[str, Any]:
|
|
519
|
+
return {
|
|
520
|
+
"hostname": spec["publicHostname"],
|
|
521
|
+
"service": f"http://{CADDY_CONTAINER}:80",
|
|
522
|
+
"originRequest": {"httpHostHeader": spec["originHostname"]},
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _split_ingress(config: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
527
|
+
ingress = config.get("ingress")
|
|
528
|
+
if not isinstance(ingress, list) or not ingress:
|
|
529
|
+
raise DeploymentKitError("Cloudflare tunnel configuration must contain ingress rules.")
|
|
530
|
+
if ingress[-1] != {"service": "http_status:404"}:
|
|
531
|
+
raise DeploymentKitError("Cloudflare tunnel must retain the Project Forge fail-closed final 404 ingress rule.")
|
|
532
|
+
rules = ingress[:-1]
|
|
533
|
+
if any(not isinstance(item, dict) for item in rules):
|
|
534
|
+
raise DeploymentKitError("Cloudflare tunnel contains a malformed ingress rule.")
|
|
535
|
+
return [deepcopy(item) for item in rules], deepcopy(ingress[-1])
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _dns_marker(spec: dict[str, Any]) -> str:
|
|
539
|
+
identity = "|".join((spec["ownerRepo"], spec["environment"], spec["appSlug"])).encode("utf-8")
|
|
540
|
+
return "Project Forge " + hashlib.sha256(identity).hexdigest()[:20]
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _exact_dns_record(records: list[dict[str, Any]], *, spec: dict[str, Any], tunnel_id: str) -> dict[str, Any] | None:
|
|
544
|
+
if not records:
|
|
545
|
+
return None
|
|
546
|
+
if len(records) != 1:
|
|
547
|
+
raise DeploymentKitError(f"Public hostname {spec['publicHostname']} has multiple Cloudflare DNS records; refusing takeover.")
|
|
548
|
+
record = records[0]
|
|
549
|
+
expected_content = f"{tunnel_id}.cfargotunnel.com"
|
|
550
|
+
if (
|
|
551
|
+
record.get("type") != "CNAME"
|
|
552
|
+
or str(record.get("content", "")).lower().rstrip(".") != expected_content
|
|
553
|
+
or record.get("proxied") is not True
|
|
554
|
+
or record.get("comment") != _dns_marker(spec)
|
|
555
|
+
):
|
|
556
|
+
raise DeploymentKitError(
|
|
557
|
+
f"Public hostname {spec['publicHostname']} already has DNS state not owned by this Project Forge route."
|
|
558
|
+
)
|
|
559
|
+
return record
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def plan_public_route(
|
|
563
|
+
name: str,
|
|
564
|
+
*,
|
|
565
|
+
scope: str,
|
|
566
|
+
owner_repo: str,
|
|
567
|
+
environment: str,
|
|
568
|
+
app_slug: str,
|
|
569
|
+
public_hostname: str,
|
|
570
|
+
origin_hostname: str,
|
|
571
|
+
api_token: str,
|
|
572
|
+
) -> dict[str, Any]:
|
|
573
|
+
host = cloudflare_ingress_status(name, scope)
|
|
574
|
+
if host["state"] != "ready":
|
|
575
|
+
raise DeploymentKitError(f"Cloudflare ingress scope {scope} is not ready on target {name}.")
|
|
576
|
+
public_hostname = _first_level_public_hostname(public_hostname, host["zoneName"])
|
|
577
|
+
spec = _route_spec(
|
|
578
|
+
owner_repo=owner_repo,
|
|
579
|
+
environment=environment,
|
|
580
|
+
app_slug=app_slug,
|
|
581
|
+
public_hostname=public_hostname,
|
|
582
|
+
origin_hostname=origin_hostname,
|
|
583
|
+
)
|
|
584
|
+
_lan_route(name, spec)
|
|
585
|
+
|
|
586
|
+
inventory = cloudflare_route_inventory(name, scope)
|
|
587
|
+
existing = _find_route(inventory, spec["routeId"])
|
|
588
|
+
if existing is not None and existing != spec:
|
|
589
|
+
raise DeploymentKitError(
|
|
590
|
+
"This Project/environment already owns a different public-ingress route. Remove it explicitly before rebinding."
|
|
591
|
+
)
|
|
592
|
+
for other in inventory["managedRoutes"]:
|
|
593
|
+
if other["routeId"] != spec["routeId"] and other["publicHostname"] == spec["publicHostname"]:
|
|
594
|
+
raise DeploymentKitError("Public hostname is already owned by another Project Forge route on this ingress scope.")
|
|
595
|
+
|
|
596
|
+
config = cloudflare_api.get_tunnel_configuration(api_token, host["accountId"], host["tunnelId"])
|
|
597
|
+
rules, final_rule = _split_ingress(config)
|
|
598
|
+
desired_rule = _cloudflare_rule(spec)
|
|
599
|
+
conflicting = [item for item in rules if item.get("hostname") == spec["publicHostname"] and item != desired_rule]
|
|
600
|
+
if conflicting:
|
|
601
|
+
raise DeploymentKitError("Cloudflare tunnel already has a conflicting ingress rule for this hostname.")
|
|
602
|
+
exact_rules = [item for item in rules if item == desired_rule]
|
|
603
|
+
if len(exact_rules) > 1:
|
|
604
|
+
raise DeploymentKitError("Cloudflare tunnel contains duplicate exact Project Forge ingress rules.")
|
|
605
|
+
|
|
606
|
+
records = cloudflare_api.list_dns_records(api_token, host["zoneId"], name=spec["publicHostname"])
|
|
607
|
+
dns = _exact_dns_record(records, spec=spec, tunnel_id=host["tunnelId"])
|
|
608
|
+
return {
|
|
609
|
+
"targetName": name,
|
|
610
|
+
"scope": scope,
|
|
611
|
+
"action": "already-configured" if existing == spec and exact_rules and dns is not None else "register",
|
|
612
|
+
"route": spec,
|
|
613
|
+
"previousRoute": existing,
|
|
614
|
+
"routeInventorySnapshot": _route_inventory_snapshot(inventory),
|
|
615
|
+
"hostStatusSnapshot": _status_snapshot(host),
|
|
616
|
+
"tunnelConfig": config,
|
|
617
|
+
"tunnelConfigSha256": _config_snapshot(config),
|
|
618
|
+
"desiredTunnelConfig": {**config, "ingress": rules + ([] if exact_rules else [desired_rule]) + [final_rule]},
|
|
619
|
+
"dnsRecord": deepcopy(dns),
|
|
620
|
+
"dnsMarker": _dns_marker(spec),
|
|
621
|
+
"requiredAuthorization": PUBLIC_INGRESS_ROUTE_WRITE_PHRASE,
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def apply_public_route(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
|
|
626
|
+
if plan.get("action") == "already-configured":
|
|
627
|
+
return {**plan, "mutated": False}
|
|
628
|
+
required = {
|
|
629
|
+
"targetName", "scope", "action", "route", "previousRoute", "routeInventorySnapshot",
|
|
630
|
+
"hostStatusSnapshot", "tunnelConfig", "tunnelConfigSha256", "desiredTunnelConfig",
|
|
631
|
+
"dnsRecord", "dnsMarker", "requiredAuthorization",
|
|
632
|
+
}
|
|
633
|
+
if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "register":
|
|
634
|
+
raise DeploymentKitError("Malformed Cloudflare public-route plan.")
|
|
635
|
+
name = plan["targetName"]
|
|
636
|
+
scope = plan["scope"]
|
|
637
|
+
spec = plan["route"]
|
|
638
|
+
host = cloudflare_ingress_status(name, scope)
|
|
639
|
+
if _status_snapshot(host) != plan["hostStatusSnapshot"]:
|
|
640
|
+
raise DeploymentKitError("Cloudflare host ingress changed after planning; create a fresh route plan.")
|
|
641
|
+
inventory = cloudflare_route_inventory(name, scope)
|
|
642
|
+
if _route_inventory_snapshot(inventory) != plan["routeInventorySnapshot"]:
|
|
643
|
+
raise DeploymentKitError("Cloudflare public-route inventory changed after planning; create a fresh plan.")
|
|
644
|
+
_lan_route(name, spec)
|
|
645
|
+
config = cloudflare_api.get_tunnel_configuration(api_token, host["accountId"], host["tunnelId"])
|
|
646
|
+
if _config_snapshot(config) != plan["tunnelConfigSha256"]:
|
|
647
|
+
raise DeploymentKitError("Cloudflare tunnel configuration changed after planning; create a fresh route plan.")
|
|
648
|
+
records = cloudflare_api.list_dns_records(api_token, host["zoneId"], name=spec["publicHostname"])
|
|
649
|
+
current_dns = _exact_dns_record(records, spec=spec, tunnel_id=host["tunnelId"])
|
|
650
|
+
if current_dns != plan["dnsRecord"]:
|
|
651
|
+
raise DeploymentKitError("Cloudflare DNS state changed after planning; create a fresh route plan.")
|
|
652
|
+
|
|
653
|
+
profile = load_profile(name)
|
|
654
|
+
access = verify_access(profile)
|
|
655
|
+
host_written = False
|
|
656
|
+
config_written = False
|
|
657
|
+
dns_created: dict[str, Any] | None = None
|
|
658
|
+
try:
|
|
659
|
+
_run_remote(
|
|
660
|
+
profile,
|
|
661
|
+
access["address"],
|
|
662
|
+
"route-register",
|
|
663
|
+
scope=scope,
|
|
664
|
+
spec=spec,
|
|
665
|
+
expected=plan["previousRoute"],
|
|
666
|
+
)
|
|
667
|
+
host_written = True
|
|
668
|
+
if plan["desiredTunnelConfig"] != plan["tunnelConfig"]:
|
|
669
|
+
cloudflare_api.update_tunnel_configuration(
|
|
670
|
+
api_token, host["accountId"], host["tunnelId"], plan["desiredTunnelConfig"]
|
|
671
|
+
)
|
|
672
|
+
config_written = True
|
|
673
|
+
if current_dns is None:
|
|
674
|
+
dns_created = cloudflare_api.create_dns_cname(
|
|
675
|
+
api_token,
|
|
676
|
+
host["zoneId"],
|
|
677
|
+
name=spec["publicHostname"],
|
|
678
|
+
content=f"{host['tunnelId']}.cfargotunnel.com",
|
|
679
|
+
comment=plan["dnsMarker"],
|
|
680
|
+
)
|
|
681
|
+
except Exception:
|
|
682
|
+
if dns_created is not None:
|
|
683
|
+
try:
|
|
684
|
+
cloudflare_api.delete_dns_record(api_token, host["zoneId"], str(dns_created["id"]))
|
|
685
|
+
except Exception:
|
|
686
|
+
pass
|
|
687
|
+
if config_written:
|
|
688
|
+
try:
|
|
689
|
+
cloudflare_api.update_tunnel_configuration(
|
|
690
|
+
api_token, host["accountId"], host["tunnelId"], plan["tunnelConfig"]
|
|
691
|
+
)
|
|
692
|
+
except Exception:
|
|
693
|
+
pass
|
|
694
|
+
if host_written and plan["previousRoute"] is None:
|
|
695
|
+
try:
|
|
696
|
+
_run_remote(profile, access["address"], "route-remove", scope=scope, spec=spec)
|
|
697
|
+
except Exception:
|
|
698
|
+
pass
|
|
699
|
+
raise
|
|
700
|
+
|
|
701
|
+
final_inventory = cloudflare_route_inventory(name, scope)
|
|
702
|
+
if _find_route(final_inventory, spec["routeId"]) != spec:
|
|
703
|
+
raise DeploymentKitError("Public route registration returned without exact host ownership metadata.")
|
|
704
|
+
final_config = cloudflare_api.get_tunnel_configuration(api_token, host["accountId"], host["tunnelId"])
|
|
705
|
+
if final_config != plan["desiredTunnelConfig"]:
|
|
706
|
+
raise DeploymentKitError("Cloudflare tunnel configuration does not match the reviewed route plan after apply.")
|
|
707
|
+
final_dns = _exact_dns_record(
|
|
708
|
+
cloudflare_api.list_dns_records(api_token, host["zoneId"], name=spec["publicHostname"]),
|
|
709
|
+
spec=spec,
|
|
710
|
+
tunnel_id=host["tunnelId"],
|
|
711
|
+
)
|
|
712
|
+
if final_dns is None:
|
|
713
|
+
raise DeploymentKitError("Cloudflare DNS record is missing after public-route apply.")
|
|
714
|
+
return {
|
|
715
|
+
"targetName": name,
|
|
716
|
+
"scope": scope,
|
|
717
|
+
"route": spec,
|
|
718
|
+
"dnsRecordId": str(final_dns["id"]),
|
|
719
|
+
"publicUrl": f"https://{spec['publicHostname']}",
|
|
720
|
+
"mutated": True,
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def plan_public_route_removal(
|
|
725
|
+
name: str,
|
|
726
|
+
*,
|
|
727
|
+
scope: str,
|
|
728
|
+
owner_repo: str,
|
|
729
|
+
environment: str,
|
|
730
|
+
app_slug: str,
|
|
731
|
+
api_token: str,
|
|
732
|
+
) -> dict[str, Any]:
|
|
733
|
+
route_id = f"{_slug(app_slug, 'appSlug')}--{_slug(environment, 'environment')}"
|
|
734
|
+
owner_repo = _owner_repo(owner_repo)
|
|
735
|
+
host = cloudflare_ingress_status(name, scope)
|
|
736
|
+
if host["state"] != "ready":
|
|
737
|
+
raise DeploymentKitError("Cloudflare ingress must be ready before removing a managed public route.")
|
|
738
|
+
inventory = cloudflare_route_inventory(name, scope)
|
|
739
|
+
route = _find_route(inventory, route_id)
|
|
740
|
+
if route is None:
|
|
741
|
+
raise DeploymentKitError(f"Managed Cloudflare public route does not exist: {route_id}")
|
|
742
|
+
if route["ownerRepo"] != owner_repo or route["environment"] != environment or route["appSlug"] != app_slug:
|
|
743
|
+
raise DeploymentKitError("Cloudflare public-route removal ownership does not match.")
|
|
744
|
+
config = cloudflare_api.get_tunnel_configuration(api_token, host["accountId"], host["tunnelId"])
|
|
745
|
+
rules, final_rule = _split_ingress(config)
|
|
746
|
+
expected_rule = _cloudflare_rule(route)
|
|
747
|
+
matches = [item for item in rules if item == expected_rule]
|
|
748
|
+
if len(matches) != 1:
|
|
749
|
+
raise DeploymentKitError("Cloudflare tunnel does not contain exactly one expected managed rule for this route.")
|
|
750
|
+
desired = {**config, "ingress": [item for item in rules if item != expected_rule] + [final_rule]}
|
|
751
|
+
dns = _exact_dns_record(
|
|
752
|
+
cloudflare_api.list_dns_records(api_token, host["zoneId"], name=route["publicHostname"]),
|
|
753
|
+
spec=route,
|
|
754
|
+
tunnel_id=host["tunnelId"],
|
|
755
|
+
)
|
|
756
|
+
if dns is None:
|
|
757
|
+
raise DeploymentKitError("Managed public route DNS record is missing; inspect drift before removal.")
|
|
758
|
+
return {
|
|
759
|
+
"targetName": name,
|
|
760
|
+
"scope": scope,
|
|
761
|
+
"action": "remove",
|
|
762
|
+
"route": route,
|
|
763
|
+
"routeInventorySnapshot": _route_inventory_snapshot(inventory),
|
|
764
|
+
"hostStatusSnapshot": _status_snapshot(host),
|
|
765
|
+
"tunnelConfig": config,
|
|
766
|
+
"tunnelConfigSha256": _config_snapshot(config),
|
|
767
|
+
"desiredTunnelConfig": desired,
|
|
768
|
+
"dnsRecord": deepcopy(dns),
|
|
769
|
+
"requiredAuthorization": PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE,
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def apply_public_route_removal(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
|
|
774
|
+
required = {
|
|
775
|
+
"targetName", "scope", "action", "route", "routeInventorySnapshot", "hostStatusSnapshot",
|
|
776
|
+
"tunnelConfig", "tunnelConfigSha256", "desiredTunnelConfig", "dnsRecord", "requiredAuthorization",
|
|
777
|
+
}
|
|
778
|
+
if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "remove":
|
|
779
|
+
raise DeploymentKitError("Malformed Cloudflare public-route removal plan.")
|
|
780
|
+
name = plan["targetName"]
|
|
781
|
+
scope = plan["scope"]
|
|
782
|
+
route = plan["route"]
|
|
783
|
+
host = cloudflare_ingress_status(name, scope)
|
|
784
|
+
if _status_snapshot(host) != plan["hostStatusSnapshot"]:
|
|
785
|
+
raise DeploymentKitError("Cloudflare ingress host state changed after removal planning.")
|
|
786
|
+
inventory = cloudflare_route_inventory(name, scope)
|
|
787
|
+
if _route_inventory_snapshot(inventory) != plan["routeInventorySnapshot"]:
|
|
788
|
+
raise DeploymentKitError("Cloudflare public-route inventory changed after removal planning.")
|
|
789
|
+
config = cloudflare_api.get_tunnel_configuration(api_token, host["accountId"], host["tunnelId"])
|
|
790
|
+
if _config_snapshot(config) != plan["tunnelConfigSha256"]:
|
|
791
|
+
raise DeploymentKitError("Cloudflare tunnel configuration changed after removal planning.")
|
|
792
|
+
dns = _exact_dns_record(
|
|
793
|
+
cloudflare_api.list_dns_records(api_token, host["zoneId"], name=route["publicHostname"]),
|
|
794
|
+
spec=route,
|
|
795
|
+
tunnel_id=host["tunnelId"],
|
|
796
|
+
)
|
|
797
|
+
if dns is None or str(dns.get("id")) != str(plan["dnsRecord"].get("id")):
|
|
798
|
+
raise DeploymentKitError("Cloudflare DNS record changed after removal planning.")
|
|
799
|
+
|
|
800
|
+
cloudflare_api.update_tunnel_configuration(
|
|
801
|
+
api_token, host["accountId"], host["tunnelId"], plan["desiredTunnelConfig"]
|
|
802
|
+
)
|
|
803
|
+
try:
|
|
804
|
+
cloudflare_api.delete_dns_record(api_token, host["zoneId"], str(dns["id"]))
|
|
805
|
+
except Exception:
|
|
806
|
+
cloudflare_api.update_tunnel_configuration(
|
|
807
|
+
api_token, host["accountId"], host["tunnelId"], plan["tunnelConfig"]
|
|
808
|
+
)
|
|
809
|
+
raise
|
|
810
|
+
|
|
811
|
+
profile = load_profile(name)
|
|
812
|
+
access = verify_access(profile)
|
|
813
|
+
try:
|
|
814
|
+
_run_remote(profile, access["address"], "route-remove", scope=scope, spec=route)
|
|
815
|
+
except Exception:
|
|
816
|
+
try:
|
|
817
|
+
cloudflare_api.update_tunnel_configuration(
|
|
818
|
+
api_token, host["accountId"], host["tunnelId"], plan["tunnelConfig"]
|
|
819
|
+
)
|
|
820
|
+
cloudflare_api.create_dns_cname(
|
|
821
|
+
api_token,
|
|
822
|
+
host["zoneId"],
|
|
823
|
+
name=route["publicHostname"],
|
|
824
|
+
content=f"{host['tunnelId']}.cfargotunnel.com",
|
|
825
|
+
comment=_dns_marker(route),
|
|
826
|
+
)
|
|
827
|
+
except Exception:
|
|
828
|
+
pass
|
|
829
|
+
raise
|
|
830
|
+
|
|
831
|
+
final = cloudflare_route_inventory(name, scope)
|
|
832
|
+
if _find_route(final, route["routeId"]) is not None:
|
|
833
|
+
raise DeploymentKitError("Cloudflare public route remains in host ownership registry after removal.")
|
|
834
|
+
return {"targetName": name, "scope": scope, "routeId": route["routeId"], "mutated": True}
|