@mriekki/project-forge 4.16.1 → 4.17.0

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