@mriekki/lean 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -67
- package/assets/components.json +33 -33
- package/assets/core/distribution.json +52 -51
- package/assets/deployment/distribution.json +34 -34
- package/assets/generated/ASSEMBLY.json +38 -33
- package/assets/generated/core/core/skills/core-sync/SKILL.md +78 -120
- package/assets/generated/core/core/skills/deploy/SKILL.md +74 -91
- package/assets/generated/core/core/skills/lean-update/SKILL.md +189 -0
- package/assets/generated/core/core/skills/upstream-fix/SKILL.md +91 -76
- package/assets/generated/deployment/distribution/lean-poc-deploy.py +8 -8
- package/assets/generated/deployment/src/lean_poc_deployment/__init__.py +3 -3
- package/assets/generated/deployment/src/lean_poc_deployment/__main__.py +3 -3
- package/assets/generated/deployment/src/lean_poc_deployment/app_cli.py +140 -140
- package/assets/generated/deployment/src/lean_poc_deployment/app_runtime.py +455 -455
- package/assets/generated/deployment/src/lean_poc_deployment/app_runtime_remote.py +415 -415
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform.py +208 -208
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform_assets.py +100 -100
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform_remote.py +271 -271
- package/assets/generated/deployment/src/lean_poc_deployment/cli.py +437 -437
- package/assets/generated/deployment/src/lean_poc_deployment/doctor.py +121 -121
- package/assets/generated/deployment/src/lean_poc_deployment/entrypoint.py +25 -25
- package/assets/generated/deployment/src/lean_poc_deployment/errors.py +5 -5
- package/assets/generated/deployment/src/lean_poc_deployment/keys.py +171 -171
- package/assets/generated/deployment/src/lean_poc_deployment/profiles.py +204 -204
- package/assets/generated/deployment/src/lean_poc_deployment/route_registry.py +367 -367
- package/assets/generated/deployment/src/lean_poc_deployment/route_registry_remote.py +443 -443
- package/assets/generated/deployment/src/lean_poc_deployment/security.py +290 -290
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter.py +334 -334
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter_remote.py +339 -339
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_cli.py +85 -85
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_import_recovery.py +437 -437
- package/assets/generated/deployment/src/lean_poc_deployment/ssh.py +118 -118
- package/assets/generated/deployment/src/lean_poc_deployment/teardown.py +286 -286
- package/assets/generated/deployment/src/lean_poc_deployment/teardown_cli.py +120 -120
- package/assets/generated/deployment/src/lean_poc_deployment/teardown_remote.py +100 -100
- package/assets/generated/deployment/src/lean_poc_deployment/trust.py +278 -278
- package/bin/lean.mjs +9 -9
- package/package.json +33 -33
- package/src/cli.mjs +131 -131
- package/src/engine.mjs +243 -243
- package/src/package-data.mjs +18 -18
|
@@ -1,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
|