@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.
- package/README.md +71 -67
- package/assets/components.json +33 -33
- package/assets/core/distribution.json +52 -51
- package/assets/deployment/distribution.json +34 -34
- package/assets/generated/ASSEMBLY.json +38 -33
- package/assets/generated/core/core/skills/core-sync/SKILL.md +78 -120
- package/assets/generated/core/core/skills/deploy/SKILL.md +74 -91
- package/assets/generated/core/core/skills/lean-update/SKILL.md +189 -0
- package/assets/generated/core/core/skills/upstream-fix/SKILL.md +91 -76
- package/assets/generated/deployment/distribution/lean-poc-deploy.py +8 -8
- package/assets/generated/deployment/src/lean_poc_deployment/__init__.py +3 -3
- package/assets/generated/deployment/src/lean_poc_deployment/__main__.py +3 -3
- package/assets/generated/deployment/src/lean_poc_deployment/app_cli.py +140 -140
- package/assets/generated/deployment/src/lean_poc_deployment/app_runtime.py +455 -455
- package/assets/generated/deployment/src/lean_poc_deployment/app_runtime_remote.py +415 -415
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform.py +208 -208
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform_assets.py +100 -100
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform_remote.py +271 -271
- package/assets/generated/deployment/src/lean_poc_deployment/cli.py +437 -437
- package/assets/generated/deployment/src/lean_poc_deployment/doctor.py +121 -121
- package/assets/generated/deployment/src/lean_poc_deployment/entrypoint.py +25 -25
- package/assets/generated/deployment/src/lean_poc_deployment/errors.py +5 -5
- package/assets/generated/deployment/src/lean_poc_deployment/keys.py +171 -171
- package/assets/generated/deployment/src/lean_poc_deployment/profiles.py +204 -204
- package/assets/generated/deployment/src/lean_poc_deployment/route_registry.py +367 -367
- package/assets/generated/deployment/src/lean_poc_deployment/route_registry_remote.py +443 -443
- package/assets/generated/deployment/src/lean_poc_deployment/security.py +290 -290
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter.py +334 -334
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter_remote.py +339 -339
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_cli.py +85 -85
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_import_recovery.py +437 -437
- package/assets/generated/deployment/src/lean_poc_deployment/ssh.py +118 -118
- package/assets/generated/deployment/src/lean_poc_deployment/teardown.py +286 -286
- package/assets/generated/deployment/src/lean_poc_deployment/teardown_cli.py +120 -120
- package/assets/generated/deployment/src/lean_poc_deployment/teardown_remote.py +100 -100
- package/assets/generated/deployment/src/lean_poc_deployment/trust.py +278 -278
- package/bin/lean.mjs +9 -9
- package/package.json +33 -33
- package/src/cli.mjs +131 -131
- package/src/engine.mjs +243 -243
- 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
|