@mriekki/project-forge 4.18.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
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
|
{
|
|
@@ -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",
|
|
@@ -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 )) || {
|
|
@@ -287,6 +295,70 @@ acquire_operation_lock() {
|
|
|
287
295
|
}
|
|
288
296
|
}
|
|
289
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
|
|
346
|
+
fi
|
|
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;
|
|
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"
|
|
360
|
+
}
|
|
361
|
+
|
|
290
362
|
operate() {
|
|
291
363
|
decode_spec
|
|
292
364
|
configure_registry_auth
|
|
@@ -296,6 +368,16 @@ operate() {
|
|
|
296
368
|
local db="$DATA/$DATABASE_FILE" stamp backup_name source_real source_parent
|
|
297
369
|
managed_runtime_info "$ROOT"
|
|
298
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
|
+
|
|
299
381
|
case "$ACTION" in
|
|
300
382
|
import)
|
|
301
383
|
[[ "$EXPECTED_RESERVATION_SHA" == "NONE" && ! -e "$MARKER" ]] || { echo "SQLite import destination was reserved after planning." >&2; exit 47; }
|
|
@@ -306,7 +388,7 @@ operate() {
|
|
|
306
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; }
|
|
307
389
|
[[ "$(basename "$SOURCE_DATABASE")" == "$DATABASE_FILE" ]] || { echo "SQLite import source file name mismatch." >&2; exit 49; }
|
|
308
390
|
create_reserved_dirs
|
|
309
|
-
|
|
391
|
+
select_execution_image
|
|
310
392
|
source_parent="$(dirname "$SOURCE_DATABASE")"
|
|
311
393
|
run_backup "$source_parent" "$DATA" "$DATABASE_FILE"
|
|
312
394
|
run_check "$DATA"
|
|
@@ -318,7 +400,7 @@ operate() {
|
|
|
318
400
|
assert_marker "$MARKER" "$EXPECTED_RESERVATION_SHA"
|
|
319
401
|
verify_reserved_dirs
|
|
320
402
|
[[ -f "$db" && ! -L "$db" ]] || { echo "SQLite backup requires the reserved database file." >&2; exit 49; }
|
|
321
|
-
|
|
403
|
+
select_execution_image
|
|
322
404
|
stamp="$(date -u +'%Y%m%dT%H%M%SZ')"
|
|
323
405
|
backup_name="backup-$stamp-$DATABASE_FILE"
|
|
324
406
|
run_backup "$DATA" "$BACKUPS" "$backup_name"
|
|
@@ -330,7 +412,7 @@ operate() {
|
|
|
330
412
|
[[ "$EXPECTED_RESERVATION_SHA" == "NONE" && ! -e "$MARKER" ]] || { echo "First-install SQLite destination was reserved after planning." >&2; exit 47; }
|
|
331
413
|
[[ ! -e "$ROOT/deployment.env" && ! -e "$db" ]] || { echo "First-install SQLite migration requires no runtime state or database." >&2; exit 49; }
|
|
332
414
|
create_reserved_dirs
|
|
333
|
-
|
|
415
|
+
select_execution_image
|
|
334
416
|
run_migrate "$DATA"
|
|
335
417
|
run_check "$DATA"
|
|
336
418
|
write_marker "$MARKER"
|
|
@@ -340,7 +422,7 @@ operate() {
|
|
|
340
422
|
assert_marker "$MARKER" "$EXPECTED_RESERVATION_SHA"
|
|
341
423
|
verify_reserved_dirs
|
|
342
424
|
[[ -f "$db" && ! -L "$db" ]] || { echo "SQLite migration requires the reserved database file." >&2; exit 49; }
|
|
343
|
-
|
|
425
|
+
select_execution_image
|
|
344
426
|
stamp="$(date -u +'%Y%m%dT%H%M%SZ')"
|
|
345
427
|
backup_name="pre-migrate-$stamp-$DATABASE_FILE"
|
|
346
428
|
run_backup "$DATA" "$BACKUPS" "$backup_name"
|
|
@@ -359,7 +441,7 @@ operate() {
|
|
|
359
441
|
assert_marker "$MARKER" "$EXPECTED_RESERVATION_SHA"
|
|
360
442
|
verify_reserved_dirs
|
|
361
443
|
[[ -f "$db" && ! -L "$db" ]] || { echo "SQLite rollback check requires the reserved database file." >&2; exit 49; }
|
|
362
|
-
|
|
444
|
+
select_execution_image
|
|
363
445
|
run_check "$DATA"
|
|
364
446
|
record_evidence "$ROOT" rollback-check compatible ""
|
|
365
447
|
printf 'SQLite rollback compatibility check passed for image %s.\n' "$IMAGE"
|
|
@@ -370,7 +452,7 @@ operate() {
|
|
|
370
452
|
|
|
371
453
|
case "$ACTION" in
|
|
372
454
|
inventory) inventory ;;
|
|
373
|
-
import|backup|migrate|rollback-check) operate ;;
|
|
455
|
+
import|backup|migrate|rollback-check|prepare-image) operate ;;
|
|
374
456
|
*) echo "Unsupported SQLite adapter action: $ACTION" >&2; exit 2 ;;
|
|
375
457
|
esac
|
|
376
458
|
'''
|