@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.
- package/assets/components.json +2 -2
- package/assets/generated/ASSEMBLY.json +5 -5
- package/assets/generated/deployment/src/project_forge_deployment/app_cli.py +18 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime.py +120 -8
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime_remote.py +173 -3
- 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.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": "
|
|
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.
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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(
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|