@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.
- package/assets/components.json +3 -3
- package/assets/deployment/distribution.json +5 -0
- package/assets/generated/ASSEMBLY.json +37 -12
- package/assets/generated/core/core/process/DEPLOYMENT_ENABLEMENT.md +25 -1
- package/assets/generated/core/core/skills/deploy/SKILL.md +15 -3
- package/assets/generated/core/core/skills/deployment-readiness/SKILL.md +45 -1
- package/assets/generated/core/core/skills/setup/SKILL.md +21 -1
- package/assets/generated/deployment/docs/PROJECT_TOPOLOGY_BINDING.md +44 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_cli.py +72 -21
- package/assets/generated/deployment/src/project_forge_deployment/app_deployment.py +194 -0
- package/assets/generated/deployment/src/project_forge_deployment/entrypoint.py +4 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_cli.py +60 -1
- package/assets/generated/deployment/src/project_forge_deployment/environment_isolation.py +438 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_probe.py +57 -1
- package/assets/generated/deployment/src/project_forge_deployment/environment_setup.py +72 -26
- package/assets/generated/deployment/src/project_forge_deployment/project_topology.py +218 -0
- package/assets/generated/deployment/src/project_forge_deployment/topology_cli.py +85 -0
- package/package.json +1 -1
|
@@ -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(
|