@mriekki/project-forge 4.3.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 +2 -0
- package/assets/generated/ASSEMBLY.json +20 -10
- 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/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/environment_cli.py +4 -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/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
|
+
]
|
|
@@ -335,7 +335,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
335
335
|
)
|
|
336
336
|
if args.command == "plan":
|
|
337
337
|
_print(plan, as_json=args.json)
|
|
338
|
-
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
|
|
339
342
|
if not args.json:
|
|
340
343
|
_print(plan, as_json=False)
|
|
341
344
|
_confirm(
|
|
@@ -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
|
+
}
|