@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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.17.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": "f5f82c531da03f113209a795930db8444338c69e",
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.17.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": "f5f82c531da03f113209a795930db8444338c69e",
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": "606d6a4793ac8c8a95da4cd4c61792c69f02ad86900be1944d2ed570dd71e73e"
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": "5f7c6fdd2e84d1f239ceae605018b3c7b8a911aac442241b000333ffadf1fdeb"
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": "f86defc9a0841deee40198c7acc3b25a093e514000f0bd08030cbb253f3fa6ee"
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": "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": "32c319453add977de4e051ca9cadbffaa411adb26713c4a8731553d0d2d4adaf"
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
- exec 9>>"$lock_file"
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 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 )) || {
@@ -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 probe_fd
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
- # 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
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
- 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;
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
- docker pull "$IMAGE" >/dev/null
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
- docker pull "$IMAGE" >/dev/null
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
- docker pull "$IMAGE" >/dev/null
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
- docker pull "$IMAGE" >/dev/null
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
- docker pull "$IMAGE" >/dev/null
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
  '''
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.17.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": {