@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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.18.0",
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": "31985f3e24a3832391cfe873fbb3eab27d0fa9d4",
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.18.0",
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": "31985f3e24a3832391cfe873fbb3eab27d0fa9d4",
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": "781219312cb81ffb901dff8b8d04d0cf0511b5d37e86f9ad9024a73cba04d3b9"
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": "d60239f9b75cf688531319284f0123488f4f0ae812ac9982ce611320d8754793"
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 render_locked_sqlite_operation_stage(
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
- script = build_remote_script(plan["spec"])
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 = [plan["action"], plan["expectedReservationSha256"] or "NONE", profile["user"], str(inherited_lock_fd)]
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": plan["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" "$IMAGE" --backup --backup-path "/backup/$backup_name")
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" "$IMAGE" --check-rollback-compatibility)
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" "$IMAGE" --migrate)
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
- docker pull "$IMAGE" >/dev/null
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
- docker pull "$IMAGE" >/dev/null
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
- docker pull "$IMAGE" >/dev/null
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
- docker pull "$IMAGE" >/dev/null
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
- docker pull "$IMAGE" >/dev/null
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
  '''
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.18.0",
3
+ "version": "4.19.0",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {