@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,286 +1,286 @@
|
|
|
1
|
-
"""Scoped destructive tear-down for Kit-managed POC environments and local Docker state."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import re
|
|
7
|
-
import shlex
|
|
8
|
-
import shutil
|
|
9
|
-
import subprocess
|
|
10
|
-
from typing import Any, Iterable
|
|
11
|
-
|
|
12
|
-
from .errors import DeploymentKitError
|
|
13
|
-
from .profiles import load_profile
|
|
14
|
-
from .route_registry import apply_route_change, plan_route_removal, route_inventory
|
|
15
|
-
from .ssh import ssh_args, verify_access
|
|
16
|
-
from .teardown_remote import REMOTE_SCRIPT
|
|
17
|
-
|
|
18
|
-
TEAR_DOWN_WRITE_PHRASE = "[POC TEAR DOWN]"
|
|
19
|
-
PRODUCTION_DATA_DELETE_PHRASE = "[PRODUCTION DATA DELETE]"
|
|
20
|
-
SUPPORTED_SCOPES = ("local", "staging", "production", "all")
|
|
21
|
-
|
|
22
|
-
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
|
23
|
-
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
24
|
-
_TARGET_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
|
25
|
-
_PACKAGE_RE = re.compile(r"^ghcr\.io/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def _slug(value: str, field: str) -> str:
|
|
29
|
-
if not isinstance(value, str) or _SLUG_RE.fullmatch(value) is None:
|
|
30
|
-
raise DeploymentKitError(f"{field} must use lowercase letters, digits, and internal hyphens.")
|
|
31
|
-
return value
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _owner_repo(value: str) -> str:
|
|
35
|
-
if not isinstance(value, str) or _REPO_RE.fullmatch(value) is None:
|
|
36
|
-
raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
|
|
37
|
-
return value
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _package(value: str) -> str:
|
|
41
|
-
if not isinstance(value, str) or _PACKAGE_RE.fullmatch(value) is None:
|
|
42
|
-
raise DeploymentKitError("localPackage must use exact ghcr.io/owner/package form.")
|
|
43
|
-
return value
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def parse_environment_targets(items: Iterable[str]) -> dict[str, str]:
|
|
47
|
-
result: dict[str, str] = {}
|
|
48
|
-
for item in items:
|
|
49
|
-
if not isinstance(item, str) or "=" not in item:
|
|
50
|
-
raise DeploymentKitError("environment target bindings must use environment=target form.")
|
|
51
|
-
environment, target = item.split("=", 1)
|
|
52
|
-
environment = _slug(environment, "environment")
|
|
53
|
-
if environment not in {"staging", "production"}:
|
|
54
|
-
raise DeploymentKitError("tear-down supports only staging and production environment bindings.")
|
|
55
|
-
if not target or _TARGET_RE.fullmatch(target) is None:
|
|
56
|
-
raise DeploymentKitError("target profile names contain unsupported characters.")
|
|
57
|
-
if environment in result:
|
|
58
|
-
raise DeploymentKitError(f"Duplicate environment target binding: {environment}")
|
|
59
|
-
result[environment] = target
|
|
60
|
-
return dict(sorted(result.items()))
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _remote_environments(scope: str) -> tuple[str, ...]:
|
|
64
|
-
if scope == "staging":
|
|
65
|
-
return ("staging",)
|
|
66
|
-
if scope == "production":
|
|
67
|
-
return ("production",)
|
|
68
|
-
if scope == "all":
|
|
69
|
-
return ("staging", "production")
|
|
70
|
-
return ()
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def normalize_teardown_spec(
|
|
74
|
-
*,
|
|
75
|
-
owner_repo: str,
|
|
76
|
-
app_slug: str,
|
|
77
|
-
scope: str,
|
|
78
|
-
environment_targets: dict[str, str] | Iterable[tuple[str, str]],
|
|
79
|
-
local_package: str | None,
|
|
80
|
-
) -> dict[str, Any]:
|
|
81
|
-
if scope not in SUPPORTED_SCOPES:
|
|
82
|
-
raise DeploymentKitError("scope must be one of: local, staging, production, all.")
|
|
83
|
-
|
|
84
|
-
pairs = environment_targets.items() if isinstance(environment_targets, dict) else environment_targets
|
|
85
|
-
normalized_targets: dict[str, str] = {}
|
|
86
|
-
for environment, target in pairs:
|
|
87
|
-
environment = _slug(environment, "environment")
|
|
88
|
-
if environment not in {"staging", "production"}:
|
|
89
|
-
raise DeploymentKitError("tear-down supports only staging and production environment bindings.")
|
|
90
|
-
if not isinstance(target, str) or _TARGET_RE.fullmatch(target) is None:
|
|
91
|
-
raise DeploymentKitError("target profile names contain unsupported characters.")
|
|
92
|
-
if environment in normalized_targets:
|
|
93
|
-
raise DeploymentKitError(f"Duplicate environment target binding: {environment}")
|
|
94
|
-
normalized_targets[environment] = target
|
|
95
|
-
|
|
96
|
-
selected_targets: dict[str, str] = {}
|
|
97
|
-
for environment in _remote_environments(scope):
|
|
98
|
-
if environment not in normalized_targets:
|
|
99
|
-
raise DeploymentKitError(f"scope {scope!r} requires an explicit {environment}=target binding.")
|
|
100
|
-
selected_targets[environment] = normalized_targets[environment]
|
|
101
|
-
|
|
102
|
-
package = _package(local_package) if local_package is not None else None
|
|
103
|
-
if scope in {"local", "all"} and package is None:
|
|
104
|
-
raise DeploymentKitError(f"scope {scope!r} requires --local-package for exact local Docker ownership.")
|
|
105
|
-
|
|
106
|
-
return {
|
|
107
|
-
"schemaVersion": 1,
|
|
108
|
-
"ownerRepo": _owner_repo(owner_repo),
|
|
109
|
-
"appSlug": _slug(app_slug, "appSlug"),
|
|
110
|
-
"scope": scope,
|
|
111
|
-
"environmentTargets": selected_targets,
|
|
112
|
-
"localPackage": package,
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _find_route(inventory: dict[str, Any], app_slug: str, environment: str) -> dict[str, Any] | None:
|
|
117
|
-
route_id = f"{app_slug}--{environment}"
|
|
118
|
-
return next((item for item in inventory["managedRoutes"] if item["routeId"] == route_id), None)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def _tear_down_route(target: str, owner_repo: str, app_slug: str, environment: str) -> dict[str, Any]:
|
|
122
|
-
inventory = route_inventory(target)
|
|
123
|
-
route = _find_route(inventory, app_slug, environment)
|
|
124
|
-
if route is None:
|
|
125
|
-
return {"state": "absent", "mutated": False}
|
|
126
|
-
if route["ownerRepo"] != owner_repo:
|
|
127
|
-
raise DeploymentKitError(
|
|
128
|
-
f"Route {app_slug}--{environment} is owned by {route['ownerRepo']}, not {owner_repo}."
|
|
129
|
-
)
|
|
130
|
-
plan = plan_route_removal(
|
|
131
|
-
target,
|
|
132
|
-
owner_repo=owner_repo,
|
|
133
|
-
environment=environment,
|
|
134
|
-
app_slug=app_slug,
|
|
135
|
-
)
|
|
136
|
-
apply_route_change(plan)
|
|
137
|
-
return {"state": "removed", "mutated": True, "hostnames": route["hostnames"]}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def _tear_down_remote_environment(
|
|
141
|
-
target: str,
|
|
142
|
-
owner_repo: str,
|
|
143
|
-
app_slug: str,
|
|
144
|
-
environment: str,
|
|
145
|
-
) -> dict[str, Any]:
|
|
146
|
-
profile = load_profile(target)
|
|
147
|
-
evidence = verify_access(profile)
|
|
148
|
-
command = ssh_args(
|
|
149
|
-
profile,
|
|
150
|
-
evidence["address"],
|
|
151
|
-
command="sudo -n bash -s -- "
|
|
152
|
-
+ " ".join(shlex.quote(value) for value in (owner_repo, app_slug, environment)),
|
|
153
|
-
)
|
|
154
|
-
completed = subprocess.run(
|
|
155
|
-
command,
|
|
156
|
-
input=REMOTE_SCRIPT.encode("utf-8"),
|
|
157
|
-
capture_output=True,
|
|
158
|
-
check=False,
|
|
159
|
-
)
|
|
160
|
-
stdout = completed.stdout.decode("utf-8", errors="replace").strip()
|
|
161
|
-
stderr = completed.stderr.decode("utf-8", errors="replace").strip()
|
|
162
|
-
if completed.returncode != 0:
|
|
163
|
-
raise DeploymentKitError(
|
|
164
|
-
"Remote POC tear-down failed: " + (stderr or stdout or f"exit {completed.returncode}")
|
|
165
|
-
)
|
|
166
|
-
parts = stdout.split("\t")
|
|
167
|
-
if len(parts) not in {4, 5} or parts[0] != "TEAR_DOWN" or parts[1] not in {"absent", "removed"}:
|
|
168
|
-
raise DeploymentKitError("Remote POC tear-down returned malformed evidence.")
|
|
169
|
-
if parts[2] != app_slug or parts[3] != environment:
|
|
170
|
-
raise DeploymentKitError("Remote POC tear-down returned mismatched app/environment evidence.")
|
|
171
|
-
result: dict[str, Any] = {"state": parts[1], "mutated": parts[1] == "removed"}
|
|
172
|
-
if len(parts) == 5:
|
|
173
|
-
result["containerName"] = parts[4]
|
|
174
|
-
return result
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def _run_local(args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
178
|
-
return subprocess.run(
|
|
179
|
-
args,
|
|
180
|
-
text=True,
|
|
181
|
-
encoding="utf-8",
|
|
182
|
-
errors="replace",
|
|
183
|
-
capture_output=True,
|
|
184
|
-
check=False,
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def _image_matches_package(image: str, package: str) -> bool:
|
|
189
|
-
return image == package or image.startswith(package + ":") or image.startswith(package + "@")
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def tear_down_workstation_package(package: str) -> dict[str, Any]:
|
|
193
|
-
package = _package(package)
|
|
194
|
-
docker = shutil.which("docker")
|
|
195
|
-
if not docker:
|
|
196
|
-
return {"state": "docker-unavailable", "mutated": False, "containers": [], "images": []}
|
|
197
|
-
|
|
198
|
-
listed = _run_local([docker, "ps", "-a", "--format", "{{.ID}}"])
|
|
199
|
-
if listed.returncode != 0:
|
|
200
|
-
raise DeploymentKitError("Local Docker container inventory failed: " + (listed.stderr or listed.stdout).strip())
|
|
201
|
-
|
|
202
|
-
containers: list[str] = []
|
|
203
|
-
for container_id in [line.strip() for line in listed.stdout.splitlines() if line.strip()]:
|
|
204
|
-
inspected = _run_local([docker, "inspect", "--format", "{{.Config.Image}}", container_id])
|
|
205
|
-
if inspected.returncode != 0:
|
|
206
|
-
raise DeploymentKitError(f"Local Docker inspect failed for container {container_id}.")
|
|
207
|
-
image = inspected.stdout.strip()
|
|
208
|
-
if _image_matches_package(image, package):
|
|
209
|
-
containers.append(container_id)
|
|
210
|
-
|
|
211
|
-
if containers:
|
|
212
|
-
removed = _run_local([docker, "rm", "-f", "-v", *containers])
|
|
213
|
-
if removed.returncode != 0:
|
|
214
|
-
raise DeploymentKitError("Local POC container removal failed: " + (removed.stderr or removed.stdout).strip())
|
|
215
|
-
|
|
216
|
-
images_result = _run_local([docker, "images", "--format", "{{.Repository}}:{{.Tag}}"])
|
|
217
|
-
if images_result.returncode != 0:
|
|
218
|
-
raise DeploymentKitError("Local Docker image inventory failed: " + (images_result.stderr or images_result.stdout).strip())
|
|
219
|
-
image_refs = sorted(
|
|
220
|
-
{
|
|
221
|
-
line.strip()
|
|
222
|
-
for line in images_result.stdout.splitlines()
|
|
223
|
-
if line.strip().startswith(package + ":") and not line.strip().endswith(":<none>")
|
|
224
|
-
}
|
|
225
|
-
)
|
|
226
|
-
if image_refs:
|
|
227
|
-
removed = _run_local([docker, "image", "rm", "-f", *image_refs])
|
|
228
|
-
if removed.returncode != 0:
|
|
229
|
-
raise DeploymentKitError("Local POC image removal failed: " + (removed.stderr or removed.stdout).strip())
|
|
230
|
-
|
|
231
|
-
return {
|
|
232
|
-
"state": "removed" if containers or image_refs else "absent",
|
|
233
|
-
"mutated": bool(containers or image_refs),
|
|
234
|
-
"containers": containers,
|
|
235
|
-
"images": image_refs,
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def tear_down_poc(spec: dict[str, Any]) -> dict[str, Any]:
|
|
240
|
-
normalized = normalize_teardown_spec(
|
|
241
|
-
owner_repo=spec.get("ownerRepo"),
|
|
242
|
-
app_slug=spec.get("appSlug"),
|
|
243
|
-
scope=spec.get("scope"),
|
|
244
|
-
environment_targets=(spec.get("environmentTargets") or {}).items(),
|
|
245
|
-
local_package=spec.get("localPackage"),
|
|
246
|
-
)
|
|
247
|
-
|
|
248
|
-
environments: list[dict[str, Any]] = []
|
|
249
|
-
mutated = False
|
|
250
|
-
for environment, target in normalized["environmentTargets"].items():
|
|
251
|
-
route = _tear_down_route(target, normalized["ownerRepo"], normalized["appSlug"], environment)
|
|
252
|
-
runtime = _tear_down_remote_environment(
|
|
253
|
-
target,
|
|
254
|
-
normalized["ownerRepo"],
|
|
255
|
-
normalized["appSlug"],
|
|
256
|
-
environment,
|
|
257
|
-
)
|
|
258
|
-
mutated = mutated or route["mutated"] or runtime["mutated"]
|
|
259
|
-
environments.append(
|
|
260
|
-
{
|
|
261
|
-
"environment": environment,
|
|
262
|
-
"targetName": target,
|
|
263
|
-
"route": route,
|
|
264
|
-
"runtimeAndPersistentData": runtime,
|
|
265
|
-
}
|
|
266
|
-
)
|
|
267
|
-
|
|
268
|
-
workstation: dict[str, Any] = {"state": "not-selected", "mutated": False}
|
|
269
|
-
if normalized["scope"] in {"local", "all"}:
|
|
270
|
-
workstation = tear_down_workstation_package(normalized["localPackage"])
|
|
271
|
-
mutated = mutated or workstation["mutated"]
|
|
272
|
-
|
|
273
|
-
return {
|
|
274
|
-
"schemaVersion": 1,
|
|
275
|
-
"ownerRepo": normalized["ownerRepo"],
|
|
276
|
-
"appSlug": normalized["appSlug"],
|
|
277
|
-
"scope": normalized["scope"],
|
|
278
|
-
"environments": environments,
|
|
279
|
-
"workstation": workstation,
|
|
280
|
-
"productionDataIncluded": normalized["scope"] in {"production", "all"},
|
|
281
|
-
"mutated": mutated,
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
def render_teardown_summary(result: dict[str, Any]) -> str:
|
|
286
|
-
return json.dumps(result, indent=2, sort_keys=True)
|
|
1
|
+
"""Scoped destructive tear-down for Kit-managed POC environments and local Docker state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Any, Iterable
|
|
11
|
+
|
|
12
|
+
from .errors import DeploymentKitError
|
|
13
|
+
from .profiles import load_profile
|
|
14
|
+
from .route_registry import apply_route_change, plan_route_removal, route_inventory
|
|
15
|
+
from .ssh import ssh_args, verify_access
|
|
16
|
+
from .teardown_remote import REMOTE_SCRIPT
|
|
17
|
+
|
|
18
|
+
TEAR_DOWN_WRITE_PHRASE = "[POC TEAR DOWN]"
|
|
19
|
+
PRODUCTION_DATA_DELETE_PHRASE = "[PRODUCTION DATA DELETE]"
|
|
20
|
+
SUPPORTED_SCOPES = ("local", "staging", "production", "all")
|
|
21
|
+
|
|
22
|
+
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
|
23
|
+
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
24
|
+
_TARGET_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
|
25
|
+
_PACKAGE_RE = re.compile(r"^ghcr\.io/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _slug(value: str, field: str) -> str:
|
|
29
|
+
if not isinstance(value, str) or _SLUG_RE.fullmatch(value) is None:
|
|
30
|
+
raise DeploymentKitError(f"{field} must use lowercase letters, digits, and internal hyphens.")
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _owner_repo(value: str) -> str:
|
|
35
|
+
if not isinstance(value, str) or _REPO_RE.fullmatch(value) is None:
|
|
36
|
+
raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _package(value: str) -> str:
|
|
41
|
+
if not isinstance(value, str) or _PACKAGE_RE.fullmatch(value) is None:
|
|
42
|
+
raise DeploymentKitError("localPackage must use exact ghcr.io/owner/package form.")
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_environment_targets(items: Iterable[str]) -> dict[str, str]:
|
|
47
|
+
result: dict[str, str] = {}
|
|
48
|
+
for item in items:
|
|
49
|
+
if not isinstance(item, str) or "=" not in item:
|
|
50
|
+
raise DeploymentKitError("environment target bindings must use environment=target form.")
|
|
51
|
+
environment, target = item.split("=", 1)
|
|
52
|
+
environment = _slug(environment, "environment")
|
|
53
|
+
if environment not in {"staging", "production"}:
|
|
54
|
+
raise DeploymentKitError("tear-down supports only staging and production environment bindings.")
|
|
55
|
+
if not target or _TARGET_RE.fullmatch(target) is None:
|
|
56
|
+
raise DeploymentKitError("target profile names contain unsupported characters.")
|
|
57
|
+
if environment in result:
|
|
58
|
+
raise DeploymentKitError(f"Duplicate environment target binding: {environment}")
|
|
59
|
+
result[environment] = target
|
|
60
|
+
return dict(sorted(result.items()))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _remote_environments(scope: str) -> tuple[str, ...]:
|
|
64
|
+
if scope == "staging":
|
|
65
|
+
return ("staging",)
|
|
66
|
+
if scope == "production":
|
|
67
|
+
return ("production",)
|
|
68
|
+
if scope == "all":
|
|
69
|
+
return ("staging", "production")
|
|
70
|
+
return ()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def normalize_teardown_spec(
|
|
74
|
+
*,
|
|
75
|
+
owner_repo: str,
|
|
76
|
+
app_slug: str,
|
|
77
|
+
scope: str,
|
|
78
|
+
environment_targets: dict[str, str] | Iterable[tuple[str, str]],
|
|
79
|
+
local_package: str | None,
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
if scope not in SUPPORTED_SCOPES:
|
|
82
|
+
raise DeploymentKitError("scope must be one of: local, staging, production, all.")
|
|
83
|
+
|
|
84
|
+
pairs = environment_targets.items() if isinstance(environment_targets, dict) else environment_targets
|
|
85
|
+
normalized_targets: dict[str, str] = {}
|
|
86
|
+
for environment, target in pairs:
|
|
87
|
+
environment = _slug(environment, "environment")
|
|
88
|
+
if environment not in {"staging", "production"}:
|
|
89
|
+
raise DeploymentKitError("tear-down supports only staging and production environment bindings.")
|
|
90
|
+
if not isinstance(target, str) or _TARGET_RE.fullmatch(target) is None:
|
|
91
|
+
raise DeploymentKitError("target profile names contain unsupported characters.")
|
|
92
|
+
if environment in normalized_targets:
|
|
93
|
+
raise DeploymentKitError(f"Duplicate environment target binding: {environment}")
|
|
94
|
+
normalized_targets[environment] = target
|
|
95
|
+
|
|
96
|
+
selected_targets: dict[str, str] = {}
|
|
97
|
+
for environment in _remote_environments(scope):
|
|
98
|
+
if environment not in normalized_targets:
|
|
99
|
+
raise DeploymentKitError(f"scope {scope!r} requires an explicit {environment}=target binding.")
|
|
100
|
+
selected_targets[environment] = normalized_targets[environment]
|
|
101
|
+
|
|
102
|
+
package = _package(local_package) if local_package is not None else None
|
|
103
|
+
if scope in {"local", "all"} and package is None:
|
|
104
|
+
raise DeploymentKitError(f"scope {scope!r} requires --local-package for exact local Docker ownership.")
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
"schemaVersion": 1,
|
|
108
|
+
"ownerRepo": _owner_repo(owner_repo),
|
|
109
|
+
"appSlug": _slug(app_slug, "appSlug"),
|
|
110
|
+
"scope": scope,
|
|
111
|
+
"environmentTargets": selected_targets,
|
|
112
|
+
"localPackage": package,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _find_route(inventory: dict[str, Any], app_slug: str, environment: str) -> dict[str, Any] | None:
|
|
117
|
+
route_id = f"{app_slug}--{environment}"
|
|
118
|
+
return next((item for item in inventory["managedRoutes"] if item["routeId"] == route_id), None)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _tear_down_route(target: str, owner_repo: str, app_slug: str, environment: str) -> dict[str, Any]:
|
|
122
|
+
inventory = route_inventory(target)
|
|
123
|
+
route = _find_route(inventory, app_slug, environment)
|
|
124
|
+
if route is None:
|
|
125
|
+
return {"state": "absent", "mutated": False}
|
|
126
|
+
if route["ownerRepo"] != owner_repo:
|
|
127
|
+
raise DeploymentKitError(
|
|
128
|
+
f"Route {app_slug}--{environment} is owned by {route['ownerRepo']}, not {owner_repo}."
|
|
129
|
+
)
|
|
130
|
+
plan = plan_route_removal(
|
|
131
|
+
target,
|
|
132
|
+
owner_repo=owner_repo,
|
|
133
|
+
environment=environment,
|
|
134
|
+
app_slug=app_slug,
|
|
135
|
+
)
|
|
136
|
+
apply_route_change(plan)
|
|
137
|
+
return {"state": "removed", "mutated": True, "hostnames": route["hostnames"]}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _tear_down_remote_environment(
|
|
141
|
+
target: str,
|
|
142
|
+
owner_repo: str,
|
|
143
|
+
app_slug: str,
|
|
144
|
+
environment: str,
|
|
145
|
+
) -> dict[str, Any]:
|
|
146
|
+
profile = load_profile(target)
|
|
147
|
+
evidence = verify_access(profile)
|
|
148
|
+
command = ssh_args(
|
|
149
|
+
profile,
|
|
150
|
+
evidence["address"],
|
|
151
|
+
command="sudo -n bash -s -- "
|
|
152
|
+
+ " ".join(shlex.quote(value) for value in (owner_repo, app_slug, environment)),
|
|
153
|
+
)
|
|
154
|
+
completed = subprocess.run(
|
|
155
|
+
command,
|
|
156
|
+
input=REMOTE_SCRIPT.encode("utf-8"),
|
|
157
|
+
capture_output=True,
|
|
158
|
+
check=False,
|
|
159
|
+
)
|
|
160
|
+
stdout = completed.stdout.decode("utf-8", errors="replace").strip()
|
|
161
|
+
stderr = completed.stderr.decode("utf-8", errors="replace").strip()
|
|
162
|
+
if completed.returncode != 0:
|
|
163
|
+
raise DeploymentKitError(
|
|
164
|
+
"Remote POC tear-down failed: " + (stderr or stdout or f"exit {completed.returncode}")
|
|
165
|
+
)
|
|
166
|
+
parts = stdout.split("\t")
|
|
167
|
+
if len(parts) not in {4, 5} or parts[0] != "TEAR_DOWN" or parts[1] not in {"absent", "removed"}:
|
|
168
|
+
raise DeploymentKitError("Remote POC tear-down returned malformed evidence.")
|
|
169
|
+
if parts[2] != app_slug or parts[3] != environment:
|
|
170
|
+
raise DeploymentKitError("Remote POC tear-down returned mismatched app/environment evidence.")
|
|
171
|
+
result: dict[str, Any] = {"state": parts[1], "mutated": parts[1] == "removed"}
|
|
172
|
+
if len(parts) == 5:
|
|
173
|
+
result["containerName"] = parts[4]
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _run_local(args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
178
|
+
return subprocess.run(
|
|
179
|
+
args,
|
|
180
|
+
text=True,
|
|
181
|
+
encoding="utf-8",
|
|
182
|
+
errors="replace",
|
|
183
|
+
capture_output=True,
|
|
184
|
+
check=False,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _image_matches_package(image: str, package: str) -> bool:
|
|
189
|
+
return image == package or image.startswith(package + ":") or image.startswith(package + "@")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def tear_down_workstation_package(package: str) -> dict[str, Any]:
|
|
193
|
+
package = _package(package)
|
|
194
|
+
docker = shutil.which("docker")
|
|
195
|
+
if not docker:
|
|
196
|
+
return {"state": "docker-unavailable", "mutated": False, "containers": [], "images": []}
|
|
197
|
+
|
|
198
|
+
listed = _run_local([docker, "ps", "-a", "--format", "{{.ID}}"])
|
|
199
|
+
if listed.returncode != 0:
|
|
200
|
+
raise DeploymentKitError("Local Docker container inventory failed: " + (listed.stderr or listed.stdout).strip())
|
|
201
|
+
|
|
202
|
+
containers: list[str] = []
|
|
203
|
+
for container_id in [line.strip() for line in listed.stdout.splitlines() if line.strip()]:
|
|
204
|
+
inspected = _run_local([docker, "inspect", "--format", "{{.Config.Image}}", container_id])
|
|
205
|
+
if inspected.returncode != 0:
|
|
206
|
+
raise DeploymentKitError(f"Local Docker inspect failed for container {container_id}.")
|
|
207
|
+
image = inspected.stdout.strip()
|
|
208
|
+
if _image_matches_package(image, package):
|
|
209
|
+
containers.append(container_id)
|
|
210
|
+
|
|
211
|
+
if containers:
|
|
212
|
+
removed = _run_local([docker, "rm", "-f", "-v", *containers])
|
|
213
|
+
if removed.returncode != 0:
|
|
214
|
+
raise DeploymentKitError("Local POC container removal failed: " + (removed.stderr or removed.stdout).strip())
|
|
215
|
+
|
|
216
|
+
images_result = _run_local([docker, "images", "--format", "{{.Repository}}:{{.Tag}}"])
|
|
217
|
+
if images_result.returncode != 0:
|
|
218
|
+
raise DeploymentKitError("Local Docker image inventory failed: " + (images_result.stderr or images_result.stdout).strip())
|
|
219
|
+
image_refs = sorted(
|
|
220
|
+
{
|
|
221
|
+
line.strip()
|
|
222
|
+
for line in images_result.stdout.splitlines()
|
|
223
|
+
if line.strip().startswith(package + ":") and not line.strip().endswith(":<none>")
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
if image_refs:
|
|
227
|
+
removed = _run_local([docker, "image", "rm", "-f", *image_refs])
|
|
228
|
+
if removed.returncode != 0:
|
|
229
|
+
raise DeploymentKitError("Local POC image removal failed: " + (removed.stderr or removed.stdout).strip())
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
"state": "removed" if containers or image_refs else "absent",
|
|
233
|
+
"mutated": bool(containers or image_refs),
|
|
234
|
+
"containers": containers,
|
|
235
|
+
"images": image_refs,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def tear_down_poc(spec: dict[str, Any]) -> dict[str, Any]:
|
|
240
|
+
normalized = normalize_teardown_spec(
|
|
241
|
+
owner_repo=spec.get("ownerRepo"),
|
|
242
|
+
app_slug=spec.get("appSlug"),
|
|
243
|
+
scope=spec.get("scope"),
|
|
244
|
+
environment_targets=(spec.get("environmentTargets") or {}).items(),
|
|
245
|
+
local_package=spec.get("localPackage"),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
environments: list[dict[str, Any]] = []
|
|
249
|
+
mutated = False
|
|
250
|
+
for environment, target in normalized["environmentTargets"].items():
|
|
251
|
+
route = _tear_down_route(target, normalized["ownerRepo"], normalized["appSlug"], environment)
|
|
252
|
+
runtime = _tear_down_remote_environment(
|
|
253
|
+
target,
|
|
254
|
+
normalized["ownerRepo"],
|
|
255
|
+
normalized["appSlug"],
|
|
256
|
+
environment,
|
|
257
|
+
)
|
|
258
|
+
mutated = mutated or route["mutated"] or runtime["mutated"]
|
|
259
|
+
environments.append(
|
|
260
|
+
{
|
|
261
|
+
"environment": environment,
|
|
262
|
+
"targetName": target,
|
|
263
|
+
"route": route,
|
|
264
|
+
"runtimeAndPersistentData": runtime,
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
workstation: dict[str, Any] = {"state": "not-selected", "mutated": False}
|
|
269
|
+
if normalized["scope"] in {"local", "all"}:
|
|
270
|
+
workstation = tear_down_workstation_package(normalized["localPackage"])
|
|
271
|
+
mutated = mutated or workstation["mutated"]
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
"schemaVersion": 1,
|
|
275
|
+
"ownerRepo": normalized["ownerRepo"],
|
|
276
|
+
"appSlug": normalized["appSlug"],
|
|
277
|
+
"scope": normalized["scope"],
|
|
278
|
+
"environments": environments,
|
|
279
|
+
"workstation": workstation,
|
|
280
|
+
"productionDataIncluded": normalized["scope"] in {"production", "all"},
|
|
281
|
+
"mutated": mutated,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def render_teardown_summary(result: dict[str, Any]) -> str:
|
|
286
|
+
return json.dumps(result, indent=2, sort_keys=True)
|