@mriekki/project-forge 4.20.0 → 4.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,6 +37,8 @@ CLOUDFLARED_IMAGE = f"cloudflare/cloudflared:{CLOUDFLARED_VERSION}"
37
37
  HOST_PUBLIC_INGRESS_WRITE_PHRASE = "[HOST PUBLIC INGRESS WRITE]"
38
38
  PUBLIC_INGRESS_ROUTE_WRITE_PHRASE = "[PUBLIC INGRESS ROUTE WRITE]"
39
39
  PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE = "[PUBLIC INGRESS ROUTE REMOVE]"
40
+ AUTHENTICATED_ORIGIN_MIGRATION_PHRASE = "[CLOUDFLARE AUTHENTICATED ORIGIN MIGRATION]"
41
+ AUTHENTICATED_ORIGIN_CHANGE_FREEZE_PHRASE = "[CLOUDFLARE EXTERNAL CHANGE FREEZE]"
40
42
  CLOUDFLARE_API_TOKEN_ENV = "PROJECT_FORGE_CLOUDFLARE_API_TOKEN"
41
43
 
42
44
 
@@ -197,13 +199,15 @@ def _run_remote(
197
199
  scope: str,
198
200
  metadata: dict[str, Any] | None = None,
199
201
  spec: dict[str, Any] | None = None,
200
- expected: dict[str, Any] | None = None,
202
+ expected: dict[str, Any] | str | None = None,
201
203
  tunnel_token: str | None = None,
202
204
  authenticated_origin: bool = False,
203
205
  ) -> str:
204
206
  if action not in {
205
207
  "status", "install", "upgrade", "route-inventory",
206
208
  "route-register", "route-remove", "route-register-locked", "route-remove-locked",
209
+ "migration-inspect", "migrate-authenticated", "rollback-authenticated",
210
+ "finalize-authenticated", "verify-authenticated",
207
211
  }:
208
212
  raise DeploymentKitError(f"Unsupported Cloudflare ingress action: {action}")
209
213
  args = [action, _slug(scope, "scope")]
@@ -213,6 +217,11 @@ def _run_remote(
213
217
  args.append(_route_tsv(expected) if expected is not None else "NONE")
214
218
  elif action in {"route-remove", "route-remove-locked"}:
215
219
  args.append("")
220
+ elif action in {
221
+ "migration-inspect", "migrate-authenticated", "rollback-authenticated",
222
+ "finalize-authenticated", "verify-authenticated",
223
+ }:
224
+ args.append(str(expected) if expected is not None else "")
216
225
  args.append("true" if authenticated_origin else "false")
217
226
  remote_command = "sudo -n bash -s -- " + " ".join(shlex.quote(item) for item in args)
218
227
  completed = subprocess.run(
@@ -649,6 +658,25 @@ def _route_inventory_snapshot(value: dict[str, Any]) -> str:
649
658
  return hashlib.sha256(json.dumps(selected, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
650
659
 
651
660
 
661
+ def _route_inventory_wire_snapshot(value: dict[str, Any]) -> str:
662
+ routes = sorted(value["managedRoutes"], key=lambda item: item["routeId"])
663
+ lines = [
664
+ f"SUMMARY\tscope\t{value['scope']}",
665
+ f"SUMMARY\tmanagedRouteCount\t{len(routes)}",
666
+ ]
667
+ for spec in routes:
668
+ lines.append(
669
+ "ROUTE\t" + "\t".join(
670
+ str(spec[key])
671
+ for key in (
672
+ "schemaVersion", "ownerRepo", "environment", "appSlug", "routeId",
673
+ "publicHostname", "originHostname",
674
+ )
675
+ )
676
+ )
677
+ return hashlib.sha256(("\n".join(lines) + "\n").encode("utf-8")).hexdigest()
678
+
679
+
652
680
  def _find_route(inventory: dict[str, Any], route_id: str) -> dict[str, Any] | None:
653
681
  return next((item for item in inventory["managedRoutes"] if item["routeId"] == route_id), None)
654
682
 
@@ -696,6 +724,571 @@ def _split_ingress(config: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[s
696
724
  return [deepcopy(item) for item in rules], deepcopy(ingress[-1])
697
725
 
698
726
 
727
+ def _migration_ingress_config(
728
+ config: dict[str, Any],
729
+ routes: list[dict[str, Any]],
730
+ *,
731
+ host_authenticated: bool,
732
+ ) -> tuple[dict[str, Any], int]:
733
+ rules, final_rule = _split_ingress(config)
734
+ if not routes:
735
+ raise DeploymentKitError("Authenticated-origin migration requires at least one managed public route.")
736
+ desired_rules = deepcopy(rules)
737
+ seen_route_ids: set[str] = set()
738
+ seen_hostnames: set[str] = set()
739
+ for spec in routes:
740
+ route_id = spec["routeId"]
741
+ hostname = spec["publicHostname"]
742
+ if route_id in seen_route_ids or hostname in seen_hostnames:
743
+ raise DeploymentKitError("Managed public-route identities are ambiguous; migration was not planned.")
744
+ seen_route_ids.add(route_id)
745
+ seen_hostnames.add(hostname)
746
+ legacy = _cloudflare_rule(spec)
747
+ authenticated = _cloudflare_rule(spec, authenticated_origin=True)
748
+ matches = [index for index, rule in enumerate(rules) if rule.get("hostname") == hostname]
749
+ if len(matches) != 1:
750
+ raise DeploymentKitError(
751
+ f"Managed public route {route_id} does not have exactly one provider ingress rule; refusing takeover."
752
+ )
753
+ index = matches[0]
754
+ if rules[index] == legacy:
755
+ desired_rules[index] = authenticated
756
+ elif rules[index] == authenticated and host_authenticated:
757
+ desired_rules[index] = authenticated
758
+ elif rules[index] == authenticated:
759
+ raise DeploymentKitError(
760
+ "Provider ingress is already authenticated while the managed connector is in legacy mode."
761
+ )
762
+ else:
763
+ raise DeploymentKitError(
764
+ f"Provider ingress for managed route {route_id} has conflicting, unowned, or changed fields."
765
+ )
766
+ desired = deepcopy(config)
767
+ desired["ingress"] = desired_rules + [final_rule]
768
+ return desired, sum(left != right for left, right in zip(rules, desired_rules))
769
+
770
+
771
+ def _unmanaged_ingress_summary(
772
+ config: dict[str, Any],
773
+ routes: list[dict[str, Any]],
774
+ ) -> dict[str, Any]:
775
+ rules, _ = _split_ingress(config)
776
+ managed_hostnames = {spec["publicHostname"] for spec in routes}
777
+ unmanaged = [rule for rule in rules if rule.get("hostname") not in managed_hostnames]
778
+ return {
779
+ "unmanagedIngressRuleCount": len(unmanaged),
780
+ "unmanagedIngressRulesSha256": _config_snapshot({"rules": unmanaged}),
781
+ }
782
+
783
+
784
+ def _migration_dns_snapshot(
785
+ api_token: str,
786
+ host: dict[str, Any],
787
+ routes: list[dict[str, Any]],
788
+ ) -> list[dict[str, str]]:
789
+ result: list[dict[str, str]] = []
790
+ for spec in routes:
791
+ records = cloudflare_api.list_dns_records(
792
+ api_token, host["zoneId"], name=spec["publicHostname"]
793
+ )
794
+ record = _exact_dns_record(records, spec=spec, tunnel_id=host["tunnelId"])
795
+ if record is None or not record.get("id"):
796
+ raise DeploymentKitError(
797
+ f"Managed route {spec['routeId']} has no exact owned DNS record; refusing migration."
798
+ )
799
+ result.append(
800
+ {
801
+ "routeId": spec["routeId"],
802
+ "recordId": str(record["id"]),
803
+ "recordSha256": _config_snapshot(record),
804
+ }
805
+ )
806
+ return result
807
+
808
+
809
+ def _migration_freeze_context(plan: dict[str, Any]) -> str:
810
+ context = {
811
+ "scope": plan["scope"],
812
+ "hostStatusSnapshot": plan["hostStatusSnapshot"],
813
+ "zone": plan["zone"],
814
+ "tunnel": plan["tunnel"],
815
+ "routeInventorySnapshot": plan["routeInventorySnapshot"],
816
+ "dnsSnapshot": plan["dnsSnapshot"],
817
+ "tunnelConfigSha256": plan["tunnelConfigSha256"],
818
+ "desiredTunnelConfigSha256": plan["desiredTunnelConfigSha256"],
819
+ }
820
+ return hashlib.sha256(
821
+ json.dumps(context, sort_keys=True, separators=(",", ":")).encode("utf-8")
822
+ ).hexdigest()[:16]
823
+
824
+
825
+ def _required_change_freeze_acknowledgement(plan: dict[str, Any]) -> str:
826
+ return (
827
+ f"{AUTHENTICATED_ORIGIN_CHANGE_FREEZE_PHRASE} "
828
+ f"scope={plan['scope']} context={_migration_freeze_context(plan)}"
829
+ )
830
+
831
+
832
+ def _required_preplan_change_freeze_acknowledgement(scope: str) -> str:
833
+ return f"{AUTHENTICATED_ORIGIN_CHANGE_FREEZE_PHRASE} ACTIVE scope={scope}"
834
+
835
+
836
+ def _remote_migration_state(name: str, scope: str) -> str:
837
+ profile = load_profile(name)
838
+ access = verify_access(profile)
839
+ output = _run_remote(profile, access["address"], "migration-inspect", scope=scope)
840
+ lines = output.splitlines()
841
+ if len(lines) != 1 or not lines[0].startswith("migrationState="):
842
+ raise DeploymentKitError("Remote authenticated-origin migration state is malformed.")
843
+ state = lines[0].split("=", 1)[1]
844
+ if state not in {"clean", "pending", "complete"}:
845
+ raise DeploymentKitError("Remote authenticated-origin migration state is unsupported.")
846
+ return state
847
+
848
+
849
+ def plan_authenticated_origin_migration(
850
+ name: str,
851
+ *,
852
+ scope: str,
853
+ zone_name: str,
854
+ api_token: str,
855
+ change_freeze_active_acknowledgement: str,
856
+ ) -> dict[str, Any]:
857
+ """Plan a guarded migration of one existing legacy ingress scope."""
858
+ scope = _slug(scope, "scope")
859
+ if change_freeze_active_acknowledgement != _required_preplan_change_freeze_acknowledgement(scope):
860
+ raise DeploymentKitError(
861
+ "Planning requires an explicit scope-bound acknowledgement that the operator-controlled Cloudflare change freeze is active."
862
+ )
863
+ zone = cloudflare_api.resolve_zone(api_token, zone_name)
864
+ host = cloudflare_ingress_status(name, scope)
865
+ if host["state"] != "ready":
866
+ raise DeploymentKitError(f"Cloudflare ingress scope {scope} is not ready on target {name}.")
867
+ if (
868
+ host["accountId"] != zone["accountId"]
869
+ or host["zoneId"] != zone["zoneId"]
870
+ or host["zoneName"] != zone["zoneName"]
871
+ ):
872
+ raise DeploymentKitError("Cloudflare zone does not match the existing ingress scope binding.")
873
+ tunnel = cloudflare_api.find_tunnel(api_token, zone["accountId"], host["tunnelName"])
874
+ if (
875
+ tunnel is None
876
+ or str(tunnel.get("id")) != host["tunnelId"]
877
+ or tunnel.get("config_src") not in {None, "cloudflare"}
878
+ ):
879
+ raise DeploymentKitError("Existing remotely managed tunnel identity changed; refusing migration.")
880
+
881
+ migration_state = _remote_migration_state(name, scope)
882
+ host_authenticated = bool(host["authenticatedOrigin"])
883
+ if host_authenticated and migration_state != "pending":
884
+ raise DeploymentKitError(
885
+ "This scope is already authenticated and has no pending migration receipt; no migration is required."
886
+ )
887
+ if not host_authenticated and migration_state != "clean":
888
+ raise DeploymentKitError(
889
+ "Legacy ingress scope has conflicting or incomplete authenticated-origin migration state."
890
+ )
891
+
892
+ inventory = cloudflare_route_inventory(name, scope)
893
+ if inventory["scope"] != scope:
894
+ raise DeploymentKitError("Remote managed-route inventory belongs to a different ingress scope.")
895
+ routes = sorted(inventory["managedRoutes"], key=lambda item: item["routeId"])
896
+ if not routes:
897
+ raise DeploymentKitError("Authenticated-origin migration requires at least one managed public route.")
898
+ for spec in routes:
899
+ _lan_route(name, spec)
900
+ config = cloudflare_api.get_tunnel_configuration(api_token, zone["accountId"], host["tunnelId"])
901
+ desired_config, converted_count = _migration_ingress_config(
902
+ config, routes, host_authenticated=host_authenticated
903
+ )
904
+ if converted_count == 0 and not host_authenticated:
905
+ raise DeploymentKitError("No legacy managed route rules were found to migrate.")
906
+ dns_snapshot = _migration_dns_snapshot(api_token, host, routes)
907
+ unmanaged_summary = _unmanaged_ingress_summary(config, routes)
908
+ return {
909
+ "targetName": name,
910
+ "scope": scope,
911
+ "action": "resume" if migration_state == "pending" else "prepare",
912
+ "migrationState": migration_state,
913
+ "hostStatusSnapshot": _status_snapshot(host),
914
+ "zone": zone,
915
+ "tunnel": _tunnel_summary(tunnel),
916
+ "routeInventorySnapshot": _route_inventory_snapshot(inventory),
917
+ "routeInventoryWireSnapshot": _route_inventory_wire_snapshot(inventory),
918
+ "managedRoutes": routes,
919
+ "dnsSnapshot": dns_snapshot,
920
+ "tunnelConfigSha256": _config_snapshot(config),
921
+ "desiredTunnelConfigSha256": _config_snapshot(desired_config),
922
+ "convertedRouteCount": converted_count,
923
+ **unmanaged_summary,
924
+ "requiredAuthorization": AUTHENTICATED_ORIGIN_MIGRATION_PHRASE,
925
+ "requiredPrePlanChangeFreezeAcknowledgement":
926
+ _required_preplan_change_freeze_acknowledgement(scope),
927
+ "changeFreezePrerequisite": (
928
+ "Operator has stopped all Cloudflare dashboard/API tunnel-configuration writers for this scope "
929
+ "before planning and will keep them stopped through apply, verification, rollback, or recovery. "
930
+ "Deployment Kit cannot enforce or prove this external change freeze."
931
+ ),
932
+ "requiredChangeFreezeAcknowledgement": _required_change_freeze_acknowledgement({
933
+ "scope": scope,
934
+ "hostStatusSnapshot": _status_snapshot(host),
935
+ "zone": zone,
936
+ "tunnel": _tunnel_summary(tunnel),
937
+ "routeInventorySnapshot": _route_inventory_snapshot(inventory),
938
+ "dnsSnapshot": dns_snapshot,
939
+ "tunnelConfigSha256": _config_snapshot(config),
940
+ "desiredTunnelConfigSha256": _config_snapshot(desired_config),
941
+ }),
942
+ "secrets": {
943
+ "apiToken": "workstation-only; not persisted by Deployment Kit",
944
+ "tunnelToken": "existing tunnel token is unchanged and never displayed",
945
+ "providerConfiguration": "not included in this migration plan",
946
+ },
947
+ }
948
+
949
+
950
+ def _migration_remote_action(
951
+ name: str,
952
+ scope: str,
953
+ action: str,
954
+ *,
955
+ zone: dict[str, Any],
956
+ tunnel: dict[str, Any],
957
+ route_snapshot: str,
958
+ authenticated_origin: bool = True,
959
+ ) -> str:
960
+ profile = load_profile(name)
961
+ access = verify_access(profile)
962
+ return _run_remote(
963
+ profile,
964
+ access["address"],
965
+ action,
966
+ scope=scope,
967
+ metadata={
968
+ **zone,
969
+ "tunnelId": str(tunnel["id"]),
970
+ "tunnelName": str(tunnel["name"]),
971
+ },
972
+ expected=route_snapshot,
973
+ authenticated_origin=authenticated_origin,
974
+ )
975
+
976
+
977
+ def _rollback_prepared_migration(
978
+ name: str,
979
+ scope: str,
980
+ *,
981
+ zone: dict[str, Any],
982
+ tunnel: dict[str, Any],
983
+ route_snapshot: str,
984
+ ) -> None:
985
+ _migration_remote_action(
986
+ name,
987
+ scope,
988
+ "rollback-authenticated",
989
+ zone=zone,
990
+ tunnel=tunnel,
991
+ route_snapshot=route_snapshot,
992
+ )
993
+ restored_host = cloudflare_ingress_status(name, scope)
994
+ if restored_host["state"] != "ready" or restored_host["authenticatedOrigin"]:
995
+ raise DeploymentKitError("Legacy host rollback did not restore a ready unauthenticated scope.")
996
+
997
+
998
+ def _parse_migration_proof(output: str, expected_route_count: int) -> dict[str, Any]:
999
+ result: dict[str, str] = {}
1000
+ for line in output.splitlines():
1001
+ if "=" not in line:
1002
+ raise DeploymentKitError("Authenticated-origin final proof was malformed.")
1003
+ key, value = line.split("=", 1)
1004
+ if key in result:
1005
+ raise DeploymentKitError("Authenticated-origin final proof contained duplicate fields.")
1006
+ result[key] = value
1007
+ expected = {
1008
+ "evidenceLevel": "structural-only",
1009
+ "connectorOriginMount": "read-only",
1010
+ "socketMetadataVerified": "true",
1011
+ "caddyConfigValidated": "true",
1012
+ "sanitizedRouteConfigVerified": "true",
1013
+ "managedRouteCount": str(expected_route_count),
1014
+ }
1015
+ if result != expected:
1016
+ raise DeploymentKitError("Authenticated-origin structural checks did not match the reviewed contract.")
1017
+ return {
1018
+ "evidenceLevel": "structural-only",
1019
+ "connectorOriginMountReadOnly": True,
1020
+ "socketMetadataVerified": True,
1021
+ "caddyConfigValidated": True,
1022
+ "sanitizedRouteConfigVerified": True,
1023
+ "managedRouteCount": expected_route_count,
1024
+ "liveSocketForwardingVerified": False,
1025
+ }
1026
+
1027
+
1028
+ def apply_authenticated_origin_migration(
1029
+ plan: dict[str, Any],
1030
+ *,
1031
+ api_token: str,
1032
+ change_freeze_acknowledgement: str,
1033
+ _lock_held: bool = False,
1034
+ ) -> dict[str, Any]:
1035
+ """Apply one reviewed existing-scope mode migration without replacing identities."""
1036
+ required = {
1037
+ "targetName", "scope", "action", "migrationState", "hostStatusSnapshot",
1038
+ "zone", "tunnel", "routeInventorySnapshot", "routeInventoryWireSnapshot",
1039
+ "managedRoutes", "dnsSnapshot", "tunnelConfigSha256",
1040
+ "desiredTunnelConfigSha256", "convertedRouteCount", "unmanagedIngressRuleCount",
1041
+ "unmanagedIngressRulesSha256", "requiredAuthorization",
1042
+ "changeFreezePrerequisite", "requiredChangeFreezeAcknowledgement", "secrets",
1043
+ "requiredPrePlanChangeFreezeAcknowledgement",
1044
+ }
1045
+ if not isinstance(plan, dict) or set(plan) != required:
1046
+ raise DeploymentKitError("Malformed authenticated-origin migration plan.")
1047
+ if (
1048
+ plan["requiredAuthorization"] != AUTHENTICATED_ORIGIN_MIGRATION_PHRASE
1049
+ or plan["action"] not in {"prepare", "resume"}
1050
+ or plan["migrationState"] not in {"clean", "pending"}
1051
+ or type(plan["convertedRouteCount"]) is not int
1052
+ or not isinstance(plan["managedRoutes"], list)
1053
+ or not plan["managedRoutes"]
1054
+ ):
1055
+ raise DeploymentKitError("Malformed authenticated-origin migration authorization or route plan.")
1056
+ if (
1057
+ plan["requiredChangeFreezeAcknowledgement"]
1058
+ != _required_change_freeze_acknowledgement(plan)
1059
+ or plan["requiredPrePlanChangeFreezeAcknowledgement"]
1060
+ != _required_preplan_change_freeze_acknowledgement(plan["scope"])
1061
+ or change_freeze_acknowledgement != plan["requiredChangeFreezeAcknowledgement"]
1062
+ ):
1063
+ raise DeploymentKitError(
1064
+ "The required scope- and context-bound Cloudflare change-freeze acknowledgement is missing or invalid."
1065
+ )
1066
+ if not _lock_held:
1067
+ with route_transaction_lock(plan["targetName"], plan["scope"]):
1068
+ return apply_authenticated_origin_migration(
1069
+ plan,
1070
+ api_token=api_token,
1071
+ change_freeze_acknowledgement=change_freeze_acknowledgement,
1072
+ _lock_held=True,
1073
+ )
1074
+
1075
+ name, scope = plan["targetName"], _slug(plan["scope"], "scope")
1076
+ zone = cloudflare_api.resolve_zone(api_token, plan["zone"]["zoneName"])
1077
+ if zone != plan["zone"]:
1078
+ raise DeploymentKitError("Cloudflare zone identity changed after migration planning; create a fresh plan.")
1079
+ host = cloudflare_ingress_status(name, scope)
1080
+ if _status_snapshot(host) != plan["hostStatusSnapshot"]:
1081
+ raise DeploymentKitError("Host Cloudflare ingress state changed after migration planning.")
1082
+ if host["state"] != "ready" or host["authenticatedOrigin"] != (plan["migrationState"] == "pending"):
1083
+ raise DeploymentKitError("Host mode no longer matches the reviewed migration phase.")
1084
+ migration_state = _remote_migration_state(name, scope)
1085
+ if migration_state != plan["migrationState"]:
1086
+ raise DeploymentKitError("Host migration receipt changed after planning; create a fresh plan.")
1087
+
1088
+ tunnel_value = cloudflare_api.find_tunnel(api_token, zone["accountId"], host["tunnelName"])
1089
+ if (
1090
+ _tunnel_summary(tunnel_value) != plan["tunnel"]
1091
+ or tunnel_value is None
1092
+ or str(tunnel_value.get("id")) != host["tunnelId"]
1093
+ or tunnel_value.get("config_src") not in {None, "cloudflare"}
1094
+ ):
1095
+ raise DeploymentKitError("Existing Cloudflare tunnel identity changed after migration planning.")
1096
+ tunnel = {"id": host["tunnelId"], "name": host["tunnelName"]}
1097
+
1098
+ inventory = cloudflare_route_inventory(name, scope)
1099
+ if (
1100
+ _route_inventory_snapshot(inventory) != plan["routeInventorySnapshot"]
1101
+ or _route_inventory_wire_snapshot(inventory) != plan["routeInventoryWireSnapshot"]
1102
+ or sorted(inventory["managedRoutes"], key=lambda item: item["routeId"]) != plan["managedRoutes"]
1103
+ ):
1104
+ raise DeploymentKitError("Managed public-route ownership changed after migration planning.")
1105
+ for spec in plan["managedRoutes"]:
1106
+ _lan_route(name, spec)
1107
+
1108
+ config = cloudflare_api.get_tunnel_configuration(api_token, zone["accountId"], host["tunnelId"])
1109
+ if _config_snapshot(config) != plan["tunnelConfigSha256"]:
1110
+ raise DeploymentKitError("Cloudflare tunnel configuration changed after migration planning.")
1111
+ desired_config, converted_count = _migration_ingress_config(
1112
+ config, plan["managedRoutes"], host_authenticated=host["authenticatedOrigin"]
1113
+ )
1114
+ unmanaged_summary = _unmanaged_ingress_summary(config, plan["managedRoutes"])
1115
+ if (
1116
+ _config_snapshot(desired_config) != plan["desiredTunnelConfigSha256"]
1117
+ or converted_count != plan["convertedRouteCount"]
1118
+ or unmanaged_summary["unmanagedIngressRuleCount"] != plan["unmanagedIngressRuleCount"]
1119
+ or unmanaged_summary["unmanagedIngressRulesSha256"] != plan["unmanagedIngressRulesSha256"]
1120
+ ):
1121
+ raise DeploymentKitError("Managed ingress rule ownership no longer matches the reviewed migration.")
1122
+ dns_snapshot = _migration_dns_snapshot(api_token, host, plan["managedRoutes"])
1123
+ if dns_snapshot != plan["dnsSnapshot"]:
1124
+ raise DeploymentKitError("Cloudflare DNS ownership or record identities changed after planning.")
1125
+
1126
+ host_prepared_by_this_apply = plan["migrationState"] == "clean"
1127
+ if host_prepared_by_this_apply:
1128
+ _migration_remote_action(
1129
+ name,
1130
+ scope,
1131
+ "migrate-authenticated",
1132
+ zone=zone,
1133
+ tunnel=tunnel,
1134
+ route_snapshot=plan["routeInventoryWireSnapshot"],
1135
+ )
1136
+ prepared_host = cloudflare_ingress_status(name, scope)
1137
+ prepared_inventory = cloudflare_route_inventory(name, scope)
1138
+ if (
1139
+ prepared_host["state"] != "ready"
1140
+ or not prepared_host["authenticatedOrigin"]
1141
+ or _route_inventory_snapshot(prepared_inventory) != plan["routeInventorySnapshot"]
1142
+ or _remote_migration_state(name, scope) != "pending"
1143
+ ):
1144
+ raise DeploymentKitError(
1145
+ "Authenticated-origin host preparation failed verification; provider ingress was not changed."
1146
+ )
1147
+
1148
+ prewrite_host = cloudflare_ingress_status(name, scope)
1149
+ prewrite_inventory = cloudflare_route_inventory(name, scope)
1150
+ prewrite_tunnel = cloudflare_api.find_tunnel(api_token, zone["accountId"], host["tunnelName"])
1151
+ if (
1152
+ prewrite_host["state"] != "ready"
1153
+ or not prewrite_host["authenticatedOrigin"]
1154
+ or prewrite_host["tunnelId"] != host["tunnelId"]
1155
+ or prewrite_host["tunnelName"] != host["tunnelName"]
1156
+ or _route_inventory_snapshot(prewrite_inventory) != plan["routeInventorySnapshot"]
1157
+ or _tunnel_summary(prewrite_tunnel) != plan["tunnel"]
1158
+ or _migration_dns_snapshot(api_token, prewrite_host, plan["managedRoutes"]) != plan["dnsSnapshot"]
1159
+ or _remote_migration_state(name, scope) != "pending"
1160
+ ):
1161
+ raise DeploymentKitError(
1162
+ "Host, tunnel, managed-route, DNS, or recovery evidence changed before provider apply; "
1163
+ "the authenticated host phase was retained."
1164
+ )
1165
+ prewrite_config = cloudflare_api.get_tunnel_configuration(
1166
+ api_token, zone["accountId"], host["tunnelId"]
1167
+ )
1168
+ if _reviewed_config_matches(prewrite_config, desired_config):
1169
+ desired_already_applied = True
1170
+ elif _config_snapshot(prewrite_config) != plan["tunnelConfigSha256"]:
1171
+ raise DeploymentKitError(
1172
+ "Cloudflare tunnel configuration changed during host preparation; "
1173
+ "provider state was preserved and the authenticated host phase was retained."
1174
+ )
1175
+ else:
1176
+ desired_already_applied = False
1177
+
1178
+ # Cloudflare exposes a whole-configuration PUT without compare-and-swap.
1179
+ # The operator's external-writer freeze is the concurrency prerequisite;
1180
+ # these reads detect stale evidence but cannot enforce that freeze.
1181
+ if not desired_already_applied:
1182
+ try:
1183
+ cloudflare_api.update_tunnel_configuration(
1184
+ api_token, zone["accountId"], host["tunnelId"], desired_config
1185
+ )
1186
+ except Exception as write_error:
1187
+ try:
1188
+ after_error = cloudflare_api.get_tunnel_configuration(
1189
+ api_token, zone["accountId"], host["tunnelId"]
1190
+ )
1191
+ except Exception:
1192
+ raise DeploymentKitError(
1193
+ "Provider migration response is uncertain. The authenticated host phase and recovery receipt "
1194
+ "were retained; inspect Cloudflare state and re-plan before retrying."
1195
+ ) from write_error
1196
+ if _reviewed_config_matches(after_error, prewrite_config):
1197
+ if host_prepared_by_this_apply:
1198
+ try:
1199
+ _rollback_prepared_migration(
1200
+ name, scope, zone=zone, tunnel=tunnel,
1201
+ route_snapshot=plan["routeInventoryWireSnapshot"],
1202
+ )
1203
+ except Exception as rollback_error:
1204
+ raise DeploymentKitError(
1205
+ "Provider state is unchanged, but authenticated host rollback did not complete. "
1206
+ "The migration receipt was retained for safe recovery."
1207
+ ) from rollback_error
1208
+ raise DeploymentKitError(
1209
+ "Cloudflare provider update failed before changing the reviewed configuration; "
1210
+ "the operation was rolled back or remains safely resumable."
1211
+ ) from write_error
1212
+ if not _reviewed_config_matches(after_error, desired_config):
1213
+ raise DeploymentKitError(
1214
+ "Provider migration response is uncertain and current configuration differs from both "
1215
+ "reviewed states. The authenticated host phase was retained; no rollback overwrote provider state."
1216
+ ) from write_error
1217
+
1218
+ final_config = cloudflare_api.get_tunnel_configuration(
1219
+ api_token, zone["accountId"], host["tunnelId"]
1220
+ )
1221
+ if not _reviewed_config_matches(final_config, desired_config):
1222
+ raise DeploymentKitError(
1223
+ "Provider migration did not produce the exact reviewed authenticated rules; "
1224
+ "the host recovery receipt was retained."
1225
+ )
1226
+ final_host = cloudflare_ingress_status(name, scope)
1227
+ final_inventory = cloudflare_route_inventory(name, scope)
1228
+ final_dns_snapshot = _migration_dns_snapshot(api_token, final_host, plan["managedRoutes"])
1229
+ if (
1230
+ final_host["state"] != "ready"
1231
+ or not final_host["authenticatedOrigin"]
1232
+ or final_host["tunnelId"] != host["tunnelId"]
1233
+ or final_host["tunnelName"] != host["tunnelName"]
1234
+ or _route_inventory_snapshot(final_inventory) != plan["routeInventorySnapshot"]
1235
+ or final_dns_snapshot != plan["dnsSnapshot"]
1236
+ ):
1237
+ raise DeploymentKitError(
1238
+ "Final migration state changed or did not preserve the reviewed tunnel, routes, or DNS identities; "
1239
+ "host recovery evidence was retained."
1240
+ )
1241
+ proof_output = _migration_remote_action(
1242
+ name,
1243
+ scope,
1244
+ "verify-authenticated",
1245
+ zone=zone,
1246
+ tunnel=tunnel,
1247
+ route_snapshot=plan["routeInventoryWireSnapshot"],
1248
+ )
1249
+ proof = _parse_migration_proof(proof_output, len(plan["managedRoutes"]))
1250
+
1251
+ _migration_remote_action(
1252
+ name,
1253
+ scope,
1254
+ "finalize-authenticated",
1255
+ zone=zone,
1256
+ tunnel=tunnel,
1257
+ route_snapshot=plan["routeInventoryWireSnapshot"],
1258
+ )
1259
+ if _remote_migration_state(name, scope) != "complete":
1260
+ raise DeploymentKitError(
1261
+ "Migration is verified but the host recovery receipt could not be finalized; inspect before retrying."
1262
+ )
1263
+ post_finalize_host = cloudflare_ingress_status(name, scope)
1264
+ if (
1265
+ post_finalize_host["state"] != "ready"
1266
+ or not post_finalize_host["authenticatedOrigin"]
1267
+ or post_finalize_host["tunnelId"] != host["tunnelId"]
1268
+ ):
1269
+ raise DeploymentKitError("Final host status changed after migration finalization.")
1270
+ return {
1271
+ "targetName": name,
1272
+ "scope": scope,
1273
+ "tunnelId": host["tunnelId"],
1274
+ "tunnelName": host["tunnelName"],
1275
+ "authenticatedOrigin": True,
1276
+ "managedRoutes": [
1277
+ {"routeId": spec["routeId"], "publicHostname": spec["publicHostname"]}
1278
+ for spec in plan["managedRoutes"]
1279
+ ],
1280
+ "preservedDnsRecordIds": [
1281
+ {"routeId": item["routeId"], "recordId": item["recordId"]}
1282
+ for item in plan["dnsSnapshot"]
1283
+ ],
1284
+ "structuralEvidence": proof,
1285
+ "liveSocketForwardingVerified": False,
1286
+ "endToEndReadiness": "unproven",
1287
+ "requiredAuthorization": AUTHENTICATED_ORIGIN_MIGRATION_PHRASE,
1288
+ "mutated": True,
1289
+ }
1290
+
1291
+
699
1292
  def _dns_marker(spec: dict[str, Any]) -> str:
700
1293
  identity = "|".join((spec["ownerRepo"], spec["environment"], spec["appSlug"])).encode("utf-8")
701
1294
  return "Project Forge " + hashlib.sha256(identity).hexdigest()[:20]