@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.
- package/assets/components.json +2 -2
- package/assets/generated/ASSEMBLY.json +9 -9
- package/assets/generated/deployment/docs/CLOUDFLARE_INGRESS.md +36 -2
- package/assets/generated/deployment/src/project_forge_deployment/app_cli.py +18 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime.py +120 -8
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime_remote.py +173 -3
- package/assets/generated/deployment/src/project_forge_deployment/cloudflare_ingress.py +594 -1
- package/assets/generated/deployment/src/project_forge_deployment/cloudflare_ingress_remote.py +347 -10
- package/assets/generated/deployment/src/project_forge_deployment/ingress_cli.py +51 -1
- package/package.json +1 -1
|
@@ -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]
|