@mriekki/project-forge 4.10.3 → 4.10.4
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/generated/ASSEMBLY.json +9 -9
- package/assets/generated/deployment/docs/CLOUDFLARE_INGRESS.md +13 -0
- package/assets/generated/deployment/src/project_forge_deployment/cloudflare_ingress.py +353 -59
- package/assets/generated/deployment/src/project_forge_deployment/cloudflare_ingress_remote.py +10 -0
- package/assets/generated/deployment/src/project_forge_deployment/cloudflare_route_apply.py +65 -38
- package/assets/generated/deployment/src/project_forge_deployment/errors.py +4 -0
- package/assets/generated/deployment/src/project_forge_deployment/ingress_cli.py +1 -2
- package/assets/generated/deployment/src/project_forge_deployment/teardown.py +2 -1
- 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.10.
|
|
4
|
+
"version": "4.10.4",
|
|
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": "8df115fe5c27fcf6647d7e87258166616e8e2e53",
|
|
25
25
|
"distribution": "greenfield-project",
|
|
26
26
|
"descriptor": "assets/deployment/distribution.json",
|
|
27
27
|
"sourceRoot": "assets/generated/deployment",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.10.
|
|
4
|
+
"version": "4.10.4",
|
|
5
5
|
"components": {
|
|
6
6
|
"core": {
|
|
7
7
|
"revision": "8796fd8dc22df8b64078e5b460e1008a8c7a5f7d",
|
|
@@ -326,7 +326,7 @@
|
|
|
326
326
|
]
|
|
327
327
|
},
|
|
328
328
|
"deployment": {
|
|
329
|
-
"revision": "
|
|
329
|
+
"revision": "8df115fe5c27fcf6647d7e87258166616e8e2e53",
|
|
330
330
|
"distribution": "greenfield-project",
|
|
331
331
|
"files": [
|
|
332
332
|
{
|
|
@@ -402,17 +402,17 @@
|
|
|
402
402
|
{
|
|
403
403
|
"source": "src/project_forge_deployment/cloudflare_ingress.py",
|
|
404
404
|
"destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_ingress.py",
|
|
405
|
-
"sha256": "
|
|
405
|
+
"sha256": "f462bcc1cb9d1fd20a93cf7fa6ea00402df0be69507308ffbd7e2af0d0f2900f"
|
|
406
406
|
},
|
|
407
407
|
{
|
|
408
408
|
"source": "src/project_forge_deployment/cloudflare_ingress_remote.py",
|
|
409
409
|
"destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_ingress_remote.py",
|
|
410
|
-
"sha256": "
|
|
410
|
+
"sha256": "6f92ec8b50baeb6894f096935450f3cf1f86abf5313a681ee3c5bca2283ddc99"
|
|
411
411
|
},
|
|
412
412
|
{
|
|
413
413
|
"source": "src/project_forge_deployment/cloudflare_route_apply.py",
|
|
414
414
|
"destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_route_apply.py",
|
|
415
|
-
"sha256": "
|
|
415
|
+
"sha256": "ddcc5caf9006402b77c57eb77d9777f4e5b7c7a5c5847770f5963b29935c8984"
|
|
416
416
|
},
|
|
417
417
|
{
|
|
418
418
|
"source": "src/project_forge_deployment/cli.py",
|
|
@@ -452,7 +452,7 @@
|
|
|
452
452
|
{
|
|
453
453
|
"source": "src/project_forge_deployment/errors.py",
|
|
454
454
|
"destination": "tools/project-forge-deployment/project_forge_deployment/errors.py",
|
|
455
|
-
"sha256": "
|
|
455
|
+
"sha256": "e92719788aa163890f5970b2f7e3282bf5690c2340d0abba6a9a14e9b935ddfd"
|
|
456
456
|
},
|
|
457
457
|
{
|
|
458
458
|
"source": "src/project_forge_deployment/home_lab.py",
|
|
@@ -467,7 +467,7 @@
|
|
|
467
467
|
{
|
|
468
468
|
"source": "src/project_forge_deployment/ingress_cli.py",
|
|
469
469
|
"destination": "tools/project-forge-deployment/project_forge_deployment/ingress_cli.py",
|
|
470
|
-
"sha256": "
|
|
470
|
+
"sha256": "59ff40843573da74dc430b517130f300b3eb2f2e4f00de0eb6dab5f9efb27692"
|
|
471
471
|
},
|
|
472
472
|
{
|
|
473
473
|
"source": "src/project_forge_deployment/keys.py",
|
|
@@ -577,7 +577,7 @@
|
|
|
577
577
|
{
|
|
578
578
|
"source": "src/project_forge_deployment/teardown.py",
|
|
579
579
|
"destination": "tools/project-forge-deployment/project_forge_deployment/teardown.py",
|
|
580
|
-
"sha256": "
|
|
580
|
+
"sha256": "4c4516c06f8b8aa13c125fc06e47633197c50f0301fb20d0005d79952a8e92d6"
|
|
581
581
|
},
|
|
582
582
|
{
|
|
583
583
|
"source": "src/project_forge_deployment/teardown_cli.py",
|
|
@@ -602,7 +602,7 @@
|
|
|
602
602
|
{
|
|
603
603
|
"source": "docs/CLOUDFLARE_INGRESS.md",
|
|
604
604
|
"destination": "docs/project-forge/CLOUDFLARE_INGRESS.md",
|
|
605
|
-
"sha256": "
|
|
605
|
+
"sha256": "603cf5e732439efdb4f628a16555eba1226db9ef0a665872fd0341c85f9050ee"
|
|
606
606
|
},
|
|
607
607
|
{
|
|
608
608
|
"source": "docs/DEVELOPER_ENVIRONMENTS.md",
|
|
@@ -141,6 +141,19 @@ Registration requires:
|
|
|
141
141
|
[PUBLIC INGRESS ROUTE WRITE]
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
+
Route registration, removal, and post-write recovery hold the target's shared
|
|
145
|
+
per-scope lock across preflight and provider writes. This serializes Project
|
|
146
|
+
Forge route operations using the same target and prevents one operation's
|
|
147
|
+
whole-tunnel configuration rollback from replacing another concurrent managed
|
|
148
|
+
route change. Recovery also rechecks the reviewed host binding before reading
|
|
149
|
+
or changing provider state; if that binding changed or ownership is uncertain,
|
|
150
|
+
it stops and reports the state for inspection instead of attempting rollback.
|
|
151
|
+
|
|
152
|
+
The target lock coordinates Deployment Kit operations; it is not a Cloudflare
|
|
153
|
+
API compare-and-swap and cannot serialize manual or external dashboard/API
|
|
154
|
+
edits. Such drift is checked against the reviewed configuration and recovery
|
|
155
|
+
fails closed rather than restoring over an unrecognized configuration.
|
|
156
|
+
|
|
144
157
|
Remove:
|
|
145
158
|
|
|
146
159
|
```text
|
|
@@ -14,15 +14,17 @@ import json
|
|
|
14
14
|
import re
|
|
15
15
|
import shlex
|
|
16
16
|
import subprocess
|
|
17
|
+
import tempfile
|
|
17
18
|
import urllib.parse
|
|
19
|
+
from contextlib import contextmanager
|
|
18
20
|
from copy import deepcopy
|
|
19
|
-
from typing import Any
|
|
21
|
+
from typing import Any, Iterator
|
|
20
22
|
|
|
21
23
|
from . import cloudflare_api
|
|
22
24
|
from .box_platform import platform_status
|
|
23
25
|
from .box_platform_assets import CADDY_CONTAINER, NETWORK_NAME, PLATFORM_ROOT
|
|
24
26
|
from .cloudflare_ingress_remote import REMOTE_SCRIPT
|
|
25
|
-
from .errors import DeploymentKitError
|
|
27
|
+
from .errors import CloudflarePostWriteVerificationError, DeploymentKitError
|
|
26
28
|
from .profiles import load_profile
|
|
27
29
|
from .route_registry import route_inventory
|
|
28
30
|
from .ssh import ssh_args, verify_access
|
|
@@ -38,10 +40,6 @@ PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE = "[PUBLIC INGRESS ROUTE REMOVE]"
|
|
|
38
40
|
CLOUDFLARE_API_TOKEN_ENV = "PROJECT_FORGE_CLOUDFLARE_API_TOKEN"
|
|
39
41
|
|
|
40
42
|
|
|
41
|
-
class CloudflarePostWriteVerificationError(DeploymentKitError):
|
|
42
|
-
"""Final public-route verification failed after mutations completed."""
|
|
43
|
-
|
|
44
|
-
|
|
45
43
|
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
|
46
44
|
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
47
45
|
_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
|
@@ -202,14 +200,17 @@ def _run_remote(
|
|
|
202
200
|
expected: dict[str, Any] | None = None,
|
|
203
201
|
tunnel_token: str | None = None,
|
|
204
202
|
) -> str:
|
|
205
|
-
if action not in {
|
|
203
|
+
if action not in {
|
|
204
|
+
"status", "install", "upgrade", "route-inventory",
|
|
205
|
+
"route-register", "route-remove", "route-register-locked", "route-remove-locked",
|
|
206
|
+
}:
|
|
206
207
|
raise DeploymentKitError(f"Unsupported Cloudflare ingress action: {action}")
|
|
207
208
|
args = [action, _slug(scope, "scope")]
|
|
208
209
|
args.append(_metadata_tsv(metadata) if metadata is not None else "")
|
|
209
210
|
args.append(_route_tsv(spec) if spec is not None else "")
|
|
210
|
-
if action
|
|
211
|
+
if action in {"route-register", "route-register-locked"}:
|
|
211
212
|
args.append(_route_tsv(expected) if expected is not None else "NONE")
|
|
212
|
-
elif action
|
|
213
|
+
elif action in {"route-remove", "route-remove-locked"}:
|
|
213
214
|
args.append("")
|
|
214
215
|
remote_command = "sudo -n bash -s -- " + " ".join(shlex.quote(item) for item in args)
|
|
215
216
|
completed = subprocess.run(
|
|
@@ -226,6 +227,97 @@ def _run_remote(
|
|
|
226
227
|
return stdout
|
|
227
228
|
|
|
228
229
|
|
|
230
|
+
@contextmanager
|
|
231
|
+
def route_transaction_lock(name: str, scope: str) -> Iterator[None]:
|
|
232
|
+
"""Serialize route mutations and recovery through the target's shared-scope lock."""
|
|
233
|
+
scope = _slug(scope, "scope")
|
|
234
|
+
profile = load_profile(name)
|
|
235
|
+
access = verify_access(profile)
|
|
236
|
+
remote_command = "sudo -n bash -s -- " + " ".join(
|
|
237
|
+
shlex.quote(item) for item in ("route-lock", scope, "", "", "")
|
|
238
|
+
)
|
|
239
|
+
args = ssh_args(profile, access["address"], command=remote_command)
|
|
240
|
+
args[1:1] = ["-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2"]
|
|
241
|
+
|
|
242
|
+
with tempfile.TemporaryFile() as stderr_file:
|
|
243
|
+
try:
|
|
244
|
+
process = subprocess.Popen(
|
|
245
|
+
args,
|
|
246
|
+
stdin=subprocess.PIPE,
|
|
247
|
+
stdout=subprocess.PIPE,
|
|
248
|
+
stderr=stderr_file,
|
|
249
|
+
)
|
|
250
|
+
except OSError as error:
|
|
251
|
+
raise DeploymentKitError(f"Could not start the Cloudflare route transaction lock: {error}") from error
|
|
252
|
+
if process.stdin is None or process.stdout is None:
|
|
253
|
+
process.kill()
|
|
254
|
+
process.wait()
|
|
255
|
+
raise DeploymentKitError("Could not establish the Cloudflare route transaction lock pipes.")
|
|
256
|
+
try:
|
|
257
|
+
process.stdin.write(build_remote_script().encode("utf-8"))
|
|
258
|
+
process.stdin.flush()
|
|
259
|
+
ready = process.stdout.readline()
|
|
260
|
+
except OSError as error:
|
|
261
|
+
try:
|
|
262
|
+
process.stdin.close()
|
|
263
|
+
except OSError:
|
|
264
|
+
pass
|
|
265
|
+
try:
|
|
266
|
+
process.wait(timeout=20)
|
|
267
|
+
except subprocess.TimeoutExpired:
|
|
268
|
+
process.kill()
|
|
269
|
+
process.wait()
|
|
270
|
+
raise DeploymentKitError(f"Could not establish the Cloudflare route transaction lock: {error}") from error
|
|
271
|
+
if ready != b"LOCKED\n":
|
|
272
|
+
try:
|
|
273
|
+
process.stdin.close()
|
|
274
|
+
except OSError:
|
|
275
|
+
pass
|
|
276
|
+
try:
|
|
277
|
+
return_code = process.wait(timeout=20)
|
|
278
|
+
except subprocess.TimeoutExpired:
|
|
279
|
+
process.kill()
|
|
280
|
+
return_code = process.wait()
|
|
281
|
+
stderr_file.seek(0)
|
|
282
|
+
detail = stderr_file.read().decode("utf-8", errors="replace").strip()
|
|
283
|
+
if not detail:
|
|
284
|
+
detail = f"remote lock exited with status {return_code}"
|
|
285
|
+
raise DeploymentKitError(f"Could not acquire the Cloudflare route transaction lock: {detail}")
|
|
286
|
+
|
|
287
|
+
body_failed = False
|
|
288
|
+
try:
|
|
289
|
+
yield
|
|
290
|
+
except BaseException:
|
|
291
|
+
body_failed = True
|
|
292
|
+
raise
|
|
293
|
+
finally:
|
|
294
|
+
release_error: OSError | None = None
|
|
295
|
+
try:
|
|
296
|
+
process.stdin.close()
|
|
297
|
+
except OSError as error:
|
|
298
|
+
release_error = error
|
|
299
|
+
timed_out = False
|
|
300
|
+
try:
|
|
301
|
+
return_code = process.wait(timeout=20)
|
|
302
|
+
except subprocess.TimeoutExpired:
|
|
303
|
+
process.kill()
|
|
304
|
+
process.wait()
|
|
305
|
+
timed_out = True
|
|
306
|
+
if not body_failed and timed_out:
|
|
307
|
+
raise DeploymentKitError("Cloudflare route transaction lock did not release cleanly.")
|
|
308
|
+
if not body_failed and release_error is not None:
|
|
309
|
+
raise DeploymentKitError(
|
|
310
|
+
f"Could not release the Cloudflare route transaction lock: {release_error}"
|
|
311
|
+
) from release_error
|
|
312
|
+
if return_code != 0 and not body_failed:
|
|
313
|
+
stderr_file.seek(0)
|
|
314
|
+
detail = stderr_file.read().decode("utf-8", errors="replace").strip()
|
|
315
|
+
raise DeploymentKitError(
|
|
316
|
+
"Cloudflare route transaction lock exited unsuccessfully"
|
|
317
|
+
+ (f": {detail}" if detail else f" (status {return_code}).")
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
229
321
|
def cloudflare_ingress_status(name: str, scope: str) -> dict[str, Any]:
|
|
230
322
|
scope = _slug(scope, "scope")
|
|
231
323
|
profile = load_profile(name)
|
|
@@ -261,6 +353,53 @@ def _config_snapshot(config: dict[str, Any]) -> str:
|
|
|
261
353
|
return hashlib.sha256(json.dumps(config, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
262
354
|
|
|
263
355
|
|
|
356
|
+
def _reviewed_config_matches(actual: Any, reviewed: Any) -> bool:
|
|
357
|
+
if not isinstance(actual, dict) or not isinstance(reviewed, dict):
|
|
358
|
+
return False
|
|
359
|
+
normalized = deepcopy(actual)
|
|
360
|
+
if "warp-routing" not in reviewed and normalized.get("warp-routing") == {"enabled": False}:
|
|
361
|
+
normalized.pop("warp-routing")
|
|
362
|
+
return normalized == reviewed
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _restore_tunnel_configuration_if_unchanged(
|
|
366
|
+
api_token: str,
|
|
367
|
+
host: dict[str, Any],
|
|
368
|
+
*,
|
|
369
|
+
expected_current: dict[str, Any],
|
|
370
|
+
previous: dict[str, Any],
|
|
371
|
+
) -> None:
|
|
372
|
+
current = cloudflare_api.get_tunnel_configuration(
|
|
373
|
+
api_token, host["accountId"], host["tunnelId"]
|
|
374
|
+
)
|
|
375
|
+
if _reviewed_config_matches(current, previous):
|
|
376
|
+
return
|
|
377
|
+
if not _reviewed_config_matches(current, expected_current):
|
|
378
|
+
raise DeploymentKitError(
|
|
379
|
+
"Tunnel configuration rollback was skipped because current provider state changed "
|
|
380
|
+
"after this operation; concurrent changes were preserved."
|
|
381
|
+
)
|
|
382
|
+
try:
|
|
383
|
+
cloudflare_api.update_tunnel_configuration(
|
|
384
|
+
api_token, host["accountId"], host["tunnelId"], previous
|
|
385
|
+
)
|
|
386
|
+
except Exception as write_error:
|
|
387
|
+
try:
|
|
388
|
+
restored = cloudflare_api.get_tunnel_configuration(
|
|
389
|
+
api_token, host["accountId"], host["tunnelId"]
|
|
390
|
+
)
|
|
391
|
+
except Exception as read_error:
|
|
392
|
+
raise DeploymentKitError(
|
|
393
|
+
"Tunnel configuration rollback response was uncertain and current state could "
|
|
394
|
+
f"not be verified: {read_error}"
|
|
395
|
+
) from write_error
|
|
396
|
+
if not _reviewed_config_matches(restored, previous):
|
|
397
|
+
raise DeploymentKitError(
|
|
398
|
+
"Tunnel configuration rollback failed and current state does not match the "
|
|
399
|
+
f"previous configuration: {write_error}"
|
|
400
|
+
) from write_error
|
|
401
|
+
|
|
402
|
+
|
|
264
403
|
def _tunnel_summary(tunnel: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
265
404
|
if tunnel is None:
|
|
266
405
|
return None
|
|
@@ -564,6 +703,67 @@ def _exact_dns_record(records: list[dict[str, Any]], *, spec: dict[str, Any], tu
|
|
|
564
703
|
return record
|
|
565
704
|
|
|
566
705
|
|
|
706
|
+
def _read_public_route_dns(
|
|
707
|
+
api_token: str,
|
|
708
|
+
host: dict[str, Any],
|
|
709
|
+
spec: dict[str, Any],
|
|
710
|
+
) -> dict[str, Any] | None:
|
|
711
|
+
records = cloudflare_api.list_dns_records(
|
|
712
|
+
api_token, host["zoneId"], name=spec["publicHostname"]
|
|
713
|
+
)
|
|
714
|
+
return _exact_dns_record(records, spec=spec, tunnel_id=host["tunnelId"])
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _reconcile_public_route_dns(
|
|
718
|
+
api_token: str,
|
|
719
|
+
host: dict[str, Any],
|
|
720
|
+
spec: dict[str, Any],
|
|
721
|
+
*,
|
|
722
|
+
should_exist: bool,
|
|
723
|
+
) -> None:
|
|
724
|
+
current = _read_public_route_dns(api_token, host, spec)
|
|
725
|
+
if should_exist:
|
|
726
|
+
if current is not None:
|
|
727
|
+
return
|
|
728
|
+
try:
|
|
729
|
+
cloudflare_api.create_dns_cname(
|
|
730
|
+
api_token,
|
|
731
|
+
host["zoneId"],
|
|
732
|
+
name=spec["publicHostname"],
|
|
733
|
+
content=f"{host['tunnelId']}.cfargotunnel.com",
|
|
734
|
+
comment=_dns_marker(spec),
|
|
735
|
+
)
|
|
736
|
+
except Exception as write_error:
|
|
737
|
+
try:
|
|
738
|
+
created = _read_public_route_dns(api_token, host, spec)
|
|
739
|
+
except Exception as read_error:
|
|
740
|
+
raise DeploymentKitError(
|
|
741
|
+
"DNS restoration response was uncertain and current state could not be "
|
|
742
|
+
f"verified: {read_error}"
|
|
743
|
+
) from write_error
|
|
744
|
+
if created is None:
|
|
745
|
+
raise
|
|
746
|
+
return
|
|
747
|
+
|
|
748
|
+
if current is None:
|
|
749
|
+
return
|
|
750
|
+
try:
|
|
751
|
+
cloudflare_api.delete_dns_record(api_token, host["zoneId"], str(current["id"]))
|
|
752
|
+
except Exception as write_error:
|
|
753
|
+
try:
|
|
754
|
+
remaining = _read_public_route_dns(api_token, host, spec)
|
|
755
|
+
except Exception as read_error:
|
|
756
|
+
raise DeploymentKitError(
|
|
757
|
+
"DNS cleanup response was uncertain and current state could not be "
|
|
758
|
+
f"verified: {read_error}"
|
|
759
|
+
) from write_error
|
|
760
|
+
if remaining is not None:
|
|
761
|
+
raise
|
|
762
|
+
return
|
|
763
|
+
if _read_public_route_dns(api_token, host, spec) is not None:
|
|
764
|
+
raise DeploymentKitError("Owned Cloudflare DNS record remained after cleanup.")
|
|
765
|
+
|
|
766
|
+
|
|
567
767
|
def plan_public_route(
|
|
568
768
|
name: str,
|
|
569
769
|
*,
|
|
@@ -627,7 +827,12 @@ def plan_public_route(
|
|
|
627
827
|
}
|
|
628
828
|
|
|
629
829
|
|
|
630
|
-
def apply_public_route(
|
|
830
|
+
def apply_public_route(
|
|
831
|
+
plan: dict[str, Any],
|
|
832
|
+
*,
|
|
833
|
+
api_token: str,
|
|
834
|
+
_lock_held: bool = False,
|
|
835
|
+
) -> dict[str, Any]:
|
|
631
836
|
if plan.get("action") == "already-configured":
|
|
632
837
|
return {**plan, "mutated": False}
|
|
633
838
|
required = {
|
|
@@ -637,6 +842,9 @@ def apply_public_route(plan: dict[str, Any], *, api_token: str) -> dict[str, Any
|
|
|
637
842
|
}
|
|
638
843
|
if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "register":
|
|
639
844
|
raise DeploymentKitError("Malformed Cloudflare public-route plan.")
|
|
845
|
+
if not _lock_held:
|
|
846
|
+
with route_transaction_lock(plan["targetName"], plan["scope"]):
|
|
847
|
+
return apply_public_route(plan, api_token=api_token, _lock_held=True)
|
|
640
848
|
name = plan["targetName"]
|
|
641
849
|
scope = plan["scope"]
|
|
642
850
|
spec = plan["route"]
|
|
@@ -658,77 +866,100 @@ def apply_public_route(plan: dict[str, Any], *, api_token: str) -> dict[str, Any
|
|
|
658
866
|
profile = load_profile(name)
|
|
659
867
|
access = verify_access(profile)
|
|
660
868
|
host_written = False
|
|
661
|
-
|
|
662
|
-
|
|
869
|
+
config_write_attempted = False
|
|
870
|
+
dns_create_attempted = False
|
|
663
871
|
try:
|
|
664
872
|
_run_remote(
|
|
665
873
|
profile,
|
|
666
874
|
access["address"],
|
|
667
|
-
"route-register",
|
|
875
|
+
"route-register-locked",
|
|
668
876
|
scope=scope,
|
|
669
877
|
spec=spec,
|
|
670
878
|
expected=plan["previousRoute"],
|
|
671
879
|
)
|
|
672
880
|
host_written = True
|
|
673
881
|
if plan["desiredTunnelConfig"] != plan["tunnelConfig"]:
|
|
882
|
+
config_write_attempted = True
|
|
674
883
|
cloudflare_api.update_tunnel_configuration(
|
|
675
884
|
api_token, host["accountId"], host["tunnelId"], plan["desiredTunnelConfig"]
|
|
676
885
|
)
|
|
677
|
-
config_written = True
|
|
678
886
|
if current_dns is None:
|
|
679
|
-
|
|
887
|
+
dns_create_attempted = True
|
|
888
|
+
cloudflare_api.create_dns_cname(
|
|
680
889
|
api_token,
|
|
681
890
|
host["zoneId"],
|
|
682
891
|
name=spec["publicHostname"],
|
|
683
892
|
content=f"{host['tunnelId']}.cfargotunnel.com",
|
|
684
893
|
comment=plan["dnsMarker"],
|
|
685
894
|
)
|
|
686
|
-
except Exception:
|
|
687
|
-
|
|
895
|
+
except Exception as original_error:
|
|
896
|
+
rollback_errors: list[str] = []
|
|
897
|
+
if dns_create_attempted:
|
|
688
898
|
try:
|
|
689
|
-
|
|
690
|
-
except Exception:
|
|
691
|
-
|
|
692
|
-
if
|
|
899
|
+
_reconcile_public_route_dns(api_token, host, spec, should_exist=False)
|
|
900
|
+
except Exception as error:
|
|
901
|
+
rollback_errors.append(f"DNS rollback failed: {error}")
|
|
902
|
+
if config_write_attempted:
|
|
693
903
|
try:
|
|
694
|
-
|
|
695
|
-
api_token,
|
|
904
|
+
_restore_tunnel_configuration_if_unchanged(
|
|
905
|
+
api_token,
|
|
906
|
+
host,
|
|
907
|
+
expected_current=plan["desiredTunnelConfig"],
|
|
908
|
+
previous=plan["tunnelConfig"],
|
|
696
909
|
)
|
|
697
|
-
except Exception:
|
|
698
|
-
|
|
910
|
+
except Exception as error:
|
|
911
|
+
rollback_errors.append(str(error))
|
|
699
912
|
if host_written and plan["previousRoute"] is None:
|
|
700
913
|
try:
|
|
701
|
-
_run_remote(profile, access["address"], "route-remove", scope=scope, spec=spec)
|
|
702
|
-
except Exception:
|
|
703
|
-
|
|
914
|
+
_run_remote(profile, access["address"], "route-remove-locked", scope=scope, spec=spec)
|
|
915
|
+
except Exception as error:
|
|
916
|
+
rollback_errors.append(f"Host route rollback failed: {error}")
|
|
917
|
+
if rollback_errors:
|
|
918
|
+
raise DeploymentKitError(
|
|
919
|
+
"Cloudflare public-route apply failed and rollback was incomplete; external state "
|
|
920
|
+
"may remain changed. " + "; ".join(rollback_errors)
|
|
921
|
+
) from original_error
|
|
704
922
|
raise
|
|
705
923
|
|
|
706
924
|
try:
|
|
925
|
+
final_host = cloudflare_ingress_status(name, scope)
|
|
926
|
+
if _status_snapshot(final_host) != plan["hostStatusSnapshot"]:
|
|
927
|
+
raise CloudflarePostWriteVerificationError(
|
|
928
|
+
"Cloudflare host binding changed during public-route apply."
|
|
929
|
+
)
|
|
707
930
|
final_inventory = cloudflare_route_inventory(name, scope)
|
|
708
931
|
if _find_route(final_inventory, spec["routeId"]) != spec:
|
|
709
|
-
raise
|
|
932
|
+
raise CloudflarePostWriteVerificationError(
|
|
933
|
+
"Public route registration returned without exact host ownership metadata."
|
|
934
|
+
)
|
|
710
935
|
final_config = cloudflare_api.get_tunnel_configuration(api_token, host["accountId"], host["tunnelId"])
|
|
711
936
|
if final_config != plan["desiredTunnelConfig"]:
|
|
712
|
-
raise
|
|
937
|
+
raise CloudflarePostWriteVerificationError(
|
|
938
|
+
"Cloudflare tunnel configuration does not match the reviewed route plan after apply."
|
|
939
|
+
)
|
|
713
940
|
final_dns = _exact_dns_record(
|
|
714
941
|
cloudflare_api.list_dns_records(api_token, host["zoneId"], name=spec["publicHostname"]),
|
|
715
942
|
spec=spec,
|
|
716
943
|
tunnel_id=host["tunnelId"],
|
|
717
944
|
)
|
|
718
945
|
if final_dns is None:
|
|
719
|
-
raise
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
"dnsRecordId": str(final_dns["id"]),
|
|
725
|
-
"publicUrl": f"https://{spec['publicHostname']}",
|
|
726
|
-
"mutated": True,
|
|
727
|
-
}
|
|
946
|
+
raise CloudflarePostWriteVerificationError(
|
|
947
|
+
"Cloudflare DNS record is missing after public-route apply."
|
|
948
|
+
)
|
|
949
|
+
except CloudflarePostWriteVerificationError:
|
|
950
|
+
raise
|
|
728
951
|
except Exception as error:
|
|
729
952
|
raise CloudflarePostWriteVerificationError(
|
|
730
|
-
f"Cloudflare public-route post-write verification
|
|
953
|
+
f"Cloudflare public-route post-write verification could not confirm provider state: {error}"
|
|
731
954
|
) from error
|
|
955
|
+
return {
|
|
956
|
+
"targetName": name,
|
|
957
|
+
"scope": scope,
|
|
958
|
+
"route": spec,
|
|
959
|
+
"dnsRecordId": str(final_dns["id"]),
|
|
960
|
+
"publicUrl": f"https://{spec['publicHostname']}",
|
|
961
|
+
"mutated": True,
|
|
962
|
+
}
|
|
732
963
|
|
|
733
964
|
|
|
734
965
|
def plan_public_route_removal(
|
|
@@ -780,13 +1011,21 @@ def plan_public_route_removal(
|
|
|
780
1011
|
}
|
|
781
1012
|
|
|
782
1013
|
|
|
783
|
-
def apply_public_route_removal(
|
|
1014
|
+
def apply_public_route_removal(
|
|
1015
|
+
plan: dict[str, Any],
|
|
1016
|
+
*,
|
|
1017
|
+
api_token: str,
|
|
1018
|
+
_lock_held: bool = False,
|
|
1019
|
+
) -> dict[str, Any]:
|
|
784
1020
|
required = {
|
|
785
1021
|
"targetName", "scope", "action", "route", "routeInventorySnapshot", "hostStatusSnapshot",
|
|
786
1022
|
"tunnelConfig", "tunnelConfigSha256", "desiredTunnelConfig", "dnsRecord", "requiredAuthorization",
|
|
787
1023
|
}
|
|
788
1024
|
if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "remove":
|
|
789
1025
|
raise DeploymentKitError("Malformed Cloudflare public-route removal plan.")
|
|
1026
|
+
if not _lock_held:
|
|
1027
|
+
with route_transaction_lock(plan["targetName"], plan["scope"]):
|
|
1028
|
+
return apply_public_route_removal(plan, api_token=api_token, _lock_held=True)
|
|
790
1029
|
name = plan["targetName"]
|
|
791
1030
|
scope = plan["scope"]
|
|
792
1031
|
route = plan["route"]
|
|
@@ -807,35 +1046,90 @@ def apply_public_route_removal(plan: dict[str, Any], *, api_token: str) -> dict[
|
|
|
807
1046
|
if dns is None or str(dns.get("id")) != str(plan["dnsRecord"].get("id")):
|
|
808
1047
|
raise DeploymentKitError("Cloudflare DNS record changed after removal planning.")
|
|
809
1048
|
|
|
810
|
-
cloudflare_api.update_tunnel_configuration(
|
|
811
|
-
api_token, host["accountId"], host["tunnelId"], plan["desiredTunnelConfig"]
|
|
812
|
-
)
|
|
813
1049
|
try:
|
|
814
|
-
cloudflare_api.delete_dns_record(api_token, host["zoneId"], str(dns["id"]))
|
|
815
|
-
except Exception:
|
|
816
1050
|
cloudflare_api.update_tunnel_configuration(
|
|
817
|
-
api_token, host["accountId"], host["tunnelId"], plan["
|
|
1051
|
+
api_token, host["accountId"], host["tunnelId"], plan["desiredTunnelConfig"]
|
|
818
1052
|
)
|
|
1053
|
+
except Exception as original_error:
|
|
1054
|
+
try:
|
|
1055
|
+
_restore_tunnel_configuration_if_unchanged(
|
|
1056
|
+
api_token,
|
|
1057
|
+
host,
|
|
1058
|
+
expected_current=plan["desiredTunnelConfig"],
|
|
1059
|
+
previous=plan["tunnelConfig"],
|
|
1060
|
+
)
|
|
1061
|
+
except Exception as rollback_error:
|
|
1062
|
+
raise DeploymentKitError(
|
|
1063
|
+
"Cloudflare public-route removal failed during tunnel update and rollback was "
|
|
1064
|
+
f"incomplete: {rollback_error}"
|
|
1065
|
+
) from original_error
|
|
1066
|
+
raise
|
|
1067
|
+
try:
|
|
1068
|
+
cloudflare_api.delete_dns_record(api_token, host["zoneId"], str(dns["id"]))
|
|
1069
|
+
except Exception as original_error:
|
|
1070
|
+
rollback_errors: list[str] = []
|
|
1071
|
+
try:
|
|
1072
|
+
_restore_tunnel_configuration_if_unchanged(
|
|
1073
|
+
api_token,
|
|
1074
|
+
host,
|
|
1075
|
+
expected_current=plan["desiredTunnelConfig"],
|
|
1076
|
+
previous=plan["tunnelConfig"],
|
|
1077
|
+
)
|
|
1078
|
+
except Exception as rollback_error:
|
|
1079
|
+
rollback_errors.append(f"Tunnel configuration rollback failed: {rollback_error}")
|
|
1080
|
+
try:
|
|
1081
|
+
_reconcile_public_route_dns(api_token, host, route, should_exist=True)
|
|
1082
|
+
except Exception as rollback_error:
|
|
1083
|
+
rollback_errors.append(f"DNS rollback failed: {rollback_error}")
|
|
1084
|
+
if rollback_errors:
|
|
1085
|
+
raise DeploymentKitError(
|
|
1086
|
+
"Cloudflare public-route removal failed and rollback was incomplete; external "
|
|
1087
|
+
"state may remain changed. " + "; ".join(rollback_errors)
|
|
1088
|
+
) from original_error
|
|
819
1089
|
raise
|
|
820
1090
|
|
|
821
1091
|
profile = load_profile(name)
|
|
822
1092
|
access = verify_access(profile)
|
|
823
1093
|
try:
|
|
824
|
-
_run_remote(profile, access["address"], "route-remove", scope=scope, spec=route)
|
|
825
|
-
except Exception:
|
|
1094
|
+
_run_remote(profile, access["address"], "route-remove-locked", scope=scope, spec=route)
|
|
1095
|
+
except Exception as original_error:
|
|
1096
|
+
rollback_errors: list[str] = []
|
|
826
1097
|
try:
|
|
827
|
-
|
|
828
|
-
api_token, host["accountId"], host["tunnelId"], plan["tunnelConfig"]
|
|
829
|
-
)
|
|
830
|
-
cloudflare_api.create_dns_cname(
|
|
1098
|
+
_restore_tunnel_configuration_if_unchanged(
|
|
831
1099
|
api_token,
|
|
832
|
-
host
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
comment=_dns_marker(route),
|
|
1100
|
+
host,
|
|
1101
|
+
expected_current=plan["desiredTunnelConfig"],
|
|
1102
|
+
previous=plan["tunnelConfig"],
|
|
836
1103
|
)
|
|
837
|
-
except Exception:
|
|
838
|
-
|
|
1104
|
+
except Exception as error:
|
|
1105
|
+
rollback_errors.append(f"Tunnel configuration rollback failed: {error}")
|
|
1106
|
+
try:
|
|
1107
|
+
_reconcile_public_route_dns(api_token, host, route, should_exist=True)
|
|
1108
|
+
except Exception as error:
|
|
1109
|
+
rollback_errors.append(f"DNS rollback failed: {error}")
|
|
1110
|
+
try:
|
|
1111
|
+
current_inventory = cloudflare_route_inventory(name, scope)
|
|
1112
|
+
current_route = _find_route(current_inventory, route["routeId"])
|
|
1113
|
+
if current_route is None:
|
|
1114
|
+
_run_remote(
|
|
1115
|
+
profile,
|
|
1116
|
+
access["address"],
|
|
1117
|
+
"route-register-locked",
|
|
1118
|
+
scope=scope,
|
|
1119
|
+
spec=route,
|
|
1120
|
+
expected=None,
|
|
1121
|
+
)
|
|
1122
|
+
elif current_route != route:
|
|
1123
|
+
raise DeploymentKitError(
|
|
1124
|
+
"Host route restoration was skipped because route ownership changed."
|
|
1125
|
+
)
|
|
1126
|
+
except Exception as error:
|
|
1127
|
+
rollback_errors.append(f"Host route rollback failed: {error}")
|
|
1128
|
+
if rollback_errors:
|
|
1129
|
+
raise DeploymentKitError(
|
|
1130
|
+
"Cloudflare public-route removal failed and rollback was incomplete; external "
|
|
1131
|
+
"state may remain changed. " + "; ".join(rollback_errors)
|
|
1132
|
+
) from original_error
|
|
839
1133
|
raise
|
|
840
1134
|
|
|
841
1135
|
final = cloudflare_route_inventory(name, scope)
|
package/assets/generated/deployment/src/project_forge_deployment/cloudflare_ingress_remote.py
CHANGED
|
@@ -305,6 +305,12 @@ case "$ACTION" in
|
|
|
305
305
|
status)
|
|
306
306
|
status
|
|
307
307
|
;;
|
|
308
|
+
route-lock)
|
|
309
|
+
exec 9>>"$LOCK_FILE"
|
|
310
|
+
flock -n 9 || { echo "Another Cloudflare ingress operation holds the lock." >&2; exit 75; }
|
|
311
|
+
printf 'LOCKED\n'
|
|
312
|
+
cat >/dev/null
|
|
313
|
+
;;
|
|
308
314
|
install|upgrade)
|
|
309
315
|
exec 9>>"$LOCK_FILE"
|
|
310
316
|
flock -n 9 || { echo "Another Cloudflare ingress operation holds the lock." >&2; exit 75; }
|
|
@@ -320,6 +326,10 @@ case "$ACTION" in
|
|
|
320
326
|
if [[ "$ACTION" == route-register ]]; then route_register; else route_remove; fi
|
|
321
327
|
route_inventory
|
|
322
328
|
;;
|
|
329
|
+
route-register-locked|route-remove-locked)
|
|
330
|
+
if [[ "$ACTION" == route-register-locked ]]; then route_register; else route_remove; fi
|
|
331
|
+
route_inventory
|
|
332
|
+
;;
|
|
323
333
|
*)
|
|
324
334
|
echo "Unsupported Cloudflare ingress action: $ACTION" >&2
|
|
325
335
|
exit 2
|
|
@@ -8,24 +8,16 @@ all reviewed ingress rules and every other configuration value remain exact.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
from copy import deepcopy
|
|
12
11
|
from typing import Any
|
|
13
12
|
|
|
14
13
|
from . import cloudflare_api
|
|
15
14
|
from . import cloudflare_ingress as ingress
|
|
16
|
-
from .errors import DeploymentKitError
|
|
17
|
-
from .profiles import load_profile
|
|
18
|
-
from .ssh import verify_access
|
|
15
|
+
from .errors import CloudflarePostWriteVerificationError, DeploymentKitError
|
|
19
16
|
|
|
20
17
|
|
|
21
18
|
def _reviewed_config_matches(actual: Any, reviewed: Any) -> bool:
|
|
22
19
|
"""Match reviewed config after removing only the observed safe provider default."""
|
|
23
|
-
|
|
24
|
-
return False
|
|
25
|
-
normalized = deepcopy(actual)
|
|
26
|
-
if "warp-routing" not in reviewed and normalized.get("warp-routing") == {"enabled": False}:
|
|
27
|
-
normalized.pop("warp-routing")
|
|
28
|
-
return normalized == reviewed
|
|
20
|
+
return ingress._reviewed_config_matches(actual, reviewed)
|
|
29
21
|
|
|
30
22
|
|
|
31
23
|
def _read_final_state(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
|
|
@@ -33,6 +25,10 @@ def _read_final_state(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]
|
|
|
33
25
|
scope = plan["scope"]
|
|
34
26
|
spec = plan["route"]
|
|
35
27
|
host = ingress.cloudflare_ingress_status(name, scope)
|
|
28
|
+
if ingress._status_snapshot(host) != plan["hostStatusSnapshot"]:
|
|
29
|
+
raise DeploymentKitError(
|
|
30
|
+
"Cloudflare host binding changed after route apply; recovery was stopped before reading provider state."
|
|
31
|
+
)
|
|
36
32
|
inventory = ingress.cloudflare_route_inventory(name, scope)
|
|
37
33
|
route = ingress._find_route(inventory, spec["routeId"])
|
|
38
34
|
config = cloudflare_api.get_tunnel_configuration(api_token, host["accountId"], host["tunnelId"])
|
|
@@ -67,7 +63,12 @@ def _rollback_final_state(
|
|
|
67
63
|
dns = state["dns"]
|
|
68
64
|
if dns is not None:
|
|
69
65
|
try:
|
|
70
|
-
|
|
66
|
+
ingress._reconcile_public_route_dns(
|
|
67
|
+
api_token,
|
|
68
|
+
host,
|
|
69
|
+
spec,
|
|
70
|
+
should_exist=False,
|
|
71
|
+
)
|
|
71
72
|
except Exception as error: # pragma: no cover - provider failure detail only
|
|
72
73
|
errors.append(f"DNS rollback failed: {error}")
|
|
73
74
|
elif state["dnsError"] is not None:
|
|
@@ -77,11 +78,11 @@ def _rollback_final_state(
|
|
|
77
78
|
current = state["config"]
|
|
78
79
|
if _reviewed_config_matches(current, plan["desiredTunnelConfig"]):
|
|
79
80
|
try:
|
|
80
|
-
|
|
81
|
+
ingress._restore_tunnel_configuration_if_unchanged(
|
|
81
82
|
api_token,
|
|
82
|
-
host
|
|
83
|
-
|
|
84
|
-
plan["tunnelConfig"],
|
|
83
|
+
host,
|
|
84
|
+
expected_current=plan["desiredTunnelConfig"],
|
|
85
|
+
previous=plan["tunnelConfig"],
|
|
85
86
|
)
|
|
86
87
|
except Exception as error: # pragma: no cover - provider failure detail only
|
|
87
88
|
errors.append(f"tunnel configuration rollback failed: {error}")
|
|
@@ -92,12 +93,12 @@ def _rollback_final_state(
|
|
|
92
93
|
|
|
93
94
|
if plan["previousRoute"] is None and state["route"] == spec:
|
|
94
95
|
try:
|
|
95
|
-
profile = load_profile(plan["targetName"])
|
|
96
|
-
access = verify_access(profile)
|
|
96
|
+
profile = ingress.load_profile(plan["targetName"])
|
|
97
|
+
access = ingress.verify_access(profile)
|
|
97
98
|
ingress._run_remote(
|
|
98
99
|
profile,
|
|
99
100
|
access["address"],
|
|
100
|
-
"route-remove",
|
|
101
|
+
"route-remove-locked",
|
|
101
102
|
scope=plan["scope"],
|
|
102
103
|
spec=spec,
|
|
103
104
|
)
|
|
@@ -107,20 +108,6 @@ def _rollback_final_state(
|
|
|
107
108
|
return errors
|
|
108
109
|
|
|
109
110
|
|
|
110
|
-
def _uncertain_recovery_error(
|
|
111
|
-
original_error: Exception,
|
|
112
|
-
recovery_error: Exception,
|
|
113
|
-
) -> DeploymentKitError:
|
|
114
|
-
return DeploymentKitError(
|
|
115
|
-
"Cloudflare public-route final verification failed, and recovery could not read "
|
|
116
|
-
"the current host/provider state. External state is uncertain and rollback was "
|
|
117
|
-
"not attempted because current ownership could not be verified safely. "
|
|
118
|
-
f"Original verification error: {original_error} "
|
|
119
|
-
f"Recovery read failed: {recovery_error} "
|
|
120
|
-
"Inspect the managed host route, tunnel configuration, and DNS record before retrying."
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
|
|
124
111
|
def apply_public_route(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
|
|
125
112
|
"""Apply a reviewed public route with bounded post-write recovery.
|
|
126
113
|
|
|
@@ -129,14 +116,23 @@ def apply_public_route(plan: dict[str, Any], *, api_token: str) -> dict[str, Any
|
|
|
129
116
|
post-write verification phase, so preflight failures can never be turned
|
|
130
117
|
into success by observing later provider state.
|
|
131
118
|
"""
|
|
132
|
-
|
|
119
|
+
if (
|
|
120
|
+
not isinstance(plan, dict)
|
|
121
|
+
or plan.get("action") != "register"
|
|
122
|
+
or not {"targetName", "scope"}.issubset(plan)
|
|
123
|
+
):
|
|
133
124
|
return ingress.apply_public_route(plan, api_token=api_token)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
125
|
+
with ingress.route_transaction_lock(plan["targetName"], plan["scope"]):
|
|
126
|
+
return _apply_public_route_locked(plan, api_token=api_token)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _apply_public_route_locked(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
|
|
130
|
+
try:
|
|
131
|
+
return ingress.apply_public_route(plan, api_token=api_token, _lock_held=True)
|
|
132
|
+
except CloudflarePostWriteVerificationError as original_error:
|
|
137
133
|
required = {
|
|
138
|
-
"targetName", "scope", "route", "previousRoute", "
|
|
139
|
-
"desiredTunnelConfig", "dnsRecord", "dnsMarker",
|
|
134
|
+
"targetName", "scope", "route", "previousRoute", "hostStatusSnapshot",
|
|
135
|
+
"tunnelConfig", "desiredTunnelConfig", "dnsRecord", "dnsMarker",
|
|
140
136
|
}
|
|
141
137
|
if not required.issubset(plan):
|
|
142
138
|
raise
|
|
@@ -146,6 +142,12 @@ def apply_public_route(plan: dict[str, Any], *, api_token: str) -> dict[str, Any
|
|
|
146
142
|
except Exception as recovery_error:
|
|
147
143
|
raise _uncertain_recovery_error(original_error, recovery_error) from recovery_error
|
|
148
144
|
|
|
145
|
+
if ingress._status_snapshot(state["host"]) != plan["hostStatusSnapshot"]:
|
|
146
|
+
recovery_error = DeploymentKitError(
|
|
147
|
+
"Cloudflare host binding changed after route apply; recovery was stopped."
|
|
148
|
+
)
|
|
149
|
+
raise _uncertain_recovery_error(original_error, recovery_error) from recovery_error
|
|
150
|
+
|
|
149
151
|
route_ok = state["route"] == plan["route"]
|
|
150
152
|
config_ok = _reviewed_config_matches(state["config"], plan["desiredTunnelConfig"])
|
|
151
153
|
dns_ok = state["dns"] is not None
|
|
@@ -168,3 +170,28 @@ def apply_public_route(plan: dict[str, Any], *, api_token: str) -> dict[str, Any
|
|
|
168
170
|
+ " Inspect the managed host route, tunnel configuration, and DNS record before retrying."
|
|
169
171
|
) from original_error
|
|
170
172
|
raise original_error
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _uncertain_recovery_error(
|
|
176
|
+
original_error: Exception,
|
|
177
|
+
recovery_error: Exception,
|
|
178
|
+
) -> DeploymentKitError:
|
|
179
|
+
return DeploymentKitError(
|
|
180
|
+
"Cloudflare public-route final verification failed, and recovery could not confirm "
|
|
181
|
+
"the reviewed host binding and current provider state. External state is uncertain "
|
|
182
|
+
"and rollback was not attempted because ownership could not be verified safely. "
|
|
183
|
+
f"Original verification error: {original_error} "
|
|
184
|
+
f"Recovery check failed: {recovery_error} "
|
|
185
|
+
"Inspect the managed host route, tunnel configuration, and DNS record before retrying."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def apply_public_route_removal(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
|
|
190
|
+
if (
|
|
191
|
+
not isinstance(plan, dict)
|
|
192
|
+
or plan.get("action") != "remove"
|
|
193
|
+
or not {"targetName", "scope"}.issubset(plan)
|
|
194
|
+
):
|
|
195
|
+
return ingress.apply_public_route_removal(plan, api_token=api_token)
|
|
196
|
+
with ingress.route_transaction_lock(plan["targetName"], plan["scope"]):
|
|
197
|
+
return ingress.apply_public_route_removal(plan, api_token=api_token, _lock_held=True)
|
|
@@ -14,7 +14,6 @@ from .cloudflare_ingress import (
|
|
|
14
14
|
PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE,
|
|
15
15
|
PUBLIC_INGRESS_ROUTE_WRITE_PHRASE,
|
|
16
16
|
apply_cloudflare_ingress,
|
|
17
|
-
apply_public_route_removal,
|
|
18
17
|
cloudflare_ingress_doctor,
|
|
19
18
|
cloudflare_ingress_status,
|
|
20
19
|
cloudflare_route_inventory,
|
|
@@ -22,7 +21,7 @@ from .cloudflare_ingress import (
|
|
|
22
21
|
plan_public_route,
|
|
23
22
|
plan_public_route_removal,
|
|
24
23
|
)
|
|
25
|
-
from .cloudflare_route_apply import apply_public_route
|
|
24
|
+
from .cloudflare_route_apply import apply_public_route, apply_public_route_removal
|
|
26
25
|
from .errors import DeploymentKitError
|
|
27
26
|
|
|
28
27
|
|
|
@@ -15,7 +15,6 @@ from .box_platform import platform_status
|
|
|
15
15
|
from .cloudflare_ingress import (
|
|
16
16
|
CLOUDFLARE_API_TOKEN_ENV,
|
|
17
17
|
PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE,
|
|
18
|
-
apply_public_route_removal,
|
|
19
18
|
cloudflare_ingress_status,
|
|
20
19
|
cloudflare_route_inventory,
|
|
21
20
|
plan_public_route_removal,
|
|
@@ -313,6 +312,8 @@ def _tear_down_public_ingress(
|
|
|
313
312
|
app_slug: str,
|
|
314
313
|
environment: str,
|
|
315
314
|
) -> dict[str, Any]:
|
|
315
|
+
from .cloudflare_route_apply import apply_public_route_removal
|
|
316
|
+
|
|
316
317
|
inventory = cloudflare_route_inventory(target, scope)
|
|
317
318
|
route = _find_route(inventory, app_slug, environment)
|
|
318
319
|
if route is None:
|