@mriekki/project-forge 4.10.0 → 4.10.1

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.0",
4
+ "version": "4.10.1",
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": "23799aec3e7c9a99873aaf912b040d43658fa4fb",
24
+ "revision": "6b13a6e66218c067771311db1336c73229d23899",
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.0",
4
+ "version": "4.10.1",
5
5
  "components": {
6
6
  "core": {
7
7
  "revision": "8796fd8dc22df8b64078e5b460e1008a8c7a5f7d",
@@ -326,7 +326,7 @@
326
326
  ]
327
327
  },
328
328
  "deployment": {
329
- "revision": "23799aec3e7c9a99873aaf912b040d43658fa4fb",
329
+ "revision": "6b13a6e66218c067771311db1336c73229d23899",
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": "eea7a5b4322e0601db36d28cb0529522d6acc37c55dfc9a7b41d91518fe14cd4"
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": "021434f8f601ebbff0aae354e6c6b25b36d598a521a7948d02d5f1cc797c22fd"
430
+ "sha256": "c9dd332a68e923ef15a9f228e0666306c9692dd3ffe724b73160ce313f627ab6"
426
431
  },
427
432
  {
428
433
  "source": "src/project_forge_deployment/environment_cli.py",
@@ -0,0 +1,165 @@
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 apply_public_route(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
117
+ """Apply a reviewed public route with bounded post-write recovery.
118
+
119
+ The underlying apply retains all pre-write stale-state checks and its write
120
+ rollback. Recovery is entered only for errors that are emitted exclusively
121
+ by the final verification phase, so preflight failures can never be turned
122
+ into success by observing later provider state.
123
+ """
124
+ try:
125
+ return ingress.apply_public_route(plan, api_token=api_token)
126
+ except Exception as original_error:
127
+ if (
128
+ not isinstance(original_error, DeploymentKitError)
129
+ or str(original_error) not in _FINAL_VERIFICATION_ERRORS
130
+ or not isinstance(plan, dict)
131
+ or plan.get("action") != "register"
132
+ ):
133
+ raise
134
+ required = {
135
+ "targetName", "scope", "route", "previousRoute", "tunnelConfig",
136
+ "desiredTunnelConfig", "dnsRecord", "dnsMarker",
137
+ }
138
+ if not required.issubset(plan):
139
+ raise
140
+
141
+ try:
142
+ state = _read_final_state(plan, api_token=api_token)
143
+ except Exception:
144
+ raise original_error
145
+
146
+ route_ok = state["route"] == plan["route"]
147
+ config_ok = _reviewed_config_matches(state["config"], plan["desiredTunnelConfig"])
148
+ dns_ok = state["dns"] is not None
149
+ if route_ok and config_ok and dns_ok:
150
+ return {
151
+ "targetName": plan["targetName"],
152
+ "scope": plan["scope"],
153
+ "route": plan["route"],
154
+ "dnsRecordId": str(state["dns"]["id"]),
155
+ "publicUrl": f"https://{plan['route']['publicHostname']}",
156
+ "mutated": True,
157
+ }
158
+
159
+ rollback_errors = _rollback_final_state(plan, state, api_token=api_token)
160
+ if rollback_errors:
161
+ raise DeploymentKitError(
162
+ "Cloudflare public-route final verification failed and rollback was incomplete: "
163
+ + "; ".join(rollback_errors)
164
+ ) from original_error
165
+ 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
- from .access_cli import main as access_main
8
- from .app_cli import main as app_main
9
- from .cli import main as core_main
10
- from .environment_cli import main as environment_main
11
- from .ingress_cli import main as ingress_main
12
- from .local_cli import main as local_main
13
- from .registry_cli import main as registry_main
14
- from .sqlite_cli import main as sqlite_main
15
- from .teardown_cli import inventory_main as target_inventory_main
16
- from .teardown_cli import main as teardown_main
17
- from .topology_cli import main as topology_main
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.10.0",
3
+ "version": "4.10.1",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {