@mriekki/lean 0.0.0-stage → 0.1.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 (82) hide show
  1. package/README.md +67 -3
  2. package/assets/components.json +33 -0
  3. package/assets/core/distribution.json +51 -0
  4. package/assets/deployment/distribution.json +34 -0
  5. package/assets/generated/ASSEMBLY.json +382 -0
  6. package/assets/generated/core/core/agents/coach.agent.md +69 -0
  7. package/assets/generated/core/core/agents/reviewer.agent.md +85 -0
  8. package/assets/generated/core/core/instructions/lean-docs.instructions.md +14 -0
  9. package/assets/generated/core/core/instructions/lean-workflow.instructions.md +35 -0
  10. package/assets/generated/core/core/learning/CODING_DRILLS.md +35 -0
  11. package/assets/generated/core/core/learning/DIAGRAM_GUIDE.md +19 -0
  12. package/assets/generated/core/core/learning/ENGINEERING_LEARNING_WORKBOOK.md +82 -0
  13. package/assets/generated/core/core/learning/LEARNING_MAP.md +51 -0
  14. package/assets/generated/core/core/learning/LEARNING_PROGRESS.md +31 -0
  15. package/assets/generated/core/core/learning/RESOURCE_CATALOG.md +25 -0
  16. package/assets/generated/core/core/learning/SQL_DRILLS.md +27 -0
  17. package/assets/generated/core/core/learning/SYSTEM_DESIGN_DRILLS.md +21 -0
  18. package/assets/generated/core/core/process/COACHING_GUIDE.md +57 -0
  19. package/assets/generated/core/core/process/GUIDED_NEXT_STEPS.md +48 -0
  20. package/assets/generated/core/core/process/HUMAN_GATES.md +81 -0
  21. package/assets/generated/core/core/process/INTERACTIVE_SESSION_GUIDE.md +62 -0
  22. package/assets/generated/core/core/process/PRODUCTION_ENABLEMENT.md +59 -0
  23. package/assets/generated/core/core/process/PR_WORKFLOW.md +53 -0
  24. package/assets/generated/core/core/process/SETUP_ACCEPTANCE_CHECKLIST.md +72 -0
  25. package/assets/generated/core/core/process/WORK_ITEM_MODEL.md +56 -0
  26. package/assets/generated/core/core/skills/cleanup-branches/SKILL.md +190 -0
  27. package/assets/generated/core/core/skills/coach/SKILL.md +136 -0
  28. package/assets/generated/core/core/skills/code-walkthrough/SKILL.md +197 -0
  29. package/assets/generated/core/core/skills/core-sync/SKILL.md +220 -0
  30. package/assets/generated/core/core/skills/define-feature/SKILL.md +144 -0
  31. package/assets/generated/core/core/skills/deliver/SKILL.md +315 -0
  32. package/assets/generated/core/core/skills/deploy/SKILL.md +193 -0
  33. package/assets/generated/core/core/skills/grill-me/SKILL.md +77 -0
  34. package/assets/generated/core/core/skills/grill-with-docs/SKILL.md +90 -0
  35. package/assets/generated/core/core/skills/learning-review/SKILL.md +90 -0
  36. package/assets/generated/core/core/skills/next-step/SKILL.md +166 -0
  37. package/assets/generated/core/core/skills/production-readiness/SKILL.md +357 -0
  38. package/assets/generated/core/core/skills/pull-latest/SKILL.md +166 -0
  39. package/assets/generated/core/core/skills/refine-requirements/SKILL.md +207 -0
  40. package/assets/generated/core/core/skills/start-work/SKILL.md +178 -0
  41. package/assets/generated/core/core/skills/tear-down/SKILL.md +138 -0
  42. package/assets/generated/core/core/skills/upstream-fix/SKILL.md +206 -0
  43. package/assets/generated/core/core/skills/wait-what/SKILL.md +86 -0
  44. package/assets/generated/core/core/templates/FEATURE_BRIEF_TEMPLATE.md +27 -0
  45. package/assets/generated/core/core/templates/PRODUCT_BRIEF_TEMPLATE.md +27 -0
  46. package/assets/generated/core/core/templates/PULL_REQUEST_TEMPLATE.md +37 -0
  47. package/assets/generated/core/core/templates/REQUIREMENTS_TEMPLATE.md +17 -0
  48. package/assets/generated/core/profiles/greenfield/lean-workspace.json +12 -0
  49. package/assets/generated/core/tools/package/check_baseline.py +102 -0
  50. package/assets/generated/core/tools/package/check_upstream.py +171 -0
  51. package/assets/generated/deployment/distribution/lean-poc-deploy.py +8 -0
  52. package/assets/generated/deployment/src/lean_poc_deployment/__init__.py +3 -0
  53. package/assets/generated/deployment/src/lean_poc_deployment/__main__.py +3 -0
  54. package/assets/generated/deployment/src/lean_poc_deployment/app_cli.py +140 -0
  55. package/assets/generated/deployment/src/lean_poc_deployment/app_runtime.py +455 -0
  56. package/assets/generated/deployment/src/lean_poc_deployment/app_runtime_remote.py +416 -0
  57. package/assets/generated/deployment/src/lean_poc_deployment/box_platform.py +208 -0
  58. package/assets/generated/deployment/src/lean_poc_deployment/box_platform_assets.py +100 -0
  59. package/assets/generated/deployment/src/lean_poc_deployment/box_platform_remote.py +271 -0
  60. package/assets/generated/deployment/src/lean_poc_deployment/cli.py +437 -0
  61. package/assets/generated/deployment/src/lean_poc_deployment/doctor.py +121 -0
  62. package/assets/generated/deployment/src/lean_poc_deployment/entrypoint.py +25 -0
  63. package/assets/generated/deployment/src/lean_poc_deployment/errors.py +5 -0
  64. package/assets/generated/deployment/src/lean_poc_deployment/keys.py +171 -0
  65. package/assets/generated/deployment/src/lean_poc_deployment/profiles.py +204 -0
  66. package/assets/generated/deployment/src/lean_poc_deployment/route_registry.py +367 -0
  67. package/assets/generated/deployment/src/lean_poc_deployment/route_registry_remote.py +443 -0
  68. package/assets/generated/deployment/src/lean_poc_deployment/security.py +290 -0
  69. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter.py +335 -0
  70. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter_remote.py +340 -0
  71. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_cli.py +86 -0
  72. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_import_recovery.py +437 -0
  73. package/assets/generated/deployment/src/lean_poc_deployment/ssh.py +118 -0
  74. package/assets/generated/deployment/src/lean_poc_deployment/teardown.py +286 -0
  75. package/assets/generated/deployment/src/lean_poc_deployment/teardown_cli.py +120 -0
  76. package/assets/generated/deployment/src/lean_poc_deployment/teardown_remote.py +100 -0
  77. package/assets/generated/deployment/src/lean_poc_deployment/trust.py +278 -0
  78. package/bin/lean.mjs +9 -0
  79. package/package.json +33 -6
  80. package/src/cli.mjs +131 -0
  81. package/src/engine.mjs +243 -0
  82. package/src/package-data.mjs +18 -0
@@ -0,0 +1,290 @@
1
+ """Filesystem integrity checks for SSH identity and host-trust material."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import os
8
+ import shutil
9
+ import stat
10
+ import subprocess
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .errors import DeploymentKitError
15
+
16
+
17
+ WINDOWS_SID_RE = __import__("re").compile(r"^S-1-(?:[0-9]+-){1,15}[0-9]+$")
18
+ HOST_KEY_TYPES = {"ssh-ed25519", "ecdsa-sha2-nistp256", "ssh-rsa"}
19
+
20
+
21
+ def _valid_windows_sid(value: Any) -> bool:
22
+ if not isinstance(value, str) or WINDOWS_SID_RE.fullmatch(value) is None:
23
+ return False
24
+ components = value.split("-")
25
+ subauthorities = components[3:]
26
+ if not 1 <= len(subauthorities) <= 15:
27
+ return False
28
+
29
+ def within_bound(component: str, maximum: int) -> bool:
30
+ if component != "0" and component.startswith("0"):
31
+ return False
32
+ limit = str(maximum)
33
+ return len(component) < len(limit) or (len(component) == len(limit) and component <= limit)
34
+
35
+ return within_bound(components[2], 0xFFFFFFFFFFFF) and all(
36
+ within_bound(component, 0xFFFFFFFF) for component in subauthorities
37
+ )
38
+
39
+
40
+ def validate_windows_acl_report(report: Any) -> None:
41
+ try:
42
+ if not isinstance(report, dict) or set(report) != {"currentUserSid", "entries"}:
43
+ raise ValueError("unexpected ACL report shape")
44
+ current_user = report["currentUserSid"]
45
+ entries = report["entries"]
46
+ if not _valid_windows_sid(current_user):
47
+ raise ValueError("invalid current-user SID")
48
+ if not isinstance(entries, list) or not entries:
49
+ raise ValueError("missing ACL entries")
50
+
51
+ trusted_sids = {current_user, "S-1-5-18", "S-1-5-32-544"}
52
+ write_rights = 2 | 4 | 16 | 64 | 256 | 65536 | 262144 | 524288
53
+ known_rights = (
54
+ 1 | 2 | 4 | 8 | 16 | 32 | 64 | 128 | 256 |
55
+ 65536 | 131072 | 262144 | 524288 | 1048576
56
+ )
57
+
58
+ for entry in entries:
59
+ if (
60
+ not isinstance(entry, dict)
61
+ or set(entry) != {"path", "ownerSid", "reparsePoint", "rules"}
62
+ or not isinstance(entry["path"], str)
63
+ or not entry["path"]
64
+ or not _valid_windows_sid(entry["ownerSid"])
65
+ or type(entry["reparsePoint"]) is not bool
66
+ or not isinstance(entry["rules"], list)
67
+ ):
68
+ raise ValueError("malformed ACL entry")
69
+ if entry["reparsePoint"] or entry["ownerSid"] not in trusted_sids:
70
+ raise DeploymentKitError(
71
+ f"Windows SSH material has an untrusted owner or reparse point: {entry['path']}"
72
+ )
73
+ for rule in entry["rules"]:
74
+ if (
75
+ not isinstance(rule, dict)
76
+ or set(rule) != {"sid", "rights", "type"}
77
+ or not _valid_windows_sid(rule["sid"])
78
+ or rule["type"] not in {"Allow", "Deny"}
79
+ or type(rule["rights"]) is not int
80
+ or rule["rights"] < 0
81
+ or rule["rights"] & ~known_rights
82
+ ):
83
+ raise ValueError("malformed ACL rule")
84
+ if (
85
+ rule["type"] == "Allow"
86
+ and rule["sid"] not in trusted_sids
87
+ and rule["rights"] & write_rights
88
+ ):
89
+ raise DeploymentKitError(
90
+ f"Windows SSH material grants write access to an untrusted principal: {entry['path']}"
91
+ )
92
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
93
+ raise DeploymentKitError(
94
+ "Windows SSH-material ACL report was malformed; verification is blocked."
95
+ ) from error
96
+
97
+
98
+ def _ssh_directory_for(path: Path) -> Path:
99
+ candidates = (path, *path.parents)
100
+ ssh_directory = next((candidate for candidate in candidates if candidate.name.lower() == ".ssh"), None)
101
+ if ssh_directory is None:
102
+ raise DeploymentKitError("SSH material is not inside the user's .ssh directory.")
103
+ return ssh_directory
104
+
105
+
106
+ def _check_windows_acl(path: Path) -> None:
107
+ powershell = shutil.which("pwsh") or shutil.which("powershell")
108
+ if powershell is None:
109
+ raise DeploymentKitError(
110
+ "Cannot verify Windows SSH-material ACLs because PowerShell is unavailable."
111
+ )
112
+ ssh_directory = _ssh_directory_for(path)
113
+
114
+ checked_paths = [path]
115
+ directory = path.parent
116
+ while True:
117
+ checked_paths.append(directory)
118
+ if directory == ssh_directory.parent:
119
+ break
120
+ if directory != ssh_directory and ssh_directory not in directory.parents:
121
+ raise DeploymentKitError("SSH-material path escaped the user's .ssh directory.")
122
+ directory = directory.parent
123
+
124
+ serialized_paths = json.dumps([str(candidate) for candidate in dict.fromkeys(checked_paths)])
125
+ serialized_paths = serialized_paths.replace("'", "''")
126
+ script = f"""
127
+ $ErrorActionPreference = 'Stop'
128
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
129
+ $paths = @(ConvertFrom-Json -InputObject '{serialized_paths}')
130
+ $userSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
131
+ $entries = foreach ($candidate in $paths) {{
132
+ $item = Get-Item -LiteralPath $candidate -Force
133
+ $acl = Get-Acl -LiteralPath $candidate
134
+ $rules = foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) {{
135
+ [ordered]@{{
136
+ sid = $rule.IdentityReference.Value
137
+ rights = [int]$rule.FileSystemRights
138
+ type = $rule.AccessControlType.ToString()
139
+ }}
140
+ }}
141
+ [ordered]@{{
142
+ path = $candidate
143
+ ownerSid = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
144
+ reparsePoint = (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)
145
+ rules = @($rules)
146
+ }}
147
+ }}
148
+ [ordered]@{{ currentUserSid = $userSid; entries = @($entries) }} | ConvertTo-Json -Depth 6 -Compress
149
+ """
150
+ result = subprocess.run(
151
+ [powershell, "-NoProfile", "-NonInteractive", "-Command", script],
152
+ check=False,
153
+ capture_output=True,
154
+ text=True,
155
+ encoding="utf-8",
156
+ errors="replace",
157
+ )
158
+ if result.returncode != 0:
159
+ raise DeploymentKitError(
160
+ "Cannot verify Windows SSH-material ACLs; access verification is blocked."
161
+ )
162
+ try:
163
+ validate_windows_acl_report(json.loads(result.stdout))
164
+ except (TypeError, ValueError, json.JSONDecodeError) as error:
165
+ raise DeploymentKitError(
166
+ "Windows SSH-material ACL report was malformed; verification is blocked."
167
+ ) from error
168
+
169
+
170
+ def _posix_directory_chain(path: Path) -> list[Path]:
171
+ ssh_directory = _ssh_directory_for(path)
172
+ directories: list[Path] = []
173
+ current = path.parent
174
+ while True:
175
+ directories.append(current)
176
+ if current == ssh_directory:
177
+ return directories
178
+ if current == current.parent:
179
+ raise DeploymentKitError("SSH-material path escaped the user's .ssh directory.")
180
+ current = current.parent
181
+
182
+
183
+ def _check_posix_directories(path: Path) -> None:
184
+ for directory in _posix_directory_chain(path):
185
+ if directory.is_symlink() or not directory.is_dir():
186
+ raise DeploymentKitError(f"SSH directory must be a real directory: {directory}")
187
+ if directory.stat().st_mode & (stat.S_IRWXG | stat.S_IRWXO):
188
+ raise DeploymentKitError(f"SSH directory permissions are too broad: {directory}")
189
+
190
+
191
+ def prepare_ssh_directory(path: Path) -> None:
192
+ """Create one SSH-material directory when absent, then verify its safety before secrets are written."""
193
+ _ssh_directory_for(path)
194
+ if path.exists():
195
+ if path.is_symlink() or not path.is_dir():
196
+ raise DeploymentKitError(f"SSH material directory is not a real directory: {path}")
197
+ else:
198
+ path.mkdir(parents=True, mode=0o700)
199
+ if os.name != "nt":
200
+ path.chmod(0o700)
201
+ _check_posix_directories(path / "placeholder")
202
+ else:
203
+ _check_windows_acl(path)
204
+
205
+
206
+ def check_identity_security(identity: Path) -> None:
207
+ if not identity.is_file():
208
+ raise DeploymentKitError(f"Required SSH identity is missing: {identity}")
209
+ if identity.is_symlink():
210
+ raise DeploymentKitError(f"SSH material must not use symbolic links: {identity}")
211
+ if os.name == "nt":
212
+ _check_windows_acl(identity)
213
+ else:
214
+ _check_posix_directories(identity)
215
+ if identity.stat().st_mode & (stat.S_IRWXG | stat.S_IRWXO):
216
+ raise DeploymentKitError(f"Private key permissions are too broad: {identity}")
217
+
218
+
219
+ def check_known_hosts_security(known_hosts: Path) -> None:
220
+ if not known_hosts.is_file():
221
+ raise DeploymentKitError(f"Required SSH trust file is missing: {known_hosts}")
222
+ if known_hosts.is_symlink():
223
+ raise DeploymentKitError(f"SSH material must not use symbolic links: {known_hosts}")
224
+ if os.name == "nt":
225
+ _check_windows_acl(known_hosts)
226
+ else:
227
+ _check_posix_directories(known_hosts)
228
+ if known_hosts.stat().st_mode & (stat.S_IWGRP | stat.S_IWOTH):
229
+ raise DeploymentKitError(f"Target trust file is writable by another principal: {known_hosts}")
230
+
231
+
232
+ def check_ssh_material_security(identity: Path, known_hosts: Path) -> None:
233
+ check_identity_security(identity)
234
+ check_known_hosts_security(known_hosts)
235
+
236
+
237
+ def known_hosts_token(profile: dict[str, Any]) -> str:
238
+ alias = profile["sshAlias"]
239
+ port = profile["port"]
240
+ return alias if port == 22 else f"[{alias}]:{port}"
241
+
242
+
243
+ def validate_target_trust_file(
244
+ profile: dict[str, Any],
245
+ *,
246
+ allow_missing_or_empty: bool = False,
247
+ ) -> list[tuple[str, str]]:
248
+ """Validate that a target trust file contains only one exact stable-alias host key."""
249
+ path = Path(profile["knownHostsFile"])
250
+ if not path.exists():
251
+ if allow_missing_or_empty:
252
+ return []
253
+ raise DeploymentKitError(f"Target-specific trust file is missing: {path}")
254
+ if path.is_symlink() or not path.is_file():
255
+ raise DeploymentKitError(f"Target-specific trust file is not a real file: {path}")
256
+ try:
257
+ raw_lines = path.read_text(encoding="utf-8").splitlines()
258
+ except (OSError, UnicodeError) as error:
259
+ raise DeploymentKitError(f"Cannot read target-specific trust file {path}: {error}") from error
260
+
261
+ lines = [line.strip() for line in raw_lines if line.strip()]
262
+ if not lines:
263
+ if allow_missing_or_empty:
264
+ return []
265
+ raise DeploymentKitError(f"Target-specific trust file is empty: {path}")
266
+ if len(lines) != 1:
267
+ raise DeploymentKitError(
268
+ "Target-specific trust file must contain exactly one literal stable-alias host key."
269
+ )
270
+
271
+ fields = lines[0].split()
272
+ if len(fields) != 3:
273
+ raise DeploymentKitError("Target-specific trust entry must contain exactly host, key type, and key data.")
274
+ host_token, key_type, key_data = fields
275
+ expected = known_hosts_token(profile)
276
+ if host_token != expected:
277
+ raise DeploymentKitError(
278
+ f"Target-specific trust entry must use only the stable alias token '{expected}'."
279
+ )
280
+ if any(marker in host_token for marker in ("*", "?", ",", "|")) or host_token.startswith("@"):
281
+ raise DeploymentKitError("Wildcard, hashed, marked, or multi-host trust entries are not allowed.")
282
+ if key_type not in HOST_KEY_TYPES:
283
+ raise DeploymentKitError(f"Unsupported target host-key type in trust file: {key_type}")
284
+ try:
285
+ decoded = base64.b64decode(key_data.encode("ascii"), validate=True)
286
+ except (ValueError, UnicodeError) as error:
287
+ raise DeploymentKitError("Target-specific trust entry contains invalid key data.") from error
288
+ if not decoded:
289
+ raise DeploymentKitError("Target-specific trust entry contains empty key data.")
290
+ return [(key_type, key_data)]
@@ -0,0 +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":
335
+ raise DeploymentKitError("Prepared SQLite persistence requires directory application persistence.")