@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,367 +1,367 @@
1
- """Owned application-route registry for the shared Ubuntu box platform."""
2
-
3
- from __future__ import annotations
4
-
5
- import hashlib
6
- import ipaddress
7
- import json
8
- import re
9
- import shlex
10
- import subprocess
11
- from typing import Any, Iterable
12
-
13
- from .box_platform import platform_status
14
- from .box_platform_assets import (
15
- CADDY_CONTAINER,
16
- CADDY_IMAGE,
17
- NETWORK_NAME,
18
- PLATFORM_DISTRIBUTION,
19
- PLATFORM_ROOT,
20
- PLATFORM_SCHEMA_VERSION,
21
- PLATFORM_VERSION,
22
- )
23
- from .errors import DeploymentKitError
24
- from .profiles import load_profile
25
- from .route_registry_remote import REMOTE_SCRIPT
26
- from .ssh import ssh_args, verify_access
27
-
28
- ROUTE_WRITE_PHRASE = "[ROUTE WRITE]"
29
- ROUTE_REMOVE_PHRASE = "[ROUTE REMOVE]"
30
- ROUTE_SCHEMA_VERSION = 1
31
-
32
- _SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
33
- _CONTAINER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
34
- _REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
35
- _HOST_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
36
-
37
-
38
- def _validate_slug(value: str, field: str) -> str:
39
- if not isinstance(value, str) or _SLUG_RE.fullmatch(value) is None:
40
- raise DeploymentKitError(f"{field} must use lowercase letters, digits, and internal hyphens.")
41
- return value
42
-
43
-
44
- def _validate_owner_repo(value: str) -> str:
45
- if not isinstance(value, str) or _REPO_RE.fullmatch(value) is None:
46
- raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
47
- return value
48
-
49
-
50
- def _validate_container(value: str) -> str:
51
- if not isinstance(value, str) or _CONTAINER_RE.fullmatch(value) is None:
52
- raise DeploymentKitError("upstreamContainer contains unsupported characters.")
53
- return value
54
-
55
-
56
- def _validate_hostname(value: str) -> str:
57
- if not isinstance(value, str) or not value or len(value) > 253:
58
- raise DeploymentKitError("Route hostnames must be non-empty DNS/mDNS names.")
59
- value = value.lower().rstrip(".")
60
- try:
61
- ipaddress.ip_address(value)
62
- except ValueError:
63
- pass
64
- else:
65
- raise DeploymentKitError("Raw IP routes are not allowed; each application route must use an owned hostname.")
66
- labels = value.split(".")
67
- if any(_HOST_LABEL_RE.fullmatch(label) is None for label in labels):
68
- raise DeploymentKitError(f"Invalid route hostname: {value}")
69
- if not value.endswith(".local"):
70
- raise DeploymentKitError("The trusted-LAN distribution currently supports only .local application hostnames.")
71
- return value
72
-
73
-
74
- def normalize_route_spec(
75
- *,
76
- owner_repo: str,
77
- environment: str,
78
- app_slug: str,
79
- upstream_container: str,
80
- upstream_port: int,
81
- hostnames: Iterable[str],
82
- ) -> dict[str, Any]:
83
- owner_repo = _validate_owner_repo(owner_repo)
84
- environment = _validate_slug(environment, "environment")
85
- app_slug = _validate_slug(app_slug, "appSlug")
86
- upstream_container = _validate_container(upstream_container)
87
- if type(upstream_port) is not int or not 1 <= upstream_port <= 65535:
88
- raise DeploymentKitError("upstreamPort must be an integer from 1 through 65535.")
89
- normalized_hosts = sorted({_validate_hostname(item) for item in hostnames})
90
- if not normalized_hosts:
91
- raise DeploymentKitError("At least one route hostname is required.")
92
- return {
93
- "schemaVersion": ROUTE_SCHEMA_VERSION,
94
- "ownerRepo": owner_repo,
95
- "environment": environment,
96
- "appSlug": app_slug,
97
- "routeId": f"{app_slug}--{environment}",
98
- "upstreamContainer": upstream_container,
99
- "upstreamPort": upstream_port,
100
- "hostnames": normalized_hosts,
101
- }
102
-
103
-
104
- def _encode_spec(spec: dict[str, Any]) -> str:
105
- return "\t".join(
106
- [
107
- str(spec["schemaVersion"]),
108
- spec["ownerRepo"],
109
- spec["environment"],
110
- spec["appSlug"],
111
- spec["routeId"],
112
- spec["upstreamContainer"],
113
- str(spec["upstreamPort"]),
114
- ",".join(spec["hostnames"]),
115
- ]
116
- )
117
-
118
-
119
- def _parse_inventory(output: str) -> dict[str, Any]:
120
- summary: dict[str, str] = {}
121
- routes: list[dict[str, Any]] = []
122
- for raw in output.splitlines():
123
- if not raw:
124
- continue
125
- parts = raw.split("\t")
126
- kind = parts[0]
127
- if kind == "SUMMARY":
128
- if len(parts) != 3 or parts[1] in summary:
129
- raise DeploymentKitError("Malformed or duplicate route-inventory summary field.")
130
- summary[parts[1]] = parts[2]
131
- elif kind == "ROUTE":
132
- if len(parts) != 9:
133
- raise DeploymentKitError("Malformed managed route inventory record.")
134
- try:
135
- schema = int(parts[1])
136
- port = int(parts[7])
137
- except ValueError as error:
138
- raise DeploymentKitError("Malformed numeric route inventory value.") from error
139
- spec = normalize_route_spec(
140
- owner_repo=parts[2],
141
- environment=parts[3],
142
- app_slug=parts[4],
143
- upstream_container=parts[6],
144
- upstream_port=port,
145
- hostnames=parts[8].split(",") if parts[8] else [],
146
- )
147
- if schema != ROUTE_SCHEMA_VERSION or parts[5] != spec["routeId"]:
148
- raise DeploymentKitError("Managed route metadata has an unsupported schema or route ID mismatch.")
149
- routes.append(spec)
150
- else:
151
- raise DeploymentKitError(f"Unexpected route inventory record type: {kind!r}")
152
-
153
- expected_summary = {"platformState", "managedRouteCount"}
154
- if set(summary) != expected_summary:
155
- raise DeploymentKitError("Route inventory omitted required summary fields.")
156
- try:
157
- count = int(summary["managedRouteCount"])
158
- except ValueError as error:
159
- raise DeploymentKitError("Malformed managed route count.") from error
160
- if count != len(routes):
161
- raise DeploymentKitError("Managed route count does not match inventory records.")
162
- route_ids = [item["routeId"] for item in routes]
163
- if len(route_ids) != len(set(route_ids)):
164
- raise DeploymentKitError("Duplicate managed route IDs were reported by the target.")
165
- return {
166
- "platformState": summary["platformState"],
167
- "managedRoutes": sorted(routes, key=lambda item: item["routeId"]),
168
- }
169
-
170
-
171
- def _inventory_snapshot(inventory: dict[str, Any]) -> str:
172
- value = {
173
- "platformState": inventory["platformState"],
174
- "managedRoutes": inventory["managedRoutes"],
175
- }
176
- return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
177
-
178
-
179
- def build_remote_script() -> str:
180
- replacements = {
181
- "__PLATFORM_ROOT__": PLATFORM_ROOT,
182
- "__NETWORK_NAME__": NETWORK_NAME,
183
- "__CADDY_CONTAINER__": CADDY_CONTAINER,
184
- "__CADDY_IMAGE__": CADDY_IMAGE,
185
- "__PLATFORM_SCHEMA_VERSION__": str(PLATFORM_SCHEMA_VERSION),
186
- "__PLATFORM_VERSION__": PLATFORM_VERSION,
187
- "__PLATFORM_DISTRIBUTION__": PLATFORM_DISTRIBUTION,
188
- "__ROUTE_SCHEMA_VERSION__": str(ROUTE_SCHEMA_VERSION),
189
- }
190
- script = REMOTE_SCRIPT
191
- for placeholder, value in replacements.items():
192
- script = script.replace(placeholder, value)
193
- if "__" in script:
194
- raise DeploymentKitError("Internal route-registry script contains an unresolved placeholder.")
195
- return script
196
-
197
-
198
- def _run_remote(
199
- profile: dict[str, Any],
200
- address: str,
201
- action: str,
202
- *,
203
- sudo: bool,
204
- spec: dict[str, Any] | None = None,
205
- previous_spec: dict[str, Any] | None = None,
206
- ) -> str:
207
- if action not in {"inventory", "register", "remove"}:
208
- raise DeploymentKitError(f"Unsupported route-registry action: {action}")
209
- args = [action]
210
- if spec is not None:
211
- args.append(_encode_spec(spec))
212
- if action == "register":
213
- args.append(_encode_spec(previous_spec) if previous_spec is not None else "NONE")
214
- remote_command = "bash -s -- " + " ".join(shlex.quote(item) for item in args)
215
- if sudo:
216
- remote_command = "sudo -n " + remote_command
217
- completed = subprocess.run(
218
- ssh_args(profile, address, command=remote_command),
219
- input=build_remote_script().encode("utf-8"),
220
- check=False,
221
- capture_output=True,
222
- )
223
- stdout = completed.stdout.decode("utf-8", errors="replace")
224
- stderr = completed.stderr.decode("utf-8", errors="replace")
225
- if completed.returncode != 0:
226
- detail = stderr.strip() or stdout.strip() or f"exit {completed.returncode}"
227
- raise DeploymentKitError(f"Remote route-registry {action} failed: {detail}")
228
- return stdout
229
-
230
-
231
- def route_inventory(name: str) -> dict[str, Any]:
232
- profile = load_profile(name)
233
- evidence = verify_access(profile)
234
- inventory = _parse_inventory(_run_remote(profile, evidence["address"], "inventory", sudo=True))
235
- inventory["targetName"] = name
236
- inventory["verifiedAddress"] = evidence["address"]
237
- inventory["mutated"] = False
238
- return inventory
239
-
240
-
241
- def _find_route(inventory: dict[str, Any], route_id: str) -> dict[str, Any] | None:
242
- return next((item for item in inventory["managedRoutes"] if item["routeId"] == route_id), None)
243
-
244
-
245
- def plan_route_registration(
246
- name: str,
247
- *,
248
- owner_repo: str,
249
- environment: str,
250
- app_slug: str,
251
- upstream_container: str,
252
- upstream_port: int,
253
- hostnames: Iterable[str],
254
- ) -> dict[str, Any]:
255
- spec = normalize_route_spec(
256
- owner_repo=owner_repo,
257
- environment=environment,
258
- app_slug=app_slug,
259
- upstream_container=upstream_container,
260
- upstream_port=upstream_port,
261
- hostnames=hostnames,
262
- )
263
- inventory = route_inventory(name)
264
- if inventory["platformState"] != "ready":
265
- raise DeploymentKitError(
266
- f"Route registration requires a ready Kit-owned platform; target '{name}' is '{inventory['platformState']}'."
267
- )
268
- existing = _find_route(inventory, spec["routeId"])
269
- if existing is not None:
270
- owner_fields = ("ownerRepo", "environment", "appSlug")
271
- if any(existing[field] != spec[field] for field in owner_fields):
272
- raise DeploymentKitError(
273
- f"Route ID {spec['routeId']} is already owned by another POC/environment; refusing takeover."
274
- )
275
- requested = set(spec["hostnames"])
276
- for route in inventory["managedRoutes"]:
277
- if route["routeId"] == spec["routeId"]:
278
- continue
279
- collision = requested.intersection(route["hostnames"])
280
- if collision:
281
- raise DeploymentKitError(
282
- "Hostname collision with route " + route["routeId"] + ": " + ", ".join(sorted(collision))
283
- )
284
- return {
285
- "targetName": name,
286
- "action": "register",
287
- "route": spec,
288
- "changeType": "update" if existing else "create",
289
- "previousRoute": existing,
290
- "inventorySnapshot": _inventory_snapshot(inventory),
291
- "sharedPlatformAssetsModified": False,
292
- "rawHostIpRoute": False,
293
- "requiresUpstreamOnSharedNetwork": True,
294
- }
295
-
296
-
297
- def plan_route_removal(
298
- name: str,
299
- *,
300
- owner_repo: str,
301
- environment: str,
302
- app_slug: str,
303
- ) -> dict[str, Any]:
304
- route_id = f"{_validate_slug(app_slug, 'appSlug')}--{_validate_slug(environment, 'environment')}"
305
- owner_repo = _validate_owner_repo(owner_repo)
306
- inventory = route_inventory(name)
307
- if inventory["platformState"] != "ready":
308
- raise DeploymentKitError(
309
- f"Route removal requires a ready Kit-owned platform; target '{name}' is '{inventory['platformState']}'."
310
- )
311
- existing = _find_route(inventory, route_id)
312
- if existing is None:
313
- raise DeploymentKitError(f"Managed route does not exist: {route_id}")
314
- if existing["ownerRepo"] != owner_repo or existing["environment"] != environment or existing["appSlug"] != app_slug:
315
- raise DeploymentKitError("Route removal ownership does not match the registered POC/environment.")
316
- return {
317
- "targetName": name,
318
- "action": "remove",
319
- "route": existing,
320
- "inventorySnapshot": _inventory_snapshot(inventory),
321
- "removes": ["caddy-route", "mdns-aliases", "route-ownership-metadata"],
322
- "preserves": ["shared-platform", "shared-network", "application-container", "application-data"],
323
- }
324
-
325
-
326
- def apply_route_change(plan: dict[str, Any]) -> dict[str, Any]:
327
- if not isinstance(plan, dict) or plan.get("action") not in {"register", "remove"}:
328
- raise DeploymentKitError("Malformed route-registry change plan.")
329
- required_register = {
330
- "targetName", "action", "route", "changeType", "previousRoute", "inventorySnapshot",
331
- "sharedPlatformAssetsModified", "rawHostIpRoute", "requiresUpstreamOnSharedNetwork",
332
- }
333
- required_remove = {"targetName", "action", "route", "inventorySnapshot", "removes", "preserves"}
334
- expected = required_register if plan["action"] == "register" else required_remove
335
- if set(plan) != expected:
336
- raise DeploymentKitError("Malformed route-registry change plan.")
337
-
338
- name = plan["targetName"]
339
- current = route_inventory(name)
340
- if _inventory_snapshot(current) != plan["inventorySnapshot"]:
341
- raise DeploymentKitError(
342
- "Route inventory changed after the plan was reviewed; create and authorize a fresh plan."
343
- )
344
- platform = platform_status(name)
345
- if platform["state"] != "ready":
346
- raise DeploymentKitError("Shared host platform is no longer ready; route change is blocked.")
347
-
348
- profile = load_profile(name)
349
- evidence = verify_access(profile)
350
- _run_remote(
351
- profile,
352
- evidence["address"],
353
- plan["action"],
354
- sudo=True,
355
- spec=plan["route"],
356
- previous_spec=plan.get("previousRoute"),
357
- )
358
- final = route_inventory(name)
359
- route_id = plan["route"]["routeId"]
360
- registered = _find_route(final, route_id)
361
- if plan["action"] == "register":
362
- if registered != plan["route"]:
363
- raise DeploymentKitError("Route registration returned without the exact requested ownership metadata.")
364
- elif registered is not None:
365
- raise DeploymentKitError("Route removal returned but the route remains registered.")
366
- final["mutated"] = True
367
- return final
1
+ """Owned application-route registry for the shared Ubuntu box platform."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import ipaddress
7
+ import json
8
+ import re
9
+ import shlex
10
+ import subprocess
11
+ from typing import Any, Iterable
12
+
13
+ from .box_platform import platform_status
14
+ from .box_platform_assets import (
15
+ CADDY_CONTAINER,
16
+ CADDY_IMAGE,
17
+ NETWORK_NAME,
18
+ PLATFORM_DISTRIBUTION,
19
+ PLATFORM_ROOT,
20
+ PLATFORM_SCHEMA_VERSION,
21
+ PLATFORM_VERSION,
22
+ )
23
+ from .errors import DeploymentKitError
24
+ from .profiles import load_profile
25
+ from .route_registry_remote import REMOTE_SCRIPT
26
+ from .ssh import ssh_args, verify_access
27
+
28
+ ROUTE_WRITE_PHRASE = "[ROUTE WRITE]"
29
+ ROUTE_REMOVE_PHRASE = "[ROUTE REMOVE]"
30
+ ROUTE_SCHEMA_VERSION = 1
31
+
32
+ _SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
33
+ _CONTAINER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
34
+ _REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
35
+ _HOST_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
36
+
37
+
38
+ def _validate_slug(value: str, field: str) -> str:
39
+ if not isinstance(value, str) or _SLUG_RE.fullmatch(value) is None:
40
+ raise DeploymentKitError(f"{field} must use lowercase letters, digits, and internal hyphens.")
41
+ return value
42
+
43
+
44
+ def _validate_owner_repo(value: str) -> str:
45
+ if not isinstance(value, str) or _REPO_RE.fullmatch(value) is None:
46
+ raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
47
+ return value
48
+
49
+
50
+ def _validate_container(value: str) -> str:
51
+ if not isinstance(value, str) or _CONTAINER_RE.fullmatch(value) is None:
52
+ raise DeploymentKitError("upstreamContainer contains unsupported characters.")
53
+ return value
54
+
55
+
56
+ def _validate_hostname(value: str) -> str:
57
+ if not isinstance(value, str) or not value or len(value) > 253:
58
+ raise DeploymentKitError("Route hostnames must be non-empty DNS/mDNS names.")
59
+ value = value.lower().rstrip(".")
60
+ try:
61
+ ipaddress.ip_address(value)
62
+ except ValueError:
63
+ pass
64
+ else:
65
+ raise DeploymentKitError("Raw IP routes are not allowed; each application route must use an owned hostname.")
66
+ labels = value.split(".")
67
+ if any(_HOST_LABEL_RE.fullmatch(label) is None for label in labels):
68
+ raise DeploymentKitError(f"Invalid route hostname: {value}")
69
+ if not value.endswith(".local"):
70
+ raise DeploymentKitError("The trusted-LAN distribution currently supports only .local application hostnames.")
71
+ return value
72
+
73
+
74
+ def normalize_route_spec(
75
+ *,
76
+ owner_repo: str,
77
+ environment: str,
78
+ app_slug: str,
79
+ upstream_container: str,
80
+ upstream_port: int,
81
+ hostnames: Iterable[str],
82
+ ) -> dict[str, Any]:
83
+ owner_repo = _validate_owner_repo(owner_repo)
84
+ environment = _validate_slug(environment, "environment")
85
+ app_slug = _validate_slug(app_slug, "appSlug")
86
+ upstream_container = _validate_container(upstream_container)
87
+ if type(upstream_port) is not int or not 1 <= upstream_port <= 65535:
88
+ raise DeploymentKitError("upstreamPort must be an integer from 1 through 65535.")
89
+ normalized_hosts = sorted({_validate_hostname(item) for item in hostnames})
90
+ if not normalized_hosts:
91
+ raise DeploymentKitError("At least one route hostname is required.")
92
+ return {
93
+ "schemaVersion": ROUTE_SCHEMA_VERSION,
94
+ "ownerRepo": owner_repo,
95
+ "environment": environment,
96
+ "appSlug": app_slug,
97
+ "routeId": f"{app_slug}--{environment}",
98
+ "upstreamContainer": upstream_container,
99
+ "upstreamPort": upstream_port,
100
+ "hostnames": normalized_hosts,
101
+ }
102
+
103
+
104
+ def _encode_spec(spec: dict[str, Any]) -> str:
105
+ return "\t".join(
106
+ [
107
+ str(spec["schemaVersion"]),
108
+ spec["ownerRepo"],
109
+ spec["environment"],
110
+ spec["appSlug"],
111
+ spec["routeId"],
112
+ spec["upstreamContainer"],
113
+ str(spec["upstreamPort"]),
114
+ ",".join(spec["hostnames"]),
115
+ ]
116
+ )
117
+
118
+
119
+ def _parse_inventory(output: str) -> dict[str, Any]:
120
+ summary: dict[str, str] = {}
121
+ routes: list[dict[str, Any]] = []
122
+ for raw in output.splitlines():
123
+ if not raw:
124
+ continue
125
+ parts = raw.split("\t")
126
+ kind = parts[0]
127
+ if kind == "SUMMARY":
128
+ if len(parts) != 3 or parts[1] in summary:
129
+ raise DeploymentKitError("Malformed or duplicate route-inventory summary field.")
130
+ summary[parts[1]] = parts[2]
131
+ elif kind == "ROUTE":
132
+ if len(parts) != 9:
133
+ raise DeploymentKitError("Malformed managed route inventory record.")
134
+ try:
135
+ schema = int(parts[1])
136
+ port = int(parts[7])
137
+ except ValueError as error:
138
+ raise DeploymentKitError("Malformed numeric route inventory value.") from error
139
+ spec = normalize_route_spec(
140
+ owner_repo=parts[2],
141
+ environment=parts[3],
142
+ app_slug=parts[4],
143
+ upstream_container=parts[6],
144
+ upstream_port=port,
145
+ hostnames=parts[8].split(",") if parts[8] else [],
146
+ )
147
+ if schema != ROUTE_SCHEMA_VERSION or parts[5] != spec["routeId"]:
148
+ raise DeploymentKitError("Managed route metadata has an unsupported schema or route ID mismatch.")
149
+ routes.append(spec)
150
+ else:
151
+ raise DeploymentKitError(f"Unexpected route inventory record type: {kind!r}")
152
+
153
+ expected_summary = {"platformState", "managedRouteCount"}
154
+ if set(summary) != expected_summary:
155
+ raise DeploymentKitError("Route inventory omitted required summary fields.")
156
+ try:
157
+ count = int(summary["managedRouteCount"])
158
+ except ValueError as error:
159
+ raise DeploymentKitError("Malformed managed route count.") from error
160
+ if count != len(routes):
161
+ raise DeploymentKitError("Managed route count does not match inventory records.")
162
+ route_ids = [item["routeId"] for item in routes]
163
+ if len(route_ids) != len(set(route_ids)):
164
+ raise DeploymentKitError("Duplicate managed route IDs were reported by the target.")
165
+ return {
166
+ "platformState": summary["platformState"],
167
+ "managedRoutes": sorted(routes, key=lambda item: item["routeId"]),
168
+ }
169
+
170
+
171
+ def _inventory_snapshot(inventory: dict[str, Any]) -> str:
172
+ value = {
173
+ "platformState": inventory["platformState"],
174
+ "managedRoutes": inventory["managedRoutes"],
175
+ }
176
+ return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
177
+
178
+
179
+ def build_remote_script() -> str:
180
+ replacements = {
181
+ "__PLATFORM_ROOT__": PLATFORM_ROOT,
182
+ "__NETWORK_NAME__": NETWORK_NAME,
183
+ "__CADDY_CONTAINER__": CADDY_CONTAINER,
184
+ "__CADDY_IMAGE__": CADDY_IMAGE,
185
+ "__PLATFORM_SCHEMA_VERSION__": str(PLATFORM_SCHEMA_VERSION),
186
+ "__PLATFORM_VERSION__": PLATFORM_VERSION,
187
+ "__PLATFORM_DISTRIBUTION__": PLATFORM_DISTRIBUTION,
188
+ "__ROUTE_SCHEMA_VERSION__": str(ROUTE_SCHEMA_VERSION),
189
+ }
190
+ script = REMOTE_SCRIPT
191
+ for placeholder, value in replacements.items():
192
+ script = script.replace(placeholder, value)
193
+ if "__" in script:
194
+ raise DeploymentKitError("Internal route-registry script contains an unresolved placeholder.")
195
+ return script
196
+
197
+
198
+ def _run_remote(
199
+ profile: dict[str, Any],
200
+ address: str,
201
+ action: str,
202
+ *,
203
+ sudo: bool,
204
+ spec: dict[str, Any] | None = None,
205
+ previous_spec: dict[str, Any] | None = None,
206
+ ) -> str:
207
+ if action not in {"inventory", "register", "remove"}:
208
+ raise DeploymentKitError(f"Unsupported route-registry action: {action}")
209
+ args = [action]
210
+ if spec is not None:
211
+ args.append(_encode_spec(spec))
212
+ if action == "register":
213
+ args.append(_encode_spec(previous_spec) if previous_spec is not None else "NONE")
214
+ remote_command = "bash -s -- " + " ".join(shlex.quote(item) for item in args)
215
+ if sudo:
216
+ remote_command = "sudo -n " + remote_command
217
+ completed = subprocess.run(
218
+ ssh_args(profile, address, command=remote_command),
219
+ input=build_remote_script().encode("utf-8"),
220
+ check=False,
221
+ capture_output=True,
222
+ )
223
+ stdout = completed.stdout.decode("utf-8", errors="replace")
224
+ stderr = completed.stderr.decode("utf-8", errors="replace")
225
+ if completed.returncode != 0:
226
+ detail = stderr.strip() or stdout.strip() or f"exit {completed.returncode}"
227
+ raise DeploymentKitError(f"Remote route-registry {action} failed: {detail}")
228
+ return stdout
229
+
230
+
231
+ def route_inventory(name: str) -> dict[str, Any]:
232
+ profile = load_profile(name)
233
+ evidence = verify_access(profile)
234
+ inventory = _parse_inventory(_run_remote(profile, evidence["address"], "inventory", sudo=True))
235
+ inventory["targetName"] = name
236
+ inventory["verifiedAddress"] = evidence["address"]
237
+ inventory["mutated"] = False
238
+ return inventory
239
+
240
+
241
+ def _find_route(inventory: dict[str, Any], route_id: str) -> dict[str, Any] | None:
242
+ return next((item for item in inventory["managedRoutes"] if item["routeId"] == route_id), None)
243
+
244
+
245
+ def plan_route_registration(
246
+ name: str,
247
+ *,
248
+ owner_repo: str,
249
+ environment: str,
250
+ app_slug: str,
251
+ upstream_container: str,
252
+ upstream_port: int,
253
+ hostnames: Iterable[str],
254
+ ) -> dict[str, Any]:
255
+ spec = normalize_route_spec(
256
+ owner_repo=owner_repo,
257
+ environment=environment,
258
+ app_slug=app_slug,
259
+ upstream_container=upstream_container,
260
+ upstream_port=upstream_port,
261
+ hostnames=hostnames,
262
+ )
263
+ inventory = route_inventory(name)
264
+ if inventory["platformState"] != "ready":
265
+ raise DeploymentKitError(
266
+ f"Route registration requires a ready Kit-owned platform; target '{name}' is '{inventory['platformState']}'."
267
+ )
268
+ existing = _find_route(inventory, spec["routeId"])
269
+ if existing is not None:
270
+ owner_fields = ("ownerRepo", "environment", "appSlug")
271
+ if any(existing[field] != spec[field] for field in owner_fields):
272
+ raise DeploymentKitError(
273
+ f"Route ID {spec['routeId']} is already owned by another POC/environment; refusing takeover."
274
+ )
275
+ requested = set(spec["hostnames"])
276
+ for route in inventory["managedRoutes"]:
277
+ if route["routeId"] == spec["routeId"]:
278
+ continue
279
+ collision = requested.intersection(route["hostnames"])
280
+ if collision:
281
+ raise DeploymentKitError(
282
+ "Hostname collision with route " + route["routeId"] + ": " + ", ".join(sorted(collision))
283
+ )
284
+ return {
285
+ "targetName": name,
286
+ "action": "register",
287
+ "route": spec,
288
+ "changeType": "update" if existing else "create",
289
+ "previousRoute": existing,
290
+ "inventorySnapshot": _inventory_snapshot(inventory),
291
+ "sharedPlatformAssetsModified": False,
292
+ "rawHostIpRoute": False,
293
+ "requiresUpstreamOnSharedNetwork": True,
294
+ }
295
+
296
+
297
+ def plan_route_removal(
298
+ name: str,
299
+ *,
300
+ owner_repo: str,
301
+ environment: str,
302
+ app_slug: str,
303
+ ) -> dict[str, Any]:
304
+ route_id = f"{_validate_slug(app_slug, 'appSlug')}--{_validate_slug(environment, 'environment')}"
305
+ owner_repo = _validate_owner_repo(owner_repo)
306
+ inventory = route_inventory(name)
307
+ if inventory["platformState"] != "ready":
308
+ raise DeploymentKitError(
309
+ f"Route removal requires a ready Kit-owned platform; target '{name}' is '{inventory['platformState']}'."
310
+ )
311
+ existing = _find_route(inventory, route_id)
312
+ if existing is None:
313
+ raise DeploymentKitError(f"Managed route does not exist: {route_id}")
314
+ if existing["ownerRepo"] != owner_repo or existing["environment"] != environment or existing["appSlug"] != app_slug:
315
+ raise DeploymentKitError("Route removal ownership does not match the registered POC/environment.")
316
+ return {
317
+ "targetName": name,
318
+ "action": "remove",
319
+ "route": existing,
320
+ "inventorySnapshot": _inventory_snapshot(inventory),
321
+ "removes": ["caddy-route", "mdns-aliases", "route-ownership-metadata"],
322
+ "preserves": ["shared-platform", "shared-network", "application-container", "application-data"],
323
+ }
324
+
325
+
326
+ def apply_route_change(plan: dict[str, Any]) -> dict[str, Any]:
327
+ if not isinstance(plan, dict) or plan.get("action") not in {"register", "remove"}:
328
+ raise DeploymentKitError("Malformed route-registry change plan.")
329
+ required_register = {
330
+ "targetName", "action", "route", "changeType", "previousRoute", "inventorySnapshot",
331
+ "sharedPlatformAssetsModified", "rawHostIpRoute", "requiresUpstreamOnSharedNetwork",
332
+ }
333
+ required_remove = {"targetName", "action", "route", "inventorySnapshot", "removes", "preserves"}
334
+ expected = required_register if plan["action"] == "register" else required_remove
335
+ if set(plan) != expected:
336
+ raise DeploymentKitError("Malformed route-registry change plan.")
337
+
338
+ name = plan["targetName"]
339
+ current = route_inventory(name)
340
+ if _inventory_snapshot(current) != plan["inventorySnapshot"]:
341
+ raise DeploymentKitError(
342
+ "Route inventory changed after the plan was reviewed; create and authorize a fresh plan."
343
+ )
344
+ platform = platform_status(name)
345
+ if platform["state"] != "ready":
346
+ raise DeploymentKitError("Shared host platform is no longer ready; route change is blocked.")
347
+
348
+ profile = load_profile(name)
349
+ evidence = verify_access(profile)
350
+ _run_remote(
351
+ profile,
352
+ evidence["address"],
353
+ plan["action"],
354
+ sudo=True,
355
+ spec=plan["route"],
356
+ previous_spec=plan.get("previousRoute"),
357
+ )
358
+ final = route_inventory(name)
359
+ route_id = plan["route"]["routeId"]
360
+ registered = _find_route(final, route_id)
361
+ if plan["action"] == "register":
362
+ if registered != plan["route"]:
363
+ raise DeploymentKitError("Route registration returned without the exact requested ownership metadata.")
364
+ elif registered is not None:
365
+ raise DeploymentKitError("Route removal returned but the route remains registered.")
366
+ final["mutated"] = True
367
+ return final