@mriekki/project-forge 4.16.0 → 4.16.1

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.16.0",
4
+ "version": "4.16.1",
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": "f5325556e1ee1e256e3dfb426d91feb42cbdc2c3",
24
+ "revision": "6da5b1591bf483cedc9c4b2b5fecfd5613d836f6",
25
25
  "distribution": "greenfield-project",
26
26
  "descriptor": "assets/deployment/distribution.json",
27
27
  "sourceRoot": "assets/generated/deployment",
@@ -33,6 +33,7 @@
33
33
  {"source": "src/project_forge_deployment/ingress_cli.py", "destination": "tools/project-forge-deployment/project_forge_deployment/ingress_cli.py"},
34
34
  {"source": "src/project_forge_deployment/keys.py", "destination": "tools/project-forge-deployment/project_forge_deployment/keys.py"},
35
35
  {"source": "src/project_forge_deployment/local_cli.py", "destination": "tools/project-forge-deployment/project_forge_deployment/local_cli.py"},
36
+ {"source": "src/project_forge_deployment/local_extensions.py", "destination": "tools/project-forge-deployment/project_forge_deployment/local_extensions.py"},
36
37
  {"source": "src/project_forge_deployment/local_runtime.py", "destination": "tools/project-forge-deployment/project_forge_deployment/local_runtime.py"},
37
38
  {"source": "src/project_forge_deployment/local_spec.py", "destination": "tools/project-forge-deployment/project_forge_deployment/local_spec.py"},
38
39
  {"source": "src/project_forge_deployment/local_sqlite.py", "destination": "tools/project-forge-deployment/project_forge_deployment/local_sqlite.py"},
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.16.0",
4
+ "version": "4.16.1",
5
5
  "components": {
6
6
  "core": {
7
7
  "revision": "4c74c1801b82d3aa438447d392024d32ca5697b0",
@@ -361,7 +361,7 @@
361
361
  ]
362
362
  },
363
363
  "deployment": {
364
- "revision": "f5325556e1ee1e256e3dfb426d91feb42cbdc2c3",
364
+ "revision": "6da5b1591bf483cedc9c4b2b5fecfd5613d836f6",
365
365
  "distribution": "greenfield-project",
366
366
  "files": [
367
367
  {
@@ -514,6 +514,11 @@
514
514
  "destination": "tools/project-forge-deployment/project_forge_deployment/local_cli.py",
515
515
  "sha256": "b0b99dc66a95a5f9cb386a15d7a32f3f935bf37be2cf06175ceb9cc2c9a479cd"
516
516
  },
517
+ {
518
+ "source": "src/project_forge_deployment/local_extensions.py",
519
+ "destination": "tools/project-forge-deployment/project_forge_deployment/local_extensions.py",
520
+ "sha256": "cf90ef1fc122c6626b7f53d8e9b1eb88c97f7f8106d248e5b04f25263184ca93"
521
+ },
517
522
  {
518
523
  "source": "src/project_forge_deployment/local_runtime.py",
519
524
  "destination": "tools/project-forge-deployment/project_forge_deployment/local_runtime.py",
@@ -0,0 +1,123 @@
1
+ """Optional, loopback-only workstation bindings and scoped read-only material mounts.
2
+
3
+ No material is provisioned by the Kit. Source directories belong to the Project
4
+ and must already exist below its dedicated local-materials root.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import re
12
+ import stat
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .errors import DeploymentKitError
17
+ from .profiles import config_root
18
+
19
+ MATERIAL_TARGET_ROOT = "/run/project-forge/material"
20
+ _SAFE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
21
+ MAX_ADDITIONAL_PORTS = 8
22
+
23
+
24
+ def material_root(app_slug: str, environment: str) -> Path:
25
+ return config_root() / "local-materials" / app_slug / environment
26
+
27
+
28
+ def bindings(spec: dict[str, Any]) -> list[dict[str, int]]:
29
+ return [{"hostPort": spec["hostPort"], "runtimePort": spec["runtimePort"]}, *spec.get("additionalPortBindings", [])]
30
+
31
+
32
+ def normalize_bindings(primary_host: int, primary_runtime: int, values: object) -> list[dict[str, int]]:
33
+ if not isinstance(values, (list, tuple)) or len(values) > MAX_ADDITIONAL_PORTS:
34
+ raise DeploymentKitError("additionalPortBindings must be a list of at most eight TCP mappings.")
35
+ result = []
36
+ host_ports = {primary_host}
37
+ runtime_ports = {primary_runtime}
38
+ for item in values:
39
+ if not isinstance(item, dict) or set(item) != {"hostPort", "runtimePort"}:
40
+ raise DeploymentKitError("Additional port mapping requires hostPort and runtimePort.")
41
+ host, runtime = item["hostPort"], item["runtimePort"]
42
+ if any(type(port) is not int or not 1 <= port <= 65535 for port in (host, runtime)):
43
+ raise DeploymentKitError("Additional TCP ports must be integers from 1 through 65535.")
44
+ if host in host_ports or runtime in runtime_ports:
45
+ raise DeploymentKitError("All host ports and container runtime ports must be distinct.")
46
+ host_ports.add(host)
47
+ runtime_ports.add(runtime)
48
+ result.append({"hostPort": host, "runtimePort": runtime})
49
+ return sorted(result, key=lambda item: (item["hostPort"], item["runtimePort"]))
50
+
51
+
52
+ def _absolute_path(value: object, field: str) -> Path:
53
+ if not isinstance(value, str) or not value or any(ch in value for ch in ("\x00", "\n", "\r", ",")):
54
+ raise DeploymentKitError(f"{field} must be a safe absolute path.")
55
+ value = value.replace("\\", "/") if os.name == "nt" else value
56
+ path = Path(value)
57
+ if not path.is_absolute() or any(part in (".", "..") for part in value.replace("\\", "/").split("/")):
58
+ raise DeploymentKitError(f"{field} must be an absolute path without traversal.")
59
+ return path
60
+
61
+
62
+ def _no_symlink_components(path: Path) -> None:
63
+ for entry in (path, *path.parents):
64
+ if entry.is_symlink():
65
+ raise DeploymentKitError("External material paths cannot contain symbolic links.")
66
+
67
+
68
+ def normalize_mounts(app_slug: str, environment: str, values: object, data_target: str | None) -> list[dict[str, str]]:
69
+ if not isinstance(values, (list, tuple)):
70
+ raise DeploymentKitError("readOnlyMounts must be a list.")
71
+ root = material_root(app_slug, environment)
72
+ result: list[dict[str, str]] = []
73
+ for item in values:
74
+ if not isinstance(item, dict) or set(item) != {"source", "target"}:
75
+ raise DeploymentKitError("readOnlyMounts entries require source and target.")
76
+ source = _absolute_path(item["source"], "material source")
77
+ _no_symlink_components(source)
78
+ try:
79
+ relative = source.relative_to(root)
80
+ except ValueError as error:
81
+ raise DeploymentKitError("Material source must be beneath the Project-scoped local-materials root.") from error
82
+ if not relative.parts or relative == Path("."):
83
+ raise DeploymentKitError("Mount a named material directory, not the Project-scoped root.")
84
+ target = item["target"]
85
+ if not isinstance(target, str) or not target.startswith(MATERIAL_TARGET_ROOT + "/"):
86
+ raise DeploymentKitError("Material targets must be under /run/project-forge/material/.")
87
+ parts = target[len(MATERIAL_TARGET_ROOT) + 1:].split("/")
88
+ if not all(_SAFE_NAME.fullmatch(part) and part not in {".", ".."} for part in parts):
89
+ raise DeploymentKitError("Material target must use safe nonempty path segments.")
90
+ target = MATERIAL_TARGET_ROOT + "/" + "/".join(parts)
91
+ if data_target and (target == data_target or target.startswith(data_target.rstrip("/") + "/") or data_target.startswith(target + "/")):
92
+ raise DeploymentKitError("Material target overlaps managed persistence.")
93
+ result.append({"source": str(source), "target": target})
94
+ for i, first in enumerate(result):
95
+ for second in result[i+1:]:
96
+ if any(a == b or a.startswith(b.rstrip("/") + "/") or b.startswith(a.rstrip("/") + "/")
97
+ for a, b in ((first["target"], second["target"]), (first["source"], second["source"]))):
98
+ raise DeploymentKitError("Read-only material sources and targets cannot overlap.")
99
+ return sorted(result, key=lambda item: (item["target"], item["source"]))
100
+
101
+
102
+ def snapshot_material(spec: dict[str, Any]) -> str | None:
103
+ """Non-secret metadata snapshot; detects many, but not all, concurrent changes."""
104
+ mounts = spec.get("readOnlyMounts", [])
105
+ if not mounts:
106
+ return None
107
+ entries: list[object] = []
108
+ for mount in mounts:
109
+ source = Path(mount["source"])
110
+ _no_symlink_components(source)
111
+ if not source.is_dir():
112
+ raise DeploymentKitError("External material source directory is missing.")
113
+ for path in [source, *sorted(source.rglob("*"))]:
114
+ _no_symlink_components(path)
115
+ try:
116
+ info = path.lstat()
117
+ except OSError as error:
118
+ raise DeploymentKitError("Cannot inspect external material source.") from error
119
+ if not (stat.S_ISDIR(info.st_mode) or stat.S_ISREG(info.st_mode)):
120
+ raise DeploymentKitError("Material directories may contain only regular files and directories.")
121
+ entries.append((mount["source"], str(path.relative_to(source)), info.st_dev, info.st_ino,
122
+ info.st_mode, info.st_size, info.st_mtime_ns, info.st_ctime_ns))
123
+ return hashlib.sha256(json.dumps(entries, separators=(",", ":")).encode("utf-8")).hexdigest()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.16.0",
3
+ "version": "4.16.1",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {