@mriekki/project-forge 4.20.0 → 4.21.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.20.0",
4
+ "version": "4.21.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": "977ada7310f649adea34059381339012a0c0daaf",
24
+ "revision": "f2d20ec7aac61494ef9fdb7f3702060b8d8d8865",
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.20.0",
4
+ "version": "4.21.0",
5
5
  "components": {
6
6
  "core": {
7
7
  "revision": "4c74c1801b82d3aa438447d392024d32ca5697b0",
@@ -361,7 +361,7 @@
361
361
  ]
362
362
  },
363
363
  "deployment": {
364
- "revision": "977ada7310f649adea34059381339012a0c0daaf",
364
+ "revision": "f2d20ec7aac61494ef9fdb7f3702060b8d8d8865",
365
365
  "distribution": "greenfield-project",
366
366
  "files": [
367
367
  {
@@ -392,7 +392,7 @@
392
392
  {
393
393
  "source": "src/project_forge_deployment/app_cli.py",
394
394
  "destination": "tools/project-forge-deployment/project_forge_deployment/app_cli.py",
395
- "sha256": "f27704c80b1eff45888bf56745bcf8755f2fad88851147600af7056953757825"
395
+ "sha256": "b44ec913fd1080ff66bff6643d2d9fad17262725c0f35557c56f52c86090c060"
396
396
  },
397
397
  {
398
398
  "source": "src/project_forge_deployment/app_deployment.py",
@@ -402,12 +402,12 @@
402
402
  {
403
403
  "source": "src/project_forge_deployment/app_runtime.py",
404
404
  "destination": "tools/project-forge-deployment/project_forge_deployment/app_runtime.py",
405
- "sha256": "73736aada7c3db0e2a7c74a3e88990316b89e6c34a71b4997cdc4c79c7e31708"
405
+ "sha256": "6f4ae7f663dc7a4e51225c1420f7aa6be5d2b77445b792f9e4495ba3eeb059ad"
406
406
  },
407
407
  {
408
408
  "source": "src/project_forge_deployment/app_runtime_remote.py",
409
409
  "destination": "tools/project-forge-deployment/project_forge_deployment/app_runtime_remote.py",
410
- "sha256": "791cc7592cff93555f7b6facdd31fbe58de5e711732d9dfcb9321500a0d79cdf"
410
+ "sha256": "5b6ddffcc1105d3a401ae17456cdfdb279c3005bfcb6c81bbd1eabc27479de05"
411
411
  },
412
412
  {
413
413
  "source": "src/project_forge_deployment/app_runtime_workstation.py",
@@ -49,6 +49,16 @@ def _parse_env(items: list[str]) -> list[tuple[str, str]]:
49
49
  return result
50
50
 
51
51
 
52
+ def _parse_materials(items: list[str]) -> list[tuple[str, str]]:
53
+ values: list[tuple[str, str]] = []
54
+ for item in items:
55
+ if "=" not in item:
56
+ raise DeploymentKitError("--read-only-material requires NAME=/absolute/container/directory.")
57
+ name, target = item.split("=", 1)
58
+ values.append((name, target))
59
+ return values
60
+
61
+
52
62
  def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
53
63
  if args.yes:
54
64
  return
@@ -91,6 +101,13 @@ def _add_runtime_contract(parser: argparse.ArgumentParser) -> None:
91
101
  parser.add_argument("--health-path", required=True)
92
102
  parser.add_argument("--run-as", required=True)
93
103
  parser.add_argument("--data-container-path")
104
+ parser.add_argument(
105
+ "--read-only-material",
106
+ action="append",
107
+ default=[],
108
+ metavar="NAME=/container/directory",
109
+ help="Opt-in root-owned remote material directory; source derived from Project/environment/name",
110
+ )
94
111
  parser.add_argument(
95
112
  "--env",
96
113
  action="append",
@@ -185,6 +202,7 @@ def main(argv: list[str] | None = None) -> int:
185
202
  persistence_mode=args.persistence_mode,
186
203
  data_container_path=args.data_container_path,
187
204
  environment_variables=_parse_env(args.env),
205
+ read_only_materials=_parse_materials(args.read_only_material),
188
206
  )
189
207
  assert_app_spec_matches_persistence(args.name, spec)
190
208
 
@@ -19,6 +19,8 @@ from .app_runtime_remote import REMOTE_SCRIPT
19
19
 
20
20
  APP_ROOT = "/srv/project-forge-apps"
21
21
  APP_SCHEMA_VERSION = 1
22
+ # Operator-provisioned remote sources: never workstation paths or auto-created directories.
23
+ REMOTE_MATERIAL_ROOT = "/srv/project-forge-material"
22
24
  APP_DEPLOY_WRITE_PHRASE = "[APP DEPLOY WRITE]"
23
25
  PRODUCTION_APP_DEPLOY_WRITE_PHRASE = "[PRODUCTION APP DEPLOY]"
24
26
 
@@ -84,6 +86,67 @@ def _container_path(value: str | None) -> str | None:
84
86
  return value.rstrip("/") or "/"
85
87
 
86
88
 
89
+
90
+ def _normalize_read_only_materials(
91
+ materials: Iterable[tuple[str, str]] | None,
92
+ *,
93
+ data_path: str | None,
94
+ ) -> list[dict[str, str]]:
95
+ if materials is None:
96
+ return []
97
+ result: list[dict[str, str]] = []
98
+ names: set[str] = set()
99
+ paths: list[str] = []
100
+ for name, target in materials:
101
+ if len(result) >= 8:
102
+ raise DeploymentKitError("Remote read-only material mount count must not exceed eight.")
103
+ name = _slug(name, "read-only material name")
104
+ if name in names:
105
+ raise DeploymentKitError(f"Duplicate read-only material name: {name}")
106
+ if not isinstance(target, str) or _CONTAINER_PATH_RE.fullmatch(target) is None:
107
+ raise DeploymentKitError("Read-only material target must be a safe absolute container directory.")
108
+ parts = target.split("/")[1:]
109
+ if not parts or any(part in {"", ".", ".."} for part in parts):
110
+ raise DeploymentKitError("Read-only material target must be normalized and cannot be root.")
111
+ if parts[0] in {"proc", "sys", "dev", "run"} or parts[:2] == ["var", "run"]:
112
+ raise DeploymentKitError("Read-only material cannot mask a container system runtime directory.")
113
+ for other in paths + ([data_path] if data_path else []):
114
+ if target == other or target.startswith(other.rstrip("/") + "/") or other.startswith(target + "/"):
115
+ raise DeploymentKitError("Read-only material target overlaps another mount.")
116
+ names.add(name)
117
+ paths.append(target)
118
+ result.append({"name": name, "containerPath": target})
119
+ return sorted(result, key=lambda item: item["name"])
120
+
121
+
122
+ def _material_evidence_for_spec(name: str, spec: dict[str, Any]) -> dict[str, str] | None:
123
+ materials = spec.get("readOnlyMaterials")
124
+ if not materials:
125
+ return None
126
+ profile = load_profile(name)
127
+ evidence = verify_access(profile)
128
+ output = _run_remote(profile, evidence["address"], "material-inspect", spec=spec)
129
+ observed: dict[str, str] = {}
130
+ for line in output.splitlines():
131
+ parts = line.split("\t")
132
+ if len(parts) != 3 or parts[0] != "MATERIAL":
133
+ raise DeploymentKitError("Malformed remote material source evidence.")
134
+ _, material_name, identity = parts
135
+ if not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", material_name) or not re.fullmatch(r"[0-9]+:[0-9]+", identity):
136
+ raise DeploymentKitError("Malformed remote material source identity.")
137
+ if material_name in observed:
138
+ raise DeploymentKitError("Duplicate remote material source evidence.")
139
+ observed[material_name] = identity
140
+ if set(observed) != {item["name"] for item in materials}:
141
+ raise DeploymentKitError("Remote material inspection omitted or added a source.")
142
+ return dict(sorted(observed.items()))
143
+
144
+
145
+ def _verify_material_evidence(name: str, plan: dict[str, Any]) -> None:
146
+ if plan["spec"].get("readOnlyMaterials"):
147
+ if _material_evidence_for_spec(name, plan["spec"]) != plan["readOnlyMaterialEvidence"]:
148
+ raise DeploymentKitError("Remote material directory identity changed after planning; review a new plan.")
149
+
87
150
  def _normalize_environment_variables(items: dict[str, str] | Iterable[tuple[str, str]]) -> dict[str, str]:
88
151
  pairs = items.items() if isinstance(items, dict) else items
89
152
  result: dict[str, str] = {}
@@ -111,6 +174,7 @@ def normalize_app_spec(
111
174
  persistence_mode: str,
112
175
  data_container_path: str | None,
113
176
  environment_variables: dict[str, str] | Iterable[tuple[str, str]],
177
+ read_only_materials: Iterable[tuple[str, str]] | None = None,
114
178
  ) -> dict[str, Any]:
115
179
  if persistence_mode not in {"none", "directory"}:
116
180
  raise DeploymentKitError("persistenceMode must be 'none' or 'directory'.")
@@ -135,6 +199,9 @@ def normalize_app_spec(
135
199
  },
136
200
  "environmentVariables": _normalize_environment_variables(environment_variables),
137
201
  }
202
+ materials = _normalize_read_only_materials(read_only_materials, data_path=data_path)
203
+ if materials:
204
+ spec["readOnlyMaterials"] = materials
138
205
  spec["specSha256"] = _spec_sha(spec)
139
206
  return spec
140
207
 
@@ -150,8 +217,13 @@ def _b64(value: str) -> str:
150
217
  return base64.b64encode(value.encode("utf-8")).decode("ascii")
151
218
 
152
219
 
153
- def build_remote_script(spec: dict[str, Any] | None = None) -> str:
220
+ def build_remote_script(
221
+ spec: dict[str, Any] | None = None, *, expected_materials: dict[str, str] | None = None,
222
+ ) -> str:
154
223
  env_keys: list[str] = []
224
+ material_names: list[str] = []
225
+ material_targets: list[str] = []
226
+ material_expected: list[str] = []
155
227
  env_values: list[str] = []
156
228
  desired = {
157
229
  "ownerRepo": "",
@@ -183,9 +255,14 @@ def build_remote_script(spec: dict[str, Any] | None = None) -> str:
183
255
  for key, value in spec["environmentVariables"].items():
184
256
  env_keys.append(_b64(key))
185
257
  env_values.append(_b64(value))
258
+ for item in spec.get("readOnlyMaterials", []):
259
+ material_names.append(_b64(item["name"]))
260
+ material_targets.append(_b64(item["containerPath"]))
261
+ material_expected.append((expected_materials or {}).get(item["name"], ""))
186
262
 
187
263
  replacements = {
188
264
  "__APP_ROOT__": APP_ROOT,
265
+ "__MATERIAL_ROOT__": REMOTE_MATERIAL_ROOT,
189
266
  "__APP_SCHEMA_VERSION__": str(APP_SCHEMA_VERSION),
190
267
  "__NETWORK_NAME__": NETWORK_NAME,
191
268
  "__CADDY_CONTAINER__": CADDY_CONTAINER,
@@ -203,6 +280,9 @@ def build_remote_script(spec: dict[str, Any] | None = None) -> str:
203
280
  "__D_SPEC_SHA__": desired["specSha256"],
204
281
  "__D_ENV_KEYS__": " ".join(f"'{item}'" for item in env_keys),
205
282
  "__D_ENV_VALUES__": " ".join(f"'{item}'" for item in env_values),
283
+ "__D_MATERIAL_NAMES__": " ".join(shlex.quote(x) for x in material_names),
284
+ "__D_MATERIAL_TARGETS__": " ".join(shlex.quote(x) for x in material_targets),
285
+ "__D_MATERIAL_EXPECTED__": " ".join(shlex.quote(x) for x in material_expected),
206
286
  }
207
287
  script = REMOTE_SCRIPT
208
288
  for placeholder, value in replacements.items():
@@ -224,7 +304,7 @@ def _parse_inventory(output: str) -> dict[str, Any]:
224
304
  raise DeploymentKitError("Malformed or duplicate application inventory summary.")
225
305
  summary[parts[1]] = parts[2]
226
306
  continue
227
- if parts[0] != "APP" or len(parts) != 15:
307
+ if parts[0] != "APP" or len(parts) not in (15, 17):
228
308
  raise DeploymentKitError("Malformed application inventory record.")
229
309
  try:
230
310
  port = int(parts[7])
@@ -258,6 +338,29 @@ def _parse_inventory(output: str) -> dict[str, Any]:
258
338
  "connectedToSharedNetwork": parts[13] == "true",
259
339
  "httpHealthy": parts[14] == "true",
260
340
  }
341
+ if len(parts) == 17:
342
+ if parts[15] not in {"true", "false"} or not parts[16]:
343
+ raise DeploymentKitError("Malformed remote material mount readiness.")
344
+ materials: list[dict[str, str]] = []
345
+ for encoded in parts[16].split(";"):
346
+ pieces = encoded.split("@")
347
+ if len(pieces) != 3 or re.fullmatch(r"[0-9]+:[0-9]+", pieces[2]) is None:
348
+ raise DeploymentKitError("Malformed remote material inventory identity.")
349
+ materials.append({
350
+ "name": pieces[0],
351
+ "containerPath": pieces[1],
352
+ "sourceIdentity": pieces[2],
353
+ })
354
+ normalized = _normalize_read_only_materials(
355
+ [(item["name"], item["containerPath"]) for item in materials],
356
+ data_path=None,
357
+ )
358
+ if [(item["name"], item["containerPath"]) for item in normalized] != [
359
+ (item["name"], item["containerPath"]) for item in materials
360
+ ]:
361
+ raise DeploymentKitError("Remote material inventory is unordered or conflicting.")
362
+ app["readOnlyMaterials"] = materials
363
+ app["materialMountsVerified"] = parts[15] == "true"
261
364
  apps.append(app)
262
365
  if set(summary) != {"appCount"}:
263
366
  raise DeploymentKitError("Application inventory omitted appCount.")
@@ -280,8 +383,9 @@ def _run_remote(
280
383
  *,
281
384
  spec: dict[str, Any] | None = None,
282
385
  expected_spec_sha: str = "",
386
+ expected_materials: dict[str, str] | None = None,
283
387
  ) -> str:
284
- if action not in {"inventory", "deploy"}:
388
+ if action not in {"inventory", "deploy", "material-inspect"}:
285
389
  raise DeploymentKitError(f"Unsupported application-runtime action: {action}")
286
390
  args = [action]
287
391
  if action == "deploy":
@@ -293,7 +397,7 @@ def _run_remote(
293
397
  )
294
398
  completed = subprocess.run(
295
399
  command,
296
- input=build_remote_script(spec).encode("utf-8"),
400
+ input=build_remote_script(spec, expected_materials=expected_materials).encode("utf-8"),
297
401
  check=False,
298
402
  capture_output=True,
299
403
  )
@@ -360,9 +464,10 @@ def plan_app_deploy(name: str, spec: dict[str, Any]) -> dict[str, Any]:
360
464
  and existing["containerRunning"]
361
465
  and existing["connectedToSharedNetwork"]
362
466
  and existing["httpHealthy"]
467
+ and existing.get("materialMountsVerified", True)
363
468
  ):
364
469
  raise DeploymentKitError("The exact application runtime spec is already deployed and healthy.")
365
- return {
470
+ plan = {
366
471
  "targetName": name,
367
472
  "action": "deploy",
368
473
  "environment": spec["environment"],
@@ -385,6 +490,9 @@ def plan_app_deploy(name: str, spec: dict[str, Any]) -> dict[str, Any]:
385
490
  "expectedSpecSha256": existing["specSha256"] if existing is not None else None,
386
491
  "spec": spec,
387
492
  }
493
+ if spec.get("readOnlyMaterials"):
494
+ plan["readOnlyMaterialEvidence"] = _material_evidence_for_spec(name, spec)
495
+ return plan
388
496
 
389
497
 
390
498
  def deploy_write_phrase(plan: dict[str, Any]) -> str:
@@ -428,7 +536,7 @@ def render_locked_app_deploy_stage(
428
536
  "expectedSpecSha256",
429
537
  "spec",
430
538
  }
431
- if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "deploy":
539
+ if not isinstance(plan, dict) or set(plan) != required | ({"readOnlyMaterialEvidence"} if plan.get("spec", {}).get("readOnlyMaterials") else set()) or plan["action"] != "deploy":
432
540
  raise DeploymentKitError("Malformed application deployment plan.")
433
541
  if (plan["changeType"] != "update" or plan["retainsPreviousRelease"] is not True
434
542
  or not isinstance(plan["expectedSpecSha256"], str)
@@ -456,7 +564,8 @@ def render_locked_app_deploy_stage(
456
564
  )
457
565
  profile = load_profile(plan["targetName"])
458
566
  evidence = verify_access(profile)
459
- script = build_remote_script(plan["spec"])
567
+ _verify_material_evidence(plan["targetName"], plan)
568
+ script = build_remote_script(plan["spec"], expected_materials=plan.get("readOnlyMaterialEvidence"))
460
569
  delimiter = "PROJECT_FORGE_APP_DEPLOY_STAGE_" + hashlib.sha256(script.encode()).hexdigest()[:24]
461
570
  if delimiter in script:
462
571
  raise DeploymentKitError("Unsafe application deployment script delimiter collision.")
@@ -495,7 +604,7 @@ def apply_app_deploy(plan: dict[str, Any]) -> dict[str, Any]:
495
604
  "expectedSpecSha256",
496
605
  "spec",
497
606
  }
498
- if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "deploy":
607
+ if not isinstance(plan, dict) or set(plan) != required | ({"readOnlyMaterialEvidence"} if plan.get("spec", {}).get("readOnlyMaterials") else set()) or plan["action"] != "deploy":
499
608
  raise DeploymentKitError("Malformed application deployment plan.")
500
609
 
501
610
  current = app_inventory(plan["targetName"])
@@ -507,6 +616,7 @@ def apply_app_deploy(plan: dict[str, Any]) -> dict[str, Any]:
507
616
  if platform["state"] != "ready":
508
617
  raise DeploymentKitError("Shared host platform is no longer ready; application deployment is blocked.")
509
618
 
619
+ _verify_material_evidence(plan["targetName"], plan)
510
620
  profile = load_profile(plan["targetName"])
511
621
  evidence = verify_access(profile)
512
622
  _run_remote(
@@ -515,6 +625,7 @@ def apply_app_deploy(plan: dict[str, Any]) -> dict[str, Any]:
515
625
  "deploy",
516
626
  spec=plan["spec"],
517
627
  expected_spec_sha=plan["expectedSpecSha256"] or "",
628
+ expected_materials=plan.get("readOnlyMaterialEvidence"),
518
629
  )
519
630
  final = app_inventory(plan["targetName"])
520
631
  deployed = _find_app(final, plan["appSlug"], plan["environment"])
@@ -526,6 +637,7 @@ def apply_app_deploy(plan: dict[str, Any]) -> dict[str, Any]:
526
637
  deployed["containerRunning"]
527
638
  and deployed["connectedToSharedNetwork"]
528
639
  and deployed["httpHealthy"]
640
+ and deployed.get("materialMountsVerified", True)
529
641
  ):
530
642
  raise DeploymentKitError("Application deployment completed but runtime readiness is not healthy.")
531
643
  final["mutated"] = True
@@ -8,6 +8,7 @@ EXPECTED_SPEC_SHA="${2:-}"
8
8
  DEPLOY_USER="${3:-}"
9
9
  HELD_LOCK_FD="${4:-}"
10
10
  APP_ROOT="__APP_ROOT__"
11
+ MATERIAL_ROOT="__MATERIAL_ROOT__"
11
12
  APP_SCHEMA_VERSION="__APP_SCHEMA_VERSION__"
12
13
  NETWORK_NAME="__NETWORK_NAME__"
13
14
  CADDY_CONTAINER="__CADDY_CONTAINER__"
@@ -26,6 +27,9 @@ D_DATA_TARGET_B64="__D_DATA_TARGET_B64__"
26
27
  D_SPEC_SHA="__D_SPEC_SHA__"
27
28
  D_ENV_KEYS=(__D_ENV_KEYS__)
28
29
  D_ENV_VALUES=(__D_ENV_VALUES__)
30
+ D_MATERIAL_NAMES=(__D_MATERIAL_NAMES__)
31
+ D_MATERIAL_TARGETS=(__D_MATERIAL_TARGETS__)
32
+ D_MATERIAL_EXPECTED=(__D_MATERIAL_EXPECTED__)
29
33
 
30
34
  b64d() { printf '%s' "$1" | base64 --decode; }
31
35
  meta_value() {
@@ -53,6 +57,92 @@ network_has_container() {
53
57
  grep -Fxq "$container"
54
58
  }
55
59
 
60
+
61
+ material_source() {
62
+ printf '%s/%s/%s/%s' "$MATERIAL_ROOT" "$D_APP" "$D_ENV" "$1"
63
+ }
64
+ material_identity() {
65
+ local source="$1" run_as="$2" uid gid dir owner mode hazards
66
+ uid="${run_as%%:*}"
67
+ gid="$uid"
68
+ [[ "$run_as" != *:* ]] || gid="${run_as#*:}"
69
+ command -v setpriv >/dev/null 2>&1 || {
70
+ echo "setpriv is required to verify read-only material access for runAs." >&2; return 1;
71
+ }
72
+ for dir in /srv "$MATERIAL_ROOT" "$MATERIAL_ROOT/$D_APP" "$MATERIAL_ROOT/$D_APP/$D_ENV" "$source"; do
73
+ [[ -d "$dir" && ! -L "$dir" ]] || {
74
+ echo "Read-only material directory missing or linked: $dir" >&2; return 1;
75
+ }
76
+ owner="$(stat -c '%u' "$dir")"
77
+ mode="$(stat -c '%a' "$dir")"
78
+ [[ "$owner" == 0 ]] && (( (8#$mode & 022) == 0 )) || {
79
+ echo "Read-only material directory ownership or mode is unsafe: $dir" >&2; return 1;
80
+ }
81
+ done
82
+ hazards="$(find -P "$source" -mindepth 1 \( -type l -o ! -uid 0 -o -perm /022 -o \( ! -type f -a ! -type d \) \) -print -quit)" || return 1
83
+ [[ -z "$hazards" ]] || {
84
+ echo "Read-only material tree has linked, writable, unowned or special entries." >&2; return 1;
85
+ }
86
+ setpriv --reuid "$uid" --regid "$gid" --clear-groups -- bash -c '
87
+ path="$1"
88
+ [[ -r "$path" && -x "$path" ]] || exit 1
89
+ invalid="$(find -P "$path" \( -type d \( ! -readable -o ! -executable \) -o -type f ! -readable \) -print -quit)" || exit 1
90
+ [[ -z "$invalid" ]]
91
+ ' -- "$source" || {
92
+ echo "Read-only material tree is not readable/traversable by runAs." >&2; return 1;
93
+ }
94
+ stat -c '%d:%i' "$source"
95
+ }
96
+ validate_material_entries() {
97
+ [[ "${#D_MATERIAL_NAMES[@]}" -eq "${#D_MATERIAL_TARGETS[@]}" &&
98
+ "${#D_MATERIAL_NAMES[@]}" -eq "${#D_MATERIAL_EXPECTED[@]}" ]] || {
99
+ echo "Read-only material argument count mismatch." >&2; exit 33;
100
+ }
101
+ local index name target part prior data
102
+ local -a segments targets=()
103
+ data="$D_DATA_TARGET"
104
+ for index in "${!D_MATERIAL_NAMES[@]}"; do
105
+ name="$(b64d "${D_MATERIAL_NAMES[$index]}")"
106
+ target="$(b64d "${D_MATERIAL_TARGETS[$index]}")"
107
+ valid_slug "$name" || { echo "Invalid read-only material name." >&2; exit 33; }
108
+ [[ "$target" == /* && "$target" != / && "$target" =~ ^/[A-Za-z0-9._~+:/-]+$ ]] || {
109
+ echo "Invalid read-only material target." >&2; exit 33;
110
+ }
111
+ IFS='/' read -r -a segments <<< "${target#/}"
112
+ for part in "${segments[@]}"; do
113
+ [[ -n "$part" && "$part" != . && "$part" != .. ]] || {
114
+ echo "Read-only material target is not normalized." >&2; exit 33;
115
+ }
116
+ done
117
+ case "$target/" in
118
+ /proc/*|/sys/*|/dev/*|/run/*|/var/run/*)
119
+ echo "Read-only material target overlaps a system path." >&2; exit 33;;
120
+ esac
121
+ for prior in "${targets[@]}" "$data"; do
122
+ [[ -z "$prior" ]] && continue
123
+ if [[ "$target" == "$prior" || "$target" == "$prior/"* || "$prior" == "$target/"* ]]; then
124
+ echo "Read-only material target overlaps another mount." >&2; exit 33
125
+ fi
126
+ done
127
+ targets+=("$target")
128
+ done
129
+ }
130
+ material_probe() {
131
+ local check_expected="$1" index name source identity expected
132
+ for index in "${!D_MATERIAL_NAMES[@]}"; do
133
+ name="$(b64d "${D_MATERIAL_NAMES[$index]}")"
134
+ source="$(material_source "$name")"
135
+ identity="$(material_identity "$source" "$D_RUN_AS")" || return 1
136
+ expected="${D_MATERIAL_EXPECTED[$index]}"
137
+ if [[ "$check_expected" == true && "$identity" != "$expected" ]]; then
138
+ echo "Read-only material directory changed after planning: $name" >&2; return 1
139
+ fi
140
+ if [[ "$check_expected" != true ]]; then
141
+ printf 'MATERIAL\t%s\t%s\n' "$name" "$identity"
142
+ fi
143
+ done
144
+ }
145
+
56
146
  probe_http() {
57
147
  local container="$1" port="$2" path="$3"
58
148
  docker exec "$CADDY_CONTAINER" \
@@ -75,6 +165,47 @@ wait_ready() {
75
165
  return 1
76
166
  }
77
167
 
168
+
169
+ material_inventory_state() {
170
+ local file="$1" app="$2" env="$3" container="$4" persistence="$5"
171
+ local number index name target source recorded current raw mounts run_as data_target line
172
+ number="$(meta_value "$file" MATERIAL_COUNT || true)"
173
+ MATERIAL_OK=true
174
+ MATERIAL_ENTRIES=""
175
+ [[ -n "$number" ]] || return 0
176
+ [[ "$number" =~ ^[1-8]$ ]] || { echo "Invalid saved material count." >&2; return 1; }
177
+ D_APP="$app"
178
+ D_ENV="$env"
179
+ run_as="$(meta_value "$file" RUN_AS || true)"
180
+ valid_run_as "$run_as" || { echo "Invalid saved material runAs." >&2; return 1; }
181
+ mounts="$(docker inspect --format '{{range .Mounts}}{{printf "%s@%s@%t@%s\n" .Source .Destination .RW .Type}}{{end}}' "$container" 2>/dev/null)" || {
182
+ MATERIAL_OK=false
183
+ mounts=""
184
+ }
185
+ local expected_count="$number"
186
+ if [[ "$persistence" == directory ]]; then
187
+ data_target="$(b64d "$(meta_value "$file" DATA_TARGET_B64 || true)")"
188
+ line="$APP_ROOT/$app/$env/data@$data_target@true@bind"
189
+ grep -Fxq -- "$line" <<< "$mounts" || MATERIAL_OK=false
190
+ expected_count="$((number + 1))"
191
+ fi
192
+ [[ "$(grep -c '.' <<< "$mounts" || true)" == "$expected_count" ]] || MATERIAL_OK=false
193
+ for ((index=0; index < number; index++)); do
194
+ name="$(b64d "$(meta_value "$file" MATERIAL_NAME_B64_"$index" || true)")"
195
+ target="$(b64d "$(meta_value "$file" MATERIAL_TARGET_B64_"$index" || true)")"
196
+ recorded="$(meta_value "$file" MATERIAL_ID_"$index" || true)"
197
+ valid_slug "$name" && [[ "$target" =~ ^/[A-Za-z0-9._~+:/-]+$ && "$recorded" =~ ^[0-9]+:[0-9]+$ ]] || {
198
+ echo "Invalid saved material mount metadata." >&2; return 1;
199
+ }
200
+ source="$(material_source "$name")"
201
+ current="$(material_identity "$source" "$run_as" 2>/dev/null)" || { MATERIAL_OK=false; current=""; }
202
+ [[ "$current" == "$recorded" ]] || MATERIAL_OK=false
203
+ grep -Fxq -- "$source@$target@false@bind" <<< "$mounts" || MATERIAL_OK=false
204
+ [[ -z "$MATERIAL_ENTRIES" ]] || MATERIAL_ENTRIES+=";"
205
+ MATERIAL_ENTRIES+="$name@$target@$recorded"
206
+ done
207
+ }
208
+
78
209
  inventory() {
79
210
  local count=0 state current previous release_file
80
211
  local owner env app container image spec_sha port health persistence
@@ -114,9 +245,16 @@ inventory() {
114
245
  if [[ "$running" == true && "$connected" == true ]]; then
115
246
  probe_http "$container" "$port" "$health" && healthy=true || true
116
247
  fi
117
- printf 'APP\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
118
- "$owner" "$env" "$app" "$container" "$image" "$spec_sha" "$port" "$health" "$persistence" \
119
- "$current" "$previous" "$running" "$connected" "$healthy"
248
+ material_inventory_state "$release_file" "$app" "$env" "$container" "$persistence" || exit 31
249
+ if [[ -n "$MATERIAL_ENTRIES" ]]; then
250
+ printf 'APP\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
251
+ "$owner" "$env" "$app" "$container" "$image" "$spec_sha" "$port" "$health" "$persistence" \
252
+ "$current" "$previous" "$running" "$connected" "$healthy" "$MATERIAL_OK" "$MATERIAL_ENTRIES"
253
+ else
254
+ printf 'APP\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
255
+ "$owner" "$env" "$app" "$container" "$image" "$spec_sha" "$port" "$health" "$persistence" \
256
+ "$current" "$previous" "$running" "$connected" "$healthy"
257
+ fi
120
258
  count=$((count + 1))
121
259
  done < <(find "$APP_ROOT" -mindepth 3 -maxdepth 3 -type f -name deployment.env -print 2>/dev/null | sort)
122
260
  fi
@@ -173,6 +311,7 @@ decode_desired() {
173
311
  exit 33
174
312
  }
175
313
  valid_run_as "$D_RUN_AS" || { echo "Invalid numeric runAs value." >&2; exit 33; }
314
+ validate_material_entries
176
315
  [[ "$D_PERSISTENCE" == "none" || "$D_PERSISTENCE" == "directory" ]] || {
177
316
  echo "Invalid persistence mode." >&2
178
317
  exit 33
@@ -216,6 +355,14 @@ write_release_metadata() {
216
355
  printf 'PERSISTENCE_MODE=%s\n' "$D_PERSISTENCE"
217
356
  printf 'DATA_TARGET_B64=%s\n' "$D_DATA_TARGET_B64"
218
357
  printf 'SPEC_SHA256=%s\n' "$D_SPEC_SHA"
358
+ if (( ${#D_MATERIAL_NAMES[@]} > 0 )); then
359
+ printf 'MATERIAL_COUNT=%s\n' "${#D_MATERIAL_NAMES[@]}"
360
+ for index in "${!D_MATERIAL_NAMES[@]}"; do
361
+ printf 'MATERIAL_NAME_B64_%s=%s\n' "$index" "${D_MATERIAL_NAMES[$index]}"
362
+ printf 'MATERIAL_TARGET_B64_%s=%s\n' "$index" "${D_MATERIAL_TARGETS[$index]}"
363
+ printf 'MATERIAL_ID_%s=%s\n' "$index" "${D_MATERIAL_EXPECTED[$index]}"
364
+ done
365
+ fi
219
366
  printf 'ENV_COUNT=%s\n' "${#D_ENV_KEYS[@]}"
220
367
  for index in "${!D_ENV_KEYS[@]}"; do
221
368
  key_b64="${D_ENV_KEYS[$index]}"
@@ -271,6 +418,13 @@ start_desired_container() {
271
418
  value="$(b64d "${D_ENV_VALUES[$index]}")"
272
419
  docker_args+=(--env "$key=$value")
273
420
  done
421
+ for index in "${!D_MATERIAL_NAMES[@]}"; do
422
+ local source target name
423
+ name="$(b64d "${D_MATERIAL_NAMES[$index]}")"
424
+ target="$(b64d "${D_MATERIAL_TARGETS[$index]}")"
425
+ source="$(material_source "$name")"
426
+ docker_args+=(--mount "type=bind,src=$source,dst=$target,readonly")
427
+ done
274
428
  docker_args+=("$D_IMAGE")
275
429
  "${docker_args[@]}" >/dev/null
276
430
  }
@@ -339,6 +493,7 @@ deploy() {
339
493
  release_file="$release_dir/release.env"
340
494
 
341
495
  acquire_deployment_lock "$lock_file"
496
+ material_probe true || { echo "Read-only material preflight failed." >&2; exit 40; }
342
497
 
343
498
  for path in "$root" "$releases" "$state" "$root/data" "$root/deployment-evidence.jsonl"; do
344
499
  [[ ! -L "$path" ]] || { echo "Managed application path must not be a symbolic link: $path" >&2; exit 35; }
@@ -397,11 +552,22 @@ deploy() {
397
552
  echo "Release directory collision with different runtime metadata." >&2
398
553
  exit 35
399
554
  }
555
+ if (( ${#D_MATERIAL_NAMES[@]} > 0 )); then
556
+ [[ "$(release_value "$release_file" MATERIAL_COUNT || true)" == "${#D_MATERIAL_NAMES[@]}" ]] || {
557
+ echo "Existing release material metadata mismatch." >&2; exit 35;
558
+ }
559
+ for index in "${!D_MATERIAL_NAMES[@]}"; do
560
+ [[ "$(release_value "$release_file" MATERIAL_ID_"$index" || true)" == "${D_MATERIAL_EXPECTED[$index]}" ]] || {
561
+ echo "Existing release material directory identity changed." >&2; exit 35;
562
+ }
563
+ done
564
+ fi
400
565
  else
401
566
  write_release_metadata "$release_file"
402
567
  fi
403
568
 
404
569
  docker pull "$D_IMAGE" >/dev/null
570
+ material_probe true || { echo "Read-only material source changed before runtime replacement." >&2; exit 40; }
405
571
 
406
572
  if [[ -n "$current_container" ]]; then
407
573
  docker rm -f "$current_container" >/dev/null
@@ -442,6 +608,10 @@ case "$ACTION" in
442
608
  deploy)
443
609
  deploy
444
610
  ;;
611
+ material-inspect)
612
+ decode_desired
613
+ material_probe false || exit 40
614
+ ;;
445
615
  *)
446
616
  echo "Unsupported application-runtime action: $ACTION" >&2
447
617
  exit 2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.20.0",
3
+ "version": "4.21.0",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {