@mriekki/project-forge 4.10.3 → 4.10.5

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.3",
4
+ "version": "4.10.5",
5
5
  "defaultProfile": "greenfield-project",
6
6
  "profiles": {
7
7
  "greenfield-project": {
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "components": {
15
15
  "core": {
16
- "revision": "8796fd8dc22df8b64078e5b460e1008a8c7a5f7d",
16
+ "revision": "bd66fc30c233d7efae03a3a029aca6ccf4bd7991",
17
17
  "version": "4.0.0",
18
18
  "distribution": "greenfield-workspace",
19
19
  "descriptor": "assets/core/distribution.json",
@@ -21,7 +21,7 @@
21
21
  "canonicalDescriptor": "distributions/greenfield-workspace.json"
22
22
  },
23
23
  "deployment": {
24
- "revision": "75c272db383f1803383e8cd4ad4511537c175abe",
24
+ "revision": "8df115fe5c27fcf6647d7e87258166616e8e2e53",
25
25
  "distribution": "greenfield-project",
26
26
  "descriptor": "assets/deployment/distribution.json",
27
27
  "sourceRoot": "assets/generated/deployment",
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.10.3",
4
+ "version": "4.10.5",
5
5
  "components": {
6
6
  "core": {
7
- "revision": "8796fd8dc22df8b64078e5b460e1008a8c7a5f7d",
7
+ "revision": "bd66fc30c233d7efae03a3a029aca6ccf4bd7991",
8
8
  "version": "4.0.0",
9
9
  "distribution": "greenfield-workspace",
10
10
  "files": [
@@ -51,7 +51,7 @@
51
51
  {
52
52
  "source": "core/skills/setup/SKILL.md",
53
53
  "destination": ".github/skills/setup/SKILL.md",
54
- "sha256": "27419e0d330abf2b6b03a696eba352ab2ece13a616c41fc0ade9aed85e8834f1"
54
+ "sha256": "9b3dee1d965f1999490d0e3c58925c341ac5f3f5b228ccfe6a0ac53d64b19a64"
55
55
  },
56
56
  {
57
57
  "source": "core/skills/build/SKILL.md",
@@ -326,7 +326,7 @@
326
326
  ]
327
327
  },
328
328
  "deployment": {
329
- "revision": "75c272db383f1803383e8cd4ad4511537c175abe",
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": "ca2c80e0b64792f550a08fc51994bdfdac42644c35f23cdf7ae5c50b468109f8"
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": "d3fe77a2f61ae281308ad1046ee1e6e5ea460f3605c5cd275cc16cff3e9bb568"
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",
@@ -214,6 +214,28 @@ Rules:
214
214
  - the recorded setup scope comes from the developer's actual selections;
215
215
  - later environment changes belong in mutable project topology, not casual rewrites of Bootstrap provenance.
216
216
 
217
+ ### Builder-owned target namespace
218
+
219
+ Treat reusable remote target definitions as Builder-owned personal state. The Project owns logical environment roles and a logical `targetProfile` binding; it does not own another Builder's physical machine, SSH identity, trust, current address, or readiness.
220
+
221
+ The same logical target name may resolve to a different physical machine for another developer. For example, Project topology may bind `Production -> production`; each Builder may define their own personal `production` target with their own hostname, SSH account, identity, and trust. Do not treat another Builder's target profile, hostname, SSH identity/trust, address, or readiness as authoritative for this workstation.
222
+
223
+ When an already-bound remote environment references `<target>` but this Builder's personal/local profile has no such target, do not describe the Project as broken and do not ask the Builder to gain access to another person's box. Surface the condition explicitly:
224
+
225
+ ```text
226
+ <ENVIRONMENT> TARGET NOT CONFIGURED FOR THIS BUILDER
227
+
228
+ This Project binds <environment> to logical target <target>, but this
229
+ Builder has not defined that target in their personal Project Forge profile.
230
+
231
+ CHOICES
232
+ Define my target `<target>` [RECOMMENDED]
233
+ Sync/import my personal target `<target>` if I already defined it elsewhere
234
+ Keep `<environment>` unavailable on this Builder
235
+ ```
236
+
237
+ `Define my target` uses the supported personal target-profile flow and preserves the Project's existing logical `targetProfile` name. Do not silently substitute a differently named personal target merely because it is reachable; selecting a different logical target is a Project topology rebind and requires its separate explicit decision. Production-class configuration/access remains distinct from Production proof and application-deployment authorization.
238
+
217
239
  ### Define another environment
218
240
 
219
241
  For `Other / define another environment`, collect only material durable facts, normally:
@@ -482,4 +504,4 @@ Useful next actions include:
482
504
  - `/deploy <environment>` — only after readiness and its own authorization contract;
483
505
  - `/tear-down <scope>` — destructive cleanup under its separate gate.
484
506
 
485
- Do not create a second top-level developer-environment-mode workflow. Local/remote workstation convergence belongs here.
507
+ Do not create a second top-level developer-environment-mode workflow. Local/remote workstation convergence belongs here.
@@ -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 {"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
+ }:
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 == "route-register":
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 == "route-remove":
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(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]:
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
- config_written = False
662
- dns_created: dict[str, Any] | None = None
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
- dns_created = cloudflare_api.create_dns_cname(
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
- if dns_created is not None:
895
+ except Exception as original_error:
896
+ rollback_errors: list[str] = []
897
+ if dns_create_attempted:
688
898
  try:
689
- cloudflare_api.delete_dns_record(api_token, host["zoneId"], str(dns_created["id"]))
690
- except Exception:
691
- pass
692
- 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:
693
903
  try:
694
- cloudflare_api.update_tunnel_configuration(
695
- 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"],
696
909
  )
697
- except Exception:
698
- pass
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
- 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
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 DeploymentKitError("Public route registration returned without exact host ownership metadata.")
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 DeploymentKitError("Cloudflare tunnel configuration does not match the reviewed route plan after apply.")
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 DeploymentKitError("Cloudflare DNS record is missing after public-route apply.")
720
- return {
721
- "targetName": name,
722
- "scope": scope,
723
- "route": spec,
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 failed: {error}"
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(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]:
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["tunnelConfig"]
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
- cloudflare_api.update_tunnel_configuration(
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["zoneId"],
833
- name=route["publicHostname"],
834
- content=f"{host['tunnelId']}.cfargotunnel.com",
835
- comment=_dns_marker(route),
1100
+ host,
1101
+ expected_current=plan["desiredTunnelConfig"],
1102
+ previous=plan["tunnelConfig"],
836
1103
  )
837
- except Exception:
838
- 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
839
1133
  raise
840
1134
 
841
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,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
- if not isinstance(actual, dict) or not isinstance(reviewed, dict):
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
- 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
+ )
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
- cloudflare_api.update_tunnel_configuration(
81
+ ingress._restore_tunnel_configuration_if_unchanged(
81
82
  api_token,
82
- host["accountId"],
83
- host["tunnelId"],
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
- try:
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
- except ingress.CloudflarePostWriteVerificationError as original_error:
135
- if not isinstance(plan, dict) or plan.get("action") != "register":
136
- 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:
137
133
  required = {
138
- "targetName", "scope", "route", "previousRoute", "tunnelConfig",
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)
@@ -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.3",
3
+ "version": "4.10.5",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {