@mriekki/project-forge 4.10.0 → 4.10.2
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/deployment/distribution.json +1 -0
- package/assets/generated/ASSEMBLY.json +8 -3
- package/assets/generated/deployment/src/project_forge_deployment/cloudflare_route_apply.py +181 -0
- package/assets/generated/deployment/src/project_forge_deployment/entrypoint.py +70 -12
- package/package.json +1 -1
package/assets/components.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.10.
|
|
4
|
+
"version": "4.10.2",
|
|
5
5
|
"defaultProfile": "greenfield-project",
|
|
6
6
|
"profiles": {
|
|
7
7
|
"greenfield-project": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"canonicalDescriptor": "distributions/greenfield-workspace.json"
|
|
22
22
|
},
|
|
23
23
|
"deployment": {
|
|
24
|
-
"revision": "
|
|
24
|
+
"revision": "5131dd0db9bbccda1647eee352f6bdcad7f5c112",
|
|
25
25
|
"distribution": "greenfield-project",
|
|
26
26
|
"descriptor": "assets/deployment/distribution.json",
|
|
27
27
|
"sourceRoot": "assets/generated/deployment",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
{"source": "src/project_forge_deployment/cloudflare_api.py", "destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_api.py"},
|
|
20
20
|
{"source": "src/project_forge_deployment/cloudflare_ingress.py", "destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_ingress.py"},
|
|
21
21
|
{"source": "src/project_forge_deployment/cloudflare_ingress_remote.py", "destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_ingress_remote.py"},
|
|
22
|
+
{"source": "src/project_forge_deployment/cloudflare_route_apply.py", "destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_route_apply.py"},
|
|
22
23
|
{"source": "src/project_forge_deployment/cli.py", "destination": "tools/project-forge-deployment/project_forge_deployment/cli.py"},
|
|
23
24
|
{"source": "src/project_forge_deployment/doctor.py", "destination": "tools/project-forge-deployment/project_forge_deployment/doctor.py"},
|
|
24
25
|
{"source": "src/project_forge_deployment/entrypoint.py", "destination": "tools/project-forge-deployment/project_forge_deployment/entrypoint.py"},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.10.
|
|
4
|
+
"version": "4.10.2",
|
|
5
5
|
"components": {
|
|
6
6
|
"core": {
|
|
7
7
|
"revision": "8796fd8dc22df8b64078e5b460e1008a8c7a5f7d",
|
|
@@ -326,7 +326,7 @@
|
|
|
326
326
|
]
|
|
327
327
|
},
|
|
328
328
|
"deployment": {
|
|
329
|
-
"revision": "
|
|
329
|
+
"revision": "5131dd0db9bbccda1647eee352f6bdcad7f5c112",
|
|
330
330
|
"distribution": "greenfield-project",
|
|
331
331
|
"files": [
|
|
332
332
|
{
|
|
@@ -409,6 +409,11 @@
|
|
|
409
409
|
"destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_ingress_remote.py",
|
|
410
410
|
"sha256": "35ac39b4f4538d4ff23f68982cc4b176782378cf03539fa51a228becbdfcf4d0"
|
|
411
411
|
},
|
|
412
|
+
{
|
|
413
|
+
"source": "src/project_forge_deployment/cloudflare_route_apply.py",
|
|
414
|
+
"destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_route_apply.py",
|
|
415
|
+
"sha256": "9c41b4b35d15bc46e7828f0366dc53c58be47f4414e7d90dfd176ea922bf294a"
|
|
416
|
+
},
|
|
412
417
|
{
|
|
413
418
|
"source": "src/project_forge_deployment/cli.py",
|
|
414
419
|
"destination": "tools/project-forge-deployment/project_forge_deployment/cli.py",
|
|
@@ -422,7 +427,7 @@
|
|
|
422
427
|
{
|
|
423
428
|
"source": "src/project_forge_deployment/entrypoint.py",
|
|
424
429
|
"destination": "tools/project-forge-deployment/project_forge_deployment/entrypoint.py",
|
|
425
|
-
"sha256": "
|
|
430
|
+
"sha256": "c9dd332a68e923ef15a9f228e0666306c9692dd3ffe724b73160ce313f627ab6"
|
|
426
431
|
},
|
|
427
432
|
{
|
|
428
433
|
"source": "src/project_forge_deployment/environment_cli.py",
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Safe public-route apply wrapper for Cloudflare provider normalization.
|
|
2
|
+
|
|
3
|
+
Cloudflare currently adds ``warp-routing: {enabled: false}`` when returning a
|
|
4
|
+
remotely managed tunnel configuration even when that provider default was not
|
|
5
|
+
submitted. Project Forge tolerates only that observed no-op normalization;
|
|
6
|
+
all reviewed ingress rules and every other configuration value remain exact.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from copy import deepcopy
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from . import cloudflare_api
|
|
15
|
+
from . import cloudflare_ingress as ingress
|
|
16
|
+
from .errors import DeploymentKitError
|
|
17
|
+
from .profiles import load_profile
|
|
18
|
+
from .ssh import verify_access
|
|
19
|
+
|
|
20
|
+
_FINAL_VERIFICATION_ERRORS = {
|
|
21
|
+
"Public route registration returned without exact host ownership metadata.",
|
|
22
|
+
"Cloudflare tunnel configuration does not match the reviewed route plan after apply.",
|
|
23
|
+
"Cloudflare DNS record is missing after public-route apply.",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _reviewed_config_matches(actual: Any, reviewed: Any) -> bool:
|
|
28
|
+
"""Match reviewed config after removing only the observed safe provider default."""
|
|
29
|
+
if not isinstance(actual, dict) or not isinstance(reviewed, dict):
|
|
30
|
+
return False
|
|
31
|
+
normalized = deepcopy(actual)
|
|
32
|
+
if "warp-routing" not in reviewed and normalized.get("warp-routing") == {"enabled": False}:
|
|
33
|
+
normalized.pop("warp-routing")
|
|
34
|
+
return normalized == reviewed
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_final_state(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
|
|
38
|
+
name = plan["targetName"]
|
|
39
|
+
scope = plan["scope"]
|
|
40
|
+
spec = plan["route"]
|
|
41
|
+
host = ingress.cloudflare_ingress_status(name, scope)
|
|
42
|
+
inventory = ingress.cloudflare_route_inventory(name, scope)
|
|
43
|
+
route = ingress._find_route(inventory, spec["routeId"])
|
|
44
|
+
config = cloudflare_api.get_tunnel_configuration(api_token, host["accountId"], host["tunnelId"])
|
|
45
|
+
records = cloudflare_api.list_dns_records(api_token, host["zoneId"], name=spec["publicHostname"])
|
|
46
|
+
dns_error: str | None = None
|
|
47
|
+
try:
|
|
48
|
+
dns = ingress._exact_dns_record(records, spec=spec, tunnel_id=host["tunnelId"])
|
|
49
|
+
except DeploymentKitError as error:
|
|
50
|
+
dns = None
|
|
51
|
+
dns_error = str(error)
|
|
52
|
+
return {
|
|
53
|
+
"host": host,
|
|
54
|
+
"route": route,
|
|
55
|
+
"config": config,
|
|
56
|
+
"dns": dns,
|
|
57
|
+
"dnsError": dns_error,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _rollback_final_state(
|
|
62
|
+
plan: dict[str, Any],
|
|
63
|
+
state: dict[str, Any],
|
|
64
|
+
*,
|
|
65
|
+
api_token: str,
|
|
66
|
+
) -> list[str]:
|
|
67
|
+
"""Best-effort reverse only mutations that can still be attributed safely."""
|
|
68
|
+
errors: list[str] = []
|
|
69
|
+
host = state["host"]
|
|
70
|
+
spec = plan["route"]
|
|
71
|
+
|
|
72
|
+
if plan["dnsRecord"] is None:
|
|
73
|
+
dns = state["dns"]
|
|
74
|
+
if dns is not None:
|
|
75
|
+
try:
|
|
76
|
+
cloudflare_api.delete_dns_record(api_token, host["zoneId"], str(dns["id"]))
|
|
77
|
+
except Exception as error: # pragma: no cover - provider failure detail only
|
|
78
|
+
errors.append(f"DNS rollback failed: {error}")
|
|
79
|
+
elif state["dnsError"] is not None:
|
|
80
|
+
errors.append("DNS rollback skipped because current DNS ownership is ambiguous.")
|
|
81
|
+
|
|
82
|
+
if plan["desiredTunnelConfig"] != plan["tunnelConfig"]:
|
|
83
|
+
current = state["config"]
|
|
84
|
+
if _reviewed_config_matches(current, plan["desiredTunnelConfig"]):
|
|
85
|
+
try:
|
|
86
|
+
cloudflare_api.update_tunnel_configuration(
|
|
87
|
+
api_token,
|
|
88
|
+
host["accountId"],
|
|
89
|
+
host["tunnelId"],
|
|
90
|
+
plan["tunnelConfig"],
|
|
91
|
+
)
|
|
92
|
+
except Exception as error: # pragma: no cover - provider failure detail only
|
|
93
|
+
errors.append(f"tunnel configuration rollback failed: {error}")
|
|
94
|
+
elif not _reviewed_config_matches(current, plan["tunnelConfig"]):
|
|
95
|
+
errors.append(
|
|
96
|
+
"Tunnel configuration rollback skipped because current provider state cannot be safely attributed to this apply."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if plan["previousRoute"] is None and state["route"] == spec:
|
|
100
|
+
try:
|
|
101
|
+
profile = load_profile(plan["targetName"])
|
|
102
|
+
access = verify_access(profile)
|
|
103
|
+
ingress._run_remote(
|
|
104
|
+
profile,
|
|
105
|
+
access["address"],
|
|
106
|
+
"route-remove",
|
|
107
|
+
scope=plan["scope"],
|
|
108
|
+
spec=spec,
|
|
109
|
+
)
|
|
110
|
+
except Exception as error: # pragma: no cover - remote failure detail only
|
|
111
|
+
errors.append(f"host route rollback failed: {error}")
|
|
112
|
+
|
|
113
|
+
return errors
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _uncertain_recovery_error(
|
|
117
|
+
original_error: Exception,
|
|
118
|
+
recovery_error: Exception,
|
|
119
|
+
) -> DeploymentKitError:
|
|
120
|
+
return DeploymentKitError(
|
|
121
|
+
"Cloudflare public-route final verification failed, and recovery could not read "
|
|
122
|
+
"the current host/provider state. External state is uncertain and rollback was "
|
|
123
|
+
"not attempted because current ownership could not be verified safely. "
|
|
124
|
+
f"Original verification error: {original_error} "
|
|
125
|
+
f"Recovery read failed: {recovery_error} "
|
|
126
|
+
"Inspect the managed host route, tunnel configuration, and DNS record before retrying."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def apply_public_route(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
|
|
131
|
+
"""Apply a reviewed public route with bounded post-write recovery.
|
|
132
|
+
|
|
133
|
+
The underlying apply retains all pre-write stale-state checks and its write
|
|
134
|
+
rollback. Recovery is entered only for errors that are emitted exclusively
|
|
135
|
+
by the final verification phase, so preflight failures can never be turned
|
|
136
|
+
into success by observing later provider state.
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
return ingress.apply_public_route(plan, api_token=api_token)
|
|
140
|
+
except Exception as original_error:
|
|
141
|
+
if (
|
|
142
|
+
not isinstance(original_error, DeploymentKitError)
|
|
143
|
+
or str(original_error) not in _FINAL_VERIFICATION_ERRORS
|
|
144
|
+
or not isinstance(plan, dict)
|
|
145
|
+
or plan.get("action") != "register"
|
|
146
|
+
):
|
|
147
|
+
raise
|
|
148
|
+
required = {
|
|
149
|
+
"targetName", "scope", "route", "previousRoute", "tunnelConfig",
|
|
150
|
+
"desiredTunnelConfig", "dnsRecord", "dnsMarker",
|
|
151
|
+
}
|
|
152
|
+
if not required.issubset(plan):
|
|
153
|
+
raise
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
state = _read_final_state(plan, api_token=api_token)
|
|
157
|
+
except Exception as recovery_error:
|
|
158
|
+
raise _uncertain_recovery_error(original_error, recovery_error) from recovery_error
|
|
159
|
+
|
|
160
|
+
route_ok = state["route"] == plan["route"]
|
|
161
|
+
config_ok = _reviewed_config_matches(state["config"], plan["desiredTunnelConfig"])
|
|
162
|
+
dns_ok = state["dns"] is not None
|
|
163
|
+
if route_ok and config_ok and dns_ok:
|
|
164
|
+
return {
|
|
165
|
+
"targetName": plan["targetName"],
|
|
166
|
+
"scope": plan["scope"],
|
|
167
|
+
"route": plan["route"],
|
|
168
|
+
"dnsRecordId": str(state["dns"]["id"]),
|
|
169
|
+
"publicUrl": f"https://{plan['route']['publicHostname']}",
|
|
170
|
+
"mutated": True,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
rollback_errors = _rollback_final_state(plan, state, api_token=api_token)
|
|
174
|
+
if rollback_errors:
|
|
175
|
+
raise DeploymentKitError(
|
|
176
|
+
"Cloudflare public-route final verification failed and rollback was incomplete; "
|
|
177
|
+
"external state may remain changed. "
|
|
178
|
+
+ "; ".join(rollback_errors)
|
|
179
|
+
+ " Inspect the managed host route, tunnel configuration, and DNS record before retrying."
|
|
180
|
+
) from original_error
|
|
181
|
+
raise original_error
|
|
@@ -1,20 +1,78 @@
|
|
|
1
|
-
"""Top-level console entrypoint for Deployment Kit command groups.
|
|
1
|
+
"""Top-level console entrypoint for Deployment Kit command groups.
|
|
2
|
+
|
|
3
|
+
Command groups are loaded lazily so an optional capability defect cannot prevent
|
|
4
|
+
unrelated Deployment Kit commands from starting.
|
|
5
|
+
"""
|
|
2
6
|
|
|
3
7
|
from __future__ import annotations
|
|
4
8
|
|
|
5
9
|
import sys
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
from .
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
from .
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
|
|
12
|
+
def access_main(argv: list[str]) -> int:
|
|
13
|
+
from .access_cli import main
|
|
14
|
+
|
|
15
|
+
return main(argv)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def app_main(argv: list[str]) -> int:
|
|
19
|
+
from .app_cli import main
|
|
20
|
+
|
|
21
|
+
return main(argv)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def core_main(argv: list[str]) -> int:
|
|
25
|
+
from .cli import main
|
|
26
|
+
|
|
27
|
+
return main(argv)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def environment_main(argv: list[str]) -> int:
|
|
31
|
+
from .environment_cli import main
|
|
32
|
+
|
|
33
|
+
return main(argv)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ingress_main(argv: list[str]) -> int:
|
|
37
|
+
from .ingress_cli import main
|
|
38
|
+
|
|
39
|
+
return main(argv)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def local_main(argv: list[str]) -> int:
|
|
43
|
+
from .local_cli import main
|
|
44
|
+
|
|
45
|
+
return main(argv)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def registry_main(argv: list[str]) -> int:
|
|
49
|
+
from .registry_cli import main
|
|
50
|
+
|
|
51
|
+
return main(argv)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def sqlite_main(argv: list[str]) -> int:
|
|
55
|
+
from .sqlite_cli import main
|
|
56
|
+
|
|
57
|
+
return main(argv)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def target_inventory_main(argv: list[str]) -> int:
|
|
61
|
+
from .teardown_cli import inventory_main
|
|
62
|
+
|
|
63
|
+
return inventory_main(argv)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def teardown_main(argv: list[str]) -> int:
|
|
67
|
+
from .teardown_cli import main
|
|
68
|
+
|
|
69
|
+
return main(argv)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def topology_main(argv: list[str]) -> int:
|
|
73
|
+
from .topology_cli import main
|
|
74
|
+
|
|
75
|
+
return main(argv)
|
|
18
76
|
|
|
19
77
|
|
|
20
78
|
def main(argv: list[str] | None = None) -> int:
|