@mriekki/project-forge 4.16.0 → 4.17.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/deployment/distribution.json +1 -0
- package/assets/generated/ASSEMBLY.json +9 -4
- package/assets/generated/deployment/src/project_forge_deployment/local_extensions.py +123 -0
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter.py +49 -1
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter_remote.py +45 -2
- 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.17.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": "f5f82c531da03f113209a795930db8444338c69e",
|
|
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.
|
|
4
|
+
"version": "4.17.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": "f5f82c531da03f113209a795930db8444338c69e",
|
|
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",
|
|
@@ -592,12 +597,12 @@
|
|
|
592
597
|
{
|
|
593
598
|
"source": "src/project_forge_deployment/sqlite_adapter.py",
|
|
594
599
|
"destination": "tools/project-forge-deployment/project_forge_deployment/sqlite_adapter.py",
|
|
595
|
-
"sha256": "
|
|
600
|
+
"sha256": "781219312cb81ffb901dff8b8d04d0cf0511b5d37e86f9ad9024a73cba04d3b9"
|
|
596
601
|
},
|
|
597
602
|
{
|
|
598
603
|
"source": "src/project_forge_deployment/sqlite_adapter_remote.py",
|
|
599
604
|
"destination": "tools/project-forge-deployment/project_forge_deployment/sqlite_adapter_remote.py",
|
|
600
|
-
"sha256": "
|
|
605
|
+
"sha256": "32c319453add977de4e051ca9cadbffaa411adb26713c4a8731553d0d2d4adaf"
|
|
601
606
|
},
|
|
602
607
|
{
|
|
603
608
|
"source": "src/project_forge_deployment/sqlite_cli.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()
|
|
@@ -7,6 +7,7 @@ import hashlib
|
|
|
7
7
|
import json
|
|
8
8
|
import posixpath
|
|
9
9
|
import re
|
|
10
|
+
import shlex
|
|
10
11
|
import subprocess
|
|
11
12
|
from typing import Any, Iterable
|
|
12
13
|
|
|
@@ -332,7 +333,7 @@ def authorization_phrase(plan: dict[str, Any]) -> str:
|
|
|
332
333
|
return prod if production else normal
|
|
333
334
|
|
|
334
335
|
|
|
335
|
-
def
|
|
336
|
+
def _validate_sqlite_plan(plan: dict[str, Any]) -> None:
|
|
336
337
|
required = {
|
|
337
338
|
"targetName", "action", "ownerRepo", "environment", "productionClassified", "appSlug", "image",
|
|
338
339
|
"databaseFile", "runAs", "sourceDatabase", "firstInstall", "stopsManagedRuntime", "createsVerifiedBackup",
|
|
@@ -345,6 +346,10 @@ def apply_sqlite_operation(plan: dict[str, Any]) -> dict[str, Any]:
|
|
|
345
346
|
raise DeploymentKitError("Literal production environment must remain Production-classified.")
|
|
346
347
|
if plan["applicationRecoveryReadyAfterOperation"] is not False:
|
|
347
348
|
raise DeploymentKitError("SQLite operations cannot claim application recovery readiness.")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def apply_sqlite_operation(plan: dict[str, Any]) -> dict[str, Any]:
|
|
352
|
+
_validate_sqlite_plan(plan)
|
|
348
353
|
_require_ready_platform(plan["targetName"])
|
|
349
354
|
current = sqlite_inventory(plan["targetName"])
|
|
350
355
|
if _snapshot(current) != plan["inventorySnapshot"]:
|
|
@@ -373,3 +378,46 @@ def assert_app_spec_matches_persistence(name: str, app_spec: dict[str, Any]) ->
|
|
|
373
378
|
raise DeploymentKitError("Application runAs does not match the prepared SQLite persistence reservation.")
|
|
374
379
|
if app_spec.get("persistence", {}).get("mode") != "directory":
|
|
375
380
|
raise DeploymentKitError("Prepared SQLite persistence requires directory application persistence.")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def render_locked_sqlite_operation_stage(
|
|
384
|
+
plan: dict[str, Any], *, inherited_lock_fd: int, authorization: str,
|
|
385
|
+
) -> dict[str, str]:
|
|
386
|
+
"""Render a Kit-validated SQLite stage for an existing privileged remote lock scope.
|
|
387
|
+
|
|
388
|
+
This returns a Bash *stage*, not an SSH command and not an authorization to
|
|
389
|
+
run it. The caller must have obtained an exclusive non-blocking flock on
|
|
390
|
+
this exact app/environment lock in the SAME remote root shell; the Kit
|
|
391
|
+
verifies that inherited open file description before touching storage.
|
|
392
|
+
|
|
393
|
+
The caller retains the descriptor across its full-backup/current-storage
|
|
394
|
+
comparison, Kit migration, optional Kit rollback-check, and its durable
|
|
395
|
+
application-specific receipt write. Do not execute this stage via a fresh
|
|
396
|
+
SSH/sudo connection: the inherited descriptor cannot cross that boundary.
|
|
397
|
+
"""
|
|
398
|
+
_validate_sqlite_plan(plan)
|
|
399
|
+
if plan["action"] not in {"migrate", "rollback-check"} or plan["firstInstall"]:
|
|
400
|
+
raise DeploymentKitError("Lock handoff is limited to existing-database migration and rollback-check.")
|
|
401
|
+
if type(inherited_lock_fd) is not int or not 3 <= inherited_lock_fd <= 256:
|
|
402
|
+
raise DeploymentKitError("Inherited remote lock descriptor must be an integer between 3 and 256.")
|
|
403
|
+
if authorization != authorization_phrase(plan):
|
|
404
|
+
raise DeploymentKitError("Exact reviewed SQLite operation authorization phrase is required.")
|
|
405
|
+
|
|
406
|
+
_require_ready_platform(plan["targetName"])
|
|
407
|
+
current = sqlite_inventory(plan["targetName"])
|
|
408
|
+
if _snapshot(current) != plan["inventorySnapshot"]:
|
|
409
|
+
raise DeploymentKitError("SQLite state changed after the plan was reviewed; create and authorize a fresh plan.")
|
|
410
|
+
profile = load_profile(plan["targetName"])
|
|
411
|
+
evidence = verify_access(profile)
|
|
412
|
+
script = build_remote_script(plan["spec"])
|
|
413
|
+
delimiter = "PROJECT_FORGE_SQLITE_STAGE_" + hashlib.sha256(script.encode()).hexdigest()[:24]
|
|
414
|
+
if delimiter in script:
|
|
415
|
+
raise DeploymentKitError("Unsafe SQLite script delimiter collision.")
|
|
416
|
+
args = [plan["action"], plan["expectedReservationSha256"] or "NONE", profile["user"], str(inherited_lock_fd)]
|
|
417
|
+
command = "bash -s -- " + " ".join(shlex.quote(arg) for arg in args)
|
|
418
|
+
return {
|
|
419
|
+
"targetName": plan["targetName"],
|
|
420
|
+
"verifiedAddress": evidence["address"],
|
|
421
|
+
"action": plan["action"],
|
|
422
|
+
"script": f"{command} <<'{delimiter}'\n{script.rstrip()}\n{delimiter}\n",
|
|
423
|
+
}
|
|
@@ -6,6 +6,9 @@ set -Eeuo pipefail
|
|
|
6
6
|
ACTION="$1"
|
|
7
7
|
EXPECTED_RESERVATION_SHA="${2:-}"
|
|
8
8
|
DEPLOY_USER="${3:-}"
|
|
9
|
+
# Optional Kit-verified handoff from an already-locked privileged remote session.
|
|
10
|
+
# This is a remote file descriptor, not a workstation/SSH descriptor.
|
|
11
|
+
HELD_LOCK_FD="${4:-}"
|
|
9
12
|
APP_ROOT="__APP_ROOT__"
|
|
10
13
|
SQLITE_SCHEMA_VERSION="__SQLITE_SCHEMA_VERSION__"
|
|
11
14
|
OWNER_B64="__OWNER_B64__"
|
|
@@ -253,12 +256,52 @@ verify_reserved_dirs() {
|
|
|
253
256
|
}
|
|
254
257
|
}
|
|
255
258
|
|
|
259
|
+
acquire_operation_lock() {
|
|
260
|
+
if [[ -z "$HELD_LOCK_FD" ]]; then
|
|
261
|
+
exec 9>>"$LOCK_FILE"
|
|
262
|
+
flock -n 9 || { echo "Another app/persistence operation holds the lock for $APP_SLUG--$ENVIRONMENT." >&2; exit 75; }
|
|
263
|
+
return 0
|
|
264
|
+
fi
|
|
265
|
+
|
|
266
|
+
# Never convert an untrusted flag into permission to skip the lock.
|
|
267
|
+
# Inherited lock sharing is limited to existing-reservation migration and
|
|
268
|
+
# compatibility verification; other mutations retain their own lock.
|
|
269
|
+
[[ "$ACTION" == migrate || "$ACTION" == rollback-check ]] && [[ "$FIRST_INSTALL" == false ]] || {
|
|
270
|
+
echo "An inherited lock is not supported for this SQLite action." >&2; exit 75;
|
|
271
|
+
}
|
|
272
|
+
[[ "$HELD_LOCK_FD" =~ ^[0-9]+$ ]] && (( 10#$HELD_LOCK_FD >= 3 && 10#$HELD_LOCK_FD <= 256 )) || {
|
|
273
|
+
echo "Inherited SQLite lock descriptor is invalid." >&2; exit 75;
|
|
274
|
+
}
|
|
275
|
+
[[ -f "$LOCK_FILE" && ! -L "$LOCK_FILE" ]] || {
|
|
276
|
+
echo "Inherited SQLite lock path is invalid." >&2; exit 75;
|
|
277
|
+
}
|
|
278
|
+
local inherited_inode expected_inode probe_fd
|
|
279
|
+
inherited_inode="$(stat -Lc '%d:%i' "/proc/self/fd/$HELD_LOCK_FD" 2>/dev/null || true)"
|
|
280
|
+
expected_inode="$(stat -Lc '%d:%i' "$LOCK_FILE" 2>/dev/null || true)"
|
|
281
|
+
[[ -n "$inherited_inode" && "$inherited_inode" == "$expected_inode" ]] || {
|
|
282
|
+
echo "Inherited SQLite lock descriptor does not match this app/environment." >&2; exit 75;
|
|
283
|
+
}
|
|
284
|
+
# Independent open descriptions must contend: prove the caller ALREADY
|
|
285
|
+
# holds the lock, not merely that it passed an unlocked file descriptor.
|
|
286
|
+
exec {probe_fd}>>"$LOCK_FILE"
|
|
287
|
+
if flock -n "$probe_fd"; then
|
|
288
|
+
flock -u "$probe_fd"
|
|
289
|
+
exec {probe_fd}>&-
|
|
290
|
+
echo "Inherited SQLite lock was not held before this operation." >&2; exit 75
|
|
291
|
+
fi
|
|
292
|
+
exec {probe_fd}>&-
|
|
293
|
+
# A successful flock on this inherited open description proves ownership,
|
|
294
|
+
# rather than unrelated contention on a different descriptor.
|
|
295
|
+
flock -n "$HELD_LOCK_FD" || {
|
|
296
|
+
echo "Inherited SQLite lock is owned by another operation." >&2; exit 75;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
256
300
|
operate() {
|
|
257
301
|
decode_spec
|
|
258
302
|
configure_registry_auth
|
|
259
303
|
set_paths
|
|
260
|
-
|
|
261
|
-
flock -n 9 || { echo "Another app/persistence operation holds the lock for $APP_SLUG--$ENVIRONMENT." >&2; exit 75; }
|
|
304
|
+
acquire_operation_lock
|
|
262
305
|
|
|
263
306
|
local db="$DATA/$DATABASE_FILE" stamp backup_name source_real source_parent
|
|
264
307
|
managed_runtime_info "$ROOT"
|