@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,335 +1,335 @@
|
|
|
1
|
-
"""SQLite persistence adapter for Kit-managed application runtimes."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import base64
|
|
6
|
-
import hashlib
|
|
7
|
-
import json
|
|
8
|
-
import posixpath
|
|
9
|
-
import re
|
|
10
|
-
import subprocess
|
|
11
|
-
from typing import Any, Iterable
|
|
12
|
-
|
|
13
|
-
from .app_runtime import APP_ROOT
|
|
14
|
-
from .box_platform import platform_status
|
|
15
|
-
from .errors import DeploymentKitError
|
|
16
|
-
from .profiles import load_profile
|
|
17
|
-
from .sqlite_adapter_remote import REMOTE_SCRIPT
|
|
18
|
-
from .ssh import ssh_args, verify_access
|
|
19
|
-
|
|
20
|
-
SQLITE_SCHEMA_VERSION = 1
|
|
21
|
-
SQLITE_IMPORT_WRITE_PHRASE = "[SQLITE IMPORT WRITE]"
|
|
22
|
-
PRODUCTION_SQLITE_IMPORT_WRITE_PHRASE = "[PRODUCTION SQLITE IMPORT]"
|
|
23
|
-
SQLITE_BACKUP_WRITE_PHRASE = "[SQLITE BACKUP WRITE]"
|
|
24
|
-
PRODUCTION_SQLITE_BACKUP_WRITE_PHRASE = "[PRODUCTION SQLITE BACKUP]"
|
|
25
|
-
SQLITE_MIGRATE_WRITE_PHRASE = "[SQLITE MIGRATE WRITE]"
|
|
26
|
-
PRODUCTION_SQLITE_MIGRATE_WRITE_PHRASE = "[PRODUCTION SQLITE MIGRATE]"
|
|
27
|
-
SQLITE_ROLLBACK_CHECK_PHRASE = "[SQLITE ROLLBACK CHECK]"
|
|
28
|
-
PRODUCTION_SQLITE_ROLLBACK_CHECK_PHRASE = "[PRODUCTION SQLITE ROLLBACK CHECK]"
|
|
29
|
-
|
|
30
|
-
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
|
31
|
-
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
32
|
-
_IMAGE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$")
|
|
33
|
-
_RUN_AS_RE = re.compile(r"^[0-9]+(?::[0-9]+)?$")
|
|
34
|
-
_DB_FILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
35
|
-
_ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
36
|
-
_SOURCE_RE = re.compile(r"^/srv/[A-Za-z0-9._/+-]+$")
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _slug(value: str, field: str) -> str:
|
|
40
|
-
if not isinstance(value, str) or _SLUG_RE.fullmatch(value) is None:
|
|
41
|
-
raise DeploymentKitError(f"{field} must use lowercase letters, digits, and internal hyphens.")
|
|
42
|
-
return value
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def _owner_repo(value: str) -> str:
|
|
46
|
-
if not isinstance(value, str) or _REPO_RE.fullmatch(value) is None:
|
|
47
|
-
raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
|
|
48
|
-
return value
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _image(value: str) -> str:
|
|
52
|
-
if not isinstance(value, str) or _IMAGE_RE.fullmatch(value) is None:
|
|
53
|
-
raise DeploymentKitError("image must be an immutable image reference ending in @sha256:<64 lowercase hex>.")
|
|
54
|
-
return value
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def _run_as(value: str) -> str:
|
|
58
|
-
if not isinstance(value, str) or _RUN_AS_RE.fullmatch(value) is None:
|
|
59
|
-
raise DeploymentKitError("runAs must be a numeric UID or UID:GID.")
|
|
60
|
-
return value
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _database_file(value: str) -> str:
|
|
64
|
-
if not isinstance(value, str) or _DB_FILE_RE.fullmatch(value) is None or value in {".", ".."}:
|
|
65
|
-
raise DeploymentKitError("databaseFile must be a safe file name without path separators.")
|
|
66
|
-
return value
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def _source_database(value: str) -> str:
|
|
70
|
-
if not isinstance(value, str) or _SOURCE_RE.fullmatch(value) is None:
|
|
71
|
-
raise DeploymentKitError("sourceDatabase must be a safe absolute path under /srv.")
|
|
72
|
-
normalized = posixpath.normpath(value)
|
|
73
|
-
if normalized != value or "/../" in f"{value}/":
|
|
74
|
-
raise DeploymentKitError("sourceDatabase must not contain path traversal or normalization changes.")
|
|
75
|
-
if value.startswith(APP_ROOT + "/"):
|
|
76
|
-
raise DeploymentKitError("sourceDatabase must be outside the Kit-managed application root.")
|
|
77
|
-
return value
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def _normalize_env(items: dict[str, str] | Iterable[tuple[str, str]]) -> dict[str, str]:
|
|
81
|
-
pairs = items.items() if isinstance(items, dict) else items
|
|
82
|
-
result: dict[str, str] = {}
|
|
83
|
-
for key, value in pairs:
|
|
84
|
-
if not isinstance(key, str) or _ENV_KEY_RE.fullmatch(key) is None:
|
|
85
|
-
raise DeploymentKitError(f"Invalid maintenance environment variable name: {key!r}")
|
|
86
|
-
if key in result:
|
|
87
|
-
raise DeploymentKitError(f"Duplicate maintenance environment variable: {key}")
|
|
88
|
-
if not isinstance(value, str) or any(ch in value for ch in ("\x00", "\n", "\r")):
|
|
89
|
-
raise DeploymentKitError(f"Maintenance environment variable {key} contains unsupported control characters.")
|
|
90
|
-
result[key] = value
|
|
91
|
-
if not result:
|
|
92
|
-
raise DeploymentKitError("At least one maintenance environment value is required.")
|
|
93
|
-
return dict(sorted(result.items()))
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def reservation_sha(*, owner_repo: str, environment: str, app_slug: str, run_as: str, database_file: str) -> str:
|
|
97
|
-
value = {
|
|
98
|
-
"schemaVersion": SQLITE_SCHEMA_VERSION,
|
|
99
|
-
"adapter": "sqlite",
|
|
100
|
-
"ownerRepo": owner_repo,
|
|
101
|
-
"environment": environment,
|
|
102
|
-
"appSlug": app_slug,
|
|
103
|
-
"runAs": run_as,
|
|
104
|
-
"databaseFile": database_file,
|
|
105
|
-
}
|
|
106
|
-
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def normalize_sqlite_request(
|
|
110
|
-
*, owner_repo: str, environment: str, app_slug: str, image: str,
|
|
111
|
-
maintenance_environment: dict[str, str] | Iterable[tuple[str, str]],
|
|
112
|
-
run_as: str | None = None, database_file: str | None = None,
|
|
113
|
-
source_database: str | None = None, first_install: bool = False,
|
|
114
|
-
) -> dict[str, Any]:
|
|
115
|
-
request = {
|
|
116
|
-
"ownerRepo": _owner_repo(owner_repo),
|
|
117
|
-
"environment": _slug(environment, "environment"),
|
|
118
|
-
"appSlug": _slug(app_slug, "appSlug"),
|
|
119
|
-
"image": _image(image),
|
|
120
|
-
"maintenanceEnvironment": _normalize_env(maintenance_environment),
|
|
121
|
-
"firstInstall": bool(first_install),
|
|
122
|
-
"runAs": _run_as(run_as) if run_as is not None else None,
|
|
123
|
-
"databaseFile": _database_file(database_file) if database_file is not None else None,
|
|
124
|
-
"sourceDatabase": _source_database(source_database) if source_database is not None else None,
|
|
125
|
-
}
|
|
126
|
-
if request["sourceDatabase"] is not None:
|
|
127
|
-
if request["runAs"] is None or request["databaseFile"] is None:
|
|
128
|
-
raise DeploymentKitError("SQLite import requires runAs and databaseFile.")
|
|
129
|
-
if posixpath.basename(request["sourceDatabase"]) != request["databaseFile"]:
|
|
130
|
-
raise DeploymentKitError("sourceDatabase file name must match databaseFile.")
|
|
131
|
-
if request["firstInstall"] and (request["runAs"] is None or request["databaseFile"] is None):
|
|
132
|
-
raise DeploymentKitError("First-install SQLite migration requires runAs and databaseFile.")
|
|
133
|
-
return request
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def _b64(value: str) -> str:
|
|
137
|
-
return base64.b64encode(value.encode()).decode("ascii")
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def build_remote_script(spec: dict[str, Any] | None = None) -> str:
|
|
141
|
-
spec = spec or {}
|
|
142
|
-
env = spec.get("maintenanceEnvironment", {})
|
|
143
|
-
replacements = {
|
|
144
|
-
"__APP_ROOT__": APP_ROOT,
|
|
145
|
-
"__SQLITE_SCHEMA_VERSION__": str(SQLITE_SCHEMA_VERSION),
|
|
146
|
-
"__OWNER_B64__": _b64(spec.get("ownerRepo", "")),
|
|
147
|
-
"__ENV_B64__": _b64(spec.get("environment", "")),
|
|
148
|
-
"__APP_B64__": _b64(spec.get("appSlug", "")),
|
|
149
|
-
"__IMAGE_B64__": _b64(spec.get("image", "")),
|
|
150
|
-
"__RUN_AS_B64__": _b64(spec.get("runAs") or ""),
|
|
151
|
-
"__DB_FILE_B64__": _b64(spec.get("databaseFile") or ""),
|
|
152
|
-
"__SOURCE_DB_B64__": _b64(spec.get("sourceDatabase") or ""),
|
|
153
|
-
"__FIRST_INSTALL__": "true" if spec.get("firstInstall") else "false",
|
|
154
|
-
"__RESERVATION_SHA__": spec.get("reservationSha256", ""),
|
|
155
|
-
"__ENV_KEYS__": " ".join(f"'{_b64(k)}'" for k in env),
|
|
156
|
-
"__ENV_VALUES__": " ".join(f"'{_b64(v)}'" for v in env.values()),
|
|
157
|
-
}
|
|
158
|
-
script = REMOTE_SCRIPT
|
|
159
|
-
for placeholder, value in replacements.items():
|
|
160
|
-
script = script.replace(placeholder, value)
|
|
161
|
-
if "__" in script:
|
|
162
|
-
raise DeploymentKitError("Internal SQLite adapter script contains an unresolved placeholder.")
|
|
163
|
-
return script
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def _parse_inventory(output: str) -> dict[str, Any]:
|
|
167
|
-
summary: dict[str, str] = {}
|
|
168
|
-
adapters: list[dict[str, Any]] = []
|
|
169
|
-
for raw in output.splitlines():
|
|
170
|
-
if not raw:
|
|
171
|
-
continue
|
|
172
|
-
parts = raw.split("\t")
|
|
173
|
-
if parts[0] == "SUMMARY":
|
|
174
|
-
if len(parts) != 3 or parts[1] in summary:
|
|
175
|
-
raise DeploymentKitError("Malformed or duplicate SQLite inventory summary.")
|
|
176
|
-
summary[parts[1]] = parts[2]
|
|
177
|
-
continue
|
|
178
|
-
if parts[0] != "SQLITE" or len(parts) != 12:
|
|
179
|
-
raise DeploymentKitError("Malformed SQLite inventory record.")
|
|
180
|
-
if parts[7] not in {"true", "false"} or parts[10] not in {"true", "false"}:
|
|
181
|
-
raise DeploymentKitError("Malformed SQLite inventory boolean value.")
|
|
182
|
-
try:
|
|
183
|
-
database_bytes = int(parts[8]); backup_count = int(parts[9])
|
|
184
|
-
except ValueError as error:
|
|
185
|
-
raise DeploymentKitError("Malformed SQLite inventory numeric value.") from error
|
|
186
|
-
if re.fullmatch(r"[0-9a-f]{64}", parts[6]) is None:
|
|
187
|
-
raise DeploymentKitError("Malformed SQLite reservation fingerprint.")
|
|
188
|
-
adapters.append({
|
|
189
|
-
"ownerRepo": _owner_repo(parts[1]), "environment": _slug(parts[2], "environment"),
|
|
190
|
-
"appSlug": _slug(parts[3], "appSlug"), "runAs": _run_as(parts[4]),
|
|
191
|
-
"databaseFile": _database_file(parts[5]), "reservationSha256": parts[6],
|
|
192
|
-
"databasePresent": parts[7] == "true", "databaseBytes": database_bytes,
|
|
193
|
-
"backupCount": backup_count, "managedRuntimeRunning": parts[10] == "true",
|
|
194
|
-
"currentRuntimeImage": parts[11] or None,
|
|
195
|
-
})
|
|
196
|
-
if set(summary) != {"sqliteCount"}:
|
|
197
|
-
raise DeploymentKitError("SQLite inventory omitted sqliteCount.")
|
|
198
|
-
try:
|
|
199
|
-
count = int(summary["sqliteCount"])
|
|
200
|
-
except ValueError as error:
|
|
201
|
-
raise DeploymentKitError("Malformed SQLite inventory count.") from error
|
|
202
|
-
if count != len(adapters):
|
|
203
|
-
raise DeploymentKitError("SQLite inventory count does not match records.")
|
|
204
|
-
return {"sqlite": sorted(adapters, key=lambda item: (item["appSlug"], item["environment"]))}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def _run_remote(profile: dict[str, Any], address: str, action: str, *, spec: dict[str, Any] | None = None, expected_reservation_sha: str = "") -> str:
|
|
208
|
-
if action not in {"inventory", "import", "backup", "migrate", "rollback-check"}:
|
|
209
|
-
raise DeploymentKitError(f"Unsupported SQLite adapter action: {action}")
|
|
210
|
-
args = [action]
|
|
211
|
-
if action != "inventory":
|
|
212
|
-
args.extend([expected_reservation_sha or "NONE", profile["user"]])
|
|
213
|
-
completed = subprocess.run(
|
|
214
|
-
ssh_args(profile, address, command="sudo -n bash -s -- " + " ".join(args)),
|
|
215
|
-
input=build_remote_script(spec).encode("utf-8"), check=False, capture_output=True,
|
|
216
|
-
)
|
|
217
|
-
stdout = completed.stdout.decode("utf-8", errors="replace")
|
|
218
|
-
stderr = completed.stderr.decode("utf-8", errors="replace")
|
|
219
|
-
if completed.returncode != 0:
|
|
220
|
-
detail = stderr.strip() or stdout.strip() or f"exit {completed.returncode}"
|
|
221
|
-
raise DeploymentKitError(f"Remote SQLite adapter {action} failed: {detail}")
|
|
222
|
-
return stdout
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
def sqlite_inventory(name: str) -> dict[str, Any]:
|
|
226
|
-
profile = load_profile(name); evidence = verify_access(profile)
|
|
227
|
-
result = _parse_inventory(_run_remote(profile, evidence["address"], "inventory"))
|
|
228
|
-
result.update(targetName=name, verifiedAddress=evidence["address"], mutated=False)
|
|
229
|
-
return result
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
def _snapshot(inventory: dict[str, Any]) -> str:
|
|
233
|
-
return hashlib.sha256(json.dumps({"sqlite": inventory["sqlite"]}, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def _find(inventory: dict[str, Any], app_slug: str, environment: str) -> dict[str, Any] | None:
|
|
237
|
-
return next((item for item in inventory["sqlite"] if item["appSlug"] == app_slug and item["environment"] == environment), None)
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
def _require_ready_platform(name: str) -> None:
|
|
241
|
-
state = platform_status(name)["state"]
|
|
242
|
-
if state != "ready":
|
|
243
|
-
raise DeploymentKitError(f"SQLite operation requires a ready shared platform; target '{name}' is '{state}'.")
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def _reservation_from_request(request: dict[str, Any]) -> dict[str, Any]:
|
|
247
|
-
if request["runAs"] is None or request["databaseFile"] is None:
|
|
248
|
-
raise DeploymentKitError("SQLite reservation requires runAs and databaseFile.")
|
|
249
|
-
return {
|
|
250
|
-
"runAs": request["runAs"], "databaseFile": request["databaseFile"],
|
|
251
|
-
"reservationSha256": reservation_sha(owner_repo=request["ownerRepo"], environment=request["environment"], app_slug=request["appSlug"], run_as=request["runAs"], database_file=request["databaseFile"]),
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def plan_sqlite_operation(name: str, action: str, request: dict[str, Any]) -> dict[str, Any]:
|
|
256
|
-
if action not in {"import", "backup", "migrate", "rollback-check"}:
|
|
257
|
-
raise DeploymentKitError("Unsupported SQLite plan action.")
|
|
258
|
-
_require_ready_platform(name)
|
|
259
|
-
inventory = sqlite_inventory(name)
|
|
260
|
-
existing = _find(inventory, request["appSlug"], request["environment"])
|
|
261
|
-
expected_sha = existing["reservationSha256"] if existing else ""
|
|
262
|
-
if existing and existing["ownerRepo"] != request["ownerRepo"]:
|
|
263
|
-
raise DeploymentKitError("SQLite reservation ownership does not match the requested repository.")
|
|
264
|
-
spec = dict(request)
|
|
265
|
-
if action == "import":
|
|
266
|
-
if request["sourceDatabase"] is None:
|
|
267
|
-
raise DeploymentKitError("SQLite import requires sourceDatabase.")
|
|
268
|
-
if existing:
|
|
269
|
-
raise DeploymentKitError("SQLite import requires an unreserved destination.")
|
|
270
|
-
spec.update(_reservation_from_request(request))
|
|
271
|
-
elif action == "migrate" and request["firstInstall"]:
|
|
272
|
-
if existing:
|
|
273
|
-
raise DeploymentKitError("First-install SQLite migration requires an unreserved destination.")
|
|
274
|
-
spec.update(_reservation_from_request(request))
|
|
275
|
-
else:
|
|
276
|
-
if existing is None:
|
|
277
|
-
raise DeploymentKitError(f"SQLite {action} requires an existing adapter reservation.")
|
|
278
|
-
if request["runAs"] is not None and request["runAs"] != existing["runAs"]:
|
|
279
|
-
raise DeploymentKitError("Requested runAs does not match the existing SQLite reservation.")
|
|
280
|
-
if request["databaseFile"] is not None and request["databaseFile"] != existing["databaseFile"]:
|
|
281
|
-
raise DeploymentKitError("Requested databaseFile does not match the existing SQLite reservation.")
|
|
282
|
-
spec.update(runAs=existing["runAs"], databaseFile=existing["databaseFile"], reservationSha256=existing["reservationSha256"])
|
|
283
|
-
if action in {"backup", "migrate", "rollback-check"} and not existing["databasePresent"]:
|
|
284
|
-
raise DeploymentKitError(f"SQLite {action} requires an existing database.")
|
|
285
|
-
return {
|
|
286
|
-
"targetName": name, "action": action, "ownerRepo": request["ownerRepo"],
|
|
287
|
-
"environment": request["environment"], "appSlug": request["appSlug"], "image": request["image"],
|
|
288
|
-
"databaseFile": spec["databaseFile"], "runAs": spec["runAs"],
|
|
289
|
-
"sourceDatabase": request["sourceDatabase"] if action == "import" else None,
|
|
290
|
-
"firstInstall": bool(request["firstInstall"] if action == "migrate" else False),
|
|
291
|
-
"stopsManagedRuntime": bool(action == "migrate" and existing and existing["managedRuntimeRunning"]),
|
|
292
|
-
"createsVerifiedBackup": bool(action == "backup" or (action == "migrate" and not request["firstInstall"])),
|
|
293
|
-
"inventorySnapshot": _snapshot(inventory), "expectedReservationSha256": expected_sha, "spec": spec,
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
def authorization_phrase(plan: dict[str, Any]) -> str:
|
|
298
|
-
production = plan["environment"] == "production"
|
|
299
|
-
mapping = {
|
|
300
|
-
"import": (SQLITE_IMPORT_WRITE_PHRASE, PRODUCTION_SQLITE_IMPORT_WRITE_PHRASE),
|
|
301
|
-
"backup": (SQLITE_BACKUP_WRITE_PHRASE, PRODUCTION_SQLITE_BACKUP_WRITE_PHRASE),
|
|
302
|
-
"migrate": (SQLITE_MIGRATE_WRITE_PHRASE, PRODUCTION_SQLITE_MIGRATE_WRITE_PHRASE),
|
|
303
|
-
"rollback-check": (SQLITE_ROLLBACK_CHECK_PHRASE, PRODUCTION_SQLITE_ROLLBACK_CHECK_PHRASE),
|
|
304
|
-
}
|
|
305
|
-
normal, prod = mapping[plan["action"]]
|
|
306
|
-
return prod if production else normal
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
def apply_sqlite_operation(plan: dict[str, Any]) -> dict[str, Any]:
|
|
310
|
-
required = {"targetName", "action", "ownerRepo", "environment", "appSlug", "image", "databaseFile", "runAs", "sourceDatabase", "firstInstall", "stopsManagedRuntime", "createsVerifiedBackup", "inventorySnapshot", "expectedReservationSha256", "spec"}
|
|
311
|
-
if not isinstance(plan, dict) or set(plan) != required:
|
|
312
|
-
raise DeploymentKitError("Malformed SQLite operation plan.")
|
|
313
|
-
_require_ready_platform(plan["targetName"])
|
|
314
|
-
current = sqlite_inventory(plan["targetName"])
|
|
315
|
-
if _snapshot(current) != plan["inventorySnapshot"]:
|
|
316
|
-
raise DeploymentKitError("SQLite state changed after the plan was reviewed; create and authorize a fresh plan.")
|
|
317
|
-
profile = load_profile(plan["targetName"]); evidence = verify_access(profile)
|
|
318
|
-
output = _run_remote(profile, evidence["address"], plan["action"], spec=plan["spec"], expected_reservation_sha=plan["expectedReservationSha256"])
|
|
319
|
-
result = sqlite_inventory(plan["targetName"])
|
|
320
|
-
result["mutated"] = True
|
|
321
|
-
result["operation"] = plan["action"]; result["operationOutput"] = output.strip()
|
|
322
|
-
return result
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
def assert_app_spec_matches_persistence(name: str, app_spec: dict[str, Any]) -> None:
|
|
326
|
-
inventory = sqlite_inventory(name)
|
|
327
|
-
existing = _find(inventory, app_spec["appSlug"], app_spec["environment"])
|
|
328
|
-
if existing is None:
|
|
329
|
-
return
|
|
330
|
-
if existing["ownerRepo"] != app_spec["ownerRepo"]:
|
|
331
|
-
raise DeploymentKitError("Prepared SQLite persistence belongs to a different repository.")
|
|
332
|
-
if existing["runAs"] != app_spec["runAs"]:
|
|
333
|
-
raise DeploymentKitError("Application runAs does not match the prepared SQLite persistence reservation.")
|
|
334
|
-
if app_spec.get("persistence", {}).get("mode") != "directory":
|
|
1
|
+
"""SQLite persistence adapter for Kit-managed application runtimes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import posixpath
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
from typing import Any, Iterable
|
|
12
|
+
|
|
13
|
+
from .app_runtime import APP_ROOT
|
|
14
|
+
from .box_platform import platform_status
|
|
15
|
+
from .errors import DeploymentKitError
|
|
16
|
+
from .profiles import load_profile
|
|
17
|
+
from .sqlite_adapter_remote import REMOTE_SCRIPT
|
|
18
|
+
from .ssh import ssh_args, verify_access
|
|
19
|
+
|
|
20
|
+
SQLITE_SCHEMA_VERSION = 1
|
|
21
|
+
SQLITE_IMPORT_WRITE_PHRASE = "[SQLITE IMPORT WRITE]"
|
|
22
|
+
PRODUCTION_SQLITE_IMPORT_WRITE_PHRASE = "[PRODUCTION SQLITE IMPORT]"
|
|
23
|
+
SQLITE_BACKUP_WRITE_PHRASE = "[SQLITE BACKUP WRITE]"
|
|
24
|
+
PRODUCTION_SQLITE_BACKUP_WRITE_PHRASE = "[PRODUCTION SQLITE BACKUP]"
|
|
25
|
+
SQLITE_MIGRATE_WRITE_PHRASE = "[SQLITE MIGRATE WRITE]"
|
|
26
|
+
PRODUCTION_SQLITE_MIGRATE_WRITE_PHRASE = "[PRODUCTION SQLITE MIGRATE]"
|
|
27
|
+
SQLITE_ROLLBACK_CHECK_PHRASE = "[SQLITE ROLLBACK CHECK]"
|
|
28
|
+
PRODUCTION_SQLITE_ROLLBACK_CHECK_PHRASE = "[PRODUCTION SQLITE ROLLBACK CHECK]"
|
|
29
|
+
|
|
30
|
+
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
|
31
|
+
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
32
|
+
_IMAGE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$")
|
|
33
|
+
_RUN_AS_RE = re.compile(r"^[0-9]+(?::[0-9]+)?$")
|
|
34
|
+
_DB_FILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
35
|
+
_ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
36
|
+
_SOURCE_RE = re.compile(r"^/srv/[A-Za-z0-9._/+-]+$")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _slug(value: str, field: str) -> str:
|
|
40
|
+
if not isinstance(value, str) or _SLUG_RE.fullmatch(value) is None:
|
|
41
|
+
raise DeploymentKitError(f"{field} must use lowercase letters, digits, and internal hyphens.")
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _owner_repo(value: str) -> str:
|
|
46
|
+
if not isinstance(value, str) or _REPO_RE.fullmatch(value) is None:
|
|
47
|
+
raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _image(value: str) -> str:
|
|
52
|
+
if not isinstance(value, str) or _IMAGE_RE.fullmatch(value) is None:
|
|
53
|
+
raise DeploymentKitError("image must be an immutable image reference ending in @sha256:<64 lowercase hex>.")
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run_as(value: str) -> str:
|
|
58
|
+
if not isinstance(value, str) or _RUN_AS_RE.fullmatch(value) is None:
|
|
59
|
+
raise DeploymentKitError("runAs must be a numeric UID or UID:GID.")
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _database_file(value: str) -> str:
|
|
64
|
+
if not isinstance(value, str) or _DB_FILE_RE.fullmatch(value) is None or value in {".", ".."}:
|
|
65
|
+
raise DeploymentKitError("databaseFile must be a safe file name without path separators.")
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _source_database(value: str) -> str:
|
|
70
|
+
if not isinstance(value, str) or _SOURCE_RE.fullmatch(value) is None:
|
|
71
|
+
raise DeploymentKitError("sourceDatabase must be a safe absolute path under /srv.")
|
|
72
|
+
normalized = posixpath.normpath(value)
|
|
73
|
+
if normalized != value or "/../" in f"{value}/":
|
|
74
|
+
raise DeploymentKitError("sourceDatabase must not contain path traversal or normalization changes.")
|
|
75
|
+
if value.startswith(APP_ROOT + "/"):
|
|
76
|
+
raise DeploymentKitError("sourceDatabase must be outside the Kit-managed application root.")
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _normalize_env(items: dict[str, str] | Iterable[tuple[str, str]]) -> dict[str, str]:
|
|
81
|
+
pairs = items.items() if isinstance(items, dict) else items
|
|
82
|
+
result: dict[str, str] = {}
|
|
83
|
+
for key, value in pairs:
|
|
84
|
+
if not isinstance(key, str) or _ENV_KEY_RE.fullmatch(key) is None:
|
|
85
|
+
raise DeploymentKitError(f"Invalid maintenance environment variable name: {key!r}")
|
|
86
|
+
if key in result:
|
|
87
|
+
raise DeploymentKitError(f"Duplicate maintenance environment variable: {key}")
|
|
88
|
+
if not isinstance(value, str) or any(ch in value for ch in ("\x00", "\n", "\r")):
|
|
89
|
+
raise DeploymentKitError(f"Maintenance environment variable {key} contains unsupported control characters.")
|
|
90
|
+
result[key] = value
|
|
91
|
+
if not result:
|
|
92
|
+
raise DeploymentKitError("At least one maintenance environment value is required.")
|
|
93
|
+
return dict(sorted(result.items()))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def reservation_sha(*, owner_repo: str, environment: str, app_slug: str, run_as: str, database_file: str) -> str:
|
|
97
|
+
value = {
|
|
98
|
+
"schemaVersion": SQLITE_SCHEMA_VERSION,
|
|
99
|
+
"adapter": "sqlite",
|
|
100
|
+
"ownerRepo": owner_repo,
|
|
101
|
+
"environment": environment,
|
|
102
|
+
"appSlug": app_slug,
|
|
103
|
+
"runAs": run_as,
|
|
104
|
+
"databaseFile": database_file,
|
|
105
|
+
}
|
|
106
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def normalize_sqlite_request(
|
|
110
|
+
*, owner_repo: str, environment: str, app_slug: str, image: str,
|
|
111
|
+
maintenance_environment: dict[str, str] | Iterable[tuple[str, str]],
|
|
112
|
+
run_as: str | None = None, database_file: str | None = None,
|
|
113
|
+
source_database: str | None = None, first_install: bool = False,
|
|
114
|
+
) -> dict[str, Any]:
|
|
115
|
+
request = {
|
|
116
|
+
"ownerRepo": _owner_repo(owner_repo),
|
|
117
|
+
"environment": _slug(environment, "environment"),
|
|
118
|
+
"appSlug": _slug(app_slug, "appSlug"),
|
|
119
|
+
"image": _image(image),
|
|
120
|
+
"maintenanceEnvironment": _normalize_env(maintenance_environment),
|
|
121
|
+
"firstInstall": bool(first_install),
|
|
122
|
+
"runAs": _run_as(run_as) if run_as is not None else None,
|
|
123
|
+
"databaseFile": _database_file(database_file) if database_file is not None else None,
|
|
124
|
+
"sourceDatabase": _source_database(source_database) if source_database is not None else None,
|
|
125
|
+
}
|
|
126
|
+
if request["sourceDatabase"] is not None:
|
|
127
|
+
if request["runAs"] is None or request["databaseFile"] is None:
|
|
128
|
+
raise DeploymentKitError("SQLite import requires runAs and databaseFile.")
|
|
129
|
+
if posixpath.basename(request["sourceDatabase"]) != request["databaseFile"]:
|
|
130
|
+
raise DeploymentKitError("sourceDatabase file name must match databaseFile.")
|
|
131
|
+
if request["firstInstall"] and (request["runAs"] is None or request["databaseFile"] is None):
|
|
132
|
+
raise DeploymentKitError("First-install SQLite migration requires runAs and databaseFile.")
|
|
133
|
+
return request
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _b64(value: str) -> str:
|
|
137
|
+
return base64.b64encode(value.encode()).decode("ascii")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def build_remote_script(spec: dict[str, Any] | None = None) -> str:
|
|
141
|
+
spec = spec or {}
|
|
142
|
+
env = spec.get("maintenanceEnvironment", {})
|
|
143
|
+
replacements = {
|
|
144
|
+
"__APP_ROOT__": APP_ROOT,
|
|
145
|
+
"__SQLITE_SCHEMA_VERSION__": str(SQLITE_SCHEMA_VERSION),
|
|
146
|
+
"__OWNER_B64__": _b64(spec.get("ownerRepo", "")),
|
|
147
|
+
"__ENV_B64__": _b64(spec.get("environment", "")),
|
|
148
|
+
"__APP_B64__": _b64(spec.get("appSlug", "")),
|
|
149
|
+
"__IMAGE_B64__": _b64(spec.get("image", "")),
|
|
150
|
+
"__RUN_AS_B64__": _b64(spec.get("runAs") or ""),
|
|
151
|
+
"__DB_FILE_B64__": _b64(spec.get("databaseFile") or ""),
|
|
152
|
+
"__SOURCE_DB_B64__": _b64(spec.get("sourceDatabase") or ""),
|
|
153
|
+
"__FIRST_INSTALL__": "true" if spec.get("firstInstall") else "false",
|
|
154
|
+
"__RESERVATION_SHA__": spec.get("reservationSha256", ""),
|
|
155
|
+
"__ENV_KEYS__": " ".join(f"'{_b64(k)}'" for k in env),
|
|
156
|
+
"__ENV_VALUES__": " ".join(f"'{_b64(v)}'" for v in env.values()),
|
|
157
|
+
}
|
|
158
|
+
script = REMOTE_SCRIPT
|
|
159
|
+
for placeholder, value in replacements.items():
|
|
160
|
+
script = script.replace(placeholder, value)
|
|
161
|
+
if "__" in script:
|
|
162
|
+
raise DeploymentKitError("Internal SQLite adapter script contains an unresolved placeholder.")
|
|
163
|
+
return script
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_inventory(output: str) -> dict[str, Any]:
|
|
167
|
+
summary: dict[str, str] = {}
|
|
168
|
+
adapters: list[dict[str, Any]] = []
|
|
169
|
+
for raw in output.splitlines():
|
|
170
|
+
if not raw:
|
|
171
|
+
continue
|
|
172
|
+
parts = raw.split("\t")
|
|
173
|
+
if parts[0] == "SUMMARY":
|
|
174
|
+
if len(parts) != 3 or parts[1] in summary:
|
|
175
|
+
raise DeploymentKitError("Malformed or duplicate SQLite inventory summary.")
|
|
176
|
+
summary[parts[1]] = parts[2]
|
|
177
|
+
continue
|
|
178
|
+
if parts[0] != "SQLITE" or len(parts) != 12:
|
|
179
|
+
raise DeploymentKitError("Malformed SQLite inventory record.")
|
|
180
|
+
if parts[7] not in {"true", "false"} or parts[10] not in {"true", "false"}:
|
|
181
|
+
raise DeploymentKitError("Malformed SQLite inventory boolean value.")
|
|
182
|
+
try:
|
|
183
|
+
database_bytes = int(parts[8]); backup_count = int(parts[9])
|
|
184
|
+
except ValueError as error:
|
|
185
|
+
raise DeploymentKitError("Malformed SQLite inventory numeric value.") from error
|
|
186
|
+
if re.fullmatch(r"[0-9a-f]{64}", parts[6]) is None:
|
|
187
|
+
raise DeploymentKitError("Malformed SQLite reservation fingerprint.")
|
|
188
|
+
adapters.append({
|
|
189
|
+
"ownerRepo": _owner_repo(parts[1]), "environment": _slug(parts[2], "environment"),
|
|
190
|
+
"appSlug": _slug(parts[3], "appSlug"), "runAs": _run_as(parts[4]),
|
|
191
|
+
"databaseFile": _database_file(parts[5]), "reservationSha256": parts[6],
|
|
192
|
+
"databasePresent": parts[7] == "true", "databaseBytes": database_bytes,
|
|
193
|
+
"backupCount": backup_count, "managedRuntimeRunning": parts[10] == "true",
|
|
194
|
+
"currentRuntimeImage": parts[11] or None,
|
|
195
|
+
})
|
|
196
|
+
if set(summary) != {"sqliteCount"}:
|
|
197
|
+
raise DeploymentKitError("SQLite inventory omitted sqliteCount.")
|
|
198
|
+
try:
|
|
199
|
+
count = int(summary["sqliteCount"])
|
|
200
|
+
except ValueError as error:
|
|
201
|
+
raise DeploymentKitError("Malformed SQLite inventory count.") from error
|
|
202
|
+
if count != len(adapters):
|
|
203
|
+
raise DeploymentKitError("SQLite inventory count does not match records.")
|
|
204
|
+
return {"sqlite": sorted(adapters, key=lambda item: (item["appSlug"], item["environment"]))}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _run_remote(profile: dict[str, Any], address: str, action: str, *, spec: dict[str, Any] | None = None, expected_reservation_sha: str = "") -> str:
|
|
208
|
+
if action not in {"inventory", "import", "backup", "migrate", "rollback-check"}:
|
|
209
|
+
raise DeploymentKitError(f"Unsupported SQLite adapter action: {action}")
|
|
210
|
+
args = [action]
|
|
211
|
+
if action != "inventory":
|
|
212
|
+
args.extend([expected_reservation_sha or "NONE", profile["user"]])
|
|
213
|
+
completed = subprocess.run(
|
|
214
|
+
ssh_args(profile, address, command="sudo -n bash -s -- " + " ".join(args)),
|
|
215
|
+
input=build_remote_script(spec).encode("utf-8"), check=False, capture_output=True,
|
|
216
|
+
)
|
|
217
|
+
stdout = completed.stdout.decode("utf-8", errors="replace")
|
|
218
|
+
stderr = completed.stderr.decode("utf-8", errors="replace")
|
|
219
|
+
if completed.returncode != 0:
|
|
220
|
+
detail = stderr.strip() or stdout.strip() or f"exit {completed.returncode}"
|
|
221
|
+
raise DeploymentKitError(f"Remote SQLite adapter {action} failed: {detail}")
|
|
222
|
+
return stdout
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def sqlite_inventory(name: str) -> dict[str, Any]:
|
|
226
|
+
profile = load_profile(name); evidence = verify_access(profile)
|
|
227
|
+
result = _parse_inventory(_run_remote(profile, evidence["address"], "inventory"))
|
|
228
|
+
result.update(targetName=name, verifiedAddress=evidence["address"], mutated=False)
|
|
229
|
+
return result
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _snapshot(inventory: dict[str, Any]) -> str:
|
|
233
|
+
return hashlib.sha256(json.dumps({"sqlite": inventory["sqlite"]}, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _find(inventory: dict[str, Any], app_slug: str, environment: str) -> dict[str, Any] | None:
|
|
237
|
+
return next((item for item in inventory["sqlite"] if item["appSlug"] == app_slug and item["environment"] == environment), None)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _require_ready_platform(name: str) -> None:
|
|
241
|
+
state = platform_status(name)["state"]
|
|
242
|
+
if state != "ready":
|
|
243
|
+
raise DeploymentKitError(f"SQLite operation requires a ready shared platform; target '{name}' is '{state}'.")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _reservation_from_request(request: dict[str, Any]) -> dict[str, Any]:
|
|
247
|
+
if request["runAs"] is None or request["databaseFile"] is None:
|
|
248
|
+
raise DeploymentKitError("SQLite reservation requires runAs and databaseFile.")
|
|
249
|
+
return {
|
|
250
|
+
"runAs": request["runAs"], "databaseFile": request["databaseFile"],
|
|
251
|
+
"reservationSha256": reservation_sha(owner_repo=request["ownerRepo"], environment=request["environment"], app_slug=request["appSlug"], run_as=request["runAs"], database_file=request["databaseFile"]),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def plan_sqlite_operation(name: str, action: str, request: dict[str, Any]) -> dict[str, Any]:
|
|
256
|
+
if action not in {"import", "backup", "migrate", "rollback-check"}:
|
|
257
|
+
raise DeploymentKitError("Unsupported SQLite plan action.")
|
|
258
|
+
_require_ready_platform(name)
|
|
259
|
+
inventory = sqlite_inventory(name)
|
|
260
|
+
existing = _find(inventory, request["appSlug"], request["environment"])
|
|
261
|
+
expected_sha = existing["reservationSha256"] if existing else ""
|
|
262
|
+
if existing and existing["ownerRepo"] != request["ownerRepo"]:
|
|
263
|
+
raise DeploymentKitError("SQLite reservation ownership does not match the requested repository.")
|
|
264
|
+
spec = dict(request)
|
|
265
|
+
if action == "import":
|
|
266
|
+
if request["sourceDatabase"] is None:
|
|
267
|
+
raise DeploymentKitError("SQLite import requires sourceDatabase.")
|
|
268
|
+
if existing:
|
|
269
|
+
raise DeploymentKitError("SQLite import requires an unreserved destination.")
|
|
270
|
+
spec.update(_reservation_from_request(request))
|
|
271
|
+
elif action == "migrate" and request["firstInstall"]:
|
|
272
|
+
if existing:
|
|
273
|
+
raise DeploymentKitError("First-install SQLite migration requires an unreserved destination.")
|
|
274
|
+
spec.update(_reservation_from_request(request))
|
|
275
|
+
else:
|
|
276
|
+
if existing is None:
|
|
277
|
+
raise DeploymentKitError(f"SQLite {action} requires an existing adapter reservation.")
|
|
278
|
+
if request["runAs"] is not None and request["runAs"] != existing["runAs"]:
|
|
279
|
+
raise DeploymentKitError("Requested runAs does not match the existing SQLite reservation.")
|
|
280
|
+
if request["databaseFile"] is not None and request["databaseFile"] != existing["databaseFile"]:
|
|
281
|
+
raise DeploymentKitError("Requested databaseFile does not match the existing SQLite reservation.")
|
|
282
|
+
spec.update(runAs=existing["runAs"], databaseFile=existing["databaseFile"], reservationSha256=existing["reservationSha256"])
|
|
283
|
+
if action in {"backup", "migrate", "rollback-check"} and not existing["databasePresent"]:
|
|
284
|
+
raise DeploymentKitError(f"SQLite {action} requires an existing database.")
|
|
285
|
+
return {
|
|
286
|
+
"targetName": name, "action": action, "ownerRepo": request["ownerRepo"],
|
|
287
|
+
"environment": request["environment"], "appSlug": request["appSlug"], "image": request["image"],
|
|
288
|
+
"databaseFile": spec["databaseFile"], "runAs": spec["runAs"],
|
|
289
|
+
"sourceDatabase": request["sourceDatabase"] if action == "import" else None,
|
|
290
|
+
"firstInstall": bool(request["firstInstall"] if action == "migrate" else False),
|
|
291
|
+
"stopsManagedRuntime": bool(action == "migrate" and existing and existing["managedRuntimeRunning"]),
|
|
292
|
+
"createsVerifiedBackup": bool(action == "backup" or (action == "migrate" and not request["firstInstall"])),
|
|
293
|
+
"inventorySnapshot": _snapshot(inventory), "expectedReservationSha256": expected_sha, "spec": spec,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def authorization_phrase(plan: dict[str, Any]) -> str:
|
|
298
|
+
production = plan["environment"] == "production"
|
|
299
|
+
mapping = {
|
|
300
|
+
"import": (SQLITE_IMPORT_WRITE_PHRASE, PRODUCTION_SQLITE_IMPORT_WRITE_PHRASE),
|
|
301
|
+
"backup": (SQLITE_BACKUP_WRITE_PHRASE, PRODUCTION_SQLITE_BACKUP_WRITE_PHRASE),
|
|
302
|
+
"migrate": (SQLITE_MIGRATE_WRITE_PHRASE, PRODUCTION_SQLITE_MIGRATE_WRITE_PHRASE),
|
|
303
|
+
"rollback-check": (SQLITE_ROLLBACK_CHECK_PHRASE, PRODUCTION_SQLITE_ROLLBACK_CHECK_PHRASE),
|
|
304
|
+
}
|
|
305
|
+
normal, prod = mapping[plan["action"]]
|
|
306
|
+
return prod if production else normal
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def apply_sqlite_operation(plan: dict[str, Any]) -> dict[str, Any]:
|
|
310
|
+
required = {"targetName", "action", "ownerRepo", "environment", "appSlug", "image", "databaseFile", "runAs", "sourceDatabase", "firstInstall", "stopsManagedRuntime", "createsVerifiedBackup", "inventorySnapshot", "expectedReservationSha256", "spec"}
|
|
311
|
+
if not isinstance(plan, dict) or set(plan) != required:
|
|
312
|
+
raise DeploymentKitError("Malformed SQLite operation plan.")
|
|
313
|
+
_require_ready_platform(plan["targetName"])
|
|
314
|
+
current = sqlite_inventory(plan["targetName"])
|
|
315
|
+
if _snapshot(current) != plan["inventorySnapshot"]:
|
|
316
|
+
raise DeploymentKitError("SQLite state changed after the plan was reviewed; create and authorize a fresh plan.")
|
|
317
|
+
profile = load_profile(plan["targetName"]); evidence = verify_access(profile)
|
|
318
|
+
output = _run_remote(profile, evidence["address"], plan["action"], spec=plan["spec"], expected_reservation_sha=plan["expectedReservationSha256"])
|
|
319
|
+
result = sqlite_inventory(plan["targetName"])
|
|
320
|
+
result["mutated"] = True
|
|
321
|
+
result["operation"] = plan["action"]; result["operationOutput"] = output.strip()
|
|
322
|
+
return result
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def assert_app_spec_matches_persistence(name: str, app_spec: dict[str, Any]) -> None:
|
|
326
|
+
inventory = sqlite_inventory(name)
|
|
327
|
+
existing = _find(inventory, app_spec["appSlug"], app_spec["environment"])
|
|
328
|
+
if existing is None:
|
|
329
|
+
return
|
|
330
|
+
if existing["ownerRepo"] != app_spec["ownerRepo"]:
|
|
331
|
+
raise DeploymentKitError("Prepared SQLite persistence belongs to a different repository.")
|
|
332
|
+
if existing["runAs"] != app_spec["runAs"]:
|
|
333
|
+
raise DeploymentKitError("Application runAs does not match the prepared SQLite persistence reservation.")
|
|
334
|
+
if app_spec.get("persistence", {}).get("mode") != "directory":
|
|
335
335
|
raise DeploymentKitError("Prepared SQLite persistence requires directory application persistence.")
|