@mriekki/project-forge 4.17.0 → 4.19.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 +7 -7
- package/assets/generated/deployment/src/project_forge_deployment/app_deployment.py +26 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime.py +77 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime_remote.py +39 -5
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter.py +119 -16
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter_remote.py +95 -23
- 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.19.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": "09b2ace0b75fcd51ca7175d480bd5671e8be9520",
|
|
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.19.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": "09b2ace0b75fcd51ca7175d480bd5671e8be9520",
|
|
365
365
|
"distribution": "greenfield-project",
|
|
366
366
|
"files": [
|
|
367
367
|
{
|
|
@@ -397,17 +397,17 @@
|
|
|
397
397
|
{
|
|
398
398
|
"source": "src/project_forge_deployment/app_deployment.py",
|
|
399
399
|
"destination": "tools/project-forge-deployment/project_forge_deployment/app_deployment.py",
|
|
400
|
-
"sha256": "
|
|
400
|
+
"sha256": "aeecda05bd2f71276dd49f09fd599cf529ea9fe9b0aae7c1e26ab92bd57e7f22"
|
|
401
401
|
},
|
|
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": "73736aada7c3db0e2a7c74a3e88990316b89e6c34a71b4997cdc4c79c7e31708"
|
|
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": "791cc7592cff93555f7b6facdd31fbe58de5e711732d9dfcb9321500a0d79cdf"
|
|
411
411
|
},
|
|
412
412
|
{
|
|
413
413
|
"source": "src/project_forge_deployment/app_runtime_workstation.py",
|
|
@@ -597,12 +597,12 @@
|
|
|
597
597
|
{
|
|
598
598
|
"source": "src/project_forge_deployment/sqlite_adapter.py",
|
|
599
599
|
"destination": "tools/project-forge-deployment/project_forge_deployment/sqlite_adapter.py",
|
|
600
|
-
"sha256": "
|
|
600
|
+
"sha256": "a5fe59886946ef90b664959f7030831c60f8157d30825887bb1d2eac8f891e8b"
|
|
601
601
|
},
|
|
602
602
|
{
|
|
603
603
|
"source": "src/project_forge_deployment/sqlite_adapter_remote.py",
|
|
604
604
|
"destination": "tools/project-forge-deployment/project_forge_deployment/sqlite_adapter_remote.py",
|
|
605
|
-
"sha256": "
|
|
605
|
+
"sha256": "e327a079cb2fb1bec744c77684811ee3a6f382523484b70b430c9cdd73f730f1"
|
|
606
606
|
},
|
|
607
607
|
{
|
|
608
608
|
"source": "src/project_forge_deployment/sqlite_cli.py",
|
|
@@ -14,6 +14,7 @@ from .app_runtime import PRODUCTION_APP_DEPLOY_WRITE_PHRASE
|
|
|
14
14
|
from .app_runtime import apply_app_deploy as _apply_runtime_deploy
|
|
15
15
|
from .app_runtime import deploy_write_phrase
|
|
16
16
|
from .app_runtime import plan_app_deploy as _plan_runtime_deploy
|
|
17
|
+
from .app_runtime import render_locked_app_deploy_stage as _render_locked_runtime_deploy_stage
|
|
17
18
|
from .app_runtime_workstation import apply_app_deploy_with_preloaded_image
|
|
18
19
|
from .environment_isolation import environment_isolation_preflight
|
|
19
20
|
from .errors import DeploymentKitError
|
|
@@ -272,6 +273,30 @@ def apply_app_deploy(plan: dict[str, Any]) -> dict[str, Any]:
|
|
|
272
273
|
return _apply_runtime_deploy(_runtime_plan(plan))
|
|
273
274
|
|
|
274
275
|
|
|
276
|
+
def render_locked_app_deploy_stage(
|
|
277
|
+
plan: dict[str, Any], *, inherited_lock_fd: int, authorization: str,
|
|
278
|
+
) -> dict[str, str]:
|
|
279
|
+
"""Render a registry-backed deploy stage after isolation revalidation."""
|
|
280
|
+
|
|
281
|
+
runtime_plan = _runtime_plan(plan)
|
|
282
|
+
review = plan[ISOLATION_REVIEW_KEY]
|
|
283
|
+
if not isinstance(review, dict) or type(review.get("production")) is not bool:
|
|
284
|
+
raise DeploymentKitError("Application deployment plan has malformed Production classification.")
|
|
285
|
+
production = review["production"] or runtime_plan["environment"] == "production"
|
|
286
|
+
expected_authorization = (
|
|
287
|
+
PRODUCTION_APP_DEPLOY_WRITE_PHRASE if production else deploy_write_phrase(runtime_plan)
|
|
288
|
+
)
|
|
289
|
+
if authorization != expected_authorization:
|
|
290
|
+
raise DeploymentKitError("Exact reviewed application deployment authorization phrase is required.")
|
|
291
|
+
revalidate_app_isolation(plan)
|
|
292
|
+
return _render_locked_runtime_deploy_stage(
|
|
293
|
+
runtime_plan,
|
|
294
|
+
inherited_lock_fd=inherited_lock_fd,
|
|
295
|
+
authorization=authorization,
|
|
296
|
+
production=production,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
275
300
|
def apply_app_deploy_from_workstation(plan: dict[str, Any]) -> dict[str, Any]:
|
|
276
301
|
"""Transfer an exact image, then recheck isolation again before runtime mutation."""
|
|
277
302
|
|
|
@@ -291,4 +316,5 @@ __all__ = [
|
|
|
291
316
|
"plan_app_deploy",
|
|
292
317
|
"plan_app_rollout",
|
|
293
318
|
"revalidate_app_isolation",
|
|
319
|
+
"render_locked_app_deploy_stage",
|
|
294
320
|
]
|
|
@@ -6,6 +6,7 @@ import base64
|
|
|
6
6
|
import hashlib
|
|
7
7
|
import json
|
|
8
8
|
import re
|
|
9
|
+
import shlex
|
|
9
10
|
import subprocess
|
|
10
11
|
from typing import Any, Iterable
|
|
11
12
|
|
|
@@ -394,6 +395,82 @@ def deploy_write_phrase(plan: dict[str, Any]) -> str:
|
|
|
394
395
|
)
|
|
395
396
|
|
|
396
397
|
|
|
398
|
+
def render_locked_app_deploy_stage(
|
|
399
|
+
plan: dict[str, Any], *, inherited_lock_fd: int, authorization: str, production: bool = False,
|
|
400
|
+
) -> dict[str, str]:
|
|
401
|
+
"""Render a Kit-validated deployment stage for an existing remote lock scope.
|
|
402
|
+
|
|
403
|
+
The returned Bash stage is not an SSH command or authorization to run it.
|
|
404
|
+
The caller must hold the exact app/environment lock in the same privileged
|
|
405
|
+
remote shell and keep it through its application-specific checks and this
|
|
406
|
+
stage. The remote script verifies the inherited descriptor before deploy.
|
|
407
|
+
"""
|
|
408
|
+
required = {
|
|
409
|
+
"targetName",
|
|
410
|
+
"action",
|
|
411
|
+
"environment",
|
|
412
|
+
"appSlug",
|
|
413
|
+
"ownerRepo",
|
|
414
|
+
"containerName",
|
|
415
|
+
"image",
|
|
416
|
+
"runtimePort",
|
|
417
|
+
"healthPath",
|
|
418
|
+
"persistenceMode",
|
|
419
|
+
"changeType",
|
|
420
|
+
"previousImage",
|
|
421
|
+
"previousRelease",
|
|
422
|
+
"retainsPreviousRelease",
|
|
423
|
+
"publishesHostPort",
|
|
424
|
+
"attachesSharedNetwork",
|
|
425
|
+
"modifiesRoutes",
|
|
426
|
+
"persistentDataDeletion",
|
|
427
|
+
"inventorySnapshot",
|
|
428
|
+
"expectedSpecSha256",
|
|
429
|
+
"spec",
|
|
430
|
+
}
|
|
431
|
+
if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "deploy":
|
|
432
|
+
raise DeploymentKitError("Malformed application deployment plan.")
|
|
433
|
+
if (plan["changeType"] != "update" or plan["retainsPreviousRelease"] is not True
|
|
434
|
+
or not isinstance(plan["expectedSpecSha256"], str)
|
|
435
|
+
or re.fullmatch(r"[0-9a-f]{64}", plan["expectedSpecSha256"]) is None):
|
|
436
|
+
raise DeploymentKitError("Lock handoff is limited to an existing application deployment update.")
|
|
437
|
+
if type(inherited_lock_fd) is not int or not 3 <= inherited_lock_fd <= 256:
|
|
438
|
+
raise DeploymentKitError("Inherited remote lock descriptor must be an integer between 3 and 256.")
|
|
439
|
+
if type(production) is not bool:
|
|
440
|
+
raise DeploymentKitError("Production classification must be a boolean.")
|
|
441
|
+
expected_authorization = (
|
|
442
|
+
PRODUCTION_APP_DEPLOY_WRITE_PHRASE
|
|
443
|
+
if production or plan["environment"] == "production"
|
|
444
|
+
else APP_DEPLOY_WRITE_PHRASE
|
|
445
|
+
)
|
|
446
|
+
if authorization != expected_authorization:
|
|
447
|
+
raise DeploymentKitError("Exact reviewed application deployment authorization phrase is required.")
|
|
448
|
+
|
|
449
|
+
platform = platform_status(plan["targetName"])
|
|
450
|
+
if platform["state"] != "ready":
|
|
451
|
+
raise DeploymentKitError("Shared host platform is no longer ready; application deployment is blocked.")
|
|
452
|
+
current = app_inventory(plan["targetName"])
|
|
453
|
+
if _inventory_snapshot(current) != plan["inventorySnapshot"]:
|
|
454
|
+
raise DeploymentKitError(
|
|
455
|
+
"Application inventory changed after the plan was reviewed; create and authorize a fresh plan."
|
|
456
|
+
)
|
|
457
|
+
profile = load_profile(plan["targetName"])
|
|
458
|
+
evidence = verify_access(profile)
|
|
459
|
+
script = build_remote_script(plan["spec"])
|
|
460
|
+
delimiter = "PROJECT_FORGE_APP_DEPLOY_STAGE_" + hashlib.sha256(script.encode()).hexdigest()[:24]
|
|
461
|
+
if delimiter in script:
|
|
462
|
+
raise DeploymentKitError("Unsafe application deployment script delimiter collision.")
|
|
463
|
+
command = "bash -s -- " + shlex.join([
|
|
464
|
+
"deploy", plan["expectedSpecSha256"], profile["user"], str(inherited_lock_fd),
|
|
465
|
+
])
|
|
466
|
+
return {
|
|
467
|
+
"targetName": plan["targetName"],
|
|
468
|
+
"verifiedAddress": evidence["address"],
|
|
469
|
+
"action": "deploy",
|
|
470
|
+
"script": f"{command} <<'{delimiter}'\n{script.rstrip()}\n{delimiter}\n",
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
397
474
|
def apply_app_deploy(plan: dict[str, Any]) -> dict[str, Any]:
|
|
398
475
|
required = {
|
|
399
476
|
"targetName",
|
|
@@ -6,6 +6,7 @@ set -Eeuo pipefail
|
|
|
6
6
|
ACTION="$1"
|
|
7
7
|
EXPECTED_SPEC_SHA="${2:-}"
|
|
8
8
|
DEPLOY_USER="${3:-}"
|
|
9
|
+
HELD_LOCK_FD="${4:-}"
|
|
9
10
|
APP_ROOT="__APP_ROOT__"
|
|
10
11
|
APP_SCHEMA_VERSION="__APP_SCHEMA_VERSION__"
|
|
11
12
|
NETWORK_NAME="__NETWORK_NAME__"
|
|
@@ -284,6 +285,43 @@ record_evidence() {
|
|
|
284
285
|
chmod 0600 "$root/deployment-evidence.jsonl"
|
|
285
286
|
}
|
|
286
287
|
|
|
288
|
+
acquire_deployment_lock() {
|
|
289
|
+
local lock_file="$1"
|
|
290
|
+
if [[ -z "$HELD_LOCK_FD" ]]; then
|
|
291
|
+
exec 9>>"$lock_file"
|
|
292
|
+
flock -n 9 || {
|
|
293
|
+
echo "Another application deployment holds the lock for $D_APP--$D_ENV." >&2
|
|
294
|
+
exit 75
|
|
295
|
+
}
|
|
296
|
+
return 0
|
|
297
|
+
fi
|
|
298
|
+
|
|
299
|
+
[[ "$ACTION" == "deploy" && "$EXPECTED_SPEC_SHA" =~ ^[0-9a-f]{64}$ ]] || {
|
|
300
|
+
echo "An inherited lock is supported only for existing application deployments." >&2
|
|
301
|
+
exit 75
|
|
302
|
+
}
|
|
303
|
+
[[ "$HELD_LOCK_FD" =~ ^[0-9]+$ ]] && (( 10#$HELD_LOCK_FD >= 3 && 10#$HELD_LOCK_FD <= 256 )) || {
|
|
304
|
+
echo "Inherited application lock descriptor is invalid." >&2
|
|
305
|
+
exit 75
|
|
306
|
+
}
|
|
307
|
+
[[ -f "$lock_file" && ! -L "$lock_file" ]] || {
|
|
308
|
+
echo "Inherited application lock path is invalid." >&2
|
|
309
|
+
exit 75
|
|
310
|
+
}
|
|
311
|
+
local inherited_inode expected_inode
|
|
312
|
+
inherited_inode="$(stat -Lc '%d:%i' "/proc/self/fd/$HELD_LOCK_FD" 2>/dev/null || true)"
|
|
313
|
+
expected_inode="$(stat -Lc '%d:%i' "$lock_file" 2>/dev/null || true)"
|
|
314
|
+
[[ -n "$inherited_inode" && "$inherited_inode" == "$expected_inode" ]] || {
|
|
315
|
+
echo "Inherited application lock descriptor does not match this app/environment." >&2
|
|
316
|
+
exit 75
|
|
317
|
+
}
|
|
318
|
+
grep -Eq '^lock:[[:space:]]+[0-9]+:[[:space:]]+FLOCK[[:space:]]+ADVISORY[[:space:]]+WRITE([[:space:]]|$)' \
|
|
319
|
+
"/proc/self/fdinfo/$HELD_LOCK_FD" || {
|
|
320
|
+
echo "Inherited application lock was not held by this descriptor." >&2
|
|
321
|
+
exit 75
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
287
325
|
deploy() {
|
|
288
326
|
platform_ready || { echo "Kit shared platform is not ready." >&2; exit 34; }
|
|
289
327
|
decode_desired
|
|
@@ -300,11 +338,7 @@ deploy() {
|
|
|
300
338
|
release_dir="$releases/$release_id"
|
|
301
339
|
release_file="$release_dir/release.env"
|
|
302
340
|
|
|
303
|
-
|
|
304
|
-
if ! flock -n 9; then
|
|
305
|
-
echo "Another application deployment holds the lock for $D_APP--$D_ENV." >&2
|
|
306
|
-
exit 75
|
|
307
|
-
fi
|
|
341
|
+
acquire_deployment_lock "$lock_file"
|
|
308
342
|
|
|
309
343
|
for path in "$root" "$releases" "$state" "$root/data" "$root/deployment-evidence.jsonl"; do
|
|
310
344
|
[[ ! -L "$path" ]] || { echo "Managed application path must not be a symbolic link: $path" >&2; exit 35; }
|
|
@@ -36,6 +36,8 @@ _RUN_AS_RE = re.compile(r"^[0-9]+(?::[0-9]+)?$")
|
|
|
36
36
|
_DB_FILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
37
37
|
_ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
38
38
|
_SOURCE_RE = re.compile(r"^/srv/[A-Za-z0-9._/+-]+$")
|
|
39
|
+
_IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
40
|
+
_PUBLIC_LABEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
39
41
|
|
|
40
42
|
|
|
41
43
|
def _slug(value: str, field: str) -> str:
|
|
@@ -141,8 +143,30 @@ def _b64(value: str) -> str:
|
|
|
141
143
|
return base64.b64encode(value.encode()).decode("ascii")
|
|
142
144
|
|
|
143
145
|
|
|
146
|
+
def _public_label_keys(keys: list[str] | tuple[str, ...]) -> tuple[str, ...]:
|
|
147
|
+
if not isinstance(keys, (list, tuple)) or not 1 <= len(keys) <= 16:
|
|
148
|
+
raise DeploymentKitError("Image preparation requires 1-16 explicitly public label keys.")
|
|
149
|
+
if any(not isinstance(key, str) or _PUBLIC_LABEL_RE.fullmatch(key) is None for key in keys):
|
|
150
|
+
raise DeploymentKitError("Image preparation label keys must be safe public metadata names.")
|
|
151
|
+
if len(set(keys)) != len(keys):
|
|
152
|
+
raise DeploymentKitError("Image preparation label keys must be unique.")
|
|
153
|
+
return tuple(keys)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _prepared_image_id(value: str) -> str:
|
|
157
|
+
if not isinstance(value, str) or _IMAGE_ID_RE.fullmatch(value) is None:
|
|
158
|
+
raise DeploymentKitError("Bound SQLite maintenance requires an exact sha256 Docker image ID.")
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
|
|
144
162
|
def build_remote_script(spec: dict[str, Any] | None = None) -> str:
|
|
145
163
|
spec = spec or {}
|
|
164
|
+
prepared_id = spec.get("preparedImageId")
|
|
165
|
+
if prepared_id is not None:
|
|
166
|
+
_prepared_image_id(prepared_id)
|
|
167
|
+
keys = spec.get("publicLabelKeys", ())
|
|
168
|
+
if keys:
|
|
169
|
+
keys = _public_label_keys(keys)
|
|
146
170
|
env = spec.get("maintenanceEnvironment", {})
|
|
147
171
|
recovery_payload = json.dumps(spec.get("recoveryEvidence", {}), sort_keys=True, separators=(",", ":"))
|
|
148
172
|
replacements = {
|
|
@@ -152,6 +176,8 @@ def build_remote_script(spec: dict[str, Any] | None = None) -> str:
|
|
|
152
176
|
"__ENV_B64__": _b64(spec.get("environment", "")),
|
|
153
177
|
"__APP_B64__": _b64(spec.get("appSlug", "")),
|
|
154
178
|
"__IMAGE_B64__": _b64(spec.get("image", "")),
|
|
179
|
+
"__PREPARED_IMAGE_ID__": prepared_id or "",
|
|
180
|
+
"__PUBLIC_LABEL_KEYS_B64__": _b64("\n".join(keys) + "\n") if keys else "",
|
|
155
181
|
"__RUN_AS_B64__": _b64(spec.get("runAs") or ""),
|
|
156
182
|
"__DB_FILE_B64__": _b64(spec.get("databaseFile") or ""),
|
|
157
183
|
"__SOURCE_DB_B64__": _b64(spec.get("sourceDatabase") or ""),
|
|
@@ -380,28 +406,28 @@ def assert_app_spec_matches_persistence(name: str, app_spec: dict[str, Any]) ->
|
|
|
380
406
|
raise DeploymentKitError("Prepared SQLite persistence requires directory application persistence.")
|
|
381
407
|
|
|
382
408
|
|
|
383
|
-
def
|
|
409
|
+
def _render_locked_sqlite_stage(
|
|
384
410
|
plan: dict[str, Any], *, inherited_lock_fd: int, authorization: str,
|
|
411
|
+
action: str, prepared_image_id: str | None = None,
|
|
412
|
+
public_label_keys: tuple[str, ...] = (),
|
|
385
413
|
) -> 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
414
|
_validate_sqlite_plan(plan)
|
|
399
415
|
if plan["action"] not in {"migrate", "rollback-check"} or plan["firstInstall"]:
|
|
400
416
|
raise DeploymentKitError("Lock handoff is limited to existing-database migration and rollback-check.")
|
|
417
|
+
if action not in {"migrate", "rollback-check", "prepare-image"} or (
|
|
418
|
+
action != "prepare-image" and action != plan["action"]
|
|
419
|
+
):
|
|
420
|
+
raise DeploymentKitError("Image preparation or SQLite operation action differs from the reviewed plan.")
|
|
401
421
|
if type(inherited_lock_fd) is not int or not 3 <= inherited_lock_fd <= 256:
|
|
402
422
|
raise DeploymentKitError("Inherited remote lock descriptor must be an integer between 3 and 256.")
|
|
403
423
|
if authorization != authorization_phrase(plan):
|
|
404
424
|
raise DeploymentKitError("Exact reviewed SQLite operation authorization phrase is required.")
|
|
425
|
+
if action == "prepare-image" and (prepared_image_id is not None or not public_label_keys):
|
|
426
|
+
raise DeploymentKitError("Image preparation requires explicitly selected public labels and no prior binding.")
|
|
427
|
+
if action != "prepare-image" and public_label_keys:
|
|
428
|
+
raise DeploymentKitError("Public label enumeration belongs only to image preparation.")
|
|
429
|
+
if prepared_image_id is not None:
|
|
430
|
+
_prepared_image_id(prepared_image_id)
|
|
405
431
|
|
|
406
432
|
_require_ready_platform(plan["targetName"])
|
|
407
433
|
current = sqlite_inventory(plan["targetName"])
|
|
@@ -409,15 +435,92 @@ def render_locked_sqlite_operation_stage(
|
|
|
409
435
|
raise DeploymentKitError("SQLite state changed after the plan was reviewed; create and authorize a fresh plan.")
|
|
410
436
|
profile = load_profile(plan["targetName"])
|
|
411
437
|
evidence = verify_access(profile)
|
|
412
|
-
|
|
438
|
+
spec = dict(plan["spec"])
|
|
439
|
+
if prepared_image_id is not None:
|
|
440
|
+
spec["preparedImageId"] = _prepared_image_id(prepared_image_id)
|
|
441
|
+
if public_label_keys:
|
|
442
|
+
spec["publicLabelKeys"] = public_label_keys
|
|
443
|
+
script = build_remote_script(spec)
|
|
413
444
|
delimiter = "PROJECT_FORGE_SQLITE_STAGE_" + hashlib.sha256(script.encode()).hexdigest()[:24]
|
|
414
445
|
if delimiter in script:
|
|
415
446
|
raise DeploymentKitError("Unsafe SQLite script delimiter collision.")
|
|
416
|
-
args = [
|
|
447
|
+
args = [action, plan["expectedReservationSha256"] or "NONE", profile["user"], str(inherited_lock_fd)]
|
|
417
448
|
command = "bash -s -- " + " ".join(shlex.quote(arg) for arg in args)
|
|
418
449
|
return {
|
|
419
450
|
"targetName": plan["targetName"],
|
|
420
451
|
"verifiedAddress": evidence["address"],
|
|
421
|
-
"action":
|
|
452
|
+
"action": action,
|
|
422
453
|
"script": f"{command} <<'{delimiter}'\n{script.rstrip()}\n{delimiter}\n",
|
|
423
454
|
}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def render_locked_sqlite_operation_stage(
|
|
458
|
+
plan: dict[str, Any], *, inherited_lock_fd: int, authorization: str,
|
|
459
|
+
prepared_image_id: str | None = None,
|
|
460
|
+
) -> dict[str, str]:
|
|
461
|
+
"""Render a migration/check stage inside the same privileged lock-owning shell.
|
|
462
|
+
|
|
463
|
+
Opt-in prepared_image_id rechecks the remote digest-to-image-ID mapping and
|
|
464
|
+
runs all maintenance commands by that exact ID, without pulling again.
|
|
465
|
+
Without a binding, legacy pull-by-digest operation behavior remains.
|
|
466
|
+
The consumer must validate preparation label evidence before this stage.
|
|
467
|
+
"""
|
|
468
|
+
return _render_locked_sqlite_stage(
|
|
469
|
+
plan, inherited_lock_fd=inherited_lock_fd, authorization=authorization,
|
|
470
|
+
action=plan.get("action"), prepared_image_id=prepared_image_id,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def render_locked_sqlite_image_preparation_stage(
|
|
475
|
+
plan: dict[str, Any], *, inherited_lock_fd: int, authorization: str,
|
|
476
|
+
public_label_keys: list[str] | tuple[str, ...],
|
|
477
|
+
) -> dict[str, str]:
|
|
478
|
+
"""Render a lock-held remote pull/inspection stage, not a database operation.
|
|
479
|
+
|
|
480
|
+
Pulls through the protected deployment-account registry config and emits
|
|
481
|
+
selected public labels, the requested immutable digest and remote Docker ID.
|
|
482
|
+
This DOES mutate Docker cache, but does not stop the runtime/change SQLite.
|
|
483
|
+
"""
|
|
484
|
+
return _render_locked_sqlite_stage(
|
|
485
|
+
plan, inherited_lock_fd=inherited_lock_fd, authorization=authorization,
|
|
486
|
+
action="prepare-image", public_label_keys=_public_label_keys(public_label_keys),
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def parse_locked_sqlite_image_evidence(
|
|
491
|
+
output: str, *, image: str, public_label_keys: list[str] | tuple[str, ...],
|
|
492
|
+
) -> dict[str, Any]:
|
|
493
|
+
"""Fail closed on incomplete, reordered, extra, or malformed image evidence."""
|
|
494
|
+
exact_image = _image(image)
|
|
495
|
+
keys = _public_label_keys(public_label_keys)
|
|
496
|
+
if not isinstance(output, str):
|
|
497
|
+
raise DeploymentKitError("Malformed image preparation evidence.")
|
|
498
|
+
rows = output.splitlines()
|
|
499
|
+
if len(rows) != len(keys) + 2 or rows[-1] != "IMAGE_PREPARED_END":
|
|
500
|
+
raise DeploymentKitError("Incomplete or malformed image preparation evidence.")
|
|
501
|
+
header = rows[0].split("\t")
|
|
502
|
+
if (len(header) != 3 or header[0] != "IMAGE_PREPARED" or header[1] != exact_image
|
|
503
|
+
or _IMAGE_ID_RE.fullmatch(header[2]) is None):
|
|
504
|
+
raise DeploymentKitError("Image preparation digest or Docker image ID differs from the reviewed image.")
|
|
505
|
+
labels: dict[str, str] = {}
|
|
506
|
+
for key, row in zip(keys, rows[1:-1]):
|
|
507
|
+
parts = row.split("\t")
|
|
508
|
+
if len(parts) != 3 or parts[0] != "LABEL" or parts[1] != key:
|
|
509
|
+
raise DeploymentKitError("Image preparation label evidence is malformed or reordered.")
|
|
510
|
+
try:
|
|
511
|
+
raw = base64.b64decode(parts[2], validate=True)
|
|
512
|
+
value = json.loads(raw.decode("utf-8"))
|
|
513
|
+
except (ValueError, UnicodeError) as error:
|
|
514
|
+
raise DeploymentKitError("Image preparation label evidence is not valid base64 UTF-8.") from error
|
|
515
|
+
if (not isinstance(value, str) or not value or len(value.encode("utf-8")) > 1024
|
|
516
|
+
or any(ord(ch) < 32 or ord(ch) == 127 for ch in value)):
|
|
517
|
+
raise DeploymentKitError("Image preparation label evidence contains missing/unsafe metadata.")
|
|
518
|
+
labels[key] = value
|
|
519
|
+
return {
|
|
520
|
+
"schemaVersion": 1,
|
|
521
|
+
"registryDigest": exact_image,
|
|
522
|
+
"remoteImageId": header[2],
|
|
523
|
+
"labels": labels,
|
|
524
|
+
"capabilityProved": "remote-pulled-digest-bound-image-id",
|
|
525
|
+
"remoteImageCacheWritten": True,
|
|
526
|
+
}
|
|
@@ -15,6 +15,9 @@ OWNER_B64="__OWNER_B64__"
|
|
|
15
15
|
ENV_B64="__ENV_B64__"
|
|
16
16
|
APP_B64="__APP_B64__"
|
|
17
17
|
IMAGE_B64="__IMAGE_B64__"
|
|
18
|
+
PREPARED_IMAGE_ID="__PREPARED_IMAGE_ID__"
|
|
19
|
+
PUBLIC_LABEL_KEYS_B64="__PUBLIC_LABEL_KEYS_B64__"
|
|
20
|
+
EXECUTION_IMAGE=""
|
|
18
21
|
RUN_AS_B64="__RUN_AS_B64__"
|
|
19
22
|
DB_FILE_B64="__DB_FILE_B64__"
|
|
20
23
|
SOURCE_DB_B64="__SOURCE_DB_B64__"
|
|
@@ -33,6 +36,8 @@ meta_value() {
|
|
|
33
36
|
valid_slug() { [[ "$1" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; }
|
|
34
37
|
valid_repo() { [[ "$1" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; }
|
|
35
38
|
valid_image() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$ ]]; }
|
|
39
|
+
valid_image_id() { [[ "$1" =~ ^sha256:[0-9a-f]{64}$ ]]; }
|
|
40
|
+
valid_label_key() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; }
|
|
36
41
|
valid_run_as() { [[ "$1" =~ ^[0-9]+(:[0-9]+)?$ ]]; }
|
|
37
42
|
valid_db_file() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ && "$1" != "." && "$1" != ".." ]]; }
|
|
38
43
|
valid_env_key() { [[ "$1" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; }
|
|
@@ -130,6 +135,9 @@ decode_spec() {
|
|
|
130
135
|
valid_slug "$ENVIRONMENT" || { echo "Invalid SQLite environment." >&2; exit 45; }
|
|
131
136
|
valid_slug "$APP_SLUG" || { echo "Invalid SQLite app slug." >&2; exit 45; }
|
|
132
137
|
valid_image "$IMAGE" || { echo "SQLite maintenance image is not an immutable digest reference." >&2; exit 45; }
|
|
138
|
+
[[ -z "$PREPARED_IMAGE_ID" ]] || valid_image_id "$PREPARED_IMAGE_ID" || {
|
|
139
|
+
echo "Bound SQLite Docker image ID is malformed." >&2; exit 45;
|
|
140
|
+
}
|
|
133
141
|
valid_run_as "$RUN_AS" || { echo "Invalid SQLite runAs." >&2; exit 45; }
|
|
134
142
|
valid_db_file "$DATABASE_FILE" || { echo "Invalid SQLite database file name." >&2; exit 45; }
|
|
135
143
|
[[ "$RESERVATION_SHA" =~ ^[0-9a-f]{64}$ ]] || { echo "Invalid SQLite reservation fingerprint." >&2; exit 45; }
|
|
@@ -192,7 +200,7 @@ run_backup() {
|
|
|
192
200
|
local source_dir="$1" backup_dir="$2" backup_name="$3"
|
|
193
201
|
local -a args
|
|
194
202
|
maintenance_args args
|
|
195
|
-
args+=(--mount "type=bind,src=$source_dir,dst=/data" --mount "type=bind,src=$backup_dir,dst=/backup" "$
|
|
203
|
+
args+=(--mount "type=bind,src=$source_dir,dst=/data" --mount "type=bind,src=$backup_dir,dst=/backup" "$EXECUTION_IMAGE" --backup --backup-path "/backup/$backup_name")
|
|
196
204
|
"${args[@]}"
|
|
197
205
|
}
|
|
198
206
|
|
|
@@ -200,7 +208,7 @@ run_check() {
|
|
|
200
208
|
local data_dir="$1"
|
|
201
209
|
local -a args
|
|
202
210
|
maintenance_args args
|
|
203
|
-
args+=(--mount "type=bind,src=$data_dir,dst=/data" "$
|
|
211
|
+
args+=(--mount "type=bind,src=$data_dir,dst=/data" "$EXECUTION_IMAGE" --check-rollback-compatibility)
|
|
204
212
|
"${args[@]}"
|
|
205
213
|
}
|
|
206
214
|
|
|
@@ -208,7 +216,7 @@ run_migrate() {
|
|
|
208
216
|
local data_dir="$1"
|
|
209
217
|
local -a args
|
|
210
218
|
maintenance_args args
|
|
211
|
-
args+=(--mount "type=bind,src=$data_dir,dst=/data" "$
|
|
219
|
+
args+=(--mount "type=bind,src=$data_dir,dst=/data" "$EXECUTION_IMAGE" --migrate)
|
|
212
220
|
[[ "$FIRST_INSTALL" == true ]] && args+=(--first-install)
|
|
213
221
|
"${args[@]}"
|
|
214
222
|
}
|
|
@@ -266,7 +274,7 @@ acquire_operation_lock() {
|
|
|
266
274
|
# Never convert an untrusted flag into permission to skip the lock.
|
|
267
275
|
# Inherited lock sharing is limited to existing-reservation migration and
|
|
268
276
|
# compatibility verification; other mutations retain their own lock.
|
|
269
|
-
[[ "$ACTION" == migrate || "$ACTION" == rollback-check ]] && [[ "$FIRST_INSTALL" == false ]] || {
|
|
277
|
+
[[ "$ACTION" == migrate || "$ACTION" == rollback-check || "$ACTION" == prepare-image ]] && [[ "$FIRST_INSTALL" == false ]] || {
|
|
270
278
|
echo "An inherited lock is not supported for this SQLite action." >&2; exit 75;
|
|
271
279
|
}
|
|
272
280
|
[[ "$HELD_LOCK_FD" =~ ^[0-9]+$ ]] && (( 10#$HELD_LOCK_FD >= 3 && 10#$HELD_LOCK_FD <= 256 )) || {
|
|
@@ -275,26 +283,80 @@ acquire_operation_lock() {
|
|
|
275
283
|
[[ -f "$LOCK_FILE" && ! -L "$LOCK_FILE" ]] || {
|
|
276
284
|
echo "Inherited SQLite lock path is invalid." >&2; exit 75;
|
|
277
285
|
}
|
|
278
|
-
local inherited_inode expected_inode
|
|
286
|
+
local inherited_inode expected_inode
|
|
279
287
|
inherited_inode="$(stat -Lc '%d:%i' "/proc/self/fd/$HELD_LOCK_FD" 2>/dev/null || true)"
|
|
280
288
|
expected_inode="$(stat -Lc '%d:%i' "$LOCK_FILE" 2>/dev/null || true)"
|
|
281
289
|
[[ -n "$inherited_inode" && "$inherited_inode" == "$expected_inode" ]] || {
|
|
282
290
|
echo "Inherited SQLite lock descriptor does not match this app/environment." >&2; exit 75;
|
|
283
291
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
292
|
+
grep -Eq '^lock:[[:space:]]+[0-9]+:[[:space:]]+FLOCK[[:space:]]+ADVISORY[[:space:]]+WRITE([[:space:]]|$)' \
|
|
293
|
+
"/proc/self/fdinfo/$HELD_LOCK_FD" || {
|
|
294
|
+
echo "Inherited SQLite lock was not held by this descriptor." >&2; exit 75;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
prepare_image() {
|
|
299
|
+
[[ "$ACTION" == prepare-image && -z "$PREPARED_IMAGE_ID" && -n "$PUBLIC_LABEL_KEYS_B64" ]] || {
|
|
300
|
+
echo "Image preparation requires a held lock and public label selection." >&2; exit 49;
|
|
301
|
+
}
|
|
302
|
+
# The deployment account's protected registry config was verified.
|
|
303
|
+
# Suppress Docker stderr, which may contain sensitive registry details.
|
|
304
|
+
docker pull "$IMAGE" >/dev/null 2>&1 || {
|
|
305
|
+
echo "Exact immutable SQLite image pull failed." >&2; exit 49;
|
|
306
|
+
}
|
|
307
|
+
local image_id label_key label_value encoded count=0 label_length
|
|
308
|
+
image_id="$(docker image inspect --format '{{.Id}}' "$IMAGE" 2>/dev/null)" || {
|
|
309
|
+
echo "Pulled SQLite image is not inspectable." >&2; exit 49;
|
|
310
|
+
}
|
|
311
|
+
valid_image_id "$image_id" || {
|
|
312
|
+
echo "Remote SQLite Docker image ID is malformed." >&2; exit 49;
|
|
313
|
+
}
|
|
314
|
+
printf 'IMAGE_PREPARED\t%s\t%s\n' "$IMAGE" "$image_id"
|
|
315
|
+
while IFS= read -r label_key; do
|
|
316
|
+
valid_label_key "$label_key" || {
|
|
317
|
+
echo "Requested public image label name is invalid." >&2; exit 49;
|
|
318
|
+
}
|
|
319
|
+
label_value="$(docker image inspect --format "{{json (index .Config.Labels \"$label_key\")}}" "$image_id" 2>/dev/null)" || {
|
|
320
|
+
echo "Requested public image label could not be read." >&2; exit 49;
|
|
321
|
+
}
|
|
322
|
+
[[ -n "$label_value" && "$label_value" != "null" && "$label_value" != '""' &&
|
|
323
|
+
"$label_value" != *$'\n'* && "$label_value" != *$'\r'* &&
|
|
324
|
+
"$label_value" != *$'\t'* ]] || {
|
|
325
|
+
echo "Requested public image label is missing or unsafe." >&2; exit 49;
|
|
326
|
+
}
|
|
327
|
+
label_length="$(printf '%s' "$label_value" | wc -c)"
|
|
328
|
+
(( label_length <= 4096 )) || {
|
|
329
|
+
echo "Requested public image label exceeds the evidence limit." >&2; exit 49;
|
|
330
|
+
}
|
|
331
|
+
encoded="$(printf '%s' "$label_value" | base64 -w0)"
|
|
332
|
+
printf 'LABEL\t%s\t%s\n' "$label_key" "$encoded"
|
|
333
|
+
count=$((count + 1))
|
|
334
|
+
done < <(printf '%s' "$PUBLIC_LABEL_KEYS_B64" | base64 --decode)
|
|
335
|
+
[[ "$count" -ge 1 && "$count" -le 16 ]] || {
|
|
336
|
+
echo "Public image label selection is invalid." >&2; exit 49;
|
|
337
|
+
}
|
|
338
|
+
printf 'IMAGE_PREPARED_END\n'
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
select_execution_image() {
|
|
342
|
+
if [[ -z "$PREPARED_IMAGE_ID" ]]; then
|
|
343
|
+
docker pull "$IMAGE" >/dev/null
|
|
344
|
+
EXECUTION_IMAGE="$IMAGE"
|
|
345
|
+
return 0
|
|
291
346
|
fi
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
347
|
+
[[ -n "$HELD_LOCK_FD" && "$FIRST_INSTALL" == false ]] &&
|
|
348
|
+
[[ "$ACTION" == migrate || "$ACTION" == rollback-check ]] || {
|
|
349
|
+
echo "Bound SQLite execution requires a supported inherited lock." >&2; exit 49;
|
|
350
|
+
}
|
|
351
|
+
local resolved
|
|
352
|
+
resolved="$(docker image inspect --format '{{.Id}}' "$IMAGE" 2>/dev/null)" || {
|
|
353
|
+
echo "Prepared digest reference is no longer inspectable." >&2; exit 49;
|
|
297
354
|
}
|
|
355
|
+
[[ "$resolved" == "$PREPARED_IMAGE_ID" ]] &&
|
|
356
|
+
[[ "$(docker image inspect --format '{{.Id}}' "$PREPARED_IMAGE_ID" 2>/dev/null)" == "$PREPARED_IMAGE_ID" ]] || {
|
|
357
|
+
echo "Prepared digest-to-image-ID binding changed; SQLite execution is blocked." >&2; exit 49;
|
|
358
|
+
}
|
|
359
|
+
EXECUTION_IMAGE="$PREPARED_IMAGE_ID"
|
|
298
360
|
}
|
|
299
361
|
|
|
300
362
|
operate() {
|
|
@@ -306,6 +368,16 @@ operate() {
|
|
|
306
368
|
local db="$DATA/$DATABASE_FILE" stamp backup_name source_real source_parent
|
|
307
369
|
managed_runtime_info "$ROOT"
|
|
308
370
|
|
|
371
|
+
if [[ "$ACTION" == prepare-image ]]; then
|
|
372
|
+
assert_marker "$MARKER" "$EXPECTED_RESERVATION_SHA"
|
|
373
|
+
verify_reserved_dirs
|
|
374
|
+
[[ -f "$db" && ! -L "$db" ]] || {
|
|
375
|
+
echo "Image preparation requires reserved SQLite storage." >&2; exit 49;
|
|
376
|
+
}
|
|
377
|
+
prepare_image
|
|
378
|
+
return 0
|
|
379
|
+
fi
|
|
380
|
+
|
|
309
381
|
case "$ACTION" in
|
|
310
382
|
import)
|
|
311
383
|
[[ "$EXPECTED_RESERVATION_SHA" == "NONE" && ! -e "$MARKER" ]] || { echo "SQLite import destination was reserved after planning." >&2; exit 47; }
|
|
@@ -316,7 +388,7 @@ operate() {
|
|
|
316
388
|
[[ "$source_real" == "$SOURCE_DATABASE" && -f "$SOURCE_DATABASE" && ! -L "$SOURCE_DATABASE" ]] || { echo "SQLite import source must be an existing non-symlink regular file with no symlinked path components." >&2; exit 49; }
|
|
317
389
|
[[ "$(basename "$SOURCE_DATABASE")" == "$DATABASE_FILE" ]] || { echo "SQLite import source file name mismatch." >&2; exit 49; }
|
|
318
390
|
create_reserved_dirs
|
|
319
|
-
|
|
391
|
+
select_execution_image
|
|
320
392
|
source_parent="$(dirname "$SOURCE_DATABASE")"
|
|
321
393
|
run_backup "$source_parent" "$DATA" "$DATABASE_FILE"
|
|
322
394
|
run_check "$DATA"
|
|
@@ -328,7 +400,7 @@ operate() {
|
|
|
328
400
|
assert_marker "$MARKER" "$EXPECTED_RESERVATION_SHA"
|
|
329
401
|
verify_reserved_dirs
|
|
330
402
|
[[ -f "$db" && ! -L "$db" ]] || { echo "SQLite backup requires the reserved database file." >&2; exit 49; }
|
|
331
|
-
|
|
403
|
+
select_execution_image
|
|
332
404
|
stamp="$(date -u +'%Y%m%dT%H%M%SZ')"
|
|
333
405
|
backup_name="backup-$stamp-$DATABASE_FILE"
|
|
334
406
|
run_backup "$DATA" "$BACKUPS" "$backup_name"
|
|
@@ -340,7 +412,7 @@ operate() {
|
|
|
340
412
|
[[ "$EXPECTED_RESERVATION_SHA" == "NONE" && ! -e "$MARKER" ]] || { echo "First-install SQLite destination was reserved after planning." >&2; exit 47; }
|
|
341
413
|
[[ ! -e "$ROOT/deployment.env" && ! -e "$db" ]] || { echo "First-install SQLite migration requires no runtime state or database." >&2; exit 49; }
|
|
342
414
|
create_reserved_dirs
|
|
343
|
-
|
|
415
|
+
select_execution_image
|
|
344
416
|
run_migrate "$DATA"
|
|
345
417
|
run_check "$DATA"
|
|
346
418
|
write_marker "$MARKER"
|
|
@@ -350,7 +422,7 @@ operate() {
|
|
|
350
422
|
assert_marker "$MARKER" "$EXPECTED_RESERVATION_SHA"
|
|
351
423
|
verify_reserved_dirs
|
|
352
424
|
[[ -f "$db" && ! -L "$db" ]] || { echo "SQLite migration requires the reserved database file." >&2; exit 49; }
|
|
353
|
-
|
|
425
|
+
select_execution_image
|
|
354
426
|
stamp="$(date -u +'%Y%m%dT%H%M%SZ')"
|
|
355
427
|
backup_name="pre-migrate-$stamp-$DATABASE_FILE"
|
|
356
428
|
run_backup "$DATA" "$BACKUPS" "$backup_name"
|
|
@@ -369,7 +441,7 @@ operate() {
|
|
|
369
441
|
assert_marker "$MARKER" "$EXPECTED_RESERVATION_SHA"
|
|
370
442
|
verify_reserved_dirs
|
|
371
443
|
[[ -f "$db" && ! -L "$db" ]] || { echo "SQLite rollback check requires the reserved database file." >&2; exit 49; }
|
|
372
|
-
|
|
444
|
+
select_execution_image
|
|
373
445
|
run_check "$DATA"
|
|
374
446
|
record_evidence "$ROOT" rollback-check compatible ""
|
|
375
447
|
printf 'SQLite rollback compatibility check passed for image %s.\n' "$IMAGE"
|
|
@@ -380,7 +452,7 @@ operate() {
|
|
|
380
452
|
|
|
381
453
|
case "$ACTION" in
|
|
382
454
|
inventory) inventory ;;
|
|
383
|
-
import|backup|migrate|rollback-check) operate ;;
|
|
455
|
+
import|backup|migrate|rollback-check|prepare-image) operate ;;
|
|
384
456
|
*) echo "Unsupported SQLite adapter action: $ACTION" >&2; exit 2 ;;
|
|
385
457
|
esac
|
|
386
458
|
'''
|