@mriekki/lean 0.1.0 → 0.2.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.
Files changed (41) hide show
  1. package/README.md +71 -67
  2. package/assets/components.json +33 -33
  3. package/assets/core/distribution.json +52 -51
  4. package/assets/deployment/distribution.json +34 -34
  5. package/assets/generated/ASSEMBLY.json +38 -33
  6. package/assets/generated/core/core/skills/core-sync/SKILL.md +78 -120
  7. package/assets/generated/core/core/skills/deploy/SKILL.md +74 -91
  8. package/assets/generated/core/core/skills/lean-update/SKILL.md +189 -0
  9. package/assets/generated/core/core/skills/upstream-fix/SKILL.md +91 -76
  10. package/assets/generated/deployment/distribution/lean-poc-deploy.py +8 -8
  11. package/assets/generated/deployment/src/lean_poc_deployment/__init__.py +3 -3
  12. package/assets/generated/deployment/src/lean_poc_deployment/__main__.py +3 -3
  13. package/assets/generated/deployment/src/lean_poc_deployment/app_cli.py +140 -140
  14. package/assets/generated/deployment/src/lean_poc_deployment/app_runtime.py +455 -455
  15. package/assets/generated/deployment/src/lean_poc_deployment/app_runtime_remote.py +415 -415
  16. package/assets/generated/deployment/src/lean_poc_deployment/box_platform.py +208 -208
  17. package/assets/generated/deployment/src/lean_poc_deployment/box_platform_assets.py +100 -100
  18. package/assets/generated/deployment/src/lean_poc_deployment/box_platform_remote.py +271 -271
  19. package/assets/generated/deployment/src/lean_poc_deployment/cli.py +437 -437
  20. package/assets/generated/deployment/src/lean_poc_deployment/doctor.py +121 -121
  21. package/assets/generated/deployment/src/lean_poc_deployment/entrypoint.py +25 -25
  22. package/assets/generated/deployment/src/lean_poc_deployment/errors.py +5 -5
  23. package/assets/generated/deployment/src/lean_poc_deployment/keys.py +171 -171
  24. package/assets/generated/deployment/src/lean_poc_deployment/profiles.py +204 -204
  25. package/assets/generated/deployment/src/lean_poc_deployment/route_registry.py +367 -367
  26. package/assets/generated/deployment/src/lean_poc_deployment/route_registry_remote.py +443 -443
  27. package/assets/generated/deployment/src/lean_poc_deployment/security.py +290 -290
  28. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter.py +334 -334
  29. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter_remote.py +339 -339
  30. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_cli.py +85 -85
  31. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_import_recovery.py +437 -437
  32. package/assets/generated/deployment/src/lean_poc_deployment/ssh.py +118 -118
  33. package/assets/generated/deployment/src/lean_poc_deployment/teardown.py +286 -286
  34. package/assets/generated/deployment/src/lean_poc_deployment/teardown_cli.py +120 -120
  35. package/assets/generated/deployment/src/lean_poc_deployment/teardown_remote.py +100 -100
  36. package/assets/generated/deployment/src/lean_poc_deployment/trust.py +278 -278
  37. package/bin/lean.mjs +9 -9
  38. package/package.json +33 -33
  39. package/src/cli.mjs +131 -131
  40. package/src/engine.mjs +243 -243
  41. package/src/package-data.mjs +18 -18
@@ -1,437 +1,437 @@
1
- """Recover a verified SQLite import that failed before reservation metadata was written."""
2
-
3
- from __future__ import annotations
4
-
5
- import base64
6
- import hashlib
7
- import json
8
- import re
9
- import subprocess
10
- from typing import Any
11
-
12
- from .app_runtime import APP_ROOT
13
- from .box_platform import platform_status
14
- from .errors import DeploymentKitError
15
- from .profiles import load_profile
16
- from .sqlite_adapter import reservation_sha, sqlite_inventory
17
- from .ssh import ssh_args, verify_access
18
-
19
- SQLITE_IMPORT_RECOVERY_WRITE_PHRASE = "[SQLITE IMPORT RECOVERY WRITE]"
20
- PRODUCTION_SQLITE_IMPORT_RECOVERY_WRITE_PHRASE = "[PRODUCTION SQLITE IMPORT RECOVERY]"
21
- _SHA_RE = re.compile(r"^[0-9a-f]{64}$")
22
-
23
-
24
- def _b64(value: str) -> str:
25
- return base64.b64encode(value.encode("utf-8")).decode("ascii")
26
-
27
-
28
- def _require_request(request: dict[str, Any]) -> None:
29
- for key in ("ownerRepo", "environment", "appSlug", "image", "maintenanceEnvironment", "runAs", "databaseFile", "sourceDatabase"):
30
- if request.get(key) in (None, ""):
31
- raise DeploymentKitError(f"SQLite import recovery requires {key}.")
32
-
33
-
34
- def _require_ready_platform(name: str) -> None:
35
- state = platform_status(name)["state"]
36
- if state != "ready":
37
- raise DeploymentKitError(
38
- f"SQLite import recovery requires a ready shared platform; target '{name}' is '{state}'."
39
- )
40
-
41
-
42
- REMOTE_SCRIPT = r'''#!/usr/bin/env bash
43
- set -Eeuo pipefail
44
-
45
- ACTION="$1"
46
- EXPECTED_DB_SHA="${2:-}"
47
- EXPECTED_SOURCE_SHA="${3:-}"
48
- DEPLOY_USER="${4:-}"
49
- APP_ROOT="__APP_ROOT__"
50
- OWNER="$(printf '%s' '__OWNER_B64__' | base64 --decode)"
51
- ENVIRONMENT="$(printf '%s' '__ENV_B64__' | base64 --decode)"
52
- APP_SLUG="$(printf '%s' '__APP_B64__' | base64 --decode)"
53
- IMAGE="$(printf '%s' '__IMAGE_B64__' | base64 --decode)"
54
- RUN_AS="$(printf '%s' '__RUN_AS_B64__' | base64 --decode)"
55
- DATABASE_FILE="$(printf '%s' '__DB_FILE_B64__' | base64 --decode)"
56
- SOURCE_DATABASE="$(printf '%s' '__SOURCE_DB_B64__' | base64 --decode)"
57
- DATABASE_FILE_B64="__DB_FILE_B64__"
58
- RESERVATION_SHA="__RESERVATION_SHA__"
59
- ENV_KEYS=(__ENV_KEYS__)
60
- ENV_VALUES=(__ENV_VALUES__)
61
-
62
- valid_slug() { [[ "$1" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; }
63
- valid_repo() { [[ "$1" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; }
64
- valid_image() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$ ]]; }
65
- valid_run_as() { [[ "$1" =~ ^[0-9]+(:[0-9]+)?$ ]]; }
66
- valid_db_file() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ && "$1" != "." && "$1" != ".." ]]; }
67
- valid_env_key() { [[ "$1" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; }
68
- b64d() { printf '%s' "$1" | base64 --decode; }
69
-
70
- validate_spec() {
71
- valid_repo "$OWNER" || { echo "Invalid recovery owner repository." >&2; exit 61; }
72
- valid_slug "$ENVIRONMENT" || { echo "Invalid recovery environment." >&2; exit 61; }
73
- valid_slug "$APP_SLUG" || { echo "Invalid recovery app slug." >&2; exit 61; }
74
- valid_image "$IMAGE" || { echo "Recovery image must be an immutable digest reference." >&2; exit 61; }
75
- valid_run_as "$RUN_AS" || { echo "Invalid recovery runAs." >&2; exit 61; }
76
- valid_db_file "$DATABASE_FILE" || { echo "Invalid recovery database file." >&2; exit 61; }
77
- [[ "$RESERVATION_SHA" =~ ^[0-9a-f]{64}$ ]] || { echo "Invalid recovery reservation fingerprint." >&2; exit 61; }
78
- [[ "$SOURCE_DATABASE" == /srv/* && "$SOURCE_DATABASE" != "$APP_ROOT"/* ]] || {
79
- echo "Recovery source must be outside the Kit app root under /srv." >&2; exit 61;
80
- }
81
- [[ "${#ENV_KEYS[@]}" -eq "${#ENV_VALUES[@]}" && "${#ENV_KEYS[@]}" -gt 0 ]] || {
82
- echo "Invalid recovery maintenance environment payload." >&2; exit 61;
83
- }
84
- local index key
85
- for index in "${!ENV_KEYS[@]}"; do
86
- key="$(b64d "${ENV_KEYS[$index]}")"
87
- valid_env_key "$key" || { echo "Invalid recovery maintenance environment key." >&2; exit 61; }
88
- done
89
- }
90
-
91
- configure_registry_auth() {
92
- [[ -n "$DEPLOY_USER" && "$DEPLOY_USER" =~ ^[A-Za-z_][A-Za-z0-9_.-]{0,31}$ ]] || {
93
- echo "Deployment account is invalid." >&2; exit 62;
94
- }
95
- local home config dir_mode file_mode deploy_uid
96
- home="$(getent passwd "$DEPLOY_USER" | cut -d: -f6)"
97
- [[ -n "$home" && "$home" == /* ]] || { echo "Could not resolve deployment account home." >&2; exit 62; }
98
- config="$home/.docker"
99
- if [[ ! -e "$config/config.json" ]]; then return 0; fi
100
- [[ -d "$config" && ! -L "$config" && -f "$config/config.json" && ! -L "$config/config.json" ]] || {
101
- echo "Docker credential path is not a regular protected directory/file." >&2; exit 62;
102
- }
103
- dir_mode="$(stat -c '%a' "$config")"
104
- file_mode="$(stat -c '%a' "$config/config.json")"
105
- deploy_uid="$(id -u "$DEPLOY_USER")"
106
- if (( (8#$dir_mode & 077) != 0 || (8#$file_mode & 077) != 0 )) ||
107
- [[ "$(stat -c '%u' "$config")" != "$deploy_uid" || "$(stat -c '%u' "$config/config.json")" != "$deploy_uid" ]]; then
108
- echo "Docker credentials must be owned by the deployment account and deny group/other access." >&2
109
- exit 62
110
- fi
111
- export DOCKER_CONFIG="$config"
112
- }
113
-
114
- set_paths() {
115
- ROOT="$APP_ROOT/$APP_SLUG/$ENVIRONMENT"
116
- DATA="$ROOT/data"
117
- BACKUPS="$ROOT/backups"
118
- DB="$DATA/$DATABASE_FILE"
119
- MARKER="$ROOT/persistence.env"
120
- DEPLOYMENT="$ROOT/deployment.env"
121
- EVIDENCE="$ROOT/sqlite-evidence.jsonl"
122
- LOCK_FILE="/run/lock/lean-poc-app-$APP_SLUG--$ENVIRONMENT.lock"
123
- }
124
-
125
- expected_ids() {
126
- EXPECTED_UID="${RUN_AS%%:*}"
127
- [[ "$RUN_AS" == *:* ]] && EXPECTED_GID="${RUN_AS#*:}" || EXPECTED_GID="$EXPECTED_UID"
128
- }
129
-
130
- inspect_orphan() {
131
- local source_real root_entries data_entries db_mode db_sha source_sha bytes
132
- expected_ids
133
-
134
- [[ -d "$ROOT" && ! -L "$ROOT" && "$(realpath -e "$ROOT")" == "$ROOT" ]] || {
135
- echo "Recovery root is missing, invalid, or contains a symlinked path component." >&2; exit 63;
136
- }
137
- [[ "$(stat -c '%u' "$ROOT")" == "0" && "$(stat -c '%g' "$ROOT")" == "0" && "$(stat -c '%a' "$ROOT")" == "750" ]] || {
138
- echo "Recovery root ownership or mode does not match the failed-import footprint." >&2; exit 63;
139
- }
140
- for path in "$DATA" "$BACKUPS"; do
141
- [[ -d "$path" && ! -L "$path" && "$(realpath -e "$path")" == "$path" ]] || {
142
- echo "Recovery data/backup directory is missing or invalid: $path" >&2; exit 63;
143
- }
144
- [[ "$(stat -c '%u' "$path")" == "$EXPECTED_UID" && "$(stat -c '%g' "$path")" == "$EXPECTED_GID" && "$(stat -c '%a' "$path")" == "750" ]] || {
145
- echo "Recovery data/backup ownership or mode does not match runAs." >&2; exit 63;
146
- }
147
- done
148
- [[ -f "$DB" && ! -L "$DB" && "$(realpath -e "$DB")" == "$DB" ]] || {
149
- echo "Recovery database must be an existing non-symlink regular file." >&2; exit 63;
150
- }
151
- [[ "$(stat -c '%u' "$DB")" == "$EXPECTED_UID" && "$(stat -c '%g' "$DB")" == "$EXPECTED_GID" ]] || {
152
- echo "Recovery database ownership does not match runAs." >&2; exit 63;
153
- }
154
- db_mode="$(stat -c '%a' "$DB")"
155
- (( (8#$db_mode & 077) == 0 )) || { echo "Recovery database grants group/other permissions." >&2; exit 63; }
156
-
157
- [[ ! -e "$MARKER" && ! -e "$DEPLOYMENT" && ! -e "$EVIDENCE" ]] || {
158
- echo "Recovery requires no reservation, deployment state, or prior SQLite evidence." >&2; exit 63;
159
- }
160
- root_entries="$(find "$ROOT" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)"
161
- [[ "$root_entries" == $'backups\ndata' ]] || {
162
- echo "Recovery root contains unexpected entries." >&2; exit 63;
163
- }
164
- data_entries="$(find "$DATA" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)"
165
- [[ "$data_entries" == "$DATABASE_FILE" ]] || {
166
- echo "Recovery data directory contains unexpected entries." >&2; exit 63;
167
- }
168
- [[ -z "$(find "$BACKUPS" -mindepth 1 -maxdepth 1 -print -quit)" ]] || {
169
- echo "Recovery backup directory must still be empty." >&2; exit 63;
170
- }
171
-
172
- source_real="$(realpath -e "$SOURCE_DATABASE" 2>/dev/null || true)"
173
- [[ "$source_real" == "$SOURCE_DATABASE" && -f "$SOURCE_DATABASE" && ! -L "$SOURCE_DATABASE" ]] || {
174
- echo "Recovery source must remain an existing non-symlink regular file with no symlinked path components." >&2; exit 63;
175
- }
176
- [[ "$(basename "$SOURCE_DATABASE")" == "$DATABASE_FILE" ]] || {
177
- echo "Recovery source database file name mismatch." >&2; exit 63;
178
- }
179
-
180
- db_sha="$(sha256sum "$DB" | awk '{print $1}')"
181
- source_sha="$(sha256sum "$SOURCE_DATABASE" | awk '{print $1}')"
182
- bytes="$(stat -c '%s' "$DB")"
183
- [[ "$db_sha" =~ ^[0-9a-f]{64}$ && "$source_sha" =~ ^[0-9a-f]{64}$ && "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]] || {
184
- echo "Recovery database fingerprint is invalid." >&2; exit 63;
185
- }
186
- printf 'RECOVERY\t%s\t%s\t%s\n' "$db_sha" "$source_sha" "$bytes"
187
- }
188
-
189
- maintenance_args() {
190
- local -n out_ref=$1
191
- local index key value
192
- out_ref=(docker run --rm --network none --user "$RUN_AS")
193
- for index in "${!ENV_KEYS[@]}"; do
194
- key="$(b64d "${ENV_KEYS[$index]}")"
195
- value="$(b64d "${ENV_VALUES[$index]}")"
196
- out_ref+=(--env "$key=$value")
197
- done
198
- }
199
-
200
- run_check() {
201
- local -a args
202
- maintenance_args args
203
- args+=(--mount "type=bind,src=$DATA,dst=/data" "$IMAGE" --check-rollback-compatibility)
204
- "${args[@]}"
205
- }
206
-
207
- write_marker() {
208
- local tmp
209
- tmp="$MARKER.tmp.$$"
210
- {
211
- printf 'SCHEMA_VERSION=1\n'
212
- printf 'ADAPTER=sqlite\n'
213
- printf 'OWNER_REPO=%s\n' "$OWNER"
214
- printf 'ENVIRONMENT=%s\n' "$ENVIRONMENT"
215
- printf 'APP_SLUG=%s\n' "$APP_SLUG"
216
- printf 'RUN_AS=%s\n' "$RUN_AS"
217
- printf 'DATABASE_FILE_B64=%s\n' "$DATABASE_FILE_B64"
218
- printf 'RESERVATION_SHA256=%s\n' "$RESERVATION_SHA"
219
- } > "$tmp"
220
- chmod 0600 "$tmp"
221
- mv -f "$tmp" "$MARKER"
222
- }
223
-
224
- record_evidence() {
225
- local timestamp
226
- timestamp="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
227
- printf '{"timestamp":"%s","action":"import-recovery","ownerRepo":"%s","environment":"%s","appSlug":"%s","image":"%s","databaseFile":"%s","reservationSha256":"%s","artifact":"%s","outcome":"recovered"}\n' \
228
- "$timestamp" "$OWNER" "$ENVIRONMENT" "$APP_SLUG" "$IMAGE" "$DATABASE_FILE" "$RESERVATION_SHA" "$SOURCE_DATABASE" >> "$EVIDENCE"
229
- chmod 0600 "$EVIDENCE"
230
- }
231
-
232
- recover() {
233
- local inspection kind db_sha source_sha bytes
234
- exec 9>>"$LOCK_FILE"
235
- flock -n 9 || { echo "Another app/persistence operation holds the recovery lock." >&2; exit 75; }
236
- inspection="$(inspect_orphan)"
237
- IFS=$'\t' read -r kind db_sha source_sha bytes <<< "$inspection"
238
- [[ "$kind" == "RECOVERY" && "$db_sha" == "$EXPECTED_DB_SHA" && "$source_sha" == "$EXPECTED_SOURCE_SHA" ]] || {
239
- echo "Recovery state changed after planning; create and authorize a fresh recovery plan." >&2; exit 64;
240
- }
241
- configure_registry_auth
242
- docker pull "$IMAGE" >/dev/null
243
- run_check
244
- write_marker
245
- record_evidence
246
- printf 'SQLite import recovery completed and verified: %s\n' "$DB"
247
- }
248
-
249
- validate_spec
250
- set_paths
251
- case "$ACTION" in
252
- inspect) inspect_orphan ;;
253
- recover) recover ;;
254
- *) echo "Unsupported SQLite import recovery action: $ACTION" >&2; exit 2 ;;
255
- esac
256
- '''
257
-
258
-
259
- def _build_remote_script(request: dict[str, Any]) -> str:
260
- _require_request(request)
261
- reservation = reservation_sha(
262
- owner_repo=request["ownerRepo"],
263
- environment=request["environment"],
264
- app_slug=request["appSlug"],
265
- run_as=request["runAs"],
266
- database_file=request["databaseFile"],
267
- )
268
- env = request["maintenanceEnvironment"]
269
- replacements = {
270
- "__APP_ROOT__": APP_ROOT,
271
- "__OWNER_B64__": _b64(request["ownerRepo"]),
272
- "__ENV_B64__": _b64(request["environment"]),
273
- "__APP_B64__": _b64(request["appSlug"]),
274
- "__IMAGE_B64__": _b64(request["image"]),
275
- "__RUN_AS_B64__": _b64(request["runAs"]),
276
- "__DB_FILE_B64__": _b64(request["databaseFile"]),
277
- "__SOURCE_DB_B64__": _b64(request["sourceDatabase"]),
278
- "__RESERVATION_SHA__": reservation,
279
- "__ENV_KEYS__": " ".join(f"'{_b64(key)}'" for key in env),
280
- "__ENV_VALUES__": " ".join(f"'{_b64(value)}'" for value in env.values()),
281
- }
282
- script = REMOTE_SCRIPT
283
- for placeholder, value in replacements.items():
284
- script = script.replace(placeholder, value)
285
- if "__" in script:
286
- raise DeploymentKitError("Internal SQLite import recovery script contains an unresolved placeholder.")
287
- return script
288
-
289
-
290
- def _run_remote(
291
- profile: dict[str, Any],
292
- address: str,
293
- action: str,
294
- request: dict[str, Any],
295
- *,
296
- expected_db_sha: str = "",
297
- expected_source_sha: str = "",
298
- ) -> str:
299
- if action not in {"inspect", "recover"}:
300
- raise DeploymentKitError(f"Unsupported SQLite import recovery action: {action}")
301
- args = [action]
302
- if action == "recover":
303
- args.extend([expected_db_sha, expected_source_sha, profile["user"]])
304
- completed = subprocess.run(
305
- ssh_args(profile, address, command="sudo -n bash -s -- " + " ".join(args)),
306
- input=_build_remote_script(request).encode("utf-8"),
307
- check=False,
308
- capture_output=True,
309
- )
310
- stdout = completed.stdout.decode("utf-8", errors="replace")
311
- stderr = completed.stderr.decode("utf-8", errors="replace")
312
- if completed.returncode != 0:
313
- detail = stderr.strip() or stdout.strip() or f"exit {completed.returncode}"
314
- raise DeploymentKitError(f"Remote SQLite import recovery {action} failed: {detail}")
315
- return stdout
316
-
317
-
318
- def _parse_inspection(output: str) -> dict[str, Any]:
319
- lines = [line for line in output.splitlines() if line]
320
- if len(lines) != 1:
321
- raise DeploymentKitError("Malformed SQLite import recovery inspection output.")
322
- parts = lines[0].split("\t")
323
- if len(parts) != 4 or parts[0] != "RECOVERY":
324
- raise DeploymentKitError("Malformed SQLite import recovery inspection record.")
325
- if _SHA_RE.fullmatch(parts[1]) is None or _SHA_RE.fullmatch(parts[2]) is None:
326
- raise DeploymentKitError("Malformed SQLite import recovery fingerprint.")
327
- try:
328
- database_bytes = int(parts[3])
329
- except ValueError as error:
330
- raise DeploymentKitError("Malformed SQLite import recovery database size.") from error
331
- if database_bytes <= 0:
332
- raise DeploymentKitError("SQLite import recovery database must be non-empty.")
333
- return {
334
- "databaseSha256": parts[1],
335
- "sourceSha256": parts[2],
336
- "databaseBytes": database_bytes,
337
- }
338
-
339
-
340
- def inspect_sqlite_import_recovery(name: str, request: dict[str, Any]) -> dict[str, Any]:
341
- _require_ready_platform(name)
342
- _require_request(request)
343
- profile = load_profile(name)
344
- evidence = verify_access(profile)
345
- result = _parse_inspection(_run_remote(profile, evidence["address"], "inspect", request))
346
- result.update(targetName=name, verifiedAddress=evidence["address"], mutated=False)
347
- return result
348
-
349
-
350
- def _snapshot(inspection: dict[str, Any]) -> str:
351
- value = {
352
- "databaseSha256": inspection["databaseSha256"],
353
- "sourceSha256": inspection["sourceSha256"],
354
- "databaseBytes": inspection["databaseBytes"],
355
- }
356
- return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
357
-
358
-
359
- def plan_sqlite_import_recovery(name: str, request: dict[str, Any]) -> dict[str, Any]:
360
- inspection = inspect_sqlite_import_recovery(name, request)
361
- return {
362
- "targetName": name,
363
- "action": "recover-import",
364
- "ownerRepo": request["ownerRepo"],
365
- "environment": request["environment"],
366
- "appSlug": request["appSlug"],
367
- "image": request["image"],
368
- "runAs": request["runAs"],
369
- "databaseFile": request["databaseFile"],
370
- "sourceDatabase": request["sourceDatabase"],
371
- "databaseSha256": inspection["databaseSha256"],
372
- "sourceSha256": inspection["sourceSha256"],
373
- "databaseBytes": inspection["databaseBytes"],
374
- "stateSnapshot": _snapshot(inspection),
375
- "overwritesDatabase": False,
376
- "deletesPersistentData": False,
377
- "createsReservation": True,
378
- "reverifiesDatabase": True,
379
- "request": request,
380
- }
381
-
382
-
383
- def authorization_phrase(plan: dict[str, Any]) -> str:
384
- return (
385
- PRODUCTION_SQLITE_IMPORT_RECOVERY_WRITE_PHRASE
386
- if plan["environment"] == "production"
387
- else SQLITE_IMPORT_RECOVERY_WRITE_PHRASE
388
- )
389
-
390
-
391
- def apply_sqlite_import_recovery(plan: dict[str, Any]) -> dict[str, Any]:
392
- required = {
393
- "targetName", "action", "ownerRepo", "environment", "appSlug", "image", "runAs",
394
- "databaseFile", "sourceDatabase", "databaseSha256", "sourceSha256", "databaseBytes",
395
- "stateSnapshot", "overwritesDatabase", "deletesPersistentData", "createsReservation",
396
- "reverifiesDatabase", "request",
397
- }
398
- if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "recover-import":
399
- raise DeploymentKitError("Malformed SQLite import recovery plan.")
400
- current = inspect_sqlite_import_recovery(plan["targetName"], plan["request"])
401
- if _snapshot(current) != plan["stateSnapshot"]:
402
- raise DeploymentKitError(
403
- "SQLite import recovery state changed after the plan was reviewed; create and authorize a fresh plan."
404
- )
405
- profile = load_profile(plan["targetName"])
406
- evidence = verify_access(profile)
407
- output = _run_remote(
408
- profile,
409
- evidence["address"],
410
- "recover",
411
- plan["request"],
412
- expected_db_sha=plan["databaseSha256"],
413
- expected_source_sha=plan["sourceSha256"],
414
- )
415
- inventory = sqlite_inventory(plan["targetName"])
416
- reservation = next(
417
- (
418
- item for item in inventory["sqlite"]
419
- if item["appSlug"] == plan["appSlug"] and item["environment"] == plan["environment"]
420
- ),
421
- None,
422
- )
423
- if reservation is None or not reservation["databasePresent"]:
424
- raise DeploymentKitError("SQLite import recovery completed remotely but reservation verification failed.")
425
- expected_reservation = reservation_sha(
426
- owner_repo=plan["ownerRepo"],
427
- environment=plan["environment"],
428
- app_slug=plan["appSlug"],
429
- run_as=plan["runAs"],
430
- database_file=plan["databaseFile"],
431
- )
432
- if reservation["reservationSha256"] != expected_reservation:
433
- raise DeploymentKitError("Recovered SQLite reservation fingerprint does not match the reviewed plan.")
434
- inventory["mutated"] = True
435
- inventory["operation"] = "recover-import"
436
- inventory["operationOutput"] = output.strip()
437
- return inventory
1
+ """Recover a verified SQLite import that failed before reservation metadata was written."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import re
9
+ import subprocess
10
+ from typing import Any
11
+
12
+ from .app_runtime import APP_ROOT
13
+ from .box_platform import platform_status
14
+ from .errors import DeploymentKitError
15
+ from .profiles import load_profile
16
+ from .sqlite_adapter import reservation_sha, sqlite_inventory
17
+ from .ssh import ssh_args, verify_access
18
+
19
+ SQLITE_IMPORT_RECOVERY_WRITE_PHRASE = "[SQLITE IMPORT RECOVERY WRITE]"
20
+ PRODUCTION_SQLITE_IMPORT_RECOVERY_WRITE_PHRASE = "[PRODUCTION SQLITE IMPORT RECOVERY]"
21
+ _SHA_RE = re.compile(r"^[0-9a-f]{64}$")
22
+
23
+
24
+ def _b64(value: str) -> str:
25
+ return base64.b64encode(value.encode("utf-8")).decode("ascii")
26
+
27
+
28
+ def _require_request(request: dict[str, Any]) -> None:
29
+ for key in ("ownerRepo", "environment", "appSlug", "image", "maintenanceEnvironment", "runAs", "databaseFile", "sourceDatabase"):
30
+ if request.get(key) in (None, ""):
31
+ raise DeploymentKitError(f"SQLite import recovery requires {key}.")
32
+
33
+
34
+ def _require_ready_platform(name: str) -> None:
35
+ state = platform_status(name)["state"]
36
+ if state != "ready":
37
+ raise DeploymentKitError(
38
+ f"SQLite import recovery requires a ready shared platform; target '{name}' is '{state}'."
39
+ )
40
+
41
+
42
+ REMOTE_SCRIPT = r'''#!/usr/bin/env bash
43
+ set -Eeuo pipefail
44
+
45
+ ACTION="$1"
46
+ EXPECTED_DB_SHA="${2:-}"
47
+ EXPECTED_SOURCE_SHA="${3:-}"
48
+ DEPLOY_USER="${4:-}"
49
+ APP_ROOT="__APP_ROOT__"
50
+ OWNER="$(printf '%s' '__OWNER_B64__' | base64 --decode)"
51
+ ENVIRONMENT="$(printf '%s' '__ENV_B64__' | base64 --decode)"
52
+ APP_SLUG="$(printf '%s' '__APP_B64__' | base64 --decode)"
53
+ IMAGE="$(printf '%s' '__IMAGE_B64__' | base64 --decode)"
54
+ RUN_AS="$(printf '%s' '__RUN_AS_B64__' | base64 --decode)"
55
+ DATABASE_FILE="$(printf '%s' '__DB_FILE_B64__' | base64 --decode)"
56
+ SOURCE_DATABASE="$(printf '%s' '__SOURCE_DB_B64__' | base64 --decode)"
57
+ DATABASE_FILE_B64="__DB_FILE_B64__"
58
+ RESERVATION_SHA="__RESERVATION_SHA__"
59
+ ENV_KEYS=(__ENV_KEYS__)
60
+ ENV_VALUES=(__ENV_VALUES__)
61
+
62
+ valid_slug() { [[ "$1" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; }
63
+ valid_repo() { [[ "$1" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; }
64
+ valid_image() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$ ]]; }
65
+ valid_run_as() { [[ "$1" =~ ^[0-9]+(:[0-9]+)?$ ]]; }
66
+ valid_db_file() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ && "$1" != "." && "$1" != ".." ]]; }
67
+ valid_env_key() { [[ "$1" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; }
68
+ b64d() { printf '%s' "$1" | base64 --decode; }
69
+
70
+ validate_spec() {
71
+ valid_repo "$OWNER" || { echo "Invalid recovery owner repository." >&2; exit 61; }
72
+ valid_slug "$ENVIRONMENT" || { echo "Invalid recovery environment." >&2; exit 61; }
73
+ valid_slug "$APP_SLUG" || { echo "Invalid recovery app slug." >&2; exit 61; }
74
+ valid_image "$IMAGE" || { echo "Recovery image must be an immutable digest reference." >&2; exit 61; }
75
+ valid_run_as "$RUN_AS" || { echo "Invalid recovery runAs." >&2; exit 61; }
76
+ valid_db_file "$DATABASE_FILE" || { echo "Invalid recovery database file." >&2; exit 61; }
77
+ [[ "$RESERVATION_SHA" =~ ^[0-9a-f]{64}$ ]] || { echo "Invalid recovery reservation fingerprint." >&2; exit 61; }
78
+ [[ "$SOURCE_DATABASE" == /srv/* && "$SOURCE_DATABASE" != "$APP_ROOT"/* ]] || {
79
+ echo "Recovery source must be outside the Kit app root under /srv." >&2; exit 61;
80
+ }
81
+ [[ "${#ENV_KEYS[@]}" -eq "${#ENV_VALUES[@]}" && "${#ENV_KEYS[@]}" -gt 0 ]] || {
82
+ echo "Invalid recovery maintenance environment payload." >&2; exit 61;
83
+ }
84
+ local index key
85
+ for index in "${!ENV_KEYS[@]}"; do
86
+ key="$(b64d "${ENV_KEYS[$index]}")"
87
+ valid_env_key "$key" || { echo "Invalid recovery maintenance environment key." >&2; exit 61; }
88
+ done
89
+ }
90
+
91
+ configure_registry_auth() {
92
+ [[ -n "$DEPLOY_USER" && "$DEPLOY_USER" =~ ^[A-Za-z_][A-Za-z0-9_.-]{0,31}$ ]] || {
93
+ echo "Deployment account is invalid." >&2; exit 62;
94
+ }
95
+ local home config dir_mode file_mode deploy_uid
96
+ home="$(getent passwd "$DEPLOY_USER" | cut -d: -f6)"
97
+ [[ -n "$home" && "$home" == /* ]] || { echo "Could not resolve deployment account home." >&2; exit 62; }
98
+ config="$home/.docker"
99
+ if [[ ! -e "$config/config.json" ]]; then return 0; fi
100
+ [[ -d "$config" && ! -L "$config" && -f "$config/config.json" && ! -L "$config/config.json" ]] || {
101
+ echo "Docker credential path is not a regular protected directory/file." >&2; exit 62;
102
+ }
103
+ dir_mode="$(stat -c '%a' "$config")"
104
+ file_mode="$(stat -c '%a' "$config/config.json")"
105
+ deploy_uid="$(id -u "$DEPLOY_USER")"
106
+ if (( (8#$dir_mode & 077) != 0 || (8#$file_mode & 077) != 0 )) ||
107
+ [[ "$(stat -c '%u' "$config")" != "$deploy_uid" || "$(stat -c '%u' "$config/config.json")" != "$deploy_uid" ]]; then
108
+ echo "Docker credentials must be owned by the deployment account and deny group/other access." >&2
109
+ exit 62
110
+ fi
111
+ export DOCKER_CONFIG="$config"
112
+ }
113
+
114
+ set_paths() {
115
+ ROOT="$APP_ROOT/$APP_SLUG/$ENVIRONMENT"
116
+ DATA="$ROOT/data"
117
+ BACKUPS="$ROOT/backups"
118
+ DB="$DATA/$DATABASE_FILE"
119
+ MARKER="$ROOT/persistence.env"
120
+ DEPLOYMENT="$ROOT/deployment.env"
121
+ EVIDENCE="$ROOT/sqlite-evidence.jsonl"
122
+ LOCK_FILE="/run/lock/lean-poc-app-$APP_SLUG--$ENVIRONMENT.lock"
123
+ }
124
+
125
+ expected_ids() {
126
+ EXPECTED_UID="${RUN_AS%%:*}"
127
+ [[ "$RUN_AS" == *:* ]] && EXPECTED_GID="${RUN_AS#*:}" || EXPECTED_GID="$EXPECTED_UID"
128
+ }
129
+
130
+ inspect_orphan() {
131
+ local source_real root_entries data_entries db_mode db_sha source_sha bytes
132
+ expected_ids
133
+
134
+ [[ -d "$ROOT" && ! -L "$ROOT" && "$(realpath -e "$ROOT")" == "$ROOT" ]] || {
135
+ echo "Recovery root is missing, invalid, or contains a symlinked path component." >&2; exit 63;
136
+ }
137
+ [[ "$(stat -c '%u' "$ROOT")" == "0" && "$(stat -c '%g' "$ROOT")" == "0" && "$(stat -c '%a' "$ROOT")" == "750" ]] || {
138
+ echo "Recovery root ownership or mode does not match the failed-import footprint." >&2; exit 63;
139
+ }
140
+ for path in "$DATA" "$BACKUPS"; do
141
+ [[ -d "$path" && ! -L "$path" && "$(realpath -e "$path")" == "$path" ]] || {
142
+ echo "Recovery data/backup directory is missing or invalid: $path" >&2; exit 63;
143
+ }
144
+ [[ "$(stat -c '%u' "$path")" == "$EXPECTED_UID" && "$(stat -c '%g' "$path")" == "$EXPECTED_GID" && "$(stat -c '%a' "$path")" == "750" ]] || {
145
+ echo "Recovery data/backup ownership or mode does not match runAs." >&2; exit 63;
146
+ }
147
+ done
148
+ [[ -f "$DB" && ! -L "$DB" && "$(realpath -e "$DB")" == "$DB" ]] || {
149
+ echo "Recovery database must be an existing non-symlink regular file." >&2; exit 63;
150
+ }
151
+ [[ "$(stat -c '%u' "$DB")" == "$EXPECTED_UID" && "$(stat -c '%g' "$DB")" == "$EXPECTED_GID" ]] || {
152
+ echo "Recovery database ownership does not match runAs." >&2; exit 63;
153
+ }
154
+ db_mode="$(stat -c '%a' "$DB")"
155
+ (( (8#$db_mode & 077) == 0 )) || { echo "Recovery database grants group/other permissions." >&2; exit 63; }
156
+
157
+ [[ ! -e "$MARKER" && ! -e "$DEPLOYMENT" && ! -e "$EVIDENCE" ]] || {
158
+ echo "Recovery requires no reservation, deployment state, or prior SQLite evidence." >&2; exit 63;
159
+ }
160
+ root_entries="$(find "$ROOT" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)"
161
+ [[ "$root_entries" == $'backups\ndata' ]] || {
162
+ echo "Recovery root contains unexpected entries." >&2; exit 63;
163
+ }
164
+ data_entries="$(find "$DATA" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)"
165
+ [[ "$data_entries" == "$DATABASE_FILE" ]] || {
166
+ echo "Recovery data directory contains unexpected entries." >&2; exit 63;
167
+ }
168
+ [[ -z "$(find "$BACKUPS" -mindepth 1 -maxdepth 1 -print -quit)" ]] || {
169
+ echo "Recovery backup directory must still be empty." >&2; exit 63;
170
+ }
171
+
172
+ source_real="$(realpath -e "$SOURCE_DATABASE" 2>/dev/null || true)"
173
+ [[ "$source_real" == "$SOURCE_DATABASE" && -f "$SOURCE_DATABASE" && ! -L "$SOURCE_DATABASE" ]] || {
174
+ echo "Recovery source must remain an existing non-symlink regular file with no symlinked path components." >&2; exit 63;
175
+ }
176
+ [[ "$(basename "$SOURCE_DATABASE")" == "$DATABASE_FILE" ]] || {
177
+ echo "Recovery source database file name mismatch." >&2; exit 63;
178
+ }
179
+
180
+ db_sha="$(sha256sum "$DB" | awk '{print $1}')"
181
+ source_sha="$(sha256sum "$SOURCE_DATABASE" | awk '{print $1}')"
182
+ bytes="$(stat -c '%s' "$DB")"
183
+ [[ "$db_sha" =~ ^[0-9a-f]{64}$ && "$source_sha" =~ ^[0-9a-f]{64}$ && "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]] || {
184
+ echo "Recovery database fingerprint is invalid." >&2; exit 63;
185
+ }
186
+ printf 'RECOVERY\t%s\t%s\t%s\n' "$db_sha" "$source_sha" "$bytes"
187
+ }
188
+
189
+ maintenance_args() {
190
+ local -n out_ref=$1
191
+ local index key value
192
+ out_ref=(docker run --rm --network none --user "$RUN_AS")
193
+ for index in "${!ENV_KEYS[@]}"; do
194
+ key="$(b64d "${ENV_KEYS[$index]}")"
195
+ value="$(b64d "${ENV_VALUES[$index]}")"
196
+ out_ref+=(--env "$key=$value")
197
+ done
198
+ }
199
+
200
+ run_check() {
201
+ local -a args
202
+ maintenance_args args
203
+ args+=(--mount "type=bind,src=$DATA,dst=/data" "$IMAGE" --check-rollback-compatibility)
204
+ "${args[@]}"
205
+ }
206
+
207
+ write_marker() {
208
+ local tmp
209
+ tmp="$MARKER.tmp.$$"
210
+ {
211
+ printf 'SCHEMA_VERSION=1\n'
212
+ printf 'ADAPTER=sqlite\n'
213
+ printf 'OWNER_REPO=%s\n' "$OWNER"
214
+ printf 'ENVIRONMENT=%s\n' "$ENVIRONMENT"
215
+ printf 'APP_SLUG=%s\n' "$APP_SLUG"
216
+ printf 'RUN_AS=%s\n' "$RUN_AS"
217
+ printf 'DATABASE_FILE_B64=%s\n' "$DATABASE_FILE_B64"
218
+ printf 'RESERVATION_SHA256=%s\n' "$RESERVATION_SHA"
219
+ } > "$tmp"
220
+ chmod 0600 "$tmp"
221
+ mv -f "$tmp" "$MARKER"
222
+ }
223
+
224
+ record_evidence() {
225
+ local timestamp
226
+ timestamp="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
227
+ printf '{"timestamp":"%s","action":"import-recovery","ownerRepo":"%s","environment":"%s","appSlug":"%s","image":"%s","databaseFile":"%s","reservationSha256":"%s","artifact":"%s","outcome":"recovered"}\n' \
228
+ "$timestamp" "$OWNER" "$ENVIRONMENT" "$APP_SLUG" "$IMAGE" "$DATABASE_FILE" "$RESERVATION_SHA" "$SOURCE_DATABASE" >> "$EVIDENCE"
229
+ chmod 0600 "$EVIDENCE"
230
+ }
231
+
232
+ recover() {
233
+ local inspection kind db_sha source_sha bytes
234
+ exec 9>>"$LOCK_FILE"
235
+ flock -n 9 || { echo "Another app/persistence operation holds the recovery lock." >&2; exit 75; }
236
+ inspection="$(inspect_orphan)"
237
+ IFS=$'\t' read -r kind db_sha source_sha bytes <<< "$inspection"
238
+ [[ "$kind" == "RECOVERY" && "$db_sha" == "$EXPECTED_DB_SHA" && "$source_sha" == "$EXPECTED_SOURCE_SHA" ]] || {
239
+ echo "Recovery state changed after planning; create and authorize a fresh recovery plan." >&2; exit 64;
240
+ }
241
+ configure_registry_auth
242
+ docker pull "$IMAGE" >/dev/null
243
+ run_check
244
+ write_marker
245
+ record_evidence
246
+ printf 'SQLite import recovery completed and verified: %s\n' "$DB"
247
+ }
248
+
249
+ validate_spec
250
+ set_paths
251
+ case "$ACTION" in
252
+ inspect) inspect_orphan ;;
253
+ recover) recover ;;
254
+ *) echo "Unsupported SQLite import recovery action: $ACTION" >&2; exit 2 ;;
255
+ esac
256
+ '''
257
+
258
+
259
+ def _build_remote_script(request: dict[str, Any]) -> str:
260
+ _require_request(request)
261
+ reservation = reservation_sha(
262
+ owner_repo=request["ownerRepo"],
263
+ environment=request["environment"],
264
+ app_slug=request["appSlug"],
265
+ run_as=request["runAs"],
266
+ database_file=request["databaseFile"],
267
+ )
268
+ env = request["maintenanceEnvironment"]
269
+ replacements = {
270
+ "__APP_ROOT__": APP_ROOT,
271
+ "__OWNER_B64__": _b64(request["ownerRepo"]),
272
+ "__ENV_B64__": _b64(request["environment"]),
273
+ "__APP_B64__": _b64(request["appSlug"]),
274
+ "__IMAGE_B64__": _b64(request["image"]),
275
+ "__RUN_AS_B64__": _b64(request["runAs"]),
276
+ "__DB_FILE_B64__": _b64(request["databaseFile"]),
277
+ "__SOURCE_DB_B64__": _b64(request["sourceDatabase"]),
278
+ "__RESERVATION_SHA__": reservation,
279
+ "__ENV_KEYS__": " ".join(f"'{_b64(key)}'" for key in env),
280
+ "__ENV_VALUES__": " ".join(f"'{_b64(value)}'" for value in env.values()),
281
+ }
282
+ script = REMOTE_SCRIPT
283
+ for placeholder, value in replacements.items():
284
+ script = script.replace(placeholder, value)
285
+ if "__" in script:
286
+ raise DeploymentKitError("Internal SQLite import recovery script contains an unresolved placeholder.")
287
+ return script
288
+
289
+
290
+ def _run_remote(
291
+ profile: dict[str, Any],
292
+ address: str,
293
+ action: str,
294
+ request: dict[str, Any],
295
+ *,
296
+ expected_db_sha: str = "",
297
+ expected_source_sha: str = "",
298
+ ) -> str:
299
+ if action not in {"inspect", "recover"}:
300
+ raise DeploymentKitError(f"Unsupported SQLite import recovery action: {action}")
301
+ args = [action]
302
+ if action == "recover":
303
+ args.extend([expected_db_sha, expected_source_sha, profile["user"]])
304
+ completed = subprocess.run(
305
+ ssh_args(profile, address, command="sudo -n bash -s -- " + " ".join(args)),
306
+ input=_build_remote_script(request).encode("utf-8"),
307
+ check=False,
308
+ capture_output=True,
309
+ )
310
+ stdout = completed.stdout.decode("utf-8", errors="replace")
311
+ stderr = completed.stderr.decode("utf-8", errors="replace")
312
+ if completed.returncode != 0:
313
+ detail = stderr.strip() or stdout.strip() or f"exit {completed.returncode}"
314
+ raise DeploymentKitError(f"Remote SQLite import recovery {action} failed: {detail}")
315
+ return stdout
316
+
317
+
318
+ def _parse_inspection(output: str) -> dict[str, Any]:
319
+ lines = [line for line in output.splitlines() if line]
320
+ if len(lines) != 1:
321
+ raise DeploymentKitError("Malformed SQLite import recovery inspection output.")
322
+ parts = lines[0].split("\t")
323
+ if len(parts) != 4 or parts[0] != "RECOVERY":
324
+ raise DeploymentKitError("Malformed SQLite import recovery inspection record.")
325
+ if _SHA_RE.fullmatch(parts[1]) is None or _SHA_RE.fullmatch(parts[2]) is None:
326
+ raise DeploymentKitError("Malformed SQLite import recovery fingerprint.")
327
+ try:
328
+ database_bytes = int(parts[3])
329
+ except ValueError as error:
330
+ raise DeploymentKitError("Malformed SQLite import recovery database size.") from error
331
+ if database_bytes <= 0:
332
+ raise DeploymentKitError("SQLite import recovery database must be non-empty.")
333
+ return {
334
+ "databaseSha256": parts[1],
335
+ "sourceSha256": parts[2],
336
+ "databaseBytes": database_bytes,
337
+ }
338
+
339
+
340
+ def inspect_sqlite_import_recovery(name: str, request: dict[str, Any]) -> dict[str, Any]:
341
+ _require_ready_platform(name)
342
+ _require_request(request)
343
+ profile = load_profile(name)
344
+ evidence = verify_access(profile)
345
+ result = _parse_inspection(_run_remote(profile, evidence["address"], "inspect", request))
346
+ result.update(targetName=name, verifiedAddress=evidence["address"], mutated=False)
347
+ return result
348
+
349
+
350
+ def _snapshot(inspection: dict[str, Any]) -> str:
351
+ value = {
352
+ "databaseSha256": inspection["databaseSha256"],
353
+ "sourceSha256": inspection["sourceSha256"],
354
+ "databaseBytes": inspection["databaseBytes"],
355
+ }
356
+ return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
357
+
358
+
359
+ def plan_sqlite_import_recovery(name: str, request: dict[str, Any]) -> dict[str, Any]:
360
+ inspection = inspect_sqlite_import_recovery(name, request)
361
+ return {
362
+ "targetName": name,
363
+ "action": "recover-import",
364
+ "ownerRepo": request["ownerRepo"],
365
+ "environment": request["environment"],
366
+ "appSlug": request["appSlug"],
367
+ "image": request["image"],
368
+ "runAs": request["runAs"],
369
+ "databaseFile": request["databaseFile"],
370
+ "sourceDatabase": request["sourceDatabase"],
371
+ "databaseSha256": inspection["databaseSha256"],
372
+ "sourceSha256": inspection["sourceSha256"],
373
+ "databaseBytes": inspection["databaseBytes"],
374
+ "stateSnapshot": _snapshot(inspection),
375
+ "overwritesDatabase": False,
376
+ "deletesPersistentData": False,
377
+ "createsReservation": True,
378
+ "reverifiesDatabase": True,
379
+ "request": request,
380
+ }
381
+
382
+
383
+ def authorization_phrase(plan: dict[str, Any]) -> str:
384
+ return (
385
+ PRODUCTION_SQLITE_IMPORT_RECOVERY_WRITE_PHRASE
386
+ if plan["environment"] == "production"
387
+ else SQLITE_IMPORT_RECOVERY_WRITE_PHRASE
388
+ )
389
+
390
+
391
+ def apply_sqlite_import_recovery(plan: dict[str, Any]) -> dict[str, Any]:
392
+ required = {
393
+ "targetName", "action", "ownerRepo", "environment", "appSlug", "image", "runAs",
394
+ "databaseFile", "sourceDatabase", "databaseSha256", "sourceSha256", "databaseBytes",
395
+ "stateSnapshot", "overwritesDatabase", "deletesPersistentData", "createsReservation",
396
+ "reverifiesDatabase", "request",
397
+ }
398
+ if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "recover-import":
399
+ raise DeploymentKitError("Malformed SQLite import recovery plan.")
400
+ current = inspect_sqlite_import_recovery(plan["targetName"], plan["request"])
401
+ if _snapshot(current) != plan["stateSnapshot"]:
402
+ raise DeploymentKitError(
403
+ "SQLite import recovery state changed after the plan was reviewed; create and authorize a fresh plan."
404
+ )
405
+ profile = load_profile(plan["targetName"])
406
+ evidence = verify_access(profile)
407
+ output = _run_remote(
408
+ profile,
409
+ evidence["address"],
410
+ "recover",
411
+ plan["request"],
412
+ expected_db_sha=plan["databaseSha256"],
413
+ expected_source_sha=plan["sourceSha256"],
414
+ )
415
+ inventory = sqlite_inventory(plan["targetName"])
416
+ reservation = next(
417
+ (
418
+ item for item in inventory["sqlite"]
419
+ if item["appSlug"] == plan["appSlug"] and item["environment"] == plan["environment"]
420
+ ),
421
+ None,
422
+ )
423
+ if reservation is None or not reservation["databasePresent"]:
424
+ raise DeploymentKitError("SQLite import recovery completed remotely but reservation verification failed.")
425
+ expected_reservation = reservation_sha(
426
+ owner_repo=plan["ownerRepo"],
427
+ environment=plan["environment"],
428
+ app_slug=plan["appSlug"],
429
+ run_as=plan["runAs"],
430
+ database_file=plan["databaseFile"],
431
+ )
432
+ if reservation["reservationSha256"] != expected_reservation:
433
+ raise DeploymentKitError("Recovered SQLite reservation fingerprint does not match the reviewed plan.")
434
+ inventory["mutated"] = True
435
+ inventory["operation"] = "recover-import"
436
+ inventory["operationOutput"] = output.strip()
437
+ return inventory