@mriekki/project-forge 4.15.1 → 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.15.1",
4
+ "version": "4.16.0",
5
5
  "defaultProfile": "greenfield-project",
6
6
  "profiles": {
7
7
  "greenfield-project": {
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "components": {
15
15
  "core": {
16
- "revision": "6e31941ac4b6c2490b0470b1b4d25b7289b97c98",
16
+ "revision": "4c74c1801b82d3aa438447d392024d32ca5697b0",
17
17
  "version": "4.0.0",
18
18
  "distribution": "greenfield-workspace",
19
19
  "descriptor": "assets/core/distribution.json",
@@ -21,7 +21,7 @@
21
21
  "canonicalDescriptor": "distributions/greenfield-workspace.json"
22
22
  },
23
23
  "deployment": {
24
- "revision": "1119331414ec53e0c48b864403fabfef7998df60",
24
+ "revision": "f5325556e1ee1e256e3dfb426d91feb42cbdc2c3",
25
25
  "distribution": "greenfield-project",
26
26
  "descriptor": "assets/deployment/distribution.json",
27
27
  "sourceRoot": "assets/generated/deployment",
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.15.1",
4
+ "version": "4.16.0",
5
5
  "components": {
6
6
  "core": {
7
- "revision": "6e31941ac4b6c2490b0470b1b4d25b7289b97c98",
7
+ "revision": "4c74c1801b82d3aa438447d392024d32ca5697b0",
8
8
  "version": "4.0.0",
9
9
  "distribution": "greenfield-workspace",
10
10
  "files": [
@@ -56,7 +56,7 @@
56
56
  {
57
57
  "source": "core/skills/setup/SKILL.md",
58
58
  "destination": ".github/skills/setup/SKILL.md",
59
- "sha256": "d1c6e7a0a04a1fb358b136ed8f83495c27bf8959ebd8941a3d42397fa4dcbaf1"
59
+ "sha256": "0e08797b2f7d7e3e3f825cbb22bdad509255a4a0b875878d4ec1d9eecd23cad2"
60
60
  },
61
61
  {
62
62
  "source": "core/skills/build/SKILL.md",
@@ -361,7 +361,7 @@
361
361
  ]
362
362
  },
363
363
  "deployment": {
364
- "revision": "1119331414ec53e0c48b864403fabfef7998df60",
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": "f44d4900852354ee38703d35025a3efefd32686f2158659dcb62a7c2142400e0"
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": "b83c9eeef0e53088bf9617a237f5ff864903b4f9e83797d34e9f2b8763b36e8e"
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": "372b8c041edd9d3a1005861d85bb3a6bad38064b0be081a470460ad2dc62be4d"
525
+ "sha256": "bddaafcb5785e74103cb82d73f01d4e577c97476bd6a6549b3e332cca5eb50f8"
526
526
  },
527
527
  {
528
528
  "source": "src/project_forge_deployment/local_sqlite.py",
@@ -46,7 +46,7 @@ From the repository root inspect only what is needed:
46
46
  - `PROJECT_BOOTSTRAP.json` as generation/provenance and initial topology fallback;
47
47
  - `SCAFFOLD_PLAN.json` when present;
48
48
  - `.github/project-forge-workspace.json` and Project Forge/component baselines when present;
49
- - `scripts/setup.ps1` on Windows or `scripts/setup.sh` on macOS;
49
+ - `scripts/setup.ps1` on Windows or `scripts/setup.sh` on macOS/Linux; WSL2 is the supported generated Linux setup path;
50
50
  - `tools/project-forge-vscode/work-item-status/install.py` and `docs/work-items` when present;
51
51
  - current Git branch/status/upstream evidence so setup does not overwrite unrelated work;
52
52
  - workstation-local setup/readiness evidence when present;
@@ -104,8 +104,9 @@ Do not silently repair state during `status`.
104
104
  Select the launcher for the actual workstation OS:
105
105
 
106
106
  ```text
107
- Windows -> .\scripts\setup.ps1
108
- macOS -> ./scripts/setup.sh
107
+ Windows -> .\scripts\setup.ps1
108
+ macOS -> ./scripts/setup.sh
109
+ Linux/WSL2 -> ./scripts/setup.sh
109
110
  ```
110
111
 
111
112
  For normal setup, run the launcher with repository restore enabled when supported:
@@ -147,7 +148,6 @@ python tools/project-forge-vscode/work-item-status/install.py install --json
147
148
  ```
148
149
 
149
150
  This installation is already authorized by `/setup` as routine workstation preparation; do not require a second `/work-item-status enable` confirmation. The installer may replace older versions of this same Project Forge extension only and must not install unrelated extensions, alter Project files, or change Git/GitHub state.
150
-
151
151
  If the result has `reloadRequired: true`, tell the developer to reload VS Code once after setup. Do not repeatedly reinstall an already-ready version merely because the editor has not yet been reloaded.
152
152
 
153
153
  If a Project has local `docs/work-items` but the package-managed installer is absent, report:
@@ -447,7 +447,6 @@ Never silently edit `sshd`, widen account/source policy, disable host verificati
447
447
  A verified workstation-to-target access relationship may be reused by multiple Projects/environments that bind to the same logical target. Environment proof remains specific to the Project + named environment + target + adapter/isolation contract.
448
448
 
449
449
  When Staging and Production share one physical target, explicitly surface the shared failure domain and verify that their Project/environment-owned runtime identity, compose project, route/port allocation, persistence paths, and locking cannot collide. Staging evidence does not become Production evidence merely because the host is shared.
450
-
451
450
  ## 8. Prove supported remote mechanics with a disposable Project Forge probe
452
451
 
453
452
  When the installed Deployment Kit exposes environment proof operations, a selected remote environment should normally be taken through an end-to-end **environment proof deployment** during setup.
@@ -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") != LOCAL_RUNTIME_SCHEMA_VERSION:
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) != 3:
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
- if item.get("hostPort") == host_port and (item["appSlug"], item["environment"]) != (
310
- app_slug,
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
- _assert_port_available(
330
- inventory,
331
- host_port=spec["hostPort"],
332
- app_slug=spec["appSlug"],
333
- environment=spec["environment"],
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) or set(plan) != required or plan["action"] != "deploy":
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
- _persist_state(spec)
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()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.15.1",
3
+ "version": "4.16.0",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {