@mriekki/project-forge 4.17.0 → 4.18.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.18.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": "31985f3e24a3832391cfe873fbb3eab27d0fa9d4",
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.18.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": "31985f3e24a3832391cfe873fbb3eab27d0fa9d4",
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",
@@ -602,7 +602,7 @@
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": "d60239f9b75cf688531319284f0123488f4f0ae812ac9982ce611320d8754793"
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; }
@@ -275,25 +275,15 @@ acquire_operation_lock() {
275
275
  [[ -f "$LOCK_FILE" && ! -L "$LOCK_FILE" ]] || {
276
276
  echo "Inherited SQLite lock path is invalid." >&2; exit 75;
277
277
  }
278
- local inherited_inode expected_inode probe_fd
278
+ local inherited_inode expected_inode
279
279
  inherited_inode="$(stat -Lc '%d:%i' "/proc/self/fd/$HELD_LOCK_FD" 2>/dev/null || true)"
280
280
  expected_inode="$(stat -Lc '%d:%i' "$LOCK_FILE" 2>/dev/null || true)"
281
281
  [[ -n "$inherited_inode" && "$inherited_inode" == "$expected_inode" ]] || {
282
282
  echo "Inherited SQLite lock descriptor does not match this app/environment." >&2; exit 75;
283
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;
284
+ grep -Eq '^lock:[[:space:]]+[0-9]+:[[:space:]]+FLOCK[[:space:]]+ADVISORY[[:space:]]+WRITE([[:space:]]|$)' \
285
+ "/proc/self/fdinfo/$HELD_LOCK_FD" || {
286
+ echo "Inherited SQLite lock was not held by this descriptor." >&2; exit 75;
297
287
  }
298
288
  }
299
289
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.17.0",
3
+ "version": "4.18.0",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {