@mriekki/project-forge 4.10.2 → 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.10.2",
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": "5131dd0db9bbccda1647eee352f6bdcad7f5c112",
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.2",
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": "5131dd0db9bbccda1647eee352f6bdcad7f5c112",
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": "98c77950df1d0bba36da1244001bdd157251238778e7068be0c6c20d2683d2d4"
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": "35ac39b4f4538d4ff23f68982cc4b176782378cf03539fa51a228becbdfcf4d0"
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": "9c41b4b35d15bc46e7828f0366dc53c58be47f4414e7d90dfd176ea922bf294a"
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": "0bc20b1b739f999960422b7bd05249f4179f3612910ecbb6cf2805b5f4babd61"
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": "0e4088a17734f560c750528942fbd34d2464bcdd06d60467a9dc684a73fc18e1"
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": "0c75bb1ec77218d1b6ee3f54a626556ee46f2de8e91d1e9afa87200bce39254e"
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": "e0d95aa8e1c204390a51a7900ef60542537c1a9d2eeb017f34920e8dcb5e8329"
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
@@ -37,6 +39,7 @@ PUBLIC_INGRESS_ROUTE_WRITE_PHRASE = "[PUBLIC INGRESS ROUTE WRITE]"
37
39
  PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE = "[PUBLIC INGRESS ROUTE REMOVE]"
38
40
  CLOUDFLARE_API_TOKEN_ENV = "PROJECT_FORGE_CLOUDFLARE_API_TOKEN"
39
41
 
42
+
40
43
  _SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
41
44
  _REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
42
45
  _LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
@@ -197,14 +200,17 @@ def _run_remote(
197
200
  expected: dict[str, Any] | None = None,
198
201
  tunnel_token: str | None = None,
199
202
  ) -> str:
200
- if action not in {"status", "install", "upgrade", "route-inventory", "route-register", "route-remove"}:
203
+ if action not in {
204
+ "status", "install", "upgrade", "route-inventory",
205
+ "route-register", "route-remove", "route-register-locked", "route-remove-locked",
206
+ }:
201
207
  raise DeploymentKitError(f"Unsupported Cloudflare ingress action: {action}")
202
208
  args = [action, _slug(scope, "scope")]
203
209
  args.append(_metadata_tsv(metadata) if metadata is not None else "")
204
210
  args.append(_route_tsv(spec) if spec is not None else "")
205
- if action == "route-register":
211
+ if action in {"route-register", "route-register-locked"}:
206
212
  args.append(_route_tsv(expected) if expected is not None else "NONE")
207
- elif action == "route-remove":
213
+ elif action in {"route-remove", "route-remove-locked"}:
208
214
  args.append("")
209
215
  remote_command = "sudo -n bash -s -- " + " ".join(shlex.quote(item) for item in args)
210
216
  completed = subprocess.run(
@@ -221,6 +227,97 @@ def _run_remote(
221
227
  return stdout
222
228
 
223
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
+
224
321
  def cloudflare_ingress_status(name: str, scope: str) -> dict[str, Any]:
225
322
  scope = _slug(scope, "scope")
226
323
  profile = load_profile(name)
@@ -256,6 +353,53 @@ def _config_snapshot(config: dict[str, Any]) -> str:
256
353
  return hashlib.sha256(json.dumps(config, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
257
354
 
258
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
+
259
403
  def _tunnel_summary(tunnel: dict[str, Any] | None) -> dict[str, Any] | None:
260
404
  if tunnel is None:
261
405
  return None
@@ -559,6 +703,67 @@ def _exact_dns_record(records: list[dict[str, Any]], *, spec: dict[str, Any], tu
559
703
  return record
560
704
 
561
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
+
562
767
  def plan_public_route(
563
768
  name: str,
564
769
  *,
@@ -622,7 +827,12 @@ def plan_public_route(
622
827
  }
623
828
 
624
829
 
625
- def apply_public_route(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
830
+ def apply_public_route(
831
+ plan: dict[str, Any],
832
+ *,
833
+ api_token: str,
834
+ _lock_held: bool = False,
835
+ ) -> dict[str, Any]:
626
836
  if plan.get("action") == "already-configured":
627
837
  return {**plan, "mutated": False}
628
838
  required = {
@@ -632,6 +842,9 @@ def apply_public_route(plan: dict[str, Any], *, api_token: str) -> dict[str, Any
632
842
  }
633
843
  if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "register":
634
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)
635
848
  name = plan["targetName"]
636
849
  scope = plan["scope"]
637
850
  spec = plan["route"]
@@ -653,64 +866,92 @@ def apply_public_route(plan: dict[str, Any], *, api_token: str) -> dict[str, Any
653
866
  profile = load_profile(name)
654
867
  access = verify_access(profile)
655
868
  host_written = False
656
- config_written = False
657
- dns_created: dict[str, Any] | None = None
869
+ config_write_attempted = False
870
+ dns_create_attempted = False
658
871
  try:
659
872
  _run_remote(
660
873
  profile,
661
874
  access["address"],
662
- "route-register",
875
+ "route-register-locked",
663
876
  scope=scope,
664
877
  spec=spec,
665
878
  expected=plan["previousRoute"],
666
879
  )
667
880
  host_written = True
668
881
  if plan["desiredTunnelConfig"] != plan["tunnelConfig"]:
882
+ config_write_attempted = True
669
883
  cloudflare_api.update_tunnel_configuration(
670
884
  api_token, host["accountId"], host["tunnelId"], plan["desiredTunnelConfig"]
671
885
  )
672
- config_written = True
673
886
  if current_dns is None:
674
- dns_created = cloudflare_api.create_dns_cname(
887
+ dns_create_attempted = True
888
+ cloudflare_api.create_dns_cname(
675
889
  api_token,
676
890
  host["zoneId"],
677
891
  name=spec["publicHostname"],
678
892
  content=f"{host['tunnelId']}.cfargotunnel.com",
679
893
  comment=plan["dnsMarker"],
680
894
  )
681
- except Exception:
682
- if dns_created is not None:
895
+ except Exception as original_error:
896
+ rollback_errors: list[str] = []
897
+ if dns_create_attempted:
683
898
  try:
684
- cloudflare_api.delete_dns_record(api_token, host["zoneId"], str(dns_created["id"]))
685
- except Exception:
686
- pass
687
- if config_written:
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:
688
903
  try:
689
- cloudflare_api.update_tunnel_configuration(
690
- api_token, host["accountId"], host["tunnelId"], plan["tunnelConfig"]
904
+ _restore_tunnel_configuration_if_unchanged(
905
+ api_token,
906
+ host,
907
+ expected_current=plan["desiredTunnelConfig"],
908
+ previous=plan["tunnelConfig"],
691
909
  )
692
- except Exception:
693
- pass
910
+ except Exception as error:
911
+ rollback_errors.append(str(error))
694
912
  if host_written and plan["previousRoute"] is None:
695
913
  try:
696
- _run_remote(profile, access["address"], "route-remove", scope=scope, spec=spec)
697
- except Exception:
698
- pass
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
699
922
  raise
700
923
 
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.")
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
+ )
930
+ final_inventory = cloudflare_route_inventory(name, scope)
931
+ if _find_route(final_inventory, spec["routeId"]) != spec:
932
+ raise CloudflarePostWriteVerificationError(
933
+ "Public route registration returned without exact host ownership metadata."
934
+ )
935
+ final_config = cloudflare_api.get_tunnel_configuration(api_token, host["accountId"], host["tunnelId"])
936
+ if final_config != plan["desiredTunnelConfig"]:
937
+ raise CloudflarePostWriteVerificationError(
938
+ "Cloudflare tunnel configuration does not match the reviewed route plan after apply."
939
+ )
940
+ final_dns = _exact_dns_record(
941
+ cloudflare_api.list_dns_records(api_token, host["zoneId"], name=spec["publicHostname"]),
942
+ spec=spec,
943
+ tunnel_id=host["tunnelId"],
944
+ )
945
+ if final_dns is None:
946
+ raise CloudflarePostWriteVerificationError(
947
+ "Cloudflare DNS record is missing after public-route apply."
948
+ )
949
+ except CloudflarePostWriteVerificationError:
950
+ raise
951
+ except Exception as error:
952
+ raise CloudflarePostWriteVerificationError(
953
+ f"Cloudflare public-route post-write verification could not confirm provider state: {error}"
954
+ ) from error
714
955
  return {
715
956
  "targetName": name,
716
957
  "scope": scope,
@@ -770,13 +1011,21 @@ def plan_public_route_removal(
770
1011
  }
771
1012
 
772
1013
 
773
- def apply_public_route_removal(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
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]:
774
1020
  required = {
775
1021
  "targetName", "scope", "action", "route", "routeInventorySnapshot", "hostStatusSnapshot",
776
1022
  "tunnelConfig", "tunnelConfigSha256", "desiredTunnelConfig", "dnsRecord", "requiredAuthorization",
777
1023
  }
778
1024
  if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "remove":
779
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)
780
1029
  name = plan["targetName"]
781
1030
  scope = plan["scope"]
782
1031
  route = plan["route"]
@@ -797,35 +1046,90 @@ def apply_public_route_removal(plan: dict[str, Any], *, api_token: str) -> dict[
797
1046
  if dns is None or str(dns.get("id")) != str(plan["dnsRecord"].get("id")):
798
1047
  raise DeploymentKitError("Cloudflare DNS record changed after removal planning.")
799
1048
 
800
- cloudflare_api.update_tunnel_configuration(
801
- api_token, host["accountId"], host["tunnelId"], plan["desiredTunnelConfig"]
802
- )
803
1049
  try:
804
- cloudflare_api.delete_dns_record(api_token, host["zoneId"], str(dns["id"]))
805
- except Exception:
806
1050
  cloudflare_api.update_tunnel_configuration(
807
- api_token, host["accountId"], host["tunnelId"], plan["tunnelConfig"]
1051
+ api_token, host["accountId"], host["tunnelId"], plan["desiredTunnelConfig"]
808
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
809
1089
  raise
810
1090
 
811
1091
  profile = load_profile(name)
812
1092
  access = verify_access(profile)
813
1093
  try:
814
- _run_remote(profile, access["address"], "route-remove", scope=scope, spec=route)
815
- 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] = []
816
1097
  try:
817
- cloudflare_api.update_tunnel_configuration(
818
- api_token, host["accountId"], host["tunnelId"], plan["tunnelConfig"]
819
- )
820
- cloudflare_api.create_dns_cname(
1098
+ _restore_tunnel_configuration_if_unchanged(
821
1099
  api_token,
822
- host["zoneId"],
823
- name=route["publicHostname"],
824
- content=f"{host['tunnelId']}.cfargotunnel.com",
825
- comment=_dns_marker(route),
1100
+ host,
1101
+ expected_current=plan["desiredTunnelConfig"],
1102
+ previous=plan["tunnelConfig"],
826
1103
  )
827
- except Exception:
828
- pass
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
829
1133
  raise
830
1134
 
831
1135
  final = cloudflare_route_inventory(name, scope)
@@ -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,30 +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
19
-
20
- _FINAL_VERIFICATION_ERRORS = {
21
- "Public route registration returned without exact host ownership metadata.",
22
- "Cloudflare tunnel configuration does not match the reviewed route plan after apply.",
23
- "Cloudflare DNS record is missing after public-route apply.",
24
- }
15
+ from .errors import CloudflarePostWriteVerificationError, DeploymentKitError
25
16
 
26
17
 
27
18
  def _reviewed_config_matches(actual: Any, reviewed: Any) -> bool:
28
19
  """Match reviewed config after removing only the observed safe provider default."""
29
- if not isinstance(actual, dict) or not isinstance(reviewed, dict):
30
- return False
31
- normalized = deepcopy(actual)
32
- if "warp-routing" not in reviewed and normalized.get("warp-routing") == {"enabled": False}:
33
- normalized.pop("warp-routing")
34
- return normalized == reviewed
20
+ return ingress._reviewed_config_matches(actual, reviewed)
35
21
 
36
22
 
37
23
  def _read_final_state(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
@@ -39,6 +25,10 @@ def _read_final_state(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]
39
25
  scope = plan["scope"]
40
26
  spec = plan["route"]
41
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
+ )
42
32
  inventory = ingress.cloudflare_route_inventory(name, scope)
43
33
  route = ingress._find_route(inventory, spec["routeId"])
44
34
  config = cloudflare_api.get_tunnel_configuration(api_token, host["accountId"], host["tunnelId"])
@@ -73,7 +63,12 @@ def _rollback_final_state(
73
63
  dns = state["dns"]
74
64
  if dns is not None:
75
65
  try:
76
- cloudflare_api.delete_dns_record(api_token, host["zoneId"], str(dns["id"]))
66
+ ingress._reconcile_public_route_dns(
67
+ api_token,
68
+ host,
69
+ spec,
70
+ should_exist=False,
71
+ )
77
72
  except Exception as error: # pragma: no cover - provider failure detail only
78
73
  errors.append(f"DNS rollback failed: {error}")
79
74
  elif state["dnsError"] is not None:
@@ -83,11 +78,11 @@ def _rollback_final_state(
83
78
  current = state["config"]
84
79
  if _reviewed_config_matches(current, plan["desiredTunnelConfig"]):
85
80
  try:
86
- cloudflare_api.update_tunnel_configuration(
81
+ ingress._restore_tunnel_configuration_if_unchanged(
87
82
  api_token,
88
- host["accountId"],
89
- host["tunnelId"],
90
- plan["tunnelConfig"],
83
+ host,
84
+ expected_current=plan["desiredTunnelConfig"],
85
+ previous=plan["tunnelConfig"],
91
86
  )
92
87
  except Exception as error: # pragma: no cover - provider failure detail only
93
88
  errors.append(f"tunnel configuration rollback failed: {error}")
@@ -98,12 +93,12 @@ def _rollback_final_state(
98
93
 
99
94
  if plan["previousRoute"] is None and state["route"] == spec:
100
95
  try:
101
- profile = load_profile(plan["targetName"])
102
- access = verify_access(profile)
96
+ profile = ingress.load_profile(plan["targetName"])
97
+ access = ingress.verify_access(profile)
103
98
  ingress._run_remote(
104
99
  profile,
105
100
  access["address"],
106
- "route-remove",
101
+ "route-remove-locked",
107
102
  scope=plan["scope"],
108
103
  spec=spec,
109
104
  )
@@ -113,41 +108,31 @@ def _rollback_final_state(
113
108
  return errors
114
109
 
115
110
 
116
- def _uncertain_recovery_error(
117
- original_error: Exception,
118
- recovery_error: Exception,
119
- ) -> DeploymentKitError:
120
- return DeploymentKitError(
121
- "Cloudflare public-route final verification failed, and recovery could not read "
122
- "the current host/provider state. External state is uncertain and rollback was "
123
- "not attempted because current ownership could not be verified safely. "
124
- f"Original verification error: {original_error} "
125
- f"Recovery read failed: {recovery_error} "
126
- "Inspect the managed host route, tunnel configuration, and DNS record before retrying."
127
- )
128
-
129
-
130
111
  def apply_public_route(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
131
112
  """Apply a reviewed public route with bounded post-write recovery.
132
113
 
133
114
  The underlying apply retains all pre-write stale-state checks and its write
134
- rollback. Recovery is entered only for errors that are emitted exclusively
135
- by the final verification phase, so preflight failures can never be turned
115
+ rollback. Recovery is entered only for the typed error emitted by the
116
+ post-write verification phase, so preflight failures can never be turned
136
117
  into success by observing later provider state.
137
118
  """
138
- try:
119
+ if (
120
+ not isinstance(plan, dict)
121
+ or plan.get("action") != "register"
122
+ or not {"targetName", "scope"}.issubset(plan)
123
+ ):
139
124
  return ingress.apply_public_route(plan, api_token=api_token)
140
- except Exception as original_error:
141
- if (
142
- not isinstance(original_error, DeploymentKitError)
143
- or str(original_error) not in _FINAL_VERIFICATION_ERRORS
144
- or not isinstance(plan, dict)
145
- or plan.get("action") != "register"
146
- ):
147
- raise
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:
148
133
  required = {
149
- "targetName", "scope", "route", "previousRoute", "tunnelConfig",
150
- "desiredTunnelConfig", "dnsRecord", "dnsMarker",
134
+ "targetName", "scope", "route", "previousRoute", "hostStatusSnapshot",
135
+ "tunnelConfig", "desiredTunnelConfig", "dnsRecord", "dnsMarker",
151
136
  }
152
137
  if not required.issubset(plan):
153
138
  raise
@@ -157,6 +142,12 @@ def apply_public_route(plan: dict[str, Any], *, api_token: str) -> dict[str, Any
157
142
  except Exception as recovery_error:
158
143
  raise _uncertain_recovery_error(original_error, recovery_error) from recovery_error
159
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
+
160
151
  route_ok = state["route"] == plan["route"]
161
152
  config_ok = _reviewed_config_matches(state["config"], plan["desiredTunnelConfig"])
162
153
  dns_ok = state["dns"] is not None
@@ -179,3 +170,28 @@ def apply_public_route(plan: dict[str, Any], *, api_token: str) -> dict[str, Any
179
170
  + " Inspect the managed host route, tunnel configuration, and DNS record before retrying."
180
171
  ) from original_error
181
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)
@@ -3,3 +3,7 @@
3
3
 
4
4
  class DeploymentKitError(Exception):
5
5
  """Expected fail-closed operational or configuration error."""
6
+
7
+
8
+ class CloudflarePostWriteVerificationError(DeploymentKitError):
9
+ """Cloudflare route mutation completed but final provider verification failed."""
@@ -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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.10.2",
3
+ "version": "4.10.4",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {