@mriekki/project-forge 4.15.2 → 4.16.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 +2 -2
- package/assets/generated/ASSEMBLY.json +5 -5
- package/assets/generated/deployment/src/project_forge_deployment/local_cli.py +22 -0
- package/assets/generated/deployment/src/project_forge_deployment/local_runtime.py +193 -30
- package/assets/generated/deployment/src/project_forge_deployment/local_spec.py +9 -0
- package/package.json +1 -1
package/assets/components.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.16.0",
|
|
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": "
|
|
24
|
+
"revision": "f5325556e1ee1e256e3dfb426d91feb42cbdc2c3",
|
|
25
25
|
"distribution": "greenfield-project",
|
|
26
26
|
"descriptor": "assets/deployment/distribution.json",
|
|
27
27
|
"sourceRoot": "assets/generated/deployment",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.16.0",
|
|
5
5
|
"components": {
|
|
6
6
|
"core": {
|
|
7
7
|
"revision": "4c74c1801b82d3aa438447d392024d32ca5697b0",
|
|
@@ -361,7 +361,7 @@
|
|
|
361
361
|
]
|
|
362
362
|
},
|
|
363
363
|
"deployment": {
|
|
364
|
-
"revision": "
|
|
364
|
+
"revision": "f5325556e1ee1e256e3dfb426d91feb42cbdc2c3",
|
|
365
365
|
"distribution": "greenfield-project",
|
|
366
366
|
"files": [
|
|
367
367
|
{
|
|
@@ -512,17 +512,17 @@
|
|
|
512
512
|
{
|
|
513
513
|
"source": "src/project_forge_deployment/local_cli.py",
|
|
514
514
|
"destination": "tools/project-forge-deployment/project_forge_deployment/local_cli.py",
|
|
515
|
-
"sha256": "
|
|
515
|
+
"sha256": "b0b99dc66a95a5f9cb386a15d7a32f3f935bf37be2cf06175ceb9cc2c9a479cd"
|
|
516
516
|
},
|
|
517
517
|
{
|
|
518
518
|
"source": "src/project_forge_deployment/local_runtime.py",
|
|
519
519
|
"destination": "tools/project-forge-deployment/project_forge_deployment/local_runtime.py",
|
|
520
|
-
"sha256": "
|
|
520
|
+
"sha256": "1d84ab88baac81a7a9ead001156d86b4969d834e34e4f766b33cfcff2fc1d1ce"
|
|
521
521
|
},
|
|
522
522
|
{
|
|
523
523
|
"source": "src/project_forge_deployment/local_spec.py",
|
|
524
524
|
"destination": "tools/project-forge-deployment/project_forge_deployment/local_spec.py",
|
|
525
|
-
"sha256": "
|
|
525
|
+
"sha256": "bddaafcb5785e74103cb82d73f01d4e577c97476bd6a6549b3e332cca5eb50f8"
|
|
526
526
|
},
|
|
527
527
|
{
|
|
528
528
|
"source": "src/project_forge_deployment/local_sqlite.py",
|
|
@@ -42,6 +42,24 @@ def _parse_env(items: list[str]) -> list[tuple[str, str]]:
|
|
|
42
42
|
return result
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
def _parse_mapping(value: str) -> dict[str, int]:
|
|
46
|
+
try:
|
|
47
|
+
left, right = value.split(":")
|
|
48
|
+
return {"hostPort": int(left), "runtimePort": int(right)}
|
|
49
|
+
except (ValueError, TypeError) as error:
|
|
50
|
+
raise DeploymentKitError("--additional-port requires HOST_PORT:RUNTIME_PORT.") from error
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_mount(value: str) -> dict[str, str]:
|
|
54
|
+
try:
|
|
55
|
+
left, right = value.split("::", 1)
|
|
56
|
+
if not left or not right:
|
|
57
|
+
raise ValueError("empty path")
|
|
58
|
+
return {"source": left, "target": right}
|
|
59
|
+
except ValueError as error:
|
|
60
|
+
raise DeploymentKitError("--read-only-mount requires SOURCE::TARGET.") from error
|
|
61
|
+
|
|
62
|
+
|
|
45
63
|
def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
|
|
46
64
|
if args.yes:
|
|
47
65
|
return
|
|
@@ -102,6 +120,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
102
120
|
deploy.add_argument("--persistence-mode", choices=("none", "directory"), required=True)
|
|
103
121
|
deploy.add_argument("--data-container-path")
|
|
104
122
|
deploy.add_argument("--env", action="append", default=[])
|
|
123
|
+
deploy.add_argument("--additional-port", action="append", default=[], metavar="HOST_PORT:RUNTIME_PORT")
|
|
124
|
+
deploy.add_argument("--read-only-mount", action="append", default=[], metavar="SOURCE::TARGET")
|
|
105
125
|
deploy.add_argument("--json", action="store_true")
|
|
106
126
|
deploy.add_argument("--yes", action="store_true")
|
|
107
127
|
return parser
|
|
@@ -170,6 +190,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
170
190
|
persistence_mode=args.persistence_mode,
|
|
171
191
|
data_container_path=args.data_container_path,
|
|
172
192
|
environment_variables=_parse_env(args.env),
|
|
193
|
+
additional_port_bindings=[_parse_mapping(value) for value in args.additional_port],
|
|
194
|
+
read_only_mounts=[_parse_mount(value) for value in args.read_only_mount],
|
|
173
195
|
)
|
|
174
196
|
plan = plan_local_deploy(spec)
|
|
175
197
|
production_classified = args.production or plan["environment"] == "production"
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import json
|
|
7
|
+
import socket
|
|
7
8
|
import shutil
|
|
8
9
|
import subprocess
|
|
9
10
|
import time
|
|
@@ -14,6 +15,7 @@ from typing import Any, Iterable
|
|
|
14
15
|
|
|
15
16
|
from .app_runtime import PRODUCTION_APP_DEPLOY_WRITE_PHRASE, normalize_app_spec
|
|
16
17
|
from .errors import DeploymentKitError
|
|
18
|
+
from .local_extensions import bindings, normalize_bindings, normalize_mounts, snapshot_material
|
|
17
19
|
from .profiles import config_root
|
|
18
20
|
|
|
19
21
|
LOCAL_RUNTIME_SCHEMA_VERSION = 1
|
|
@@ -107,7 +109,7 @@ def _load_state(app_slug: str, environment: str) -> dict[str, Any] | None:
|
|
|
107
109
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
108
110
|
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
109
111
|
raise DeploymentKitError(f"Cannot read local deployment state {path}: {error}") from error
|
|
110
|
-
if not isinstance(value, dict) or value.get("schemaVersion")
|
|
112
|
+
if not isinstance(value, dict) or value.get("schemaVersion") not in (1, 2):
|
|
111
113
|
raise DeploymentKitError(f"Unsupported local deployment state: {path}")
|
|
112
114
|
return value
|
|
113
115
|
|
|
@@ -126,6 +128,8 @@ def normalize_local_spec(
|
|
|
126
128
|
persistence_mode: str,
|
|
127
129
|
data_container_path: str | None,
|
|
128
130
|
environment_variables: dict[str, str] | Iterable[tuple[str, str]],
|
|
131
|
+
additional_port_bindings: list[dict[str, int]] | None = None,
|
|
132
|
+
read_only_mounts: list[dict[str, str]] | None = None,
|
|
129
133
|
) -> dict[str, Any]:
|
|
130
134
|
base = normalize_app_spec(
|
|
131
135
|
owner_repo=owner_repo,
|
|
@@ -151,6 +155,12 @@ def normalize_local_spec(
|
|
|
151
155
|
"hostPort": _host_port(host_port),
|
|
152
156
|
}
|
|
153
157
|
)
|
|
158
|
+
extra_bindings = normalize_bindings(base["hostPort"], base["runtimePort"], additional_port_bindings or [])
|
|
159
|
+
extra_mounts = normalize_mounts(base["appSlug"], base["environment"], read_only_mounts or [], base["persistence"]["dataContainerPath"])
|
|
160
|
+
if extra_bindings:
|
|
161
|
+
base["additionalPortBindings"] = extra_bindings
|
|
162
|
+
if extra_mounts:
|
|
163
|
+
base["readOnlyMounts"] = extra_mounts
|
|
154
164
|
base["specSha256"] = hashlib.sha256(
|
|
155
165
|
json.dumps(base, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
156
166
|
).hexdigest()
|
|
@@ -163,7 +173,7 @@ def _inspect_owned_container(docker: str, container_name: str) -> dict[str, Any]
|
|
|
163
173
|
[
|
|
164
174
|
"inspect",
|
|
165
175
|
"--format",
|
|
166
|
-
"{{json .Config.Labels}}\t{{json .NetworkSettings.Ports}}\t{{.State.Running}}",
|
|
176
|
+
"{{json .Config.Labels}}\t{{json .NetworkSettings.Ports}}\t{{.State.Running}}\t{{json .Mounts}}\t{{json .HostConfig.PortBindings}}",
|
|
167
177
|
container_name,
|
|
168
178
|
],
|
|
169
179
|
check=False,
|
|
@@ -176,11 +186,13 @@ def _inspect_owned_container(docker: str, container_name: str) -> dict[str, Any]
|
|
|
176
186
|
f"Unable to inspect local container {container_name}: {(inspected.stderr or inspected.stdout).strip()}"
|
|
177
187
|
)
|
|
178
188
|
parts = inspected.stdout.strip().split("\t")
|
|
179
|
-
if len(parts) !=
|
|
189
|
+
if len(parts) != 5:
|
|
180
190
|
raise DeploymentKitError("Local Docker inspect returned malformed container evidence.")
|
|
181
191
|
try:
|
|
182
192
|
labels = json.loads(parts[0]) if parts[0] else {}
|
|
183
193
|
ports = json.loads(parts[1]) if parts[1] else {}
|
|
194
|
+
mounts = json.loads(parts[3]) if parts[3] else []
|
|
195
|
+
host_bindings = json.loads(parts[4]) if parts[4] else {}
|
|
184
196
|
except json.JSONDecodeError as error:
|
|
185
197
|
raise DeploymentKitError("Local Docker inspect returned malformed JSON evidence.") from error
|
|
186
198
|
if not isinstance(labels, dict):
|
|
@@ -188,6 +200,8 @@ def _inspect_owned_container(docker: str, container_name: str) -> dict[str, Any]
|
|
|
188
200
|
return {
|
|
189
201
|
"labels": labels,
|
|
190
202
|
"ports": ports if isinstance(ports, dict) else {},
|
|
203
|
+
"mounts": mounts if isinstance(mounts, list) else [],
|
|
204
|
+
"hostBindings": host_bindings if isinstance(host_bindings, dict) else {},
|
|
191
205
|
"running": parts[2].strip().lower() == "true",
|
|
192
206
|
}
|
|
193
207
|
|
|
@@ -235,6 +249,9 @@ def _managed_containers(docker: str) -> list[dict[str, Any]]:
|
|
|
235
249
|
"environment": labels[_LABEL_ENV],
|
|
236
250
|
"specSha256": labels[_LABEL_SPEC],
|
|
237
251
|
"running": info["running"],
|
|
252
|
+
"actualPorts": info["ports"],
|
|
253
|
+
"actualMounts": info["mounts"],
|
|
254
|
+
"actualHostBindings": info["hostBindings"],
|
|
238
255
|
}
|
|
239
256
|
)
|
|
240
257
|
return result
|
|
@@ -249,6 +266,98 @@ def _http_healthy(spec: dict[str, Any], *, timeout: float = 2.0) -> bool:
|
|
|
249
266
|
return False
|
|
250
267
|
|
|
251
268
|
|
|
269
|
+
|
|
270
|
+
def _actual_bindings(raw: dict[str, Any]) -> list[dict[str, int | str]]:
|
|
271
|
+
result: list[dict[str, int | str]] = []
|
|
272
|
+
if not isinstance(raw, dict):
|
|
273
|
+
raise DeploymentKitError("Invalid Docker port-binding evidence.")
|
|
274
|
+
for container, hosts in raw.items():
|
|
275
|
+
if hosts is None:
|
|
276
|
+
continue # Exposed internally, not published on the host.
|
|
277
|
+
if not isinstance(hosts, list) or not container.endswith("/tcp"):
|
|
278
|
+
raise DeploymentKitError("Unexpected Docker port protocol or malformed binding evidence.")
|
|
279
|
+
try:
|
|
280
|
+
runtime = int(container.removesuffix("/tcp"))
|
|
281
|
+
for host in hosts:
|
|
282
|
+
result.append({"hostAddress": host["HostIp"], "hostPort": int(host["HostPort"]), "runtimePort": runtime})
|
|
283
|
+
except (KeyError, ValueError, TypeError) as error:
|
|
284
|
+
raise DeploymentKitError("Invalid Docker port-binding evidence.") from error
|
|
285
|
+
return sorted(result, key=lambda item: (item["hostPort"], item["runtimePort"]))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _expected_bindings(state: dict[str, Any]) -> list[dict[str, int | str]]:
|
|
289
|
+
return sorted(({"hostAddress": LOOPBACK_ADDRESS, **item} for item in bindings(state)),
|
|
290
|
+
key=lambda item: (item["hostPort"], item["runtimePort"]))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _actual_mounts(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
294
|
+
if not isinstance(raw, list):
|
|
295
|
+
raise DeploymentKitError("Invalid Docker mount evidence.")
|
|
296
|
+
try:
|
|
297
|
+
return sorted(({"source": item["Source"], "target": item["Destination"],
|
|
298
|
+
"readOnly": not item["RW"], "type": item["Type"]} for item in raw),
|
|
299
|
+
key=lambda item: (item["target"], item["source"]))
|
|
300
|
+
except (KeyError, TypeError) as error:
|
|
301
|
+
raise DeploymentKitError("Invalid Docker mount evidence.") from error
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _expected_mounts(state: dict[str, Any]) -> list[dict[str, Any]]:
|
|
305
|
+
expected = [{"source": item["source"], "target": item["target"], "readOnly": True, "type": "bind"}
|
|
306
|
+
for item in state.get("readOnlyMounts", [])]
|
|
307
|
+
if state["persistence"]["mode"] == "directory":
|
|
308
|
+
expected.append({"source": str(_data_dir(state["appSlug"], state["environment"]).resolve()),
|
|
309
|
+
"target": state["persistence"]["dataContainerPath"], "readOnly": False, "type": "bind"})
|
|
310
|
+
return sorted(expected, key=lambda item: (item["target"], item["source"]))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _config_drift(container: dict[str, Any], state: dict[str, Any] | None) -> bool:
|
|
314
|
+
if not state or container["specSha256"] != state.get("specSha256"):
|
|
315
|
+
return True
|
|
316
|
+
try:
|
|
317
|
+
actual = _actual_bindings(container["actualPorts"])
|
|
318
|
+
host_config = _actual_bindings(container["actualHostBindings"])
|
|
319
|
+
return (actual != _expected_bindings(state)
|
|
320
|
+
or host_config != _expected_bindings(state)
|
|
321
|
+
or _actual_mounts(container["actualMounts"]) != _expected_mounts(state))
|
|
322
|
+
except (DeploymentKitError, KeyError, TypeError, ValueError):
|
|
323
|
+
return True
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _assert_bindable(ports: list[int], owned_ports: set[int]) -> None:
|
|
327
|
+
for port in ports:
|
|
328
|
+
if port in owned_ports:
|
|
329
|
+
continue
|
|
330
|
+
try:
|
|
331
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
332
|
+
probe.bind((LOOPBACK_ADDRESS, port))
|
|
333
|
+
except OSError as error:
|
|
334
|
+
raise DeploymentKitError(f"Loopback host port {port} is occupied or unavailable.") from error
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _preflight_material(docker: str, spec: dict[str, Any]) -> None:
|
|
338
|
+
if not spec.get("readOnlyMounts"):
|
|
339
|
+
return
|
|
340
|
+
for mount in spec["readOnlyMounts"]:
|
|
341
|
+
source = Path(mount["source"])
|
|
342
|
+
checks: list[str] = []
|
|
343
|
+
for path in [source, *sorted(source.rglob("*"))]:
|
|
344
|
+
suffix = path.relative_to(source).as_posix()
|
|
345
|
+
inside = mount["target"] if suffix == "." else mount["target"] + "/" + suffix
|
|
346
|
+
checks.extend(["d:" + inside if path.is_dir() else "f:" + inside])
|
|
347
|
+
args = ["run", "--rm", "--network", "none", "--read-only", "--pull", "never",
|
|
348
|
+
"--user", spec["runAs"], "--mount",
|
|
349
|
+
f"type=bind,source={mount['source']},target={mount['target']},readonly",
|
|
350
|
+
"--entrypoint", "/bin/sh", spec["image"], "-c",
|
|
351
|
+
'for item do path=${item#??}; case "$item" in d:*) [ -d "$path" ] && [ -r "$path" ] && [ -x "$path" ] ;; f:*) [ -f "$path" ] && [ -r "$path" ] ;; esac || exit 1; done',
|
|
352
|
+
"--", *checks]
|
|
353
|
+
result = _run(docker, args, check=False)
|
|
354
|
+
if result.returncode != 0:
|
|
355
|
+
raise DeploymentKitError(
|
|
356
|
+
"Material preflight failed: configured container UID/GID cannot read approved material "
|
|
357
|
+
"or the immutable image lacks /bin/sh. Existing runtime was not replaced."
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
252
361
|
def local_inventory() -> dict[str, Any]:
|
|
253
362
|
docker = _docker()
|
|
254
363
|
applications: list[dict[str, Any]] = []
|
|
@@ -266,6 +375,9 @@ def local_inventory() -> dict[str, Any]:
|
|
|
266
375
|
"runtimePort": state.get("runtimePort") if state else None,
|
|
267
376
|
"healthPath": state.get("healthPath") if state else None,
|
|
268
377
|
"httpHealthy": bool(state and container["running"] and _http_healthy(state)),
|
|
378
|
+
"additionalPortBindings": state.get("additionalPortBindings", []) if state else [],
|
|
379
|
+
"readOnlyMounts": state.get("readOnlyMounts", []) if state else [],
|
|
380
|
+
"configurationDrift": _config_drift(container, state),
|
|
269
381
|
}
|
|
270
382
|
)
|
|
271
383
|
applications.append(item)
|
|
@@ -292,6 +404,9 @@ def _inventory_snapshot(inventory: dict[str, Any]) -> str:
|
|
|
292
404
|
"specSha256",
|
|
293
405
|
"running",
|
|
294
406
|
"hostPort",
|
|
407
|
+
"additionalPortBindings",
|
|
408
|
+
"readOnlyMounts",
|
|
409
|
+
"configurationDrift",
|
|
295
410
|
)
|
|
296
411
|
}
|
|
297
412
|
for item in inventory["applications"]
|
|
@@ -306,10 +421,8 @@ def _assert_port_available(
|
|
|
306
421
|
inventory: dict[str, Any], *, host_port: int, app_slug: str, environment: str
|
|
307
422
|
) -> None:
|
|
308
423
|
for item in inventory["applications"]:
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
environment,
|
|
312
|
-
):
|
|
424
|
+
owned = {item.get("hostPort")} | {mapping["hostPort"] for mapping in item.get("additionalPortBindings", [])}
|
|
425
|
+
if host_port in owned and (item["appSlug"], item["environment"]) != (app_slug, environment):
|
|
313
426
|
raise DeploymentKitError(
|
|
314
427
|
f"Loopback host port {host_port} is already owned by "
|
|
315
428
|
f"{item['appSlug']} / {item['environment']}."
|
|
@@ -326,12 +439,11 @@ def plan_local_deploy(spec: dict[str, Any]) -> dict[str, Any]:
|
|
|
326
439
|
environment=spec["environment"],
|
|
327
440
|
)
|
|
328
441
|
inventory = local_inventory()
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
)
|
|
442
|
+
for mapping in bindings(spec):
|
|
443
|
+
_assert_port_available(
|
|
444
|
+
inventory, host_port=mapping["hostPort"],
|
|
445
|
+
app_slug=spec["appSlug"], environment=spec["environment"],
|
|
446
|
+
)
|
|
335
447
|
existing = next(
|
|
336
448
|
(
|
|
337
449
|
item
|
|
@@ -340,7 +452,7 @@ def plan_local_deploy(spec: dict[str, Any]) -> dict[str, Any]:
|
|
|
340
452
|
),
|
|
341
453
|
None,
|
|
342
454
|
)
|
|
343
|
-
if existing and existing["specSha256"] == spec["specSha256"] and existing["running"] and existing["httpHealthy"]:
|
|
455
|
+
if existing and existing["specSha256"] == spec["specSha256"] and existing["running"] and existing["httpHealthy"] and not existing.get("configurationDrift", True):
|
|
344
456
|
raise DeploymentKitError("The exact local application runtime is already deployed and healthy.")
|
|
345
457
|
return {
|
|
346
458
|
"action": "deploy",
|
|
@@ -361,6 +473,9 @@ def plan_local_deploy(spec: dict[str, Any]) -> dict[str, Any]:
|
|
|
361
473
|
"changeType": "update" if existing else "create",
|
|
362
474
|
"previousImage": existing.get("image") if existing else None,
|
|
363
475
|
"inventorySnapshot": _inventory_snapshot(inventory),
|
|
476
|
+
**({"materialSnapshot": snapshot_material(spec)} if spec.get("readOnlyMounts") else {}),
|
|
477
|
+
**({"additionalPortBindings": spec["additionalPortBindings"]} if spec.get("additionalPortBindings") else {}),
|
|
478
|
+
**({"readOnlyMounts": spec["readOnlyMounts"]} if spec.get("readOnlyMounts") else {}),
|
|
364
479
|
"spec": spec,
|
|
365
480
|
}
|
|
366
481
|
|
|
@@ -392,6 +507,10 @@ def _container_args(spec: dict[str, Any]) -> list[str]:
|
|
|
392
507
|
"--label",
|
|
393
508
|
f"{_LABEL_PLACEMENT}=workstation",
|
|
394
509
|
]
|
|
510
|
+
for mapping in spec.get("additionalPortBindings", []):
|
|
511
|
+
args.extend(["-p", f"{LOOPBACK_ADDRESS}:{mapping['hostPort']}:{mapping['runtimePort']}"])
|
|
512
|
+
for mount in spec.get("readOnlyMounts", []):
|
|
513
|
+
args.extend(["--mount", f"type=bind,source={mount['source']},target={mount['target']},readonly"])
|
|
395
514
|
if spec["persistence"]["mode"] == "directory":
|
|
396
515
|
data_dir = _data_dir(spec["appSlug"], spec["environment"]).resolve()
|
|
397
516
|
data_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -416,7 +535,9 @@ def _wait_for_health(spec: dict[str, Any], *, timeout_seconds: float = 120.0) ->
|
|
|
416
535
|
|
|
417
536
|
def _persist_state(spec: dict[str, Any]) -> None:
|
|
418
537
|
value = {
|
|
419
|
-
"schemaVersion": LOCAL_RUNTIME_SCHEMA_VERSION,
|
|
538
|
+
"schemaVersion": 2 if spec.get("additionalPortBindings") or spec.get("readOnlyMounts") else LOCAL_RUNTIME_SCHEMA_VERSION,
|
|
539
|
+
**({"additionalPortBindings": spec["additionalPortBindings"]} if spec.get("additionalPortBindings") else {}),
|
|
540
|
+
**({"readOnlyMounts": spec["readOnlyMounts"]} if spec.get("readOnlyMounts") else {}),
|
|
420
541
|
"ownerRepo": spec["ownerRepo"],
|
|
421
542
|
"appSlug": spec["appSlug"],
|
|
422
543
|
"environment": spec["environment"],
|
|
@@ -459,7 +580,13 @@ def apply_local_deploy(plan: dict[str, Any]) -> dict[str, Any]:
|
|
|
459
580
|
"inventorySnapshot",
|
|
460
581
|
"spec",
|
|
461
582
|
}
|
|
462
|
-
if not isinstance(plan, dict)
|
|
583
|
+
if not isinstance(plan, dict):
|
|
584
|
+
raise DeploymentKitError("Malformed local application deployment plan.")
|
|
585
|
+
if plan.get("additionalPortBindings"):
|
|
586
|
+
required.add("additionalPortBindings")
|
|
587
|
+
if plan.get("readOnlyMounts"):
|
|
588
|
+
required.update(("readOnlyMounts", "materialSnapshot"))
|
|
589
|
+
if set(plan) != required or plan["action"] != "deploy":
|
|
463
590
|
raise DeploymentKitError("Malformed local application deployment plan.")
|
|
464
591
|
current = local_inventory()
|
|
465
592
|
if _inventory_snapshot(current) != plan["inventorySnapshot"]:
|
|
@@ -467,6 +594,18 @@ def apply_local_deploy(plan: dict[str, Any]) -> dict[str, Any]:
|
|
|
467
594
|
"Local application inventory changed after plan review; create and authorize a fresh plan."
|
|
468
595
|
)
|
|
469
596
|
spec = plan["spec"]
|
|
597
|
+
if plan.get("additionalPortBindings", []) != spec.get("additionalPortBindings", []) or plan.get("readOnlyMounts", []) != spec.get("readOnlyMounts", []):
|
|
598
|
+
raise DeploymentKitError("Deployment plan options differ from the approved specification.")
|
|
599
|
+
if plan.get("materialSnapshot") != snapshot_material(spec):
|
|
600
|
+
raise DeploymentKitError("External material changed after review; approve a fresh deployment plan.")
|
|
601
|
+
current_app = next((item for item in current["applications"]
|
|
602
|
+
if item["appSlug"] == spec["appSlug"] and item["environment"] == spec["environment"]), None)
|
|
603
|
+
if current_app and current_app.get("configurationDrift", False):
|
|
604
|
+
raise DeploymentKitError("Existing managed Docker runtime has configuration drift; do not replace it automatically.")
|
|
605
|
+
for mapping in bindings(spec):
|
|
606
|
+
_assert_port_available(current, host_port=mapping["hostPort"], app_slug=spec["appSlug"], environment=spec["environment"])
|
|
607
|
+
owned_ports = ({item["hostPort"] for item in bindings(current_app)} if current_app else set())
|
|
608
|
+
_assert_bindable([item["hostPort"] for item in bindings(spec)], owned_ports)
|
|
470
609
|
docker = _docker()
|
|
471
610
|
existing = _assert_owned_or_absent(
|
|
472
611
|
docker,
|
|
@@ -487,30 +626,54 @@ def apply_local_deploy(plan: dict[str, Any]) -> dict[str, Any]:
|
|
|
487
626
|
"exposure": "loopback",
|
|
488
627
|
}
|
|
489
628
|
try:
|
|
490
|
-
if existing is not None:
|
|
491
|
-
_run(docker, ["rm", "-f", spec["containerName"]])
|
|
492
629
|
if spec.get("imageSource") == "local-image-id":
|
|
493
630
|
_run(docker, ["image", "inspect", spec["image"]])
|
|
494
631
|
else:
|
|
495
632
|
_run(docker, ["pull", spec["image"]])
|
|
633
|
+
# All detectable preflight checks precede removal of the existing container.
|
|
634
|
+
if plan.get("materialSnapshot") != snapshot_material(spec):
|
|
635
|
+
raise DeploymentKitError("External material changed before replacement.")
|
|
636
|
+
_preflight_material(docker, spec)
|
|
637
|
+
if existing is not None:
|
|
638
|
+
_run(docker, ["rm", "-f", spec["containerName"]])
|
|
496
639
|
_run(docker, _container_args(spec))
|
|
497
640
|
_wait_for_health(spec)
|
|
498
641
|
except DeploymentKitError:
|
|
499
642
|
_append_evidence(spec["appSlug"], spec["environment"], {**evidence_base, "outcome": "failed"})
|
|
500
643
|
raise
|
|
501
|
-
|
|
644
|
+
# Verify the new container against the approved spec, not persisted state:
|
|
645
|
+
# a late failure must not overwrite the last accepted deployment record.
|
|
646
|
+
try:
|
|
647
|
+
actual = _inspect_owned_container(docker, spec["containerName"])
|
|
648
|
+
if actual is None or not actual["running"]:
|
|
649
|
+
raise DeploymentKitError("Local deployment is not running after health verification.")
|
|
650
|
+
observed = {
|
|
651
|
+
"specSha256": actual["labels"].get(_LABEL_SPEC),
|
|
652
|
+
"actualPorts": actual["ports"],
|
|
653
|
+
"actualHostBindings": actual["hostBindings"],
|
|
654
|
+
"actualMounts": actual["mounts"],
|
|
655
|
+
}
|
|
656
|
+
if actual["labels"].get(_LABEL_OWNER) != spec["ownerRepo"] or (
|
|
657
|
+
actual["labels"].get(_LABEL_APP), actual["labels"].get(_LABEL_ENV)
|
|
658
|
+
) != (spec["appSlug"], spec["environment"]) or _config_drift(observed, spec):
|
|
659
|
+
raise DeploymentKitError("Local Docker configuration does not match the approved spec.")
|
|
660
|
+
_persist_state(spec)
|
|
661
|
+
final = local_inventory()
|
|
662
|
+
deployed = next(
|
|
663
|
+
(
|
|
664
|
+
item
|
|
665
|
+
for item in final["applications"]
|
|
666
|
+
if item["appSlug"] == spec["appSlug"] and item["environment"] == spec["environment"]
|
|
667
|
+
),
|
|
668
|
+
None,
|
|
669
|
+
)
|
|
670
|
+
if not deployed or deployed["specSha256"] != spec["specSha256"] or not deployed["httpHealthy"] or deployed.get("configurationDrift", False):
|
|
671
|
+
raise DeploymentKitError("Local deployment completed but readiness evidence is not healthy.")
|
|
672
|
+
except DeploymentKitError:
|
|
673
|
+
_append_evidence(spec["appSlug"], spec["environment"],
|
|
674
|
+
{**evidence_base, "outcome": "failed", "reason": "post-deploy-verification"})
|
|
675
|
+
raise
|
|
502
676
|
_append_evidence(spec["appSlug"], spec["environment"], {**evidence_base, "outcome": "ready"})
|
|
503
|
-
final = local_inventory()
|
|
504
|
-
deployed = next(
|
|
505
|
-
(
|
|
506
|
-
item
|
|
507
|
-
for item in final["applications"]
|
|
508
|
-
if item["appSlug"] == spec["appSlug"] and item["environment"] == spec["environment"]
|
|
509
|
-
),
|
|
510
|
-
None,
|
|
511
|
-
)
|
|
512
|
-
if not deployed or deployed["specSha256"] != spec["specSha256"] or not deployed["httpHealthy"]:
|
|
513
|
-
raise DeploymentKitError("Local deployment completed but readiness evidence is not healthy.")
|
|
514
677
|
final["mutated"] = True
|
|
515
678
|
return final
|
|
516
679
|
|
|
@@ -9,6 +9,7 @@ from typing import Any, Iterable
|
|
|
9
9
|
|
|
10
10
|
from .app_runtime import normalize_app_spec
|
|
11
11
|
from .errors import DeploymentKitError
|
|
12
|
+
from .local_extensions import normalize_bindings, normalize_mounts
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
_LOCAL_IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
@@ -40,6 +41,8 @@ def normalize_workstation_spec(
|
|
|
40
41
|
persistence_mode: str,
|
|
41
42
|
data_container_path: str | None,
|
|
42
43
|
environment_variables: dict[str, str] | Iterable[tuple[str, str]],
|
|
44
|
+
additional_port_bindings: list[dict[str, int]] | None = None,
|
|
45
|
+
read_only_mounts: list[dict[str, str]] | None = None,
|
|
43
46
|
) -> dict[str, Any]:
|
|
44
47
|
"""Normalize a loopback-only workstation spec.
|
|
45
48
|
|
|
@@ -76,6 +79,12 @@ def normalize_workstation_spec(
|
|
|
76
79
|
"imageSource": "local-image-id" if local_image_id else "registry-digest",
|
|
77
80
|
}
|
|
78
81
|
)
|
|
82
|
+
extra_bindings = normalize_bindings(base["hostPort"], base["runtimePort"], additional_port_bindings or [])
|
|
83
|
+
extra_mounts = normalize_mounts(base["appSlug"], base["environment"], read_only_mounts or [], base["persistence"]["dataContainerPath"])
|
|
84
|
+
if extra_bindings:
|
|
85
|
+
base["additionalPortBindings"] = extra_bindings
|
|
86
|
+
if extra_mounts:
|
|
87
|
+
base["readOnlyMounts"] = extra_mounts
|
|
79
88
|
base["specSha256"] = hashlib.sha256(
|
|
80
89
|
json.dumps(base, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
81
90
|
).hexdigest()
|