@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,438 @@
|
|
|
1
|
+
"""Read-only colocated-environment isolation and collision preflight."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from typing import Any, Iterable
|
|
10
|
+
|
|
11
|
+
from .app_runtime import APP_ROOT, app_inventory
|
|
12
|
+
from .errors import DeploymentKitError
|
|
13
|
+
from .route_registry import route_inventory
|
|
14
|
+
from .sqlite_adapter import sqlite_inventory
|
|
15
|
+
|
|
16
|
+
ISOLATION_SCHEMA_VERSION = 1
|
|
17
|
+
_LOCK_ROOT = "/run/lock"
|
|
18
|
+
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
|
19
|
+
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
20
|
+
_CONTAINER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
|
21
|
+
_DB_FILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _owner_repo(value: str) -> str:
|
|
25
|
+
if not isinstance(value, str) or _REPO_RE.fullmatch(value) is None:
|
|
26
|
+
raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _slug(value: str, field: str) -> str:
|
|
31
|
+
if not isinstance(value, str) or _SLUG_RE.fullmatch(value) is None:
|
|
32
|
+
raise DeploymentKitError(f"{field} must use lowercase letters, digits, and internal hyphens.")
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _container(value: str | None) -> str | None:
|
|
37
|
+
if value is None:
|
|
38
|
+
return None
|
|
39
|
+
if not isinstance(value, str) or _CONTAINER_RE.fullmatch(value) is None:
|
|
40
|
+
raise DeploymentKitError("containerName contains unsupported characters.")
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _port(value: int | None) -> int | None:
|
|
45
|
+
if value is None:
|
|
46
|
+
return None
|
|
47
|
+
if type(value) is not int or not 1 <= value <= 65535:
|
|
48
|
+
raise DeploymentKitError("runtimePort must be an integer from 1 through 65535.")
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _database_file(value: str | None) -> str | None:
|
|
53
|
+
if value is None:
|
|
54
|
+
return None
|
|
55
|
+
if not isinstance(value, str) or _DB_FILE_RE.fullmatch(value) is None or value in {".", ".."}:
|
|
56
|
+
raise DeploymentKitError("databaseFile must be a safe file name without path separators.")
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _hostnames(values: Iterable[str] | None) -> list[str]:
|
|
61
|
+
result: set[str] = set()
|
|
62
|
+
for value in values or ():
|
|
63
|
+
if not isinstance(value, str) or not value.strip():
|
|
64
|
+
raise DeploymentKitError("routeHostnames must contain non-empty strings.")
|
|
65
|
+
result.add(value.lower().rstrip("."))
|
|
66
|
+
return sorted(result)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _snapshot(value: Any) -> str:
|
|
70
|
+
return hashlib.sha256(
|
|
71
|
+
json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
72
|
+
).hexdigest()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _resource_state(conflicts: list[dict[str, Any]], resources: set[str]) -> str:
|
|
76
|
+
return "blocked" if any(item["resource"] in resources for item in conflicts) else "clear"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _identity(item: dict[str, Any]) -> tuple[str, str, str]:
|
|
80
|
+
return (item["ownerRepo"], item["appSlug"], item["environment"])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _conflict(
|
|
84
|
+
code: str,
|
|
85
|
+
resource: str,
|
|
86
|
+
message: str,
|
|
87
|
+
**detail: Any,
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
return {"code": code, "resource": resource, "message": message, **detail}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def environment_isolation_preflight(
|
|
93
|
+
target_name: str,
|
|
94
|
+
*,
|
|
95
|
+
owner_repo: str,
|
|
96
|
+
environment: str,
|
|
97
|
+
production: bool = False,
|
|
98
|
+
app_slug: str | None = None,
|
|
99
|
+
container_name: str | None = None,
|
|
100
|
+
runtime_port: int | None = None,
|
|
101
|
+
persistence_mode: str | None = None,
|
|
102
|
+
database_file: str | None = None,
|
|
103
|
+
route_hostnames: Iterable[str] | None = None,
|
|
104
|
+
require_vacant_app_identity: bool = False,
|
|
105
|
+
) -> dict[str, Any]:
|
|
106
|
+
"""Inspect managed shared-host state without mutating it.
|
|
107
|
+
|
|
108
|
+
Same-target colocation is allowed. The preflight blocks only ambiguous ownership,
|
|
109
|
+
duplicate managed identities, route/runtime drift, or a requested footprint that
|
|
110
|
+
would take over a sibling Project/environment resource.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
owner = _owner_repo(owner_repo)
|
|
114
|
+
env = _slug(environment, "environment")
|
|
115
|
+
app = _slug(app_slug, "appSlug") if app_slug is not None else None
|
|
116
|
+
container = _container(container_name)
|
|
117
|
+
port = _port(runtime_port)
|
|
118
|
+
database = _database_file(database_file)
|
|
119
|
+
hosts = _hostnames(route_hostnames)
|
|
120
|
+
if not isinstance(production, bool):
|
|
121
|
+
raise DeploymentKitError("production must be true or false.")
|
|
122
|
+
if not isinstance(require_vacant_app_identity, bool):
|
|
123
|
+
raise DeploymentKitError("requireVacantAppIdentity must be true or false.")
|
|
124
|
+
if persistence_mode not in {None, "none", "directory"}:
|
|
125
|
+
raise DeploymentKitError("persistenceMode must be omitted, 'none', or 'directory'.")
|
|
126
|
+
if app is None and any(value is not None for value in (container, port, persistence_mode, database)):
|
|
127
|
+
raise DeploymentKitError("appSlug is required when an application footprint is supplied.")
|
|
128
|
+
if app is None and hosts:
|
|
129
|
+
raise DeploymentKitError("appSlug is required when routeHostnames are supplied.")
|
|
130
|
+
if persistence_mode == "none" and database is not None:
|
|
131
|
+
raise DeploymentKitError("databaseFile cannot be supplied when persistenceMode is 'none'.")
|
|
132
|
+
|
|
133
|
+
app_state = app_inventory(target_name)
|
|
134
|
+
route_state = route_inventory(target_name)
|
|
135
|
+
sqlite_state = sqlite_inventory(target_name)
|
|
136
|
+
applications = list(app_state.get("applications", []))
|
|
137
|
+
routes = list(route_state.get("managedRoutes", []))
|
|
138
|
+
sqlite = list(sqlite_state.get("sqlite", []))
|
|
139
|
+
|
|
140
|
+
conflicts: list[dict[str, Any]] = []
|
|
141
|
+
|
|
142
|
+
containers: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
143
|
+
for item in applications:
|
|
144
|
+
containers[item["containerName"]].append(item)
|
|
145
|
+
for name, items in containers.items():
|
|
146
|
+
identities = {_identity(item) for item in items}
|
|
147
|
+
if len(identities) > 1:
|
|
148
|
+
conflicts.append(
|
|
149
|
+
_conflict(
|
|
150
|
+
"duplicate-managed-container",
|
|
151
|
+
"runtime",
|
|
152
|
+
f"Managed container name {name!r} is claimed by multiple Project/environment identities.",
|
|
153
|
+
containerName=name,
|
|
154
|
+
identities=[list(value) for value in sorted(identities)],
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
route_hosts: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
159
|
+
for route in routes:
|
|
160
|
+
for hostname in route["hostnames"]:
|
|
161
|
+
route_hosts[hostname].append(route)
|
|
162
|
+
for hostname, items in route_hosts.items():
|
|
163
|
+
route_ids = sorted({item["routeId"] for item in items})
|
|
164
|
+
if len(route_ids) > 1:
|
|
165
|
+
conflicts.append(
|
|
166
|
+
_conflict(
|
|
167
|
+
"duplicate-managed-hostname",
|
|
168
|
+
"route",
|
|
169
|
+
f"Managed hostname {hostname!r} is claimed by multiple routes.",
|
|
170
|
+
hostname=hostname,
|
|
171
|
+
routeIds=route_ids,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
sqlite_keys: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
|
|
176
|
+
for item in sqlite:
|
|
177
|
+
sqlite_keys[(item["appSlug"], item["environment"])].append(item)
|
|
178
|
+
for key, items in sqlite_keys.items():
|
|
179
|
+
owners = sorted({item["ownerRepo"] for item in items})
|
|
180
|
+
if len(items) > 1:
|
|
181
|
+
conflicts.append(
|
|
182
|
+
_conflict(
|
|
183
|
+
"duplicate-sqlite-reservation",
|
|
184
|
+
"persistence",
|
|
185
|
+
f"SQLite identity {key[0]}--{key[1]} has multiple managed reservations.",
|
|
186
|
+
appSlug=key[0],
|
|
187
|
+
environment=key[1],
|
|
188
|
+
owners=owners,
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
apps_by_key = {(item["appSlug"], item["environment"]): item for item in applications}
|
|
193
|
+
for route in routes:
|
|
194
|
+
runtime = apps_by_key.get((route["appSlug"], route["environment"]))
|
|
195
|
+
if runtime is None:
|
|
196
|
+
conflicts.append(
|
|
197
|
+
_conflict(
|
|
198
|
+
"route-without-managed-runtime",
|
|
199
|
+
"route",
|
|
200
|
+
f"Route {route['routeId']} has no matching managed application runtime.",
|
|
201
|
+
routeId=route["routeId"],
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
continue
|
|
205
|
+
if runtime["ownerRepo"] != route["ownerRepo"]:
|
|
206
|
+
conflicts.append(
|
|
207
|
+
_conflict(
|
|
208
|
+
"route-runtime-owner-mismatch",
|
|
209
|
+
"ownership",
|
|
210
|
+
f"Route {route['routeId']} and its managed runtime have different repository owners.",
|
|
211
|
+
routeOwnerRepo=route["ownerRepo"],
|
|
212
|
+
runtimeOwnerRepo=runtime["ownerRepo"],
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
if runtime["containerName"] != route["upstreamContainer"]:
|
|
216
|
+
conflicts.append(
|
|
217
|
+
_conflict(
|
|
218
|
+
"route-upstream-container-mismatch",
|
|
219
|
+
"route",
|
|
220
|
+
f"Route {route['routeId']} points at a different container than its managed runtime.",
|
|
221
|
+
upstreamContainer=route["upstreamContainer"],
|
|
222
|
+
runtimeContainer=runtime["containerName"],
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
if runtime["runtimePort"] != route["upstreamPort"]:
|
|
226
|
+
conflicts.append(
|
|
227
|
+
_conflict(
|
|
228
|
+
"route-upstream-port-mismatch",
|
|
229
|
+
"route",
|
|
230
|
+
f"Route {route['routeId']} port does not match its managed runtime port.",
|
|
231
|
+
upstreamPort=route["upstreamPort"],
|
|
232
|
+
runtimePort=runtime["runtimePort"],
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
sibling_environments: set[str] = set()
|
|
237
|
+
for collection in (applications, routes, sqlite):
|
|
238
|
+
for item in collection:
|
|
239
|
+
if item["ownerRepo"] == owner and item["environment"] != env:
|
|
240
|
+
sibling_environments.add(item["environment"])
|
|
241
|
+
|
|
242
|
+
desired_identity = (owner, app, env) if app is not None else None
|
|
243
|
+
matching_apps = [
|
|
244
|
+
item for item in applications if app is not None and item["appSlug"] == app and item["environment"] == env
|
|
245
|
+
]
|
|
246
|
+
matching_routes = [
|
|
247
|
+
item for item in routes if app is not None and item["appSlug"] == app and item["environment"] == env
|
|
248
|
+
]
|
|
249
|
+
matching_sqlite = [
|
|
250
|
+
item for item in sqlite if app is not None and item["appSlug"] == app and item["environment"] == env
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
for item in matching_apps:
|
|
254
|
+
if item["ownerRepo"] != owner:
|
|
255
|
+
conflicts.append(
|
|
256
|
+
_conflict(
|
|
257
|
+
"application-identity-owned-by-other-project",
|
|
258
|
+
"ownership",
|
|
259
|
+
f"Application identity {app}--{env} is already owned by another repository.",
|
|
260
|
+
existingOwnerRepo=item["ownerRepo"],
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
for item in matching_routes:
|
|
264
|
+
if item["ownerRepo"] != owner:
|
|
265
|
+
conflicts.append(
|
|
266
|
+
_conflict(
|
|
267
|
+
"route-identity-owned-by-other-project",
|
|
268
|
+
"ownership",
|
|
269
|
+
f"Route identity {app}--{env} is already owned by another repository.",
|
|
270
|
+
existingOwnerRepo=item["ownerRepo"],
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
for item in matching_sqlite:
|
|
274
|
+
if item["ownerRepo"] != owner:
|
|
275
|
+
conflicts.append(
|
|
276
|
+
_conflict(
|
|
277
|
+
"sqlite-identity-owned-by-other-project",
|
|
278
|
+
"ownership",
|
|
279
|
+
f"SQLite identity {app}--{env} is already owned by another repository.",
|
|
280
|
+
existingOwnerRepo=item["ownerRepo"],
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if app is not None and require_vacant_app_identity:
|
|
285
|
+
if matching_apps:
|
|
286
|
+
conflicts.append(
|
|
287
|
+
_conflict(
|
|
288
|
+
"reserved-application-identity-not-vacant",
|
|
289
|
+
"runtime",
|
|
290
|
+
f"Reserved application identity {app}--{env} already has managed runtime state.",
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
if matching_routes:
|
|
294
|
+
conflicts.append(
|
|
295
|
+
_conflict(
|
|
296
|
+
"reserved-route-identity-not-vacant",
|
|
297
|
+
"route",
|
|
298
|
+
f"Reserved application identity {app}--{env} already has managed route state.",
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
if matching_sqlite:
|
|
302
|
+
conflicts.append(
|
|
303
|
+
_conflict(
|
|
304
|
+
"reserved-persistence-identity-not-vacant",
|
|
305
|
+
"persistence",
|
|
306
|
+
f"Reserved application identity {app}--{env} already has managed SQLite state.",
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
if container is not None and desired_identity is not None:
|
|
311
|
+
for item in applications:
|
|
312
|
+
if item["containerName"] == container and _identity(item) != desired_identity:
|
|
313
|
+
conflicts.append(
|
|
314
|
+
_conflict(
|
|
315
|
+
"requested-container-name-collision",
|
|
316
|
+
"runtime",
|
|
317
|
+
f"Requested container name {container!r} belongs to another managed Project/environment.",
|
|
318
|
+
existingIdentity=list(_identity(item)),
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
requested_hosts = set(hosts)
|
|
323
|
+
if requested_hosts:
|
|
324
|
+
desired_route_id = f"{app}--{env}"
|
|
325
|
+
for route in routes:
|
|
326
|
+
if route["routeId"] == desired_route_id and route["ownerRepo"] == owner:
|
|
327
|
+
continue
|
|
328
|
+
collision = requested_hosts.intersection(route["hostnames"])
|
|
329
|
+
if collision:
|
|
330
|
+
conflicts.append(
|
|
331
|
+
_conflict(
|
|
332
|
+
"requested-hostname-collision",
|
|
333
|
+
"route",
|
|
334
|
+
"Requested hostname is already owned by another managed route.",
|
|
335
|
+
routeId=route["routeId"],
|
|
336
|
+
hostnames=sorted(collision),
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if persistence_mode == "none" and matching_sqlite:
|
|
341
|
+
conflicts.append(
|
|
342
|
+
_conflict(
|
|
343
|
+
"requested-no-persistence-conflicts-with-sqlite",
|
|
344
|
+
"persistence",
|
|
345
|
+
f"Application identity {app}--{env} requests no persistence but has managed SQLite state.",
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
conflicts.sort(
|
|
350
|
+
key=lambda item: (
|
|
351
|
+
item["resource"],
|
|
352
|
+
item["code"],
|
|
353
|
+
json.dumps(item, sort_keys=True, separators=(",", ":")),
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
app_root = f"{APP_ROOT}/{app}/{env}" if app is not None else None
|
|
358
|
+
lock_identity = f"{_LOCK_ROOT}/project-forge-app-{app}--{env}.lock" if app is not None else None
|
|
359
|
+
database_path = f"{app_root}/data/{database}" if app_root is not None and database is not None else None
|
|
360
|
+
|
|
361
|
+
desired = {
|
|
362
|
+
"appSlug": app,
|
|
363
|
+
"containerName": container,
|
|
364
|
+
"runtimePort": port,
|
|
365
|
+
"routeHostnames": hosts,
|
|
366
|
+
"persistenceMode": persistence_mode,
|
|
367
|
+
"databaseFile": database,
|
|
368
|
+
"requireVacantAppIdentity": require_vacant_app_identity,
|
|
369
|
+
}
|
|
370
|
+
evidence_value = {
|
|
371
|
+
"schemaVersion": ISOLATION_SCHEMA_VERSION,
|
|
372
|
+
"targetName": target_name,
|
|
373
|
+
"ownerRepo": owner,
|
|
374
|
+
"environment": env,
|
|
375
|
+
"production": production,
|
|
376
|
+
"desired": desired,
|
|
377
|
+
"applications": applications,
|
|
378
|
+
"routes": routes,
|
|
379
|
+
"sqlite": sqlite,
|
|
380
|
+
}
|
|
381
|
+
evidence_sha = _snapshot(evidence_value)
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
"schemaVersion": ISOLATION_SCHEMA_VERSION,
|
|
385
|
+
"targetName": target_name,
|
|
386
|
+
"ownerRepo": owner,
|
|
387
|
+
"environment": env,
|
|
388
|
+
"production": production,
|
|
389
|
+
"state": "blocked" if conflicts else "clear",
|
|
390
|
+
"sharedFailureDomain": bool(sibling_environments),
|
|
391
|
+
"siblingEnvironments": sorted(sibling_environments),
|
|
392
|
+
"desired": desired,
|
|
393
|
+
"checks": {
|
|
394
|
+
"managedOwnership": {
|
|
395
|
+
"state": _resource_state(conflicts, {"ownership"}),
|
|
396
|
+
"applicationRecords": len(applications),
|
|
397
|
+
"routeRecords": len(routes),
|
|
398
|
+
"sqliteRecords": len(sqlite),
|
|
399
|
+
},
|
|
400
|
+
"runtimeIdentity": {
|
|
401
|
+
"state": _resource_state(conflicts, {"runtime", "ownership"}),
|
|
402
|
+
"appRoot": app_root,
|
|
403
|
+
"containerName": container,
|
|
404
|
+
"composeProject": {
|
|
405
|
+
"state": "not-applicable",
|
|
406
|
+
"reason": "The remote managed runtime uses direct Docker container ownership, not Docker Compose project identity.",
|
|
407
|
+
},
|
|
408
|
+
"hostPortPublishing": {
|
|
409
|
+
"state": "not-applicable",
|
|
410
|
+
"reason": "Managed remote application containers do not publish host ports; sibling containers may safely reuse the same internal runtime port.",
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
"routes": {
|
|
414
|
+
"state": _resource_state(conflicts, {"route", "ownership"}),
|
|
415
|
+
"requestedHostnames": hosts,
|
|
416
|
+
"runtimePort": port,
|
|
417
|
+
"portIsolation": "container-scoped",
|
|
418
|
+
},
|
|
419
|
+
"persistence": {
|
|
420
|
+
"state": _resource_state(conflicts, {"persistence", "ownership"}),
|
|
421
|
+
"appRoot": app_root,
|
|
422
|
+
"databasePath": database_path,
|
|
423
|
+
},
|
|
424
|
+
"deploymentLock": {
|
|
425
|
+
"state": _resource_state(conflicts, {"runtime", "ownership"}),
|
|
426
|
+
"lockIdentity": lock_identity,
|
|
427
|
+
"scope": "app/environment",
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
"conflicts": conflicts,
|
|
431
|
+
"inventorySnapshots": {
|
|
432
|
+
"applications": _snapshot(applications),
|
|
433
|
+
"routes": _snapshot(routes),
|
|
434
|
+
"sqlite": _snapshot(sqlite),
|
|
435
|
+
},
|
|
436
|
+
"evidenceSha256": evidence_sha,
|
|
437
|
+
"mutated": False,
|
|
438
|
+
}
|
|
@@ -12,6 +12,7 @@ from typing import Any
|
|
|
12
12
|
from .app_runtime import normalize_app_spec, plan_app_deploy
|
|
13
13
|
from .app_runtime_workstation import apply_app_deploy_with_preloaded_image
|
|
14
14
|
from .box_platform import platform_status
|
|
15
|
+
from .environment_isolation import environment_isolation_preflight
|
|
15
16
|
from .errors import DeploymentKitError
|
|
16
17
|
from .image_transfer import remote_image_present, transfer_image, verify_local_image
|
|
17
18
|
from .proof_receipt import write_environment_proof_receipt
|
|
@@ -92,6 +93,27 @@ def _resolve_probe_image(image_tag: str) -> str:
|
|
|
92
93
|
raise DeploymentKitError("Environment proof image has no usable immutable sha256 RepoDigest.")
|
|
93
94
|
|
|
94
95
|
|
|
96
|
+
def _proof_isolation_preflight(
|
|
97
|
+
target_name: str,
|
|
98
|
+
*,
|
|
99
|
+
owner_repo: str,
|
|
100
|
+
environment: str,
|
|
101
|
+
production: bool,
|
|
102
|
+
probe_app_slug: str,
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
return environment_isolation_preflight(
|
|
105
|
+
target_name,
|
|
106
|
+
owner_repo=owner_repo,
|
|
107
|
+
environment=environment,
|
|
108
|
+
production=production,
|
|
109
|
+
app_slug=probe_app_slug,
|
|
110
|
+
container_name=f"{probe_app_slug}--{environment}",
|
|
111
|
+
runtime_port=80,
|
|
112
|
+
persistence_mode="none",
|
|
113
|
+
require_vacant_app_identity=True,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
95
117
|
def plan_environment_proof(
|
|
96
118
|
target_name: str,
|
|
97
119
|
*,
|
|
@@ -111,17 +133,27 @@ def plan_environment_proof(
|
|
|
111
133
|
raise DeploymentKitError(
|
|
112
134
|
f"Environment proof requires a ready shared platform; target '{target_name}' is '{platform.get('state', 'unknown')}'."
|
|
113
135
|
)
|
|
136
|
+
probe_app_slug = _probe_slug(owner)
|
|
137
|
+
isolation = _proof_isolation_preflight(
|
|
138
|
+
target_name,
|
|
139
|
+
owner_repo=owner,
|
|
140
|
+
environment=env,
|
|
141
|
+
production=production,
|
|
142
|
+
probe_app_slug=probe_app_slug,
|
|
143
|
+
)
|
|
114
144
|
return {
|
|
115
145
|
"targetName": target_name,
|
|
116
146
|
"ownerRepo": owner,
|
|
117
147
|
"environment": env,
|
|
118
148
|
"production": production,
|
|
119
|
-
"probeAppSlug":
|
|
149
|
+
"probeAppSlug": probe_app_slug,
|
|
120
150
|
"probeImageTag": probe_image,
|
|
121
151
|
"artifactDelivery": "workstation-transfer",
|
|
122
152
|
"persistence": "none",
|
|
123
153
|
"healthPath": "/",
|
|
124
154
|
"cleanup": "automatic",
|
|
155
|
+
"isolationPreflight": isolation,
|
|
156
|
+
"runnable": isolation["state"] == "clear",
|
|
125
157
|
"requiredAuthorization": (
|
|
126
158
|
PRODUCTION_ENVIRONMENT_PROOF_WRITE_PHRASE if production else ENVIRONMENT_PROOF_WRITE_PHRASE
|
|
127
159
|
),
|
|
@@ -139,6 +171,8 @@ def _proof_result(plan: dict[str, Any]) -> dict[str, Any]:
|
|
|
139
171
|
"preflight": {
|
|
140
172
|
"targetAccess": "verified-by-platform-doctor",
|
|
141
173
|
"hostPlatform": "ready",
|
|
174
|
+
"environmentIsolation": plan["isolationPreflight"]["state"],
|
|
175
|
+
"isolationEvidenceSha256": plan["isolationPreflight"]["evidenceSha256"],
|
|
142
176
|
},
|
|
143
177
|
"registryDigest": None,
|
|
144
178
|
"localImageId": None,
|
|
@@ -199,10 +233,32 @@ def run_environment_proof(plan: dict[str, Any]) -> dict[str, Any]:
|
|
|
199
233
|
"persistence",
|
|
200
234
|
"healthPath",
|
|
201
235
|
"cleanup",
|
|
236
|
+
"isolationPreflight",
|
|
237
|
+
"runnable",
|
|
202
238
|
"requiredAuthorization",
|
|
203
239
|
}
|
|
204
240
|
if not isinstance(plan, dict) or set(plan) != required:
|
|
205
241
|
raise DeploymentKitError("Malformed environment proof plan.")
|
|
242
|
+
if plan["isolationPreflight"].get("state") != "clear" or not plan["runnable"]:
|
|
243
|
+
raise DeploymentKitError(
|
|
244
|
+
"Environment proof is blocked by the reviewed isolation/collision preflight; resolve the reported conflicts and create a fresh plan."
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
current_isolation = _proof_isolation_preflight(
|
|
248
|
+
plan["targetName"],
|
|
249
|
+
owner_repo=plan["ownerRepo"],
|
|
250
|
+
environment=plan["environment"],
|
|
251
|
+
production=plan["production"],
|
|
252
|
+
probe_app_slug=plan["probeAppSlug"],
|
|
253
|
+
)
|
|
254
|
+
if current_isolation["state"] != "clear":
|
|
255
|
+
raise DeploymentKitError(
|
|
256
|
+
"Environment isolation became blocked after the proof plan was reviewed; no proof mutation was attempted."
|
|
257
|
+
)
|
|
258
|
+
if current_isolation["evidenceSha256"] != plan["isolationPreflight"].get("evidenceSha256"):
|
|
259
|
+
raise DeploymentKitError(
|
|
260
|
+
"Environment isolation evidence changed after the proof plan was reviewed; create and authorize a fresh plan."
|
|
261
|
+
)
|
|
206
262
|
|
|
207
263
|
result = _proof_result(plan)
|
|
208
264
|
deploy_plan: dict[str, Any] | None = None
|
|
@@ -248,7 +248,7 @@ def _next_action(status: dict[str, Any]) -> dict[str, Any]:
|
|
|
248
248
|
return {
|
|
249
249
|
"kind": "complete",
|
|
250
250
|
"action": "remote-environment-ready",
|
|
251
|
-
"reason": "Fresh workstation-local proof evidence matches this Project/environment/target context.",
|
|
251
|
+
"reason": "Fresh workstation-local proof evidence matches this Project/environment/target context and current isolation remains clear.",
|
|
252
252
|
"proofReceipt": phases["deploymentPath"].get("detail"),
|
|
253
253
|
}
|
|
254
254
|
|
|
@@ -259,6 +259,36 @@ def _next_action(status: dict[str, Any]) -> dict[str, Any]:
|
|
|
259
259
|
}
|
|
260
260
|
|
|
261
261
|
|
|
262
|
+
def _proof_command(name: str, proof: dict[str, Any], *, production: bool) -> str:
|
|
263
|
+
command = (
|
|
264
|
+
f"project-forge-deploy environment proof plan {name} "
|
|
265
|
+
f"--owner-repo {proof['ownerRepo']} --environment {proof['environment']}"
|
|
266
|
+
)
|
|
267
|
+
if production:
|
|
268
|
+
command += " --production"
|
|
269
|
+
return command
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _blocked_isolation_action(
|
|
273
|
+
name: str,
|
|
274
|
+
proof: dict[str, Any],
|
|
275
|
+
*,
|
|
276
|
+
production: bool,
|
|
277
|
+
) -> dict[str, Any]:
|
|
278
|
+
return {
|
|
279
|
+
"kind": "blocked",
|
|
280
|
+
"action": "resolve-environment-isolation-collision",
|
|
281
|
+
"command": _proof_command(name, proof, production=production),
|
|
282
|
+
"reason": (
|
|
283
|
+
"The read-only colocated-environment preflight found a managed ownership or footprint collision. "
|
|
284
|
+
"Do not redirect this environment to a sibling target identity or take over existing runtime, route, persistence, or lock state."
|
|
285
|
+
),
|
|
286
|
+
"proofPlan": proof,
|
|
287
|
+
"isolationPreflight": proof.get("isolationPreflight"),
|
|
288
|
+
"safety": "Inspect the reported managed ownership records and resolve the collision explicitly before creating a fresh proof plan.",
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
262
292
|
def remote_setup_plan(
|
|
263
293
|
name: str,
|
|
264
294
|
*,
|
|
@@ -272,48 +302,64 @@ def remote_setup_plan(
|
|
|
272
302
|
raise DeploymentKitError("ownerRepo and environment must be supplied together for setup proof planning.")
|
|
273
303
|
|
|
274
304
|
status = remote_setup_status(name)
|
|
275
|
-
|
|
276
|
-
|
|
305
|
+
proof: dict[str, Any] | None = None
|
|
306
|
+
receipt: dict[str, Any] | None = None
|
|
307
|
+
if (
|
|
308
|
+
owner_repo is not None
|
|
309
|
+
and environment is not None
|
|
310
|
+
and status["phases"]["hostPlatform"]["state"] == "ready"
|
|
311
|
+
):
|
|
312
|
+
# Current machine isolation must be checked even when a prior proof receipt is
|
|
313
|
+
# otherwise reusable. A sibling environment can be added after the receipt was
|
|
314
|
+
# recorded without changing workstation/target identity or trust evidence.
|
|
315
|
+
proof = plan_environment_proof(
|
|
277
316
|
name,
|
|
278
317
|
owner_repo=owner_repo,
|
|
279
318
|
environment=environment,
|
|
280
319
|
production=production,
|
|
281
320
|
)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
321
|
+
receipt = load_environment_proof_receipt(
|
|
322
|
+
name,
|
|
323
|
+
owner_repo=owner_repo,
|
|
324
|
+
environment=environment,
|
|
325
|
+
production=production,
|
|
286
326
|
)
|
|
327
|
+
isolation_clear = proof.get("runnable", True)
|
|
328
|
+
if isolation_clear:
|
|
329
|
+
status["phases"]["deploymentPath"] = _phase(
|
|
330
|
+
"DEPLOYMENT PATH PROVEN",
|
|
331
|
+
"proven" if receipt["state"] == "proven" else "not-proven",
|
|
332
|
+
detail=receipt,
|
|
333
|
+
)
|
|
334
|
+
if receipt["state"] == "proven":
|
|
335
|
+
status["state"] = "environment-ready"
|
|
336
|
+
else:
|
|
337
|
+
status["phases"]["deploymentPath"] = _phase(
|
|
338
|
+
"DEPLOYMENT PATH PROVEN",
|
|
339
|
+
"blocked",
|
|
340
|
+
detail={
|
|
341
|
+
"proofReceipt": receipt,
|
|
342
|
+
"isolationPreflight": proof.get("isolationPreflight"),
|
|
343
|
+
},
|
|
344
|
+
)
|
|
345
|
+
status["state"] = "needs-setup"
|
|
287
346
|
status["currentPhase"] = _current_phase(status["phases"])
|
|
288
|
-
if receipt["state"] == "proven":
|
|
289
|
-
status["state"] = "environment-ready"
|
|
290
347
|
|
|
291
348
|
next_action = _next_action(status)
|
|
292
349
|
|
|
293
|
-
if (
|
|
350
|
+
if proof is not None and not proof.get("runnable", True):
|
|
351
|
+
next_action = _blocked_isolation_action(name, proof, production=production)
|
|
352
|
+
elif (
|
|
294
353
|
next_action["action"] == "plan-deployment-path-proof"
|
|
295
|
-
and
|
|
296
|
-
and environment is not None
|
|
354
|
+
and proof is not None
|
|
297
355
|
):
|
|
298
|
-
proof = plan_environment_proof(
|
|
299
|
-
name,
|
|
300
|
-
owner_repo=owner_repo,
|
|
301
|
-
environment=environment,
|
|
302
|
-
production=production,
|
|
303
|
-
)
|
|
304
|
-
command = (
|
|
305
|
-
f"project-forge-deploy environment proof plan {name} "
|
|
306
|
-
f"--owner-repo {proof['ownerRepo']} --environment {proof['environment']}"
|
|
307
|
-
)
|
|
308
|
-
if production:
|
|
309
|
-
command += " --production"
|
|
310
356
|
next_action = {
|
|
311
357
|
"kind": "read-only",
|
|
312
358
|
"action": "review-deployment-path-proof",
|
|
313
|
-
"command":
|
|
359
|
+
"command": _proof_command(name, proof, production=production),
|
|
314
360
|
"proofPlan": proof,
|
|
315
361
|
"runAuthorization": proof["requiredAuthorization"],
|
|
316
|
-
"previousProof":
|
|
362
|
+
"previousProof": receipt,
|
|
317
363
|
}
|
|
318
364
|
|
|
319
365
|
return {
|