@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.
package/assets/components.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.16.
|
|
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": "
|
|
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.
|
|
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": "
|
|
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()
|