@mriekki/lean 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +71 -67
  2. package/assets/components.json +33 -33
  3. package/assets/core/distribution.json +52 -51
  4. package/assets/deployment/distribution.json +34 -34
  5. package/assets/generated/ASSEMBLY.json +38 -33
  6. package/assets/generated/core/core/skills/core-sync/SKILL.md +78 -120
  7. package/assets/generated/core/core/skills/deploy/SKILL.md +74 -91
  8. package/assets/generated/core/core/skills/lean-update/SKILL.md +189 -0
  9. package/assets/generated/core/core/skills/upstream-fix/SKILL.md +91 -76
  10. package/assets/generated/deployment/distribution/lean-poc-deploy.py +8 -8
  11. package/assets/generated/deployment/src/lean_poc_deployment/__init__.py +3 -3
  12. package/assets/generated/deployment/src/lean_poc_deployment/__main__.py +3 -3
  13. package/assets/generated/deployment/src/lean_poc_deployment/app_cli.py +140 -140
  14. package/assets/generated/deployment/src/lean_poc_deployment/app_runtime.py +455 -455
  15. package/assets/generated/deployment/src/lean_poc_deployment/app_runtime_remote.py +415 -415
  16. package/assets/generated/deployment/src/lean_poc_deployment/box_platform.py +208 -208
  17. package/assets/generated/deployment/src/lean_poc_deployment/box_platform_assets.py +100 -100
  18. package/assets/generated/deployment/src/lean_poc_deployment/box_platform_remote.py +271 -271
  19. package/assets/generated/deployment/src/lean_poc_deployment/cli.py +437 -437
  20. package/assets/generated/deployment/src/lean_poc_deployment/doctor.py +121 -121
  21. package/assets/generated/deployment/src/lean_poc_deployment/entrypoint.py +25 -25
  22. package/assets/generated/deployment/src/lean_poc_deployment/errors.py +5 -5
  23. package/assets/generated/deployment/src/lean_poc_deployment/keys.py +171 -171
  24. package/assets/generated/deployment/src/lean_poc_deployment/profiles.py +204 -204
  25. package/assets/generated/deployment/src/lean_poc_deployment/route_registry.py +367 -367
  26. package/assets/generated/deployment/src/lean_poc_deployment/route_registry_remote.py +443 -443
  27. package/assets/generated/deployment/src/lean_poc_deployment/security.py +290 -290
  28. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter.py +334 -334
  29. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter_remote.py +339 -339
  30. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_cli.py +85 -85
  31. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_import_recovery.py +437 -437
  32. package/assets/generated/deployment/src/lean_poc_deployment/ssh.py +118 -118
  33. package/assets/generated/deployment/src/lean_poc_deployment/teardown.py +286 -286
  34. package/assets/generated/deployment/src/lean_poc_deployment/teardown_cli.py +120 -120
  35. package/assets/generated/deployment/src/lean_poc_deployment/teardown_remote.py +100 -100
  36. package/assets/generated/deployment/src/lean_poc_deployment/trust.py +278 -278
  37. package/bin/lean.mjs +9 -9
  38. package/package.json +33 -33
  39. package/src/cli.mjs +131 -131
  40. package/src/engine.mjs +243 -243
  41. package/src/package-data.mjs +18 -18
@@ -1,455 +1,455 @@
1
- """Generic immutable application-runtime deployment for Kit-managed hosts."""
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, Iterable
11
-
12
- from .box_platform import platform_status
13
- from .box_platform_assets import CADDY_CONTAINER, NETWORK_NAME
14
- from .errors import DeploymentKitError
15
- from .profiles import load_profile
16
- from .ssh import ssh_args, verify_access
17
- from .app_runtime_remote import REMOTE_SCRIPT
18
-
19
- APP_ROOT = "/srv/lean-poc-apps"
20
- APP_SCHEMA_VERSION = 1
21
- APP_DEPLOY_WRITE_PHRASE = "[APP DEPLOY WRITE]"
22
- PRODUCTION_APP_DEPLOY_WRITE_PHRASE = "[PRODUCTION APP DEPLOY]"
23
-
24
- _SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
25
- _REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
26
- _CONTAINER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
27
- _IMAGE_RE = re.compile(
28
- r"^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$"
29
- )
30
- _RUN_AS_RE = re.compile(r"^[0-9]+(?::[0-9]+)?$")
31
- _ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
32
- _HEALTH_PATH_RE = re.compile(r"^/[A-Za-z0-9._~!$&'()*+,;=:@%/\-]*$")
33
- _CONTAINER_PATH_RE = re.compile(r"^/[A-Za-z0-9._~+:/\-]*$")
34
-
35
-
36
- def _slug(value: str, field: str) -> str:
37
- if not isinstance(value, str) or _SLUG_RE.fullmatch(value) is None:
38
- raise DeploymentKitError(f"{field} must use lowercase letters, digits, and internal hyphens.")
39
- return value
40
-
41
-
42
- def _owner_repo(value: str) -> str:
43
- if not isinstance(value, str) or _REPO_RE.fullmatch(value) is None:
44
- raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
45
- return value
46
-
47
-
48
- def _container_name(value: str) -> str:
49
- if not isinstance(value, str) or _CONTAINER_RE.fullmatch(value) is None:
50
- raise DeploymentKitError("containerName contains unsupported characters.")
51
- return value
52
-
53
-
54
- def _image(value: str) -> str:
55
- if not isinstance(value, str) or _IMAGE_RE.fullmatch(value) is None:
56
- raise DeploymentKitError("image must be an immutable image reference ending in @sha256:<64 lowercase hex>.")
57
- return value
58
-
59
-
60
- def _runtime_port(value: int) -> int:
61
- if type(value) is not int or not 1 <= value <= 65535:
62
- raise DeploymentKitError("runtimePort must be an integer from 1 through 65535.")
63
- return value
64
-
65
-
66
- def _health_path(value: str) -> str:
67
- if not isinstance(value, str) or _HEALTH_PATH_RE.fullmatch(value) is None:
68
- raise DeploymentKitError("healthPath must be a safe absolute HTTP path.")
69
- return value
70
-
71
-
72
- def _run_as(value: str) -> str:
73
- if not isinstance(value, str) or _RUN_AS_RE.fullmatch(value) is None:
74
- raise DeploymentKitError("runAs must be a numeric UID or UID:GID.")
75
- return value
76
-
77
-
78
- def _container_path(value: str | None) -> str | None:
79
- if value is None:
80
- return None
81
- if not isinstance(value, str) or _CONTAINER_PATH_RE.fullmatch(value) is None or "/../" in f"{value}/":
82
- raise DeploymentKitError("dataContainerPath must be a safe absolute container path.")
83
- return value.rstrip("/") or "/"
84
-
85
-
86
- def _normalize_environment_variables(items: dict[str, str] | Iterable[tuple[str, str]]) -> dict[str, str]:
87
- pairs = items.items() if isinstance(items, dict) else items
88
- result: dict[str, str] = {}
89
- for key, value in pairs:
90
- if not isinstance(key, str) or _ENV_KEY_RE.fullmatch(key) is None:
91
- raise DeploymentKitError(f"Invalid environment variable name: {key!r}")
92
- if key in result:
93
- raise DeploymentKitError(f"Duplicate environment variable: {key}")
94
- if not isinstance(value, str) or "\x00" in value or "\n" in value or "\r" in value:
95
- raise DeploymentKitError(f"Environment variable {key} contains unsupported control characters.")
96
- result[key] = value
97
- return dict(sorted(result.items()))
98
-
99
-
100
- def normalize_app_spec(
101
- *,
102
- owner_repo: str,
103
- environment: str,
104
- app_slug: str,
105
- container_name: str,
106
- image: str,
107
- runtime_port: int,
108
- health_path: str,
109
- run_as: str,
110
- persistence_mode: str,
111
- data_container_path: str | None,
112
- environment_variables: dict[str, str] | Iterable[tuple[str, str]],
113
- ) -> dict[str, Any]:
114
- if persistence_mode not in {"none", "directory"}:
115
- raise DeploymentKitError("persistenceMode must be 'none' or 'directory'.")
116
- data_path = _container_path(data_container_path)
117
- if persistence_mode == "directory" and data_path is None:
118
- raise DeploymentKitError("directory persistence requires dataContainerPath.")
119
- if persistence_mode == "none" and data_path is not None:
120
- raise DeploymentKitError("dataContainerPath must be omitted when persistenceMode is 'none'.")
121
- spec = {
122
- "schemaVersion": APP_SCHEMA_VERSION,
123
- "ownerRepo": _owner_repo(owner_repo),
124
- "environment": _slug(environment, "environment"),
125
- "appSlug": _slug(app_slug, "appSlug"),
126
- "containerName": _container_name(container_name),
127
- "image": _image(image),
128
- "runtimePort": _runtime_port(runtime_port),
129
- "healthPath": _health_path(health_path),
130
- "runAs": _run_as(run_as),
131
- "persistence": {
132
- "mode": persistence_mode,
133
- "dataContainerPath": data_path,
134
- },
135
- "environmentVariables": _normalize_environment_variables(environment_variables),
136
- }
137
- spec["specSha256"] = _spec_sha(spec)
138
- return spec
139
-
140
-
141
- def _spec_sha(spec: dict[str, Any]) -> str:
142
- value = {key: spec[key] for key in spec if key != "specSha256"}
143
- return hashlib.sha256(
144
- json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
145
- ).hexdigest()
146
-
147
-
148
- def _b64(value: str) -> str:
149
- return base64.b64encode(value.encode("utf-8")).decode("ascii")
150
-
151
-
152
- def build_remote_script(spec: dict[str, Any] | None = None) -> str:
153
- env_keys: list[str] = []
154
- env_values: list[str] = []
155
- desired = {
156
- "ownerRepo": "",
157
- "environment": "",
158
- "appSlug": "",
159
- "containerName": "",
160
- "image": "",
161
- "runtimePort": "",
162
- "healthPath": "",
163
- "runAs": "",
164
- "persistenceMode": "",
165
- "dataContainerPath": "",
166
- "specSha256": "",
167
- }
168
- if spec is not None:
169
- desired.update(
170
- ownerRepo=spec["ownerRepo"],
171
- environment=spec["environment"],
172
- appSlug=spec["appSlug"],
173
- containerName=spec["containerName"],
174
- image=spec["image"],
175
- runtimePort=str(spec["runtimePort"]),
176
- healthPath=spec["healthPath"],
177
- runAs=spec["runAs"],
178
- persistenceMode=spec["persistence"]["mode"],
179
- dataContainerPath=spec["persistence"]["dataContainerPath"] or "",
180
- specSha256=spec["specSha256"],
181
- )
182
- for key, value in spec["environmentVariables"].items():
183
- env_keys.append(_b64(key))
184
- env_values.append(_b64(value))
185
-
186
- replacements = {
187
- "__APP_ROOT__": APP_ROOT,
188
- "__APP_SCHEMA_VERSION__": str(APP_SCHEMA_VERSION),
189
- "__NETWORK_NAME__": NETWORK_NAME,
190
- "__CADDY_CONTAINER__": CADDY_CONTAINER,
191
- "__DESIRED_PRESENT__": "true" if spec is not None else "false",
192
- "__D_OWNER_B64__": _b64(desired["ownerRepo"]),
193
- "__D_ENV_B64__": _b64(desired["environment"]),
194
- "__D_APP_B64__": _b64(desired["appSlug"]),
195
- "__D_CONTAINER_B64__": _b64(desired["containerName"]),
196
- "__D_IMAGE_B64__": _b64(desired["image"]),
197
- "__D_PORT__": desired["runtimePort"],
198
- "__D_HEALTH_B64__": _b64(desired["healthPath"]),
199
- "__D_RUN_AS_B64__": _b64(desired["runAs"]),
200
- "__D_PERSISTENCE__": desired["persistenceMode"],
201
- "__D_DATA_TARGET_B64__": _b64(desired["dataContainerPath"]),
202
- "__D_SPEC_SHA__": desired["specSha256"],
203
- "__D_ENV_KEYS__": " ".join(f"'{item}'" for item in env_keys),
204
- "__D_ENV_VALUES__": " ".join(f"'{item}'" for item in env_values),
205
- }
206
- script = REMOTE_SCRIPT
207
- for placeholder, value in replacements.items():
208
- script = script.replace(placeholder, value)
209
- if "__" in script:
210
- raise DeploymentKitError("Internal application-runtime script contains an unresolved placeholder.")
211
- return script
212
-
213
-
214
- def _parse_inventory(output: str) -> dict[str, Any]:
215
- apps: list[dict[str, Any]] = []
216
- summary: dict[str, str] = {}
217
- for raw in output.splitlines():
218
- if not raw:
219
- continue
220
- parts = raw.split("\t")
221
- if parts[0] == "SUMMARY":
222
- if len(parts) != 3 or parts[1] in summary:
223
- raise DeploymentKitError("Malformed or duplicate application inventory summary.")
224
- summary[parts[1]] = parts[2]
225
- continue
226
- if parts[0] != "APP" or len(parts) != 15:
227
- raise DeploymentKitError("Malformed application inventory record.")
228
- try:
229
- port = int(parts[7])
230
- except ValueError as error:
231
- raise DeploymentKitError("Malformed application inventory runtime port.") from error
232
- if parts[12] not in {"true", "false"} or parts[13] not in {"true", "false"}:
233
- raise DeploymentKitError("Malformed application inventory boolean value.")
234
- owner = _owner_repo(parts[1])
235
- environment = _slug(parts[2], "environment")
236
- app_slug = _slug(parts[3], "appSlug")
237
- container = _container_name(parts[4])
238
- image = _image(parts[5])
239
- if re.fullmatch(r"[0-9a-f]{64}", parts[6]) is None:
240
- raise DeploymentKitError("Malformed application inventory spec fingerprint.")
241
- health_path = _health_path(parts[8])
242
- if parts[9] not in {"none", "directory"}:
243
- raise DeploymentKitError("Malformed application inventory persistence mode.")
244
- app = {
245
- "ownerRepo": owner,
246
- "environment": environment,
247
- "appSlug": app_slug,
248
- "containerName": container,
249
- "image": image,
250
- "specSha256": parts[6],
251
- "runtimePort": port,
252
- "healthPath": health_path,
253
- "persistenceMode": parts[9],
254
- "currentRelease": parts[10] or None,
255
- "previousRelease": parts[11] or None,
256
- "containerRunning": parts[12] == "true",
257
- "connectedToSharedNetwork": parts[13] == "true",
258
- "httpHealthy": parts[14] == "true",
259
- }
260
- apps.append(app)
261
- if set(summary) != {"appCount"}:
262
- raise DeploymentKitError("Application inventory omitted appCount.")
263
- try:
264
- count = int(summary["appCount"])
265
- except ValueError as error:
266
- raise DeploymentKitError("Malformed application inventory count.") from error
267
- if count != len(apps):
268
- raise DeploymentKitError("Application inventory count does not match records.")
269
- keys = [(item["appSlug"], item["environment"]) for item in apps]
270
- if len(keys) != len(set(keys)):
271
- raise DeploymentKitError("Duplicate application/environment ownership records were reported.")
272
- return {"applications": sorted(apps, key=lambda item: (item["appSlug"], item["environment"]))}
273
-
274
-
275
- def _run_remote(
276
- profile: dict[str, Any],
277
- address: str,
278
- action: str,
279
- *,
280
- spec: dict[str, Any] | None = None,
281
- expected_spec_sha: str = "",
282
- ) -> str:
283
- if action not in {"inventory", "deploy"}:
284
- raise DeploymentKitError(f"Unsupported application-runtime action: {action}")
285
- args = [action]
286
- if action == "deploy":
287
- args.extend([expected_spec_sha or "NONE", profile["user"]])
288
- command = ssh_args(
289
- profile,
290
- address,
291
- command="sudo -n bash -s -- " + " ".join(args),
292
- )
293
- completed = subprocess.run(
294
- command,
295
- input=build_remote_script(spec).encode("utf-8"),
296
- check=False,
297
- capture_output=True,
298
- )
299
- stdout = completed.stdout.decode("utf-8", errors="replace")
300
- stderr = completed.stderr.decode("utf-8", errors="replace")
301
- if completed.returncode != 0:
302
- detail = stderr.strip() or stdout.strip() or f"exit {completed.returncode}"
303
- raise DeploymentKitError(f"Remote application-runtime {action} failed: {detail}")
304
- return stdout
305
-
306
-
307
- def app_inventory(name: str) -> dict[str, Any]:
308
- profile = load_profile(name)
309
- evidence = verify_access(profile)
310
- inventory = _parse_inventory(
311
- _run_remote(profile, evidence["address"], "inventory")
312
- )
313
- inventory["targetName"] = name
314
- inventory["verifiedAddress"] = evidence["address"]
315
- inventory["mutated"] = False
316
- return inventory
317
-
318
-
319
- def _inventory_snapshot(inventory: dict[str, Any]) -> str:
320
- value = {"applications": inventory["applications"]}
321
- return hashlib.sha256(
322
- json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
323
- ).hexdigest()
324
-
325
-
326
- def _find_app(inventory: dict[str, Any], app_slug: str, environment: str) -> dict[str, Any] | None:
327
- return next(
328
- (
329
- item
330
- for item in inventory["applications"]
331
- if item["appSlug"] == app_slug and item["environment"] == environment
332
- ),
333
- None,
334
- )
335
-
336
-
337
- def plan_app_deploy(name: str, spec: dict[str, Any]) -> dict[str, Any]:
338
- platform = platform_status(name)
339
- if platform["state"] != "ready":
340
- raise DeploymentKitError(
341
- f"Application deployment requires a ready shared platform; target '{name}' is '{platform['state']}'."
342
- )
343
- inventory = app_inventory(name)
344
- existing = _find_app(inventory, spec["appSlug"], spec["environment"])
345
- if existing is not None and existing["ownerRepo"] != spec["ownerRepo"]:
346
- raise DeploymentKitError("Application/environment ownership does not match the requested repository.")
347
- for item in inventory["applications"]:
348
- if (
349
- item["containerName"] == spec["containerName"]
350
- and (item["appSlug"], item["environment"])
351
- != (spec["appSlug"], spec["environment"])
352
- ):
353
- raise DeploymentKitError(
354
- f"Container name collision with managed app {item['appSlug']}--{item['environment']}."
355
- )
356
- if (
357
- existing is not None
358
- and existing["specSha256"] == spec["specSha256"]
359
- and existing["containerRunning"]
360
- and existing["connectedToSharedNetwork"]
361
- and existing["httpHealthy"]
362
- ):
363
- raise DeploymentKitError("The exact application runtime spec is already deployed and healthy.")
364
- return {
365
- "targetName": name,
366
- "action": "deploy",
367
- "environment": spec["environment"],
368
- "appSlug": spec["appSlug"],
369
- "ownerRepo": spec["ownerRepo"],
370
- "containerName": spec["containerName"],
371
- "image": spec["image"],
372
- "runtimePort": spec["runtimePort"],
373
- "healthPath": spec["healthPath"],
374
- "persistenceMode": spec["persistence"]["mode"],
375
- "changeType": "update" if existing is not None else "create",
376
- "previousImage": existing["image"] if existing is not None else None,
377
- "previousRelease": existing["currentRelease"] if existing is not None else None,
378
- "retainsPreviousRelease": existing is not None,
379
- "publishesHostPort": False,
380
- "attachesSharedNetwork": NETWORK_NAME,
381
- "modifiesRoutes": False,
382
- "persistentDataDeletion": False,
383
- "inventorySnapshot": _inventory_snapshot(inventory),
384
- "expectedSpecSha256": existing["specSha256"] if existing is not None else None,
385
- "spec": spec,
386
- }
387
-
388
-
389
- def deploy_write_phrase(plan: dict[str, Any]) -> str:
390
- return (
391
- PRODUCTION_APP_DEPLOY_WRITE_PHRASE
392
- if plan["environment"] == "production"
393
- else APP_DEPLOY_WRITE_PHRASE
394
- )
395
-
396
-
397
- def apply_app_deploy(plan: dict[str, Any]) -> dict[str, Any]:
398
- required = {
399
- "targetName",
400
- "action",
401
- "environment",
402
- "appSlug",
403
- "ownerRepo",
404
- "containerName",
405
- "image",
406
- "runtimePort",
407
- "healthPath",
408
- "persistenceMode",
409
- "changeType",
410
- "previousImage",
411
- "previousRelease",
412
- "retainsPreviousRelease",
413
- "publishesHostPort",
414
- "attachesSharedNetwork",
415
- "modifiesRoutes",
416
- "persistentDataDeletion",
417
- "inventorySnapshot",
418
- "expectedSpecSha256",
419
- "spec",
420
- }
421
- if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "deploy":
422
- raise DeploymentKitError("Malformed application deployment plan.")
423
-
424
- current = app_inventory(plan["targetName"])
425
- if _inventory_snapshot(current) != plan["inventorySnapshot"]:
426
- raise DeploymentKitError(
427
- "Application inventory changed after the plan was reviewed; create and authorize a fresh plan."
428
- )
429
- platform = platform_status(plan["targetName"])
430
- if platform["state"] != "ready":
431
- raise DeploymentKitError("Shared host platform is no longer ready; application deployment is blocked.")
432
-
433
- profile = load_profile(plan["targetName"])
434
- evidence = verify_access(profile)
435
- _run_remote(
436
- profile,
437
- evidence["address"],
438
- "deploy",
439
- spec=plan["spec"],
440
- expected_spec_sha=plan["expectedSpecSha256"] or "",
441
- )
442
- final = app_inventory(plan["targetName"])
443
- deployed = _find_app(final, plan["appSlug"], plan["environment"])
444
- if deployed is None:
445
- raise DeploymentKitError("Application deployment returned without managed runtime state.")
446
- if deployed["specSha256"] != plan["spec"]["specSha256"]:
447
- raise DeploymentKitError("Application deployment returned with a different runtime spec.")
448
- if not (
449
- deployed["containerRunning"]
450
- and deployed["connectedToSharedNetwork"]
451
- and deployed["httpHealthy"]
452
- ):
453
- raise DeploymentKitError("Application deployment completed but runtime readiness is not healthy.")
454
- final["mutated"] = True
455
- return final
1
+ """Generic immutable application-runtime deployment for Kit-managed hosts."""
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, Iterable
11
+
12
+ from .box_platform import platform_status
13
+ from .box_platform_assets import CADDY_CONTAINER, NETWORK_NAME
14
+ from .errors import DeploymentKitError
15
+ from .profiles import load_profile
16
+ from .ssh import ssh_args, verify_access
17
+ from .app_runtime_remote import REMOTE_SCRIPT
18
+
19
+ APP_ROOT = "/srv/lean-poc-apps"
20
+ APP_SCHEMA_VERSION = 1
21
+ APP_DEPLOY_WRITE_PHRASE = "[APP DEPLOY WRITE]"
22
+ PRODUCTION_APP_DEPLOY_WRITE_PHRASE = "[PRODUCTION APP DEPLOY]"
23
+
24
+ _SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
25
+ _REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
26
+ _CONTAINER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
27
+ _IMAGE_RE = re.compile(
28
+ r"^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$"
29
+ )
30
+ _RUN_AS_RE = re.compile(r"^[0-9]+(?::[0-9]+)?$")
31
+ _ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
32
+ _HEALTH_PATH_RE = re.compile(r"^/[A-Za-z0-9._~!$&'()*+,;=:@%/\-]*$")
33
+ _CONTAINER_PATH_RE = re.compile(r"^/[A-Za-z0-9._~+:/\-]*$")
34
+
35
+
36
+ def _slug(value: str, field: str) -> str:
37
+ if not isinstance(value, str) or _SLUG_RE.fullmatch(value) is None:
38
+ raise DeploymentKitError(f"{field} must use lowercase letters, digits, and internal hyphens.")
39
+ return value
40
+
41
+
42
+ def _owner_repo(value: str) -> str:
43
+ if not isinstance(value, str) or _REPO_RE.fullmatch(value) is None:
44
+ raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
45
+ return value
46
+
47
+
48
+ def _container_name(value: str) -> str:
49
+ if not isinstance(value, str) or _CONTAINER_RE.fullmatch(value) is None:
50
+ raise DeploymentKitError("containerName contains unsupported characters.")
51
+ return value
52
+
53
+
54
+ def _image(value: str) -> str:
55
+ if not isinstance(value, str) or _IMAGE_RE.fullmatch(value) is None:
56
+ raise DeploymentKitError("image must be an immutable image reference ending in @sha256:<64 lowercase hex>.")
57
+ return value
58
+
59
+
60
+ def _runtime_port(value: int) -> int:
61
+ if type(value) is not int or not 1 <= value <= 65535:
62
+ raise DeploymentKitError("runtimePort must be an integer from 1 through 65535.")
63
+ return value
64
+
65
+
66
+ def _health_path(value: str) -> str:
67
+ if not isinstance(value, str) or _HEALTH_PATH_RE.fullmatch(value) is None:
68
+ raise DeploymentKitError("healthPath must be a safe absolute HTTP path.")
69
+ return value
70
+
71
+
72
+ def _run_as(value: str) -> str:
73
+ if not isinstance(value, str) or _RUN_AS_RE.fullmatch(value) is None:
74
+ raise DeploymentKitError("runAs must be a numeric UID or UID:GID.")
75
+ return value
76
+
77
+
78
+ def _container_path(value: str | None) -> str | None:
79
+ if value is None:
80
+ return None
81
+ if not isinstance(value, str) or _CONTAINER_PATH_RE.fullmatch(value) is None or "/../" in f"{value}/":
82
+ raise DeploymentKitError("dataContainerPath must be a safe absolute container path.")
83
+ return value.rstrip("/") or "/"
84
+
85
+
86
+ def _normalize_environment_variables(items: dict[str, str] | Iterable[tuple[str, str]]) -> dict[str, str]:
87
+ pairs = items.items() if isinstance(items, dict) else items
88
+ result: dict[str, str] = {}
89
+ for key, value in pairs:
90
+ if not isinstance(key, str) or _ENV_KEY_RE.fullmatch(key) is None:
91
+ raise DeploymentKitError(f"Invalid environment variable name: {key!r}")
92
+ if key in result:
93
+ raise DeploymentKitError(f"Duplicate environment variable: {key}")
94
+ if not isinstance(value, str) or "\x00" in value or "\n" in value or "\r" in value:
95
+ raise DeploymentKitError(f"Environment variable {key} contains unsupported control characters.")
96
+ result[key] = value
97
+ return dict(sorted(result.items()))
98
+
99
+
100
+ def normalize_app_spec(
101
+ *,
102
+ owner_repo: str,
103
+ environment: str,
104
+ app_slug: str,
105
+ container_name: str,
106
+ image: str,
107
+ runtime_port: int,
108
+ health_path: str,
109
+ run_as: str,
110
+ persistence_mode: str,
111
+ data_container_path: str | None,
112
+ environment_variables: dict[str, str] | Iterable[tuple[str, str]],
113
+ ) -> dict[str, Any]:
114
+ if persistence_mode not in {"none", "directory"}:
115
+ raise DeploymentKitError("persistenceMode must be 'none' or 'directory'.")
116
+ data_path = _container_path(data_container_path)
117
+ if persistence_mode == "directory" and data_path is None:
118
+ raise DeploymentKitError("directory persistence requires dataContainerPath.")
119
+ if persistence_mode == "none" and data_path is not None:
120
+ raise DeploymentKitError("dataContainerPath must be omitted when persistenceMode is 'none'.")
121
+ spec = {
122
+ "schemaVersion": APP_SCHEMA_VERSION,
123
+ "ownerRepo": _owner_repo(owner_repo),
124
+ "environment": _slug(environment, "environment"),
125
+ "appSlug": _slug(app_slug, "appSlug"),
126
+ "containerName": _container_name(container_name),
127
+ "image": _image(image),
128
+ "runtimePort": _runtime_port(runtime_port),
129
+ "healthPath": _health_path(health_path),
130
+ "runAs": _run_as(run_as),
131
+ "persistence": {
132
+ "mode": persistence_mode,
133
+ "dataContainerPath": data_path,
134
+ },
135
+ "environmentVariables": _normalize_environment_variables(environment_variables),
136
+ }
137
+ spec["specSha256"] = _spec_sha(spec)
138
+ return spec
139
+
140
+
141
+ def _spec_sha(spec: dict[str, Any]) -> str:
142
+ value = {key: spec[key] for key in spec if key != "specSha256"}
143
+ return hashlib.sha256(
144
+ json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
145
+ ).hexdigest()
146
+
147
+
148
+ def _b64(value: str) -> str:
149
+ return base64.b64encode(value.encode("utf-8")).decode("ascii")
150
+
151
+
152
+ def build_remote_script(spec: dict[str, Any] | None = None) -> str:
153
+ env_keys: list[str] = []
154
+ env_values: list[str] = []
155
+ desired = {
156
+ "ownerRepo": "",
157
+ "environment": "",
158
+ "appSlug": "",
159
+ "containerName": "",
160
+ "image": "",
161
+ "runtimePort": "",
162
+ "healthPath": "",
163
+ "runAs": "",
164
+ "persistenceMode": "",
165
+ "dataContainerPath": "",
166
+ "specSha256": "",
167
+ }
168
+ if spec is not None:
169
+ desired.update(
170
+ ownerRepo=spec["ownerRepo"],
171
+ environment=spec["environment"],
172
+ appSlug=spec["appSlug"],
173
+ containerName=spec["containerName"],
174
+ image=spec["image"],
175
+ runtimePort=str(spec["runtimePort"]),
176
+ healthPath=spec["healthPath"],
177
+ runAs=spec["runAs"],
178
+ persistenceMode=spec["persistence"]["mode"],
179
+ dataContainerPath=spec["persistence"]["dataContainerPath"] or "",
180
+ specSha256=spec["specSha256"],
181
+ )
182
+ for key, value in spec["environmentVariables"].items():
183
+ env_keys.append(_b64(key))
184
+ env_values.append(_b64(value))
185
+
186
+ replacements = {
187
+ "__APP_ROOT__": APP_ROOT,
188
+ "__APP_SCHEMA_VERSION__": str(APP_SCHEMA_VERSION),
189
+ "__NETWORK_NAME__": NETWORK_NAME,
190
+ "__CADDY_CONTAINER__": CADDY_CONTAINER,
191
+ "__DESIRED_PRESENT__": "true" if spec is not None else "false",
192
+ "__D_OWNER_B64__": _b64(desired["ownerRepo"]),
193
+ "__D_ENV_B64__": _b64(desired["environment"]),
194
+ "__D_APP_B64__": _b64(desired["appSlug"]),
195
+ "__D_CONTAINER_B64__": _b64(desired["containerName"]),
196
+ "__D_IMAGE_B64__": _b64(desired["image"]),
197
+ "__D_PORT__": desired["runtimePort"],
198
+ "__D_HEALTH_B64__": _b64(desired["healthPath"]),
199
+ "__D_RUN_AS_B64__": _b64(desired["runAs"]),
200
+ "__D_PERSISTENCE__": desired["persistenceMode"],
201
+ "__D_DATA_TARGET_B64__": _b64(desired["dataContainerPath"]),
202
+ "__D_SPEC_SHA__": desired["specSha256"],
203
+ "__D_ENV_KEYS__": " ".join(f"'{item}'" for item in env_keys),
204
+ "__D_ENV_VALUES__": " ".join(f"'{item}'" for item in env_values),
205
+ }
206
+ script = REMOTE_SCRIPT
207
+ for placeholder, value in replacements.items():
208
+ script = script.replace(placeholder, value)
209
+ if "__" in script:
210
+ raise DeploymentKitError("Internal application-runtime script contains an unresolved placeholder.")
211
+ return script
212
+
213
+
214
+ def _parse_inventory(output: str) -> dict[str, Any]:
215
+ apps: list[dict[str, Any]] = []
216
+ summary: dict[str, str] = {}
217
+ for raw in output.splitlines():
218
+ if not raw:
219
+ continue
220
+ parts = raw.split("\t")
221
+ if parts[0] == "SUMMARY":
222
+ if len(parts) != 3 or parts[1] in summary:
223
+ raise DeploymentKitError("Malformed or duplicate application inventory summary.")
224
+ summary[parts[1]] = parts[2]
225
+ continue
226
+ if parts[0] != "APP" or len(parts) != 15:
227
+ raise DeploymentKitError("Malformed application inventory record.")
228
+ try:
229
+ port = int(parts[7])
230
+ except ValueError as error:
231
+ raise DeploymentKitError("Malformed application inventory runtime port.") from error
232
+ if parts[12] not in {"true", "false"} or parts[13] not in {"true", "false"}:
233
+ raise DeploymentKitError("Malformed application inventory boolean value.")
234
+ owner = _owner_repo(parts[1])
235
+ environment = _slug(parts[2], "environment")
236
+ app_slug = _slug(parts[3], "appSlug")
237
+ container = _container_name(parts[4])
238
+ image = _image(parts[5])
239
+ if re.fullmatch(r"[0-9a-f]{64}", parts[6]) is None:
240
+ raise DeploymentKitError("Malformed application inventory spec fingerprint.")
241
+ health_path = _health_path(parts[8])
242
+ if parts[9] not in {"none", "directory"}:
243
+ raise DeploymentKitError("Malformed application inventory persistence mode.")
244
+ app = {
245
+ "ownerRepo": owner,
246
+ "environment": environment,
247
+ "appSlug": app_slug,
248
+ "containerName": container,
249
+ "image": image,
250
+ "specSha256": parts[6],
251
+ "runtimePort": port,
252
+ "healthPath": health_path,
253
+ "persistenceMode": parts[9],
254
+ "currentRelease": parts[10] or None,
255
+ "previousRelease": parts[11] or None,
256
+ "containerRunning": parts[12] == "true",
257
+ "connectedToSharedNetwork": parts[13] == "true",
258
+ "httpHealthy": parts[14] == "true",
259
+ }
260
+ apps.append(app)
261
+ if set(summary) != {"appCount"}:
262
+ raise DeploymentKitError("Application inventory omitted appCount.")
263
+ try:
264
+ count = int(summary["appCount"])
265
+ except ValueError as error:
266
+ raise DeploymentKitError("Malformed application inventory count.") from error
267
+ if count != len(apps):
268
+ raise DeploymentKitError("Application inventory count does not match records.")
269
+ keys = [(item["appSlug"], item["environment"]) for item in apps]
270
+ if len(keys) != len(set(keys)):
271
+ raise DeploymentKitError("Duplicate application/environment ownership records were reported.")
272
+ return {"applications": sorted(apps, key=lambda item: (item["appSlug"], item["environment"]))}
273
+
274
+
275
+ def _run_remote(
276
+ profile: dict[str, Any],
277
+ address: str,
278
+ action: str,
279
+ *,
280
+ spec: dict[str, Any] | None = None,
281
+ expected_spec_sha: str = "",
282
+ ) -> str:
283
+ if action not in {"inventory", "deploy"}:
284
+ raise DeploymentKitError(f"Unsupported application-runtime action: {action}")
285
+ args = [action]
286
+ if action == "deploy":
287
+ args.extend([expected_spec_sha or "NONE", profile["user"]])
288
+ command = ssh_args(
289
+ profile,
290
+ address,
291
+ command="sudo -n bash -s -- " + " ".join(args),
292
+ )
293
+ completed = subprocess.run(
294
+ command,
295
+ input=build_remote_script(spec).encode("utf-8"),
296
+ check=False,
297
+ capture_output=True,
298
+ )
299
+ stdout = completed.stdout.decode("utf-8", errors="replace")
300
+ stderr = completed.stderr.decode("utf-8", errors="replace")
301
+ if completed.returncode != 0:
302
+ detail = stderr.strip() or stdout.strip() or f"exit {completed.returncode}"
303
+ raise DeploymentKitError(f"Remote application-runtime {action} failed: {detail}")
304
+ return stdout
305
+
306
+
307
+ def app_inventory(name: str) -> dict[str, Any]:
308
+ profile = load_profile(name)
309
+ evidence = verify_access(profile)
310
+ inventory = _parse_inventory(
311
+ _run_remote(profile, evidence["address"], "inventory")
312
+ )
313
+ inventory["targetName"] = name
314
+ inventory["verifiedAddress"] = evidence["address"]
315
+ inventory["mutated"] = False
316
+ return inventory
317
+
318
+
319
+ def _inventory_snapshot(inventory: dict[str, Any]) -> str:
320
+ value = {"applications": inventory["applications"]}
321
+ return hashlib.sha256(
322
+ json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
323
+ ).hexdigest()
324
+
325
+
326
+ def _find_app(inventory: dict[str, Any], app_slug: str, environment: str) -> dict[str, Any] | None:
327
+ return next(
328
+ (
329
+ item
330
+ for item in inventory["applications"]
331
+ if item["appSlug"] == app_slug and item["environment"] == environment
332
+ ),
333
+ None,
334
+ )
335
+
336
+
337
+ def plan_app_deploy(name: str, spec: dict[str, Any]) -> dict[str, Any]:
338
+ platform = platform_status(name)
339
+ if platform["state"] != "ready":
340
+ raise DeploymentKitError(
341
+ f"Application deployment requires a ready shared platform; target '{name}' is '{platform['state']}'."
342
+ )
343
+ inventory = app_inventory(name)
344
+ existing = _find_app(inventory, spec["appSlug"], spec["environment"])
345
+ if existing is not None and existing["ownerRepo"] != spec["ownerRepo"]:
346
+ raise DeploymentKitError("Application/environment ownership does not match the requested repository.")
347
+ for item in inventory["applications"]:
348
+ if (
349
+ item["containerName"] == spec["containerName"]
350
+ and (item["appSlug"], item["environment"])
351
+ != (spec["appSlug"], spec["environment"])
352
+ ):
353
+ raise DeploymentKitError(
354
+ f"Container name collision with managed app {item['appSlug']}--{item['environment']}."
355
+ )
356
+ if (
357
+ existing is not None
358
+ and existing["specSha256"] == spec["specSha256"]
359
+ and existing["containerRunning"]
360
+ and existing["connectedToSharedNetwork"]
361
+ and existing["httpHealthy"]
362
+ ):
363
+ raise DeploymentKitError("The exact application runtime spec is already deployed and healthy.")
364
+ return {
365
+ "targetName": name,
366
+ "action": "deploy",
367
+ "environment": spec["environment"],
368
+ "appSlug": spec["appSlug"],
369
+ "ownerRepo": spec["ownerRepo"],
370
+ "containerName": spec["containerName"],
371
+ "image": spec["image"],
372
+ "runtimePort": spec["runtimePort"],
373
+ "healthPath": spec["healthPath"],
374
+ "persistenceMode": spec["persistence"]["mode"],
375
+ "changeType": "update" if existing is not None else "create",
376
+ "previousImage": existing["image"] if existing is not None else None,
377
+ "previousRelease": existing["currentRelease"] if existing is not None else None,
378
+ "retainsPreviousRelease": existing is not None,
379
+ "publishesHostPort": False,
380
+ "attachesSharedNetwork": NETWORK_NAME,
381
+ "modifiesRoutes": False,
382
+ "persistentDataDeletion": False,
383
+ "inventorySnapshot": _inventory_snapshot(inventory),
384
+ "expectedSpecSha256": existing["specSha256"] if existing is not None else None,
385
+ "spec": spec,
386
+ }
387
+
388
+
389
+ def deploy_write_phrase(plan: dict[str, Any]) -> str:
390
+ return (
391
+ PRODUCTION_APP_DEPLOY_WRITE_PHRASE
392
+ if plan["environment"] == "production"
393
+ else APP_DEPLOY_WRITE_PHRASE
394
+ )
395
+
396
+
397
+ def apply_app_deploy(plan: dict[str, Any]) -> dict[str, Any]:
398
+ required = {
399
+ "targetName",
400
+ "action",
401
+ "environment",
402
+ "appSlug",
403
+ "ownerRepo",
404
+ "containerName",
405
+ "image",
406
+ "runtimePort",
407
+ "healthPath",
408
+ "persistenceMode",
409
+ "changeType",
410
+ "previousImage",
411
+ "previousRelease",
412
+ "retainsPreviousRelease",
413
+ "publishesHostPort",
414
+ "attachesSharedNetwork",
415
+ "modifiesRoutes",
416
+ "persistentDataDeletion",
417
+ "inventorySnapshot",
418
+ "expectedSpecSha256",
419
+ "spec",
420
+ }
421
+ if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "deploy":
422
+ raise DeploymentKitError("Malformed application deployment plan.")
423
+
424
+ current = app_inventory(plan["targetName"])
425
+ if _inventory_snapshot(current) != plan["inventorySnapshot"]:
426
+ raise DeploymentKitError(
427
+ "Application inventory changed after the plan was reviewed; create and authorize a fresh plan."
428
+ )
429
+ platform = platform_status(plan["targetName"])
430
+ if platform["state"] != "ready":
431
+ raise DeploymentKitError("Shared host platform is no longer ready; application deployment is blocked.")
432
+
433
+ profile = load_profile(plan["targetName"])
434
+ evidence = verify_access(profile)
435
+ _run_remote(
436
+ profile,
437
+ evidence["address"],
438
+ "deploy",
439
+ spec=plan["spec"],
440
+ expected_spec_sha=plan["expectedSpecSha256"] or "",
441
+ )
442
+ final = app_inventory(plan["targetName"])
443
+ deployed = _find_app(final, plan["appSlug"], plan["environment"])
444
+ if deployed is None:
445
+ raise DeploymentKitError("Application deployment returned without managed runtime state.")
446
+ if deployed["specSha256"] != plan["spec"]["specSha256"]:
447
+ raise DeploymentKitError("Application deployment returned with a different runtime spec.")
448
+ if not (
449
+ deployed["containerRunning"]
450
+ and deployed["connectedToSharedNetwork"]
451
+ and deployed["httpHealthy"]
452
+ ):
453
+ raise DeploymentKitError("Application deployment completed but runtime readiness is not healthy.")
454
+ final["mutated"] = True
455
+ return final