@mriekki/project-forge 4.2.0 → 4.4.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.
@@ -0,0 +1,194 @@
1
+ """Isolation-aware application deployment orchestration.
2
+
3
+ The low-level application runtime owns immutable container mechanics. This module
4
+ binds those mechanics to the broader read-only environment isolation contract so
5
+ real application deployment plans cannot ignore route, persistence, or sibling
6
+ ownership changes that occur after human review.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Iterable
12
+
13
+ from .app_runtime import apply_app_deploy as _apply_runtime_deploy
14
+ from .app_runtime import plan_app_deploy as _plan_runtime_deploy
15
+ from .app_runtime_workstation import apply_app_deploy_with_preloaded_image
16
+ from .environment_isolation import environment_isolation_preflight
17
+ from .errors import DeploymentKitError
18
+ from .image_transfer import transfer_image
19
+
20
+
21
+ ISOLATION_REVIEW_KEY = "isolationReview"
22
+
23
+
24
+ def app_isolation_check(
25
+ target_name: str,
26
+ *,
27
+ owner_repo: str,
28
+ environment: str,
29
+ production: bool = False,
30
+ app_slug: str,
31
+ container_name: str,
32
+ runtime_port: int,
33
+ persistence_mode: str,
34
+ database_file: str | None = None,
35
+ route_hostnames: Iterable[str] | None = None,
36
+ ) -> dict[str, Any]:
37
+ """Return read-only isolation evidence for a real application footprint."""
38
+
39
+ return environment_isolation_preflight(
40
+ target_name,
41
+ owner_repo=owner_repo,
42
+ environment=environment,
43
+ production=production,
44
+ app_slug=app_slug,
45
+ container_name=container_name,
46
+ runtime_port=runtime_port,
47
+ persistence_mode=persistence_mode,
48
+ database_file=database_file,
49
+ route_hostnames=route_hostnames,
50
+ require_vacant_app_identity=False,
51
+ )
52
+
53
+
54
+ def _check_from_spec(
55
+ target_name: str,
56
+ spec: dict[str, Any],
57
+ *,
58
+ production: bool,
59
+ database_file: str | None,
60
+ route_hostnames: Iterable[str] | None,
61
+ ) -> dict[str, Any]:
62
+ return app_isolation_check(
63
+ target_name,
64
+ owner_repo=spec["ownerRepo"],
65
+ environment=spec["environment"],
66
+ production=production,
67
+ app_slug=spec["appSlug"],
68
+ container_name=spec["containerName"],
69
+ runtime_port=spec["runtimePort"],
70
+ persistence_mode=spec["persistence"]["mode"],
71
+ database_file=database_file,
72
+ route_hostnames=route_hostnames,
73
+ )
74
+
75
+
76
+ def _require_clear(isolation: dict[str, Any], *, context: str) -> None:
77
+ if isolation.get("state") == "clear":
78
+ return
79
+ conflicts = isolation.get("conflicts") or []
80
+ codes = sorted(
81
+ {
82
+ item.get("code", "unknown-collision")
83
+ for item in conflicts
84
+ if isinstance(item, dict)
85
+ }
86
+ )
87
+ detail = ", ".join(codes) if codes else "unspecified collision"
88
+ raise DeploymentKitError(
89
+ f"Application isolation {context} is blocked: {detail}. Resolve the collision before deployment."
90
+ )
91
+
92
+
93
+ def _review(isolation: dict[str, Any]) -> dict[str, Any]:
94
+ return {
95
+ "schemaVersion": isolation["schemaVersion"],
96
+ "targetName": isolation["targetName"],
97
+ "ownerRepo": isolation["ownerRepo"],
98
+ "environment": isolation["environment"],
99
+ "production": isolation["production"],
100
+ "state": isolation["state"],
101
+ "sharedFailureDomain": isolation["sharedFailureDomain"],
102
+ "siblingEnvironments": isolation["siblingEnvironments"],
103
+ "desired": isolation["desired"],
104
+ "checks": isolation["checks"],
105
+ "conflicts": isolation["conflicts"],
106
+ "inventorySnapshots": isolation["inventorySnapshots"],
107
+ "evidenceSha256": isolation["evidenceSha256"],
108
+ }
109
+
110
+
111
+ def _runtime_plan(plan: dict[str, Any]) -> dict[str, Any]:
112
+ if not isinstance(plan, dict) or ISOLATION_REVIEW_KEY not in plan:
113
+ raise DeploymentKitError("Application deployment plan is missing reviewed isolation evidence.")
114
+ return {key: value for key, value in plan.items() if key != ISOLATION_REVIEW_KEY}
115
+
116
+
117
+ def plan_app_deploy(
118
+ target_name: str,
119
+ spec: dict[str, Any],
120
+ *,
121
+ production: bool = False,
122
+ database_file: str | None = None,
123
+ route_hostnames: Iterable[str] | None = None,
124
+ ) -> dict[str, Any]:
125
+ """Plan a real application deployment with current isolation evidence."""
126
+
127
+ runtime_plan = _plan_runtime_deploy(target_name, spec)
128
+ isolation = _check_from_spec(
129
+ target_name,
130
+ spec,
131
+ production=production,
132
+ database_file=database_file,
133
+ route_hostnames=route_hostnames,
134
+ )
135
+ _require_clear(isolation, context="preflight")
136
+
137
+ plan = dict(runtime_plan)
138
+ plan[ISOLATION_REVIEW_KEY] = _review(isolation)
139
+ return plan
140
+
141
+
142
+ def revalidate_app_isolation(plan: dict[str, Any]) -> dict[str, Any]:
143
+ """Recheck the reviewed application footprint immediately before mutation."""
144
+
145
+ runtime_plan = _runtime_plan(plan)
146
+ review = plan[ISOLATION_REVIEW_KEY]
147
+ if not isinstance(review, dict):
148
+ raise DeploymentKitError("Application deployment plan has malformed isolation evidence.")
149
+ desired = review.get("desired")
150
+ if not isinstance(desired, dict):
151
+ raise DeploymentKitError("Application deployment plan has malformed isolation desired state.")
152
+
153
+ current = _check_from_spec(
154
+ runtime_plan["targetName"],
155
+ runtime_plan["spec"],
156
+ production=bool(review.get("production")),
157
+ database_file=desired.get("databaseFile"),
158
+ route_hostnames=desired.get("routeHostnames") or [],
159
+ )
160
+ _require_clear(current, context="recheck")
161
+
162
+ reviewed_sha = review.get("evidenceSha256")
163
+ if not isinstance(reviewed_sha, str) or current["evidenceSha256"] != reviewed_sha:
164
+ raise DeploymentKitError(
165
+ "Application isolation state changed after the plan was reviewed; create and authorize a fresh plan."
166
+ )
167
+ return current
168
+
169
+
170
+ def apply_app_deploy(plan: dict[str, Any]) -> dict[str, Any]:
171
+ """Apply a registry-backed runtime plan after isolation revalidation."""
172
+
173
+ revalidate_app_isolation(plan)
174
+ return _apply_runtime_deploy(_runtime_plan(plan))
175
+
176
+
177
+ def apply_app_deploy_from_workstation(plan: dict[str, Any]) -> dict[str, Any]:
178
+ """Transfer an exact image, then recheck isolation again before runtime mutation."""
179
+
180
+ revalidate_app_isolation(plan)
181
+ runtime_plan = _runtime_plan(plan)
182
+ transfer = transfer_image(runtime_plan["targetName"], runtime_plan["image"])
183
+ revalidate_app_isolation(plan)
184
+ return apply_app_deploy_with_preloaded_image(runtime_plan, transfer)
185
+
186
+
187
+ __all__ = [
188
+ "ISOLATION_REVIEW_KEY",
189
+ "app_isolation_check",
190
+ "apply_app_deploy",
191
+ "apply_app_deploy_from_workstation",
192
+ "plan_app_deploy",
193
+ "revalidate_app_isolation",
194
+ ]
@@ -11,6 +11,7 @@ from .local_cli import main as local_main
11
11
  from .registry_cli import main as registry_main
12
12
  from .sqlite_cli import main as sqlite_main
13
13
  from .teardown_cli import main as teardown_main
14
+ from .topology_cli import main as topology_main
14
15
 
15
16
 
16
17
  def main(argv: list[str] | None = None) -> int:
@@ -27,6 +28,8 @@ def main(argv: list[str] | None = None) -> int:
27
28
  return sqlite_main(args[1:])
28
29
  if args and args[0] == "tear-down":
29
30
  return teardown_main(args[1:])
31
+ if args and args[0] == "topology":
32
+ return topology_main(args[1:])
30
33
  if not args or args == ["--help"] or args == ["-h"]:
31
34
  print("Command group: app Remote immutable application runtime inventory/deployment")
32
35
  print("Command group: environment Portable profiles, Home Lab onboarding, and image transfer")
@@ -34,4 +37,5 @@ def main(argv: list[str] | None = None) -> int:
34
37
  print("Command group: registry Read-only exact-image registry access verification")
35
38
  print("Command group: sqlite SQLite persistence import/backup/migration/rollback checks")
36
39
  print("Command group: tear-down Scoped destructive Project environment/local cleanup")
40
+ print("Command group: topology Project-owned logical environment binding")
37
41
  return core_main(args)
@@ -56,6 +56,32 @@ def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
56
56
  raise DeploymentKitError(f"{action.capitalize()} was not authorized.")
57
57
 
58
58
 
59
+ def _setup_readiness(result: dict[str, Any], *, require_deployment_path: bool) -> dict[str, Any]:
60
+ required_phases = ["workspace", "targetDefinition", "targetAccess", "hostPlatform"]
61
+ expected_states = {
62
+ "workspace": "ready",
63
+ "targetDefinition": "ready",
64
+ "targetAccess": "ready",
65
+ "hostPlatform": "ready",
66
+ }
67
+ if require_deployment_path:
68
+ required_phases.append("deploymentPath")
69
+ expected_states["deploymentPath"] = "proven"
70
+
71
+ not_ready = [
72
+ phase
73
+ for phase in required_phases
74
+ if result["phases"][phase]["state"] != expected_states[phase]
75
+ ]
76
+ return {
77
+ "ready": not not_ready,
78
+ "requiredThrough": "deploymentPath" if require_deployment_path else "hostPlatform",
79
+ "requiredPhases": required_phases,
80
+ "notReadyPhases": not_ready,
81
+ "applicationRequired": False,
82
+ }
83
+
84
+
59
85
  def build_parser() -> argparse.ArgumentParser:
60
86
  parser = argparse.ArgumentParser(prog="project-forge-deploy environment")
61
87
  groups = parser.add_subparsers(dest="group", required=True)
@@ -68,6 +94,19 @@ def build_parser() -> argparse.ArgumentParser:
68
94
  setup_status = setup_sub.add_parser("status", help="Show one terminal readiness model for a remote target")
69
95
  setup_status.add_argument("name")
70
96
  setup_status.add_argument("--json", action="store_true")
97
+ setup_check = setup_sub.add_parser(
98
+ "check",
99
+ help="Return success only when the requested remote setup readiness contract is satisfied",
100
+ )
101
+ setup_check.add_argument("name")
102
+ setup_check.add_argument("--owner-repo")
103
+ setup_check.add_argument("--environment")
104
+ setup_check.add_argument(
105
+ "--production",
106
+ action="store_true",
107
+ help="Require proof evidence classified as Production; requires owner/environment proof context",
108
+ )
109
+ setup_check.add_argument("--json", action="store_true")
71
110
  setup_plan = setup_sub.add_parser("plan", help="Show the exact next deterministic remote setup action")
72
111
  setup_plan.add_argument("name")
73
112
  setup_plan.add_argument("--owner-repo")
@@ -201,6 +240,23 @@ def main(argv: list[str] | None = None) -> int:
201
240
  if args.group == "setup" and args.command == "status":
202
241
  _print(remote_setup_status(args.name), as_json=args.json)
203
242
  return 0
243
+ if args.group == "setup" and args.command == "check":
244
+ if args.production and (args.owner_repo is None or args.environment is None):
245
+ raise DeploymentKitError(
246
+ "--production requires --owner-repo and --environment for setup readiness checking."
247
+ )
248
+ result = remote_setup_plan(
249
+ args.name,
250
+ owner_repo=args.owner_repo,
251
+ environment=args.environment,
252
+ production=args.production,
253
+ )
254
+ readiness = _setup_readiness(
255
+ result,
256
+ require_deployment_path=args.owner_repo is not None,
257
+ )
258
+ _print({**result, "readinessCheck": readiness}, as_json=args.json)
259
+ return 0 if readiness["ready"] else 2
204
260
  if args.group == "setup" and args.command == "plan":
205
261
  _print(
206
262
  remote_setup_plan(
@@ -279,7 +335,10 @@ def main(argv: list[str] | None = None) -> int:
279
335
  )
280
336
  if args.command == "plan":
281
337
  _print(plan, as_json=args.json)
282
- return 0
338
+ return 0 if plan.get("runnable", True) else 2
339
+ if not plan.get("runnable", True):
340
+ _print(plan, as_json=args.json)
341
+ return 2
283
342
  if not args.json:
284
343
  _print(plan, as_json=False)
285
344
  _confirm(