@mriekki/project-forge 4.11.2 → 4.13.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.
- 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 +38 -18
- 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 +8 -0
- package/assets/generated/core/core/process/HUMAN_GATES.md +63 -0
- package/assets/generated/core/core/process/INTERACTIVE_SESSION_GUIDE.md +28 -1
- 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/export-requirements/SKILL.md +20 -1
- package/assets/generated/core/core/skills/refine-requirements/SKILL.md +25 -2
- package/assets/generated/core/core/skills/replatform/SKILL.md +24 -1
- package/assets/generated/core/core/skills/run/SKILL.md +39 -13
- package/assets/generated/core/core/skills/start-work/SKILL.md +120 -18
- package/assets/generated/core/tools/vscode_work_item_status/extension/README.md +20 -2
- package/assets/generated/core/tools/vscode_work_item_status/extension/extension.js +136 -1
- package/assets/generated/core/tools/vscode_work_item_status/extension/package.json +11 -5
- 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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Recovery Readiness Contract V1
|
|
2
|
+
|
|
3
|
+
Project Forge distinguishes a verified database backup from verified application recovery. A successful SQLite backup proves only the database artifact created by the application's SQLite maintenance contract; it does not prove that every dependency required to recover authentication or the full application is available or usable.
|
|
4
|
+
|
|
5
|
+
## Recovery states
|
|
6
|
+
|
|
7
|
+
Reusable evidence must keep these concepts separate:
|
|
8
|
+
|
|
9
|
+
1. `databaseBackupVerified` — the SQLite database backup operation completed successfully.
|
|
10
|
+
2. `verifiedBackupScope` — currently `database` for SQLite backup evidence.
|
|
11
|
+
3. `recoveryScopeDeclared` — the consumer supplied a strict, non-secret recovery declaration.
|
|
12
|
+
4. `unresolvedRequiredDependencies` — required non-database dependencies that still need recovery-time verification.
|
|
13
|
+
5. `authenticationPolicyVerified` — the selected authentication recovery policy has been dynamically verified after restore.
|
|
14
|
+
6. `applicationRecoveryReady` — all required recovery dependencies and the selected policy have been verified for the restored application.
|
|
15
|
+
|
|
16
|
+
Current SQLite backup/import/migration commands never set `applicationRecoveryReady` to true. Deployment Kit does not yet provide a general restore engine.
|
|
17
|
+
|
|
18
|
+
## Recovery manifest
|
|
19
|
+
|
|
20
|
+
`sqlite backup` and existing-database `sqlite migrate` may receive `--recovery-manifest <path>` pointing to UTF-8 JSON with exactly this schema:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"schemaVersion": 1,
|
|
25
|
+
"authenticationPolicy": "invalidate-authentication",
|
|
26
|
+
"dependencies": [
|
|
27
|
+
{
|
|
28
|
+
"id": "data-protection-key-ring",
|
|
29
|
+
"type": "cryptographic-key-ring",
|
|
30
|
+
"required": true
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`authenticationPolicy` may be `preserve-authentication`, `invalidate-authentication`, or `null`. Dependency IDs/types are non-secret identifiers only. Unknown fields fail closed. Do not put paths, keys, credentials, cookies, tokens, passwords, action links, provider secrets, or secret values in the manifest.
|
|
37
|
+
|
|
38
|
+
The normalized manifest is fingerprinted deterministically and the privacy-safe declaration/readiness summary is included in SQLite evidence. Secret contents are neither archived nor printed by this contract. First-install migration, import, interrupted-import recovery, and rollback checking do not accept a recovery manifest in V1.
|
|
39
|
+
|
|
40
|
+
## Authentication recovery policies
|
|
41
|
+
|
|
42
|
+
`preserve-authentication` means a future recovery workflow must prove that required cryptographic/protector dependencies remain usable and that retaining historical authentication state is acceptable.
|
|
43
|
+
|
|
44
|
+
`invalidate-authentication` means a future recovery workflow must prove that sessions and action links from historical state cannot authenticate or mutate accounts after recovery. Merely replacing a key ring is not sufficient evidence because password hashes, security stamps, sessions, token expiry/consumption, and similar state may also exist in the restored database.
|
|
45
|
+
|
|
46
|
+
Neither policy is considered verified by a database backup alone.
|
|
47
|
+
|
|
48
|
+
## Pre-traffic invariant
|
|
49
|
+
|
|
50
|
+
A future restore operation must fail closed and keep the restored application unavailable to normal traffic until all required recovery dependencies and the selected authentication policy have been verified. Restore completion and database integrity must never be treated as equivalent to application recovery readiness.
|
|
51
|
+
|
|
52
|
+
## Requirements
|
|
53
|
+
|
|
54
|
+
- **PF-REC-001** — Database backup verification must never imply whole-application recovery readiness.
|
|
55
|
+
- **PF-REC-002** — Consumers may declare non-database recovery dependencies without exposing secret values.
|
|
56
|
+
- **PF-REC-003** — Required dependencies must be verified before application recovery readiness can pass.
|
|
57
|
+
- **PF-REC-004** — Authentication-bearing consumers must select an explicit post-restore authentication policy when they want application recovery readiness.
|
|
58
|
+
- **PF-REC-005** — Historical restore must remain unavailable to normal traffic until that policy is verified.
|
|
59
|
+
- **PF-REC-006** — Incomplete or failed recovery evidence fails closed.
|
|
60
|
+
- **PF-REC-007** — Existing SQLite-only consumers retain current database backup behavior and database-only success semantics.
|
|
61
|
+
- **PF-REC-008** — Read-only planning/readiness inspection performs no restore, key manipulation, revocation, rotation, or other destructive action.
|
|
62
|
+
- **PF-REC-009** — Dynamic recovery validation uses disposable fixtures and never logs secret material.
|
|
63
|
+
|
|
64
|
+
## Non-scope
|
|
65
|
+
|
|
66
|
+
This contract does not implement generic secret archival, provider-specific secret storage, key transport, key rotation, session/token mutation, or a general database/application restore workflow. Those require separate requirements, plan approval, implementation review, and release/adoption gates.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Reusable recovery-scope declarations and non-secret readiness evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .errors import DeploymentKitError
|
|
11
|
+
|
|
12
|
+
RECOVERY_SCHEMA_VERSION = 1
|
|
13
|
+
AUTHENTICATION_POLICIES = {"preserve-authentication", "invalidate-authentication"}
|
|
14
|
+
_ID_RE = re.compile(r"^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$")
|
|
15
|
+
_ALLOWED_TOP = {"schemaVersion", "authenticationPolicy", "dependencies"}
|
|
16
|
+
_ALLOWED_DEPENDENCY = {"id", "type", "required"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _identifier(value: Any, field: str) -> str:
|
|
20
|
+
if not isinstance(value, str) or _ID_RE.fullmatch(value) is None:
|
|
21
|
+
raise DeploymentKitError(f"{field} must use lowercase letters, digits, dots, underscores, and internal hyphens.")
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalize_recovery_scope(value: dict[str, Any] | None) -> dict[str, Any]:
|
|
26
|
+
"""Normalize a strict, non-secret recovery declaration.
|
|
27
|
+
|
|
28
|
+
The declaration deliberately has no free-form values, paths, credentials, or
|
|
29
|
+
provider configuration. It describes recovery dependencies without storing
|
|
30
|
+
their contents or teaching generic persistence tooling how to archive them.
|
|
31
|
+
"""
|
|
32
|
+
if value is None:
|
|
33
|
+
return {
|
|
34
|
+
"schemaVersion": RECOVERY_SCHEMA_VERSION,
|
|
35
|
+
"declared": False,
|
|
36
|
+
"authenticationPolicy": None,
|
|
37
|
+
"dependencies": [],
|
|
38
|
+
"manifestSha256": None,
|
|
39
|
+
}
|
|
40
|
+
if not isinstance(value, dict) or set(value) != _ALLOWED_TOP:
|
|
41
|
+
raise DeploymentKitError(
|
|
42
|
+
"Recovery manifest must contain exactly schemaVersion, authenticationPolicy, and dependencies."
|
|
43
|
+
)
|
|
44
|
+
if value["schemaVersion"] != RECOVERY_SCHEMA_VERSION:
|
|
45
|
+
raise DeploymentKitError(f"Recovery manifest schemaVersion must be {RECOVERY_SCHEMA_VERSION}.")
|
|
46
|
+
policy = value["authenticationPolicy"]
|
|
47
|
+
if policy is not None and policy not in AUTHENTICATION_POLICIES:
|
|
48
|
+
raise DeploymentKitError(
|
|
49
|
+
"authenticationPolicy must be preserve-authentication, invalidate-authentication, or null."
|
|
50
|
+
)
|
|
51
|
+
dependencies = value["dependencies"]
|
|
52
|
+
if not isinstance(dependencies, list):
|
|
53
|
+
raise DeploymentKitError("Recovery manifest dependencies must be an array.")
|
|
54
|
+
normalized: list[dict[str, Any]] = []
|
|
55
|
+
seen: set[str] = set()
|
|
56
|
+
for dependency in dependencies:
|
|
57
|
+
if not isinstance(dependency, dict) or set(dependency) != _ALLOWED_DEPENDENCY:
|
|
58
|
+
raise DeploymentKitError("Each recovery dependency must contain exactly id, type, and required.")
|
|
59
|
+
dependency_id = _identifier(dependency["id"], "Recovery dependency id")
|
|
60
|
+
dependency_type = _identifier(dependency["type"], "Recovery dependency type")
|
|
61
|
+
required = dependency["required"]
|
|
62
|
+
if not isinstance(required, bool):
|
|
63
|
+
raise DeploymentKitError("Recovery dependency required must be a boolean.")
|
|
64
|
+
if dependency_id in seen:
|
|
65
|
+
raise DeploymentKitError(f"Duplicate recovery dependency id: {dependency_id}")
|
|
66
|
+
seen.add(dependency_id)
|
|
67
|
+
normalized.append({"id": dependency_id, "type": dependency_type, "required": required})
|
|
68
|
+
normalized.sort(key=lambda item: item["id"])
|
|
69
|
+
canonical = {
|
|
70
|
+
"schemaVersion": RECOVERY_SCHEMA_VERSION,
|
|
71
|
+
"authenticationPolicy": policy,
|
|
72
|
+
"dependencies": normalized,
|
|
73
|
+
}
|
|
74
|
+
fingerprint = hashlib.sha256(
|
|
75
|
+
json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
76
|
+
).hexdigest()
|
|
77
|
+
return {
|
|
78
|
+
**canonical,
|
|
79
|
+
"declared": True,
|
|
80
|
+
"manifestSha256": fingerprint,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def public_recovery_scope(scope: dict[str, Any]) -> dict[str, Any]:
|
|
85
|
+
return {
|
|
86
|
+
"schemaVersion": scope["schemaVersion"],
|
|
87
|
+
"declared": scope["declared"],
|
|
88
|
+
"authenticationPolicy": scope["authenticationPolicy"],
|
|
89
|
+
"dependencies": [dict(item) for item in scope["dependencies"]],
|
|
90
|
+
"manifestSha256": scope["manifestSha256"],
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def recovery_readiness(scope: dict[str, Any], *, database_backup_verified: bool) -> dict[str, Any]:
|
|
95
|
+
unresolved = [item["id"] for item in scope["dependencies"] if item["required"]]
|
|
96
|
+
return {
|
|
97
|
+
"schemaVersion": RECOVERY_SCHEMA_VERSION,
|
|
98
|
+
"databaseBackupVerified": bool(database_backup_verified),
|
|
99
|
+
"verifiedBackupScope": "database" if database_backup_verified else None,
|
|
100
|
+
"recoveryScopeDeclared": scope["declared"],
|
|
101
|
+
"authenticationPolicy": scope["authenticationPolicy"],
|
|
102
|
+
"authenticationPolicyVerified": False if scope["authenticationPolicy"] is not None else None,
|
|
103
|
+
"unresolvedRequiredDependencies": unresolved,
|
|
104
|
+
"manifestSha256": scope["manifestSha256"],
|
|
105
|
+
"applicationRecoveryReady": False,
|
|
106
|
+
"readinessReason": "restore-policy-not-verified" if scope["declared"] else "recovery-scope-not-declared",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def recovery_evidence(scope: dict[str, Any], *, database_backup_verified: bool) -> dict[str, Any]:
|
|
111
|
+
"""Return the privacy-safe evidence payload persisted with a SQLite operation."""
|
|
112
|
+
readiness = recovery_readiness(scope, database_backup_verified=database_backup_verified)
|
|
113
|
+
return {
|
|
114
|
+
**readiness,
|
|
115
|
+
"dependencies": [dict(item) for item in scope["dependencies"]],
|
|
116
|
+
}
|
|
@@ -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)
|