@mriekki/project-forge 4.12.0 → 4.13.1
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/README.md +2 -0
- package/assets/components.json +3 -3
- package/assets/core/distribution.json +2 -0
- package/assets/deployment/distribution.json +2 -0
- package/assets/generated/ASSEMBLY.json +35 -15
- package/assets/generated/core/core/instructions/project-forge-local-development.instructions.md +28 -0
- package/assets/generated/core/core/instructions/project-forge-workflow.instructions.md +7 -5
- package/assets/generated/core/core/process/HUMAN_GATES.md +21 -4
- package/assets/generated/core/core/process/INTERACTIVE_SESSION_GUIDE.md +15 -6
- package/assets/generated/core/core/process/LOCAL_DEVELOPMENT_COMMANDS.md +103 -0
- package/assets/generated/core/core/skills/build/SKILL.md +10 -5
- package/assets/generated/core/core/skills/run/SKILL.md +39 -13
- package/assets/generated/core/core/skills/start-work/SKILL.md +15 -6
- package/assets/generated/core/tools/vscode_work_item_status/extension/README.md +8 -4
- package/assets/generated/core/tools/vscode_work_item_status/extension/extension.js +79 -21
- package/assets/generated/core/tools/vscode_work_item_status/extension/package.json +26 -2
- package/assets/generated/deployment/docs/RECOVERY_READINESS.md +66 -0
- package/assets/generated/deployment/src/project_forge_deployment/recovery_scope.py +116 -0
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter.py +27 -2
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter_remote.py +9 -6
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_cli.py +23 -3
- package/package.json +1 -1
|
@@ -14,6 +14,7 @@ from .app_runtime import APP_ROOT
|
|
|
14
14
|
from .box_platform import platform_status
|
|
15
15
|
from .errors import DeploymentKitError
|
|
16
16
|
from .profiles import load_profile
|
|
17
|
+
from .recovery_scope import normalize_recovery_scope, public_recovery_scope, recovery_evidence, recovery_readiness
|
|
17
18
|
from .sqlite_adapter_remote import REMOTE_SCRIPT
|
|
18
19
|
from .ssh import ssh_args, verify_access
|
|
19
20
|
|
|
@@ -111,6 +112,7 @@ def normalize_sqlite_request(
|
|
|
111
112
|
maintenance_environment: dict[str, str] | Iterable[tuple[str, str]],
|
|
112
113
|
run_as: str | None = None, database_file: str | None = None,
|
|
113
114
|
source_database: str | None = None, first_install: bool = False,
|
|
115
|
+
recovery_scope: dict[str, Any] | None = None,
|
|
114
116
|
) -> dict[str, Any]:
|
|
115
117
|
request = {
|
|
116
118
|
"ownerRepo": _owner_repo(owner_repo),
|
|
@@ -122,6 +124,7 @@ def normalize_sqlite_request(
|
|
|
122
124
|
"runAs": _run_as(run_as) if run_as is not None else None,
|
|
123
125
|
"databaseFile": _database_file(database_file) if database_file is not None else None,
|
|
124
126
|
"sourceDatabase": _source_database(source_database) if source_database is not None else None,
|
|
127
|
+
"recoveryScope": normalize_recovery_scope(recovery_scope),
|
|
125
128
|
}
|
|
126
129
|
if request["sourceDatabase"] is not None:
|
|
127
130
|
if request["runAs"] is None or request["databaseFile"] is None:
|
|
@@ -140,6 +143,7 @@ def _b64(value: str) -> str:
|
|
|
140
143
|
def build_remote_script(spec: dict[str, Any] | None = None) -> str:
|
|
141
144
|
spec = spec or {}
|
|
142
145
|
env = spec.get("maintenanceEnvironment", {})
|
|
146
|
+
recovery_payload = json.dumps(spec.get("recoveryEvidence", {}), sort_keys=True, separators=(",", ":"))
|
|
143
147
|
replacements = {
|
|
144
148
|
"__APP_ROOT__": APP_ROOT,
|
|
145
149
|
"__SQLITE_SCHEMA_VERSION__": str(SQLITE_SCHEMA_VERSION),
|
|
@@ -154,6 +158,7 @@ def build_remote_script(spec: dict[str, Any] | None = None) -> str:
|
|
|
154
158
|
"__RESERVATION_SHA__": spec.get("reservationSha256", ""),
|
|
155
159
|
"__ENV_KEYS__": " ".join(f"'{_b64(k)}'" for k in env),
|
|
156
160
|
"__ENV_VALUES__": " ".join(f"'{_b64(v)}'" for v in env.values()),
|
|
161
|
+
"__RECOVERY_EVIDENCE_B64__": _b64(recovery_payload),
|
|
157
162
|
}
|
|
158
163
|
script = REMOTE_SCRIPT
|
|
159
164
|
for placeholder, value in replacements.items():
|
|
@@ -291,6 +296,12 @@ def plan_sqlite_operation(
|
|
|
291
296
|
spec.update(runAs=existing["runAs"], databaseFile=existing["databaseFile"], reservationSha256=existing["reservationSha256"])
|
|
292
297
|
if action in {"backup", "migrate", "rollback-check"} and not existing["databasePresent"]:
|
|
293
298
|
raise DeploymentKitError(f"SQLite {action} requires an existing database.")
|
|
299
|
+
creates_verified_backup = bool(action == "backup" or (action == "migrate" and not request["firstInstall"]))
|
|
300
|
+
recovery_scope = request["recoveryScope"]
|
|
301
|
+
spec["recoveryEvidence"] = recovery_evidence(
|
|
302
|
+
recovery_scope,
|
|
303
|
+
database_backup_verified=creates_verified_backup,
|
|
304
|
+
)
|
|
294
305
|
return {
|
|
295
306
|
"targetName": name, "action": action, "ownerRepo": request["ownerRepo"],
|
|
296
307
|
"environment": request["environment"], "productionClassified": production_classified,
|
|
@@ -299,7 +310,10 @@ def plan_sqlite_operation(
|
|
|
299
310
|
"sourceDatabase": request["sourceDatabase"] if action == "import" else None,
|
|
300
311
|
"firstInstall": bool(request["firstInstall"] if action == "migrate" else False),
|
|
301
312
|
"stopsManagedRuntime": bool(action == "migrate" and existing and existing["managedRuntimeRunning"]),
|
|
302
|
-
"createsVerifiedBackup":
|
|
313
|
+
"createsVerifiedBackup": creates_verified_backup,
|
|
314
|
+
"verifiedBackupScope": "database" if creates_verified_backup else None,
|
|
315
|
+
"recoveryScope": public_recovery_scope(recovery_scope),
|
|
316
|
+
"applicationRecoveryReadyAfterOperation": False,
|
|
303
317
|
"inventorySnapshot": _snapshot(inventory), "expectedReservationSha256": expected_sha, "spec": spec,
|
|
304
318
|
}
|
|
305
319
|
|
|
@@ -319,11 +333,18 @@ def authorization_phrase(plan: dict[str, Any]) -> str:
|
|
|
319
333
|
|
|
320
334
|
|
|
321
335
|
def apply_sqlite_operation(plan: dict[str, Any]) -> dict[str, Any]:
|
|
322
|
-
required = {
|
|
336
|
+
required = {
|
|
337
|
+
"targetName", "action", "ownerRepo", "environment", "productionClassified", "appSlug", "image",
|
|
338
|
+
"databaseFile", "runAs", "sourceDatabase", "firstInstall", "stopsManagedRuntime", "createsVerifiedBackup",
|
|
339
|
+
"verifiedBackupScope", "recoveryScope", "applicationRecoveryReadyAfterOperation", "inventorySnapshot",
|
|
340
|
+
"expectedReservationSha256", "spec",
|
|
341
|
+
}
|
|
323
342
|
if not isinstance(plan, dict) or set(plan) != required or not isinstance(plan["productionClassified"], bool):
|
|
324
343
|
raise DeploymentKitError("Malformed SQLite operation plan.")
|
|
325
344
|
if plan["environment"] == "production" and not plan["productionClassified"]:
|
|
326
345
|
raise DeploymentKitError("Literal production environment must remain Production-classified.")
|
|
346
|
+
if plan["applicationRecoveryReadyAfterOperation"] is not False:
|
|
347
|
+
raise DeploymentKitError("SQLite operations cannot claim application recovery readiness.")
|
|
327
348
|
_require_ready_platform(plan["targetName"])
|
|
328
349
|
current = sqlite_inventory(plan["targetName"])
|
|
329
350
|
if _snapshot(current) != plan["inventorySnapshot"]:
|
|
@@ -334,6 +355,10 @@ def apply_sqlite_operation(plan: dict[str, Any]) -> dict[str, Any]:
|
|
|
334
355
|
result["mutated"] = True
|
|
335
356
|
result["operation"] = plan["action"]; result["operationOutput"] = output.strip()
|
|
336
357
|
result["productionClassified"] = plan["productionClassified"]
|
|
358
|
+
result["recoveryReadiness"] = recovery_readiness(
|
|
359
|
+
plan["spec"]["recoveryScope"],
|
|
360
|
+
database_backup_verified=plan["createsVerifiedBackup"],
|
|
361
|
+
)
|
|
337
362
|
return result
|
|
338
363
|
|
|
339
364
|
|
|
@@ -19,6 +19,7 @@ FIRST_INSTALL="__FIRST_INSTALL__"
|
|
|
19
19
|
RESERVATION_SHA="__RESERVATION_SHA__"
|
|
20
20
|
ENV_KEYS=(__ENV_KEYS__)
|
|
21
21
|
ENV_VALUES=(__ENV_VALUES__)
|
|
22
|
+
RECOVERY_EVIDENCE_B64="__RECOVERY_EVIDENCE_B64__"
|
|
22
23
|
|
|
23
24
|
b64d() { printf '%s' "$1" | base64 --decode; }
|
|
24
25
|
meta_value() {
|
|
@@ -121,6 +122,7 @@ decode_spec() {
|
|
|
121
122
|
RUN_AS="$(b64d "$RUN_AS_B64")"
|
|
122
123
|
DATABASE_FILE="$(b64d "$DB_FILE_B64")"
|
|
123
124
|
SOURCE_DATABASE="$(b64d "$SOURCE_DB_B64")"
|
|
125
|
+
RECOVERY_EVIDENCE_JSON="$(b64d "$RECOVERY_EVIDENCE_B64")"
|
|
124
126
|
valid_repo "$OWNER" || { echo "Invalid SQLite owner repository." >&2; exit 45; }
|
|
125
127
|
valid_slug "$ENVIRONMENT" || { echo "Invalid SQLite environment." >&2; exit 45; }
|
|
126
128
|
valid_slug "$APP_SLUG" || { echo "Invalid SQLite app slug." >&2; exit 45; }
|
|
@@ -129,6 +131,7 @@ decode_spec() {
|
|
|
129
131
|
valid_db_file "$DATABASE_FILE" || { echo "Invalid SQLite database file name." >&2; exit 45; }
|
|
130
132
|
[[ "$RESERVATION_SHA" =~ ^[0-9a-f]{64}$ ]] || { echo "Invalid SQLite reservation fingerprint." >&2; exit 45; }
|
|
131
133
|
[[ "${#ENV_KEYS[@]}" -eq "${#ENV_VALUES[@]}" && "${#ENV_KEYS[@]}" -gt 0 ]] || { echo "Invalid SQLite maintenance environment payload." >&2; exit 45; }
|
|
134
|
+
[[ "$RECOVERY_EVIDENCE_JSON" == \{*\} ]] || { echo "Invalid SQLite recovery evidence payload." >&2; exit 45; }
|
|
132
135
|
local index key
|
|
133
136
|
for index in "${!ENV_KEYS[@]}"; do
|
|
134
137
|
key="$(b64d "${ENV_KEYS[$index]}")"
|
|
@@ -210,8 +213,8 @@ run_migrate() {
|
|
|
210
213
|
record_evidence() {
|
|
211
214
|
local root="$1" action="$2" outcome="$3" artifact="${4:-}" timestamp
|
|
212
215
|
timestamp="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
|
213
|
-
printf '{"timestamp":"%s","action":"%s","ownerRepo":"%s","environment":"%s","appSlug":"%s","image":"%s","databaseFile":"%s","reservationSha256":"%s","artifact":"%s","outcome":"%s"}\n' \
|
|
214
|
-
"$timestamp" "$action" "$OWNER" "$ENVIRONMENT" "$APP_SLUG" "$IMAGE" "$DATABASE_FILE" "$RESERVATION_SHA" "$artifact" "$outcome" >> "$root/sqlite-evidence.jsonl"
|
|
216
|
+
printf '{"timestamp":"%s","action":"%s","ownerRepo":"%s","environment":"%s","appSlug":"%s","image":"%s","databaseFile":"%s","reservationSha256":"%s","artifact":"%s","outcome":"%s","recovery":%s}\n' \
|
|
217
|
+
"$timestamp" "$action" "$OWNER" "$ENVIRONMENT" "$APP_SLUG" "$IMAGE" "$DATABASE_FILE" "$RESERVATION_SHA" "$artifact" "$outcome" "$RECOVERY_EVIDENCE_JSON" >> "$root/sqlite-evidence.jsonl"
|
|
215
218
|
chmod 0600 "$root/sqlite-evidence.jsonl"
|
|
216
219
|
}
|
|
217
220
|
|
|
@@ -287,7 +290,7 @@ operate() {
|
|
|
287
290
|
backup_name="backup-$stamp-$DATABASE_FILE"
|
|
288
291
|
run_backup "$DATA" "$BACKUPS" "$backup_name"
|
|
289
292
|
record_evidence "$ROOT" backup backed-up "$backup_name"
|
|
290
|
-
printf 'SQLite verified backup created: %s\n' "$BACKUPS/$backup_name"
|
|
293
|
+
printf 'SQLite verified database backup created: %s\n' "$BACKUPS/$backup_name"
|
|
291
294
|
;;
|
|
292
295
|
migrate)
|
|
293
296
|
if [[ "$FIRST_INSTALL" == true ]]; then
|
|
@@ -311,12 +314,12 @@ operate() {
|
|
|
311
314
|
if [[ "$MANAGED_RUNNING" == true ]]; then docker stop "$MANAGED_CONTAINER" >/dev/null; fi
|
|
312
315
|
if ! run_migrate "$DATA"; then
|
|
313
316
|
record_evidence "$ROOT" migrate failed "$backup_name"
|
|
314
|
-
echo "SQLite migration failed; verified pre-migration backup is retained and managed runtime remains stopped." >&2
|
|
317
|
+
echo "SQLite migration failed; verified pre-migration database backup is retained and managed runtime remains stopped." >&2
|
|
315
318
|
exit 50
|
|
316
319
|
fi
|
|
317
320
|
run_check "$DATA"
|
|
318
321
|
record_evidence "$ROOT" migrate migrated "$backup_name"
|
|
319
|
-
printf 'SQLite migration completed; verified pre-migration backup: %s\n' "$BACKUPS/$backup_name"
|
|
322
|
+
printf 'SQLite migration completed; verified pre-migration database backup: %s\n' "$BACKUPS/$backup_name"
|
|
320
323
|
fi
|
|
321
324
|
;;
|
|
322
325
|
rollback-check)
|
|
@@ -337,4 +340,4 @@ case "$ACTION" in
|
|
|
337
340
|
import|backup|migrate|rollback-check) operate ;;
|
|
338
341
|
*) echo "Unsupported SQLite adapter action: $ACTION" >&2; exit 2 ;;
|
|
339
342
|
esac
|
|
340
|
-
'''
|
|
343
|
+
'''
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""CLI for the SQLite persistence adapter."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
import argparse, json, sys
|
|
4
|
+
from pathlib import Path
|
|
4
5
|
from typing import Any
|
|
5
6
|
from .errors import DeploymentKitError
|
|
6
7
|
from .sqlite_adapter import authorization_phrase, apply_sqlite_operation, normalize_sqlite_request, plan_sqlite_operation, sqlite_inventory
|
|
@@ -28,6 +29,18 @@ def _parse_env(items: list[str]) -> list[tuple[str,str]]:
|
|
|
28
29
|
return out
|
|
29
30
|
|
|
30
31
|
|
|
32
|
+
def _load_recovery_manifest(path: str | None) -> dict[str, Any] | None:
|
|
33
|
+
if path is None:
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
value = json.loads(Path(path).read_text(encoding='utf-8'))
|
|
37
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
38
|
+
raise DeploymentKitError(f"Could not read recovery manifest as UTF-8 JSON: {error}") from error
|
|
39
|
+
if not isinstance(value, dict):
|
|
40
|
+
raise DeploymentKitError("Recovery manifest root must be a JSON object.")
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
31
44
|
def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
|
|
32
45
|
if args.yes: return
|
|
33
46
|
if input(f"Type {phrase} to {action}: ").strip() != phrase:
|
|
@@ -50,6 +63,10 @@ def _common(parser: argparse.ArgumentParser) -> None:
|
|
|
50
63
|
parser.add_argument('--yes', action='store_true')
|
|
51
64
|
|
|
52
65
|
|
|
66
|
+
def _recovery_manifest_arg(parser: argparse.ArgumentParser) -> None:
|
|
67
|
+
parser.add_argument('--recovery-manifest', help='Optional strict non-secret JSON declaration of recovery dependencies and authentication policy')
|
|
68
|
+
|
|
69
|
+
|
|
53
70
|
def _import_args(parser: argparse.ArgumentParser) -> None:
|
|
54
71
|
parser.add_argument('--run-as', required=True)
|
|
55
72
|
parser.add_argument('--database-file', required=True)
|
|
@@ -62,8 +79,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
62
79
|
inv=sub.add_parser('inventory',help='Read SQLite persistence reservations without writes'); inv.add_argument('name'); inv.add_argument('--json',action='store_true')
|
|
63
80
|
imp=sub.add_parser('import',help='Import an existing SQLite DB via the exact image online-backup contract'); _common(imp); _import_args(imp)
|
|
64
81
|
recover=sub.add_parser('recover-import',help='Recover a verified failed import without overwriting or recopying the database'); _common(recover); _import_args(recover)
|
|
65
|
-
backup=sub.add_parser('backup',help='Create a verified SQLite backup with the exact image'); _common(backup)
|
|
66
|
-
migrate=sub.add_parser('migrate',help='Create required backup and run exact-image SQLite migration'); _common(migrate)
|
|
82
|
+
backup=sub.add_parser('backup',help='Create a verified SQLite database backup with the exact image'); _common(backup); _recovery_manifest_arg(backup)
|
|
83
|
+
migrate=sub.add_parser('migrate',help='Create required database backup and run exact-image SQLite migration'); _common(migrate); _recovery_manifest_arg(migrate)
|
|
67
84
|
migrate.add_argument('--first-install',action='store_true'); migrate.add_argument('--run-as'); migrate.add_argument('--database-file')
|
|
68
85
|
check=sub.add_parser('rollback-check',help='Verify that an exact rollback image accepts the current SQLite state'); _common(check)
|
|
69
86
|
return parser
|
|
@@ -73,10 +90,13 @@ def main(argv: list[str]|None=None) -> int:
|
|
|
73
90
|
args=build_parser().parse_args(argv)
|
|
74
91
|
try:
|
|
75
92
|
if args.command=='inventory': _print(sqlite_inventory(args.name),as_json=args.json); return 0
|
|
93
|
+
if args.command=='migrate' and getattr(args,'first_install',False) and getattr(args,'recovery_manifest',None):
|
|
94
|
+
raise DeploymentKitError("--recovery-manifest is supported only for existing-database SQLite migration, not --first-install.")
|
|
76
95
|
production_classified = args.production or args.environment == 'production'
|
|
77
96
|
request=normalize_sqlite_request(owner_repo=args.owner_repo,environment=args.environment,app_slug=args.app_slug,image=args.image,
|
|
78
97
|
maintenance_environment=_parse_env(args.maintenance_env),run_as=getattr(args,'run_as',None),database_file=getattr(args,'database_file',None),
|
|
79
|
-
source_database=getattr(args,'source_database',None),first_install=getattr(args,'first_install',False)
|
|
98
|
+
source_database=getattr(args,'source_database',None),first_install=getattr(args,'first_install',False),
|
|
99
|
+
recovery_scope=_load_recovery_manifest(getattr(args,'recovery_manifest',None)))
|
|
80
100
|
if args.command=='recover-import':
|
|
81
101
|
plan=plan_sqlite_import_recovery(args.name,request,production=production_classified)
|
|
82
102
|
_print({k:v for k,v in plan.items() if k!='request'},as_json=False)
|