@mriekki/project-forge 4.8.1 → 4.10.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/assets/components.json +3 -3
- package/assets/deployment/distribution.json +5 -0
- package/assets/generated/ASSEMBLY.json +33 -8
- package/assets/generated/core/core/skills/tear-down/SKILL.md +76 -18
- package/assets/generated/deployment/docs/CLOUDFLARE_INGRESS.md +226 -0
- package/assets/generated/deployment/src/project_forge_deployment/cloudflare_api.py +260 -0
- package/assets/generated/deployment/src/project_forge_deployment/cloudflare_ingress.py +834 -0
- package/assets/generated/deployment/src/project_forge_deployment/cloudflare_ingress_remote.py +328 -0
- package/assets/generated/deployment/src/project_forge_deployment/entrypoint.py +10 -0
- package/assets/generated/deployment/src/project_forge_deployment/ingress_cli.py +192 -0
- package/assets/generated/deployment/src/project_forge_deployment/teardown.py +253 -5
- package/assets/generated/deployment/src/project_forge_deployment/teardown_cli.py +79 -3
- package/assets/generated/deployment/src/project_forge_deployment/teardown_remote.py +58 -1
- package/package.json +1 -1
|
@@ -3,12 +3,23 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import os
|
|
6
7
|
import re
|
|
7
8
|
import shlex
|
|
8
9
|
import shutil
|
|
9
10
|
import subprocess
|
|
10
11
|
from typing import Any, Iterable
|
|
11
12
|
|
|
13
|
+
from .app_runtime import app_inventory
|
|
14
|
+
from .box_platform import platform_status
|
|
15
|
+
from .cloudflare_ingress import (
|
|
16
|
+
CLOUDFLARE_API_TOKEN_ENV,
|
|
17
|
+
PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE,
|
|
18
|
+
apply_public_route_removal,
|
|
19
|
+
cloudflare_ingress_status,
|
|
20
|
+
cloudflare_route_inventory,
|
|
21
|
+
plan_public_route_removal,
|
|
22
|
+
)
|
|
12
23
|
from .errors import DeploymentKitError
|
|
13
24
|
from .profiles import load_profile
|
|
14
25
|
from .route_registry import apply_route_change, plan_route_removal, route_inventory
|
|
@@ -60,6 +71,24 @@ def parse_environment_targets(items: Iterable[str]) -> dict[str, str]:
|
|
|
60
71
|
return dict(sorted(result.items()))
|
|
61
72
|
|
|
62
73
|
|
|
74
|
+
def parse_environment_scopes(items: Iterable[str]) -> dict[str, str]:
|
|
75
|
+
result: dict[str, str] = {}
|
|
76
|
+
for item in items:
|
|
77
|
+
if not isinstance(item, str) or "=" not in item:
|
|
78
|
+
raise DeploymentKitError("public-ingress scope bindings must use environment=scope form.")
|
|
79
|
+
environment, scope = item.split("=", 1)
|
|
80
|
+
environment = _slug(environment, "environment")
|
|
81
|
+
if environment in RESERVED_SCOPES:
|
|
82
|
+
raise DeploymentKitError(f"{environment!r} is reserved and cannot be a remote environment name.")
|
|
83
|
+
scope = _slug(scope, "public ingress scope")
|
|
84
|
+
if scope in RESERVED_SCOPES:
|
|
85
|
+
raise DeploymentKitError(f"{scope!r} is reserved and cannot be a public ingress scope.")
|
|
86
|
+
if environment in result:
|
|
87
|
+
raise DeploymentKitError(f"Duplicate public-ingress scope binding: {environment}")
|
|
88
|
+
result[environment] = scope
|
|
89
|
+
return dict(sorted(result.items()))
|
|
90
|
+
|
|
91
|
+
|
|
63
92
|
def _normalize_production_environments(items: Iterable[str]) -> set[str]:
|
|
64
93
|
result = {"production"}
|
|
65
94
|
for item in items:
|
|
@@ -78,6 +107,7 @@ def normalize_teardown_spec(
|
|
|
78
107
|
environment_targets: dict[str, str] | Iterable[tuple[str, str]],
|
|
79
108
|
local_package: str | None,
|
|
80
109
|
production_environments: Iterable[str] = (),
|
|
110
|
+
public_ingress_scopes: dict[str, str] | Iterable[tuple[str, str]] = (),
|
|
81
111
|
) -> dict[str, Any]:
|
|
82
112
|
scope = _slug(scope, "scope")
|
|
83
113
|
pairs = environment_targets.items() if isinstance(environment_targets, dict) else environment_targets
|
|
@@ -92,6 +122,23 @@ def normalize_teardown_spec(
|
|
|
92
122
|
raise DeploymentKitError(f"Duplicate environment target binding: {environment}")
|
|
93
123
|
normalized_targets[environment] = target
|
|
94
124
|
|
|
125
|
+
ingress_pairs = (
|
|
126
|
+
public_ingress_scopes.items()
|
|
127
|
+
if isinstance(public_ingress_scopes, dict)
|
|
128
|
+
else public_ingress_scopes
|
|
129
|
+
)
|
|
130
|
+
normalized_ingress: dict[str, str] = {}
|
|
131
|
+
for environment, ingress_scope in ingress_pairs:
|
|
132
|
+
environment = _slug(environment, "environment")
|
|
133
|
+
ingress_scope = _slug(ingress_scope, "public ingress scope")
|
|
134
|
+
if environment not in normalized_targets:
|
|
135
|
+
raise DeploymentKitError(
|
|
136
|
+
f"public ingress scope binding {environment!r} has no matching environment target binding."
|
|
137
|
+
)
|
|
138
|
+
if environment in normalized_ingress:
|
|
139
|
+
raise DeploymentKitError(f"Duplicate public-ingress scope binding: {environment}")
|
|
140
|
+
normalized_ingress[environment] = ingress_scope
|
|
141
|
+
|
|
95
142
|
if scope == "local":
|
|
96
143
|
selected_targets: dict[str, str] = {}
|
|
97
144
|
elif scope == "all":
|
|
@@ -107,15 +154,21 @@ def normalize_teardown_spec(
|
|
|
107
154
|
|
|
108
155
|
production_set = _normalize_production_environments(production_environments)
|
|
109
156
|
selected_production = sorted(set(selected_targets).intersection(production_set))
|
|
157
|
+
selected_ingress = {
|
|
158
|
+
environment: normalized_ingress[environment]
|
|
159
|
+
for environment in selected_targets
|
|
160
|
+
if environment in normalized_ingress
|
|
161
|
+
}
|
|
110
162
|
|
|
111
163
|
return {
|
|
112
|
-
"schemaVersion":
|
|
164
|
+
"schemaVersion": 3,
|
|
113
165
|
"ownerRepo": _owner_repo(owner_repo),
|
|
114
166
|
"appSlug": _slug(app_slug, "appSlug"),
|
|
115
167
|
"scope": scope,
|
|
116
168
|
"environmentTargets": selected_targets,
|
|
117
169
|
"localPackage": package,
|
|
118
170
|
"productionEnvironments": selected_production,
|
|
171
|
+
"publicIngressScopes": selected_ingress,
|
|
119
172
|
}
|
|
120
173
|
|
|
121
174
|
|
|
@@ -124,6 +177,178 @@ def _find_route(inventory: dict[str, Any], app_slug: str, environment: str) -> d
|
|
|
124
177
|
return next((item for item in inventory["managedRoutes"] if item["routeId"] == route_id), None)
|
|
125
178
|
|
|
126
179
|
|
|
180
|
+
def _ensure_candidate(
|
|
181
|
+
candidates: dict[str, dict[str, Any]],
|
|
182
|
+
*,
|
|
183
|
+
owner_repo: str,
|
|
184
|
+
app_slug: str,
|
|
185
|
+
environment: str,
|
|
186
|
+
target_name: str,
|
|
187
|
+
) -> dict[str, Any]:
|
|
188
|
+
route_id = f"{app_slug}--{environment}"
|
|
189
|
+
candidate = candidates.get(route_id)
|
|
190
|
+
if candidate is None:
|
|
191
|
+
candidate = {
|
|
192
|
+
"routeId": route_id,
|
|
193
|
+
"ownerRepo": owner_repo,
|
|
194
|
+
"appSlug": app_slug,
|
|
195
|
+
"environment": environment,
|
|
196
|
+
"targetName": target_name,
|
|
197
|
+
"runtime": None,
|
|
198
|
+
"trustedLanRoute": None,
|
|
199
|
+
"publicIngress": [],
|
|
200
|
+
}
|
|
201
|
+
candidates[route_id] = candidate
|
|
202
|
+
return candidate
|
|
203
|
+
if candidate["ownerRepo"] != owner_repo:
|
|
204
|
+
raise DeploymentKitError(
|
|
205
|
+
f"Target inventory ownership mismatch for {route_id}: "
|
|
206
|
+
f"{candidate['ownerRepo']} versus {owner_repo}."
|
|
207
|
+
)
|
|
208
|
+
return candidate
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def target_inventory(name: str, *, ingress_scopes: Iterable[str] = ()) -> dict[str, Any]:
|
|
212
|
+
"""Read one target's shared platform and application-owned tear-down candidates."""
|
|
213
|
+
platform = platform_status(name)
|
|
214
|
+
apps_result = app_inventory(name)
|
|
215
|
+
lan_result = route_inventory(name)
|
|
216
|
+
|
|
217
|
+
candidates: dict[str, dict[str, Any]] = {}
|
|
218
|
+
inferred_scopes: set[str] = set()
|
|
219
|
+
|
|
220
|
+
for app in apps_result["applications"]:
|
|
221
|
+
candidate = _ensure_candidate(
|
|
222
|
+
candidates,
|
|
223
|
+
owner_repo=app["ownerRepo"],
|
|
224
|
+
app_slug=app["appSlug"],
|
|
225
|
+
environment=app["environment"],
|
|
226
|
+
target_name=name,
|
|
227
|
+
)
|
|
228
|
+
candidate["runtime"] = app
|
|
229
|
+
inferred_scopes.add(app["environment"])
|
|
230
|
+
|
|
231
|
+
for route in lan_result["managedRoutes"]:
|
|
232
|
+
candidate = _ensure_candidate(
|
|
233
|
+
candidates,
|
|
234
|
+
owner_repo=route["ownerRepo"],
|
|
235
|
+
app_slug=route["appSlug"],
|
|
236
|
+
environment=route["environment"],
|
|
237
|
+
target_name=name,
|
|
238
|
+
)
|
|
239
|
+
candidate["trustedLanRoute"] = route
|
|
240
|
+
inferred_scopes.add(route["environment"])
|
|
241
|
+
|
|
242
|
+
if isinstance(name, str) and _SLUG_RE.fullmatch(name):
|
|
243
|
+
inferred_scopes.add(name)
|
|
244
|
+
requested_scopes = {_slug(item, "public ingress scope") for item in ingress_scopes}
|
|
245
|
+
scopes = sorted(inferred_scopes | requested_scopes)
|
|
246
|
+
|
|
247
|
+
public_scopes: list[dict[str, Any]] = []
|
|
248
|
+
for scope in scopes:
|
|
249
|
+
status = cloudflare_ingress_status(name, scope)
|
|
250
|
+
routes: list[dict[str, Any]] = []
|
|
251
|
+
if status["markerPresent"]:
|
|
252
|
+
routes = cloudflare_route_inventory(name, scope)["managedRoutes"]
|
|
253
|
+
for route in routes:
|
|
254
|
+
candidate = _ensure_candidate(
|
|
255
|
+
candidates,
|
|
256
|
+
owner_repo=route["ownerRepo"],
|
|
257
|
+
app_slug=route["appSlug"],
|
|
258
|
+
environment=route["environment"],
|
|
259
|
+
target_name=name,
|
|
260
|
+
)
|
|
261
|
+
candidate["publicIngress"].append({"scope": scope, **route})
|
|
262
|
+
public_scopes.append(
|
|
263
|
+
{
|
|
264
|
+
"scope": scope,
|
|
265
|
+
"state": status["state"],
|
|
266
|
+
"markerPresent": status["markerPresent"],
|
|
267
|
+
"managedRoutes": routes,
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
applications: list[dict[str, Any]] = []
|
|
272
|
+
for candidate in sorted(
|
|
273
|
+
candidates.values(),
|
|
274
|
+
key=lambda item: (item["appSlug"], item["environment"], item["ownerRepo"]),
|
|
275
|
+
):
|
|
276
|
+
public_bindings = sorted(
|
|
277
|
+
{item["scope"] for item in candidate["publicIngress"]}
|
|
278
|
+
)
|
|
279
|
+
literal_production = candidate["environment"] == "production"
|
|
280
|
+
candidate["tearDown"] = {
|
|
281
|
+
"available": True,
|
|
282
|
+
"scope": candidate["environment"],
|
|
283
|
+
"environmentTarget": f"{candidate['environment']}={name}",
|
|
284
|
+
"publicIngressScopes": [
|
|
285
|
+
f"{candidate['environment']}={scope}" for scope in public_bindings
|
|
286
|
+
],
|
|
287
|
+
"productionClassification": (
|
|
288
|
+
"production" if literal_production else "requires-trusted-evidence"
|
|
289
|
+
),
|
|
290
|
+
"productionClassificationSource": (
|
|
291
|
+
"literal-environment-name" if literal_production else "not-encoded-in-target-inventory"
|
|
292
|
+
),
|
|
293
|
+
"requiresProductionDataAuthorization": True if literal_production else None,
|
|
294
|
+
"requiresPublicIngressAuthorization": bool(public_bindings),
|
|
295
|
+
}
|
|
296
|
+
applications.append(candidate)
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
"schemaVersion": 1,
|
|
300
|
+
"targetName": name,
|
|
301
|
+
"verifiedAddress": apps_result.get("verifiedAddress") or lan_result.get("verifiedAddress"),
|
|
302
|
+
"sharedPlatform": platform,
|
|
303
|
+
"applications": applications,
|
|
304
|
+
"publicIngressScopes": public_scopes,
|
|
305
|
+
"mutated": False,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _tear_down_public_ingress(
|
|
310
|
+
target: str,
|
|
311
|
+
scope: str,
|
|
312
|
+
owner_repo: str,
|
|
313
|
+
app_slug: str,
|
|
314
|
+
environment: str,
|
|
315
|
+
) -> dict[str, Any]:
|
|
316
|
+
inventory = cloudflare_route_inventory(target, scope)
|
|
317
|
+
route = _find_route(inventory, app_slug, environment)
|
|
318
|
+
if route is None:
|
|
319
|
+
return {"state": "absent", "mutated": False, "scope": scope}
|
|
320
|
+
if (
|
|
321
|
+
route["ownerRepo"] != owner_repo
|
|
322
|
+
or route["appSlug"] != app_slug
|
|
323
|
+
or route["environment"] != environment
|
|
324
|
+
):
|
|
325
|
+
raise DeploymentKitError(
|
|
326
|
+
f"Public route {app_slug}--{environment} ownership does not match the requested Project."
|
|
327
|
+
)
|
|
328
|
+
token = os.environ.get(CLOUDFLARE_API_TOKEN_ENV, "").strip()
|
|
329
|
+
if not token:
|
|
330
|
+
raise DeploymentKitError(
|
|
331
|
+
f"Cloudflare API authorization is required to remove owned public ingress. "
|
|
332
|
+
f"Set {CLOUDFLARE_API_TOKEN_ENV} in the current workstation process/secret boundary; "
|
|
333
|
+
"never put the token in the Project repository or command line."
|
|
334
|
+
)
|
|
335
|
+
plan = plan_public_route_removal(
|
|
336
|
+
target,
|
|
337
|
+
scope=scope,
|
|
338
|
+
owner_repo=owner_repo,
|
|
339
|
+
environment=environment,
|
|
340
|
+
app_slug=app_slug,
|
|
341
|
+
api_token=token,
|
|
342
|
+
)
|
|
343
|
+
result = apply_public_route_removal(plan, api_token=token)
|
|
344
|
+
return {
|
|
345
|
+
"state": "removed",
|
|
346
|
+
"mutated": bool(result.get("mutated")),
|
|
347
|
+
"scope": scope,
|
|
348
|
+
"publicHostname": route["publicHostname"],
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
127
352
|
def _tear_down_route(target: str, owner_repo: str, app_slug: str, environment: str) -> dict[str, Any]:
|
|
128
353
|
inventory = route_inventory(target)
|
|
129
354
|
route = _find_route(inventory, app_slug, environment)
|
|
@@ -170,13 +395,18 @@ def _tear_down_remote_environment(
|
|
|
170
395
|
"Remote Project tear-down failed: " + (stderr or stdout or f"exit {completed.returncode}")
|
|
171
396
|
)
|
|
172
397
|
parts = stdout.split("\t")
|
|
173
|
-
if len(parts) not in {4, 5} or parts[0] != "TEAR_DOWN" or parts[1] not in {"absent", "removed"}:
|
|
398
|
+
if len(parts) not in {4, 5, 7} or parts[0] != "TEAR_DOWN" or parts[1] not in {"absent", "removed"}:
|
|
174
399
|
raise DeploymentKitError("Remote Project tear-down returned malformed evidence.")
|
|
175
400
|
if parts[2] != app_slug or parts[3] != environment:
|
|
176
401
|
raise DeploymentKitError("Remote Project tear-down returned mismatched app/environment evidence.")
|
|
177
402
|
result: dict[str, Any] = {"state": parts[1], "mutated": parts[1] == "removed"}
|
|
178
|
-
if len(parts)
|
|
403
|
+
if len(parts) >= 5:
|
|
179
404
|
result["containerName"] = parts[4]
|
|
405
|
+
if len(parts) == 7:
|
|
406
|
+
if parts[6] not in {"true", "false"}:
|
|
407
|
+
raise DeploymentKitError("Remote Project tear-down returned malformed image-removal evidence.")
|
|
408
|
+
result["images"] = [item for item in parts[5].split(",") if item]
|
|
409
|
+
result["allImagesRemoved"] = parts[6] == "true"
|
|
180
410
|
return result
|
|
181
411
|
|
|
182
412
|
|
|
@@ -256,11 +486,21 @@ def tear_down_project(spec: dict[str, Any]) -> dict[str, Any]:
|
|
|
256
486
|
environment_targets=(spec.get("environmentTargets") or {}).items(),
|
|
257
487
|
local_package=spec.get("localPackage"),
|
|
258
488
|
production_environments=spec.get("productionEnvironments") or (),
|
|
489
|
+
public_ingress_scopes=(spec.get("publicIngressScopes") or {}).items(),
|
|
259
490
|
)
|
|
260
491
|
|
|
261
492
|
environments: list[dict[str, Any]] = []
|
|
262
493
|
mutated = False
|
|
263
494
|
for environment, target in normalized["environmentTargets"].items():
|
|
495
|
+
public_ingress: dict[str, Any] = {"state": "not-selected", "mutated": False}
|
|
496
|
+
if environment in normalized["publicIngressScopes"]:
|
|
497
|
+
public_ingress = _tear_down_public_ingress(
|
|
498
|
+
target,
|
|
499
|
+
normalized["publicIngressScopes"][environment],
|
|
500
|
+
normalized["ownerRepo"],
|
|
501
|
+
normalized["appSlug"],
|
|
502
|
+
environment,
|
|
503
|
+
)
|
|
264
504
|
route = _tear_down_route(target, normalized["ownerRepo"], normalized["appSlug"], environment)
|
|
265
505
|
runtime = _tear_down_remote_environment(
|
|
266
506
|
target,
|
|
@@ -268,11 +508,17 @@ def tear_down_project(spec: dict[str, Any]) -> dict[str, Any]:
|
|
|
268
508
|
normalized["appSlug"],
|
|
269
509
|
environment,
|
|
270
510
|
)
|
|
271
|
-
mutated =
|
|
511
|
+
mutated = (
|
|
512
|
+
mutated
|
|
513
|
+
or public_ingress["mutated"]
|
|
514
|
+
or route["mutated"]
|
|
515
|
+
or runtime["mutated"]
|
|
516
|
+
)
|
|
272
517
|
environments.append(
|
|
273
518
|
{
|
|
274
519
|
"environment": environment,
|
|
275
520
|
"targetName": target,
|
|
521
|
+
"publicIngress": public_ingress,
|
|
276
522
|
"route": route,
|
|
277
523
|
"runtimeAndPersistentData": runtime,
|
|
278
524
|
}
|
|
@@ -284,7 +530,7 @@ def tear_down_project(spec: dict[str, Any]) -> dict[str, Any]:
|
|
|
284
530
|
mutated = mutated or workstation["mutated"]
|
|
285
531
|
|
|
286
532
|
return {
|
|
287
|
-
"schemaVersion":
|
|
533
|
+
"schemaVersion": 3,
|
|
288
534
|
"ownerRepo": normalized["ownerRepo"],
|
|
289
535
|
"appSlug": normalized["appSlug"],
|
|
290
536
|
"scope": normalized["scope"],
|
|
@@ -292,6 +538,8 @@ def tear_down_project(spec: dict[str, Any]) -> dict[str, Any]:
|
|
|
292
538
|
"workstation": workstation,
|
|
293
539
|
"productionEnvironments": normalized["productionEnvironments"],
|
|
294
540
|
"productionDataIncluded": bool(normalized["productionEnvironments"]),
|
|
541
|
+
"publicIngressScopes": normalized["publicIngressScopes"],
|
|
542
|
+
"publicIngressIncluded": bool(normalized["publicIngressScopes"]),
|
|
295
543
|
"mutated": mutated,
|
|
296
544
|
}
|
|
297
545
|
|
|
@@ -3,15 +3,19 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
+
import json
|
|
6
7
|
import sys
|
|
7
8
|
|
|
8
9
|
from .errors import DeploymentKitError
|
|
9
10
|
from .teardown import (
|
|
10
11
|
PRODUCTION_DATA_DELETE_PHRASE,
|
|
12
|
+
PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE,
|
|
11
13
|
TEAR_DOWN_WRITE_PHRASE,
|
|
12
14
|
normalize_teardown_spec,
|
|
15
|
+
parse_environment_scopes,
|
|
13
16
|
parse_environment_targets,
|
|
14
17
|
render_teardown_summary,
|
|
18
|
+
target_inventory,
|
|
15
19
|
tear_down_project,
|
|
16
20
|
)
|
|
17
21
|
|
|
@@ -22,7 +26,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
|
22
26
|
description=(
|
|
23
27
|
"Remove one named Kit-managed environment, all explicitly bound environments, "
|
|
24
28
|
"and/or exact-package local Docker state. Remote tear-down deletes persistent "
|
|
25
|
-
"application data for selected environments."
|
|
29
|
+
"application data for selected environments while preserving shared Project Forge platform state."
|
|
26
30
|
),
|
|
27
31
|
)
|
|
28
32
|
parser.add_argument(
|
|
@@ -38,6 +42,16 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
|
38
42
|
metavar="ENV=TARGET",
|
|
39
43
|
help="Explicit named environment-to-target binding; repeat as needed.",
|
|
40
44
|
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--public-ingress-scope",
|
|
47
|
+
action="append",
|
|
48
|
+
default=[],
|
|
49
|
+
metavar="ENV=SCOPE",
|
|
50
|
+
help=(
|
|
51
|
+
"Optional exact environment-to-public-ingress-scope binding discovered from target inventory; "
|
|
52
|
+
"repeat as needed. Selected public routes are removed before LAN/runtime cleanup."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
41
55
|
parser.add_argument(
|
|
42
56
|
"--production-environment",
|
|
43
57
|
action="append",
|
|
@@ -66,34 +80,84 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
|
66
80
|
f"{PRODUCTION_DATA_DELETE_PHRASE}"
|
|
67
81
|
),
|
|
68
82
|
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--public-ingress-authorization",
|
|
85
|
+
help=(
|
|
86
|
+
"Additional phrase required whenever selected environments include public ingress cleanup: "
|
|
87
|
+
f"{PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE}"
|
|
88
|
+
),
|
|
89
|
+
)
|
|
69
90
|
return parser.parse_args(argv)
|
|
70
91
|
|
|
71
92
|
|
|
72
93
|
def _preview(spec: dict[str, object]) -> dict[str, object]:
|
|
73
94
|
production = bool(spec["productionEnvironments"])
|
|
95
|
+
public_ingress = bool(spec["publicIngressScopes"])
|
|
74
96
|
return {
|
|
75
|
-
"schemaVersion":
|
|
97
|
+
"schemaVersion": 3,
|
|
76
98
|
"ownerRepo": spec["ownerRepo"],
|
|
77
99
|
"appSlug": spec["appSlug"],
|
|
78
100
|
"scope": spec["scope"],
|
|
79
101
|
"environmentTargets": spec["environmentTargets"],
|
|
102
|
+
"publicIngressScopes": spec["publicIngressScopes"],
|
|
80
103
|
"localPackage": spec["localPackage"],
|
|
81
104
|
"productionEnvironments": spec["productionEnvironments"],
|
|
82
105
|
"destructive": True,
|
|
83
106
|
"deletesPersistentData": bool(spec["environmentTargets"]),
|
|
84
107
|
"deletesProductionData": production,
|
|
108
|
+
"removesPublicIngress": public_ingress,
|
|
109
|
+
"removesUnreferencedRemoteImages": bool(spec["environmentTargets"]),
|
|
85
110
|
"preservesSharedPlatform": True,
|
|
111
|
+
"preservesSharedPublicIngressRuntime": True,
|
|
86
112
|
"preservesUnrelatedProjects": True,
|
|
87
113
|
"deletesGhcrPackage": False,
|
|
88
114
|
"requiredAuthorization": TEAR_DOWN_WRITE_PHRASE,
|
|
89
115
|
"requiredProductionDataAuthorization": PRODUCTION_DATA_DELETE_PHRASE if production else None,
|
|
116
|
+
"requiredPublicIngressAuthorization": (
|
|
117
|
+
PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE if public_ingress else None
|
|
118
|
+
),
|
|
90
119
|
}
|
|
91
120
|
|
|
92
121
|
|
|
122
|
+
def inventory_main(argv: list[str] | None = None) -> int:
|
|
123
|
+
parser = argparse.ArgumentParser(
|
|
124
|
+
prog="project-forge-deploy target inventory",
|
|
125
|
+
description=(
|
|
126
|
+
"Read shared platform state plus application-owned resources that are valid tear-down candidates "
|
|
127
|
+
"on one target. This command is read-only and does not require a consumer repository."
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument("name", help="Logical Project Forge target profile")
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
"--ingress-scope",
|
|
133
|
+
action="append",
|
|
134
|
+
default=[],
|
|
135
|
+
metavar="SCOPE",
|
|
136
|
+
help=(
|
|
137
|
+
"Additional public-ingress scope to inspect when it cannot be inferred from managed application "
|
|
138
|
+
"or LAN-route environment names; repeat as needed."
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
parser.add_argument("--json", action="store_true")
|
|
142
|
+
try:
|
|
143
|
+
args = parser.parse_args(argv)
|
|
144
|
+
result = target_inventory(args.name, ingress_scopes=args.ingress_scope)
|
|
145
|
+
if args.json:
|
|
146
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
147
|
+
else:
|
|
148
|
+
print("TARGET INVENTORY")
|
|
149
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
150
|
+
return 0
|
|
151
|
+
except DeploymentKitError as exc:
|
|
152
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
153
|
+
return 2
|
|
154
|
+
|
|
155
|
+
|
|
93
156
|
def main(argv: list[str] | None = None) -> int:
|
|
94
157
|
try:
|
|
95
158
|
args = parse_args(argv)
|
|
96
159
|
bindings = parse_environment_targets(args.environment_target)
|
|
160
|
+
public_bindings = parse_environment_scopes(args.public_ingress_scope)
|
|
97
161
|
spec = normalize_teardown_spec(
|
|
98
162
|
owner_repo=args.owner_repo,
|
|
99
163
|
app_slug=args.app_slug,
|
|
@@ -101,6 +165,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
101
165
|
environment_targets=bindings,
|
|
102
166
|
local_package=args.local_package,
|
|
103
167
|
production_environments=args.production_environment,
|
|
168
|
+
public_ingress_scopes=public_bindings,
|
|
104
169
|
)
|
|
105
170
|
preview = _preview(spec)
|
|
106
171
|
|
|
@@ -110,7 +175,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
110
175
|
needs_production
|
|
111
176
|
and args.production_data_authorization != PRODUCTION_DATA_DELETE_PHRASE
|
|
112
177
|
)
|
|
113
|
-
|
|
178
|
+
needs_public_ingress = bool(spec["publicIngressScopes"])
|
|
179
|
+
missing_public_ingress = (
|
|
180
|
+
needs_public_ingress
|
|
181
|
+
and args.public_ingress_authorization != PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE
|
|
182
|
+
)
|
|
183
|
+
if missing_general or missing_production or missing_public_ingress:
|
|
114
184
|
print("PROJECT TEAR-DOWN PREVIEW")
|
|
115
185
|
print(render_teardown_summary(preview))
|
|
116
186
|
if missing_general:
|
|
@@ -124,6 +194,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
124
194
|
f"--production-data-authorization {PRODUCTION_DATA_DELETE_PHRASE!r}",
|
|
125
195
|
file=sys.stderr,
|
|
126
196
|
)
|
|
197
|
+
if missing_public_ingress:
|
|
198
|
+
print(
|
|
199
|
+
"ERROR: public-ingress removal requires "
|
|
200
|
+
f"--public-ingress-authorization {PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE!r}",
|
|
201
|
+
file=sys.stderr,
|
|
202
|
+
)
|
|
127
203
|
return 2
|
|
128
204
|
|
|
129
205
|
result = tear_down_project(spec)
|
|
@@ -10,6 +10,7 @@ ENVIRONMENT="$3"
|
|
|
10
10
|
|
|
11
11
|
valid_slug() { [[ "$1" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; }
|
|
12
12
|
valid_repo() { [[ "$1" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; }
|
|
13
|
+
valid_image() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$ ]]; }
|
|
13
14
|
meta_value() {
|
|
14
15
|
local path="$1" key="$2"
|
|
15
16
|
[[ -f "$path" && ! -L "$path" ]] || return 1
|
|
@@ -22,6 +23,13 @@ assert_owned_marker() {
|
|
|
22
23
|
"$(meta_value "$path" APP_SLUG || true)" == "$APP" && \
|
|
23
24
|
"$(meta_value "$path" ENVIRONMENT || true)" == "$ENVIRONMENT" ]]
|
|
24
25
|
}
|
|
26
|
+
append_unique_image() {
|
|
27
|
+
local value="$1" existing
|
|
28
|
+
for existing in "${IMAGES[@]:-}"; do
|
|
29
|
+
[[ "$existing" == "$value" ]] && return 0
|
|
30
|
+
done
|
|
31
|
+
IMAGES+=("$value")
|
|
32
|
+
}
|
|
25
33
|
|
|
26
34
|
valid_repo "$OWNER" || { echo "Invalid owner repository." >&2; exit 20; }
|
|
27
35
|
valid_slug "$APP" || { echo "Invalid application slug." >&2; exit 20; }
|
|
@@ -60,6 +68,23 @@ if [[ "$HAS_PERSISTENCE" == true ]] && ! assert_owned_marker "$PERSISTENCE"; the
|
|
|
60
68
|
fi
|
|
61
69
|
|
|
62
70
|
CONTAINER=""
|
|
71
|
+
IMAGES=()
|
|
72
|
+
if [[ -d "$ROOT/releases" && ! -L "$ROOT/releases" ]]; then
|
|
73
|
+
while IFS= read -r release_file; do
|
|
74
|
+
[[ -f "$release_file" && ! -L "$release_file" ]] || continue
|
|
75
|
+
assert_owned_marker "$release_file" || {
|
|
76
|
+
echo "Managed release ownership does not match selected environment: $release_file" >&2
|
|
77
|
+
exit 23
|
|
78
|
+
}
|
|
79
|
+
image="$(meta_value "$release_file" IMAGE || true)"
|
|
80
|
+
valid_image "$image" || {
|
|
81
|
+
echo "Managed release contains an invalid immutable image reference: $release_file" >&2
|
|
82
|
+
exit 23
|
|
83
|
+
}
|
|
84
|
+
append_unique_image "$image"
|
|
85
|
+
done < <(find "$ROOT/releases" -mindepth 2 -maxdepth 2 -type f -name release.env -print 2>/dev/null || true)
|
|
86
|
+
fi
|
|
87
|
+
|
|
63
88
|
if [[ "$HAS_STATE" == true ]]; then
|
|
64
89
|
CURRENT_RELEASE="$(meta_value "$STATE" CURRENT_RELEASE || true)"
|
|
65
90
|
[[ -n "$CURRENT_RELEASE" && "$CURRENT_RELEASE" != */* && "$CURRENT_RELEASE" != *..* ]] || {
|
|
@@ -96,5 +121,37 @@ if [[ -d "$APP_DIR" ]] && ! find "$APP_DIR" -mindepth 1 -print -quit | grep -q .
|
|
|
96
121
|
rmdir "$APP_DIR"
|
|
97
122
|
fi
|
|
98
123
|
|
|
99
|
-
|
|
124
|
+
RETAINED_IMAGE=false
|
|
125
|
+
for IMAGE in "${IMAGES[@]:-}"; do
|
|
126
|
+
[[ -n "$IMAGE" ]] || continue
|
|
127
|
+
IMAGE_REFERENCED_ELSEWHERE=false
|
|
128
|
+
while IFS= read -r candidate; do
|
|
129
|
+
[[ -f "$candidate" && ! -L "$candidate" ]] || continue
|
|
130
|
+
if [[ "$(meta_value "$candidate" IMAGE || true)" == "$IMAGE" ]]; then
|
|
131
|
+
IMAGE_REFERENCED_ELSEWHERE=true
|
|
132
|
+
break
|
|
133
|
+
fi
|
|
134
|
+
done < <(find "$APP_ROOT" -mindepth 5 -maxdepth 5 -type f -name release.env -print 2>/dev/null || true)
|
|
135
|
+
|
|
136
|
+
if [[ "$IMAGE_REFERENCED_ELSEWHERE" == true ]] || \
|
|
137
|
+
docker ps -a --filter "ancestor=$IMAGE" --format '{{.ID}}' | grep -q .; then
|
|
138
|
+
RETAINED_IMAGE=true
|
|
139
|
+
continue
|
|
140
|
+
fi
|
|
141
|
+
if docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
|
142
|
+
if ! docker image rm "$IMAGE" >/dev/null 2>&1; then
|
|
143
|
+
RETAINED_IMAGE=true
|
|
144
|
+
fi
|
|
145
|
+
fi
|
|
146
|
+
done
|
|
147
|
+
|
|
148
|
+
IMAGES_CSV=""
|
|
149
|
+
if [[ ${#IMAGES[@]} -gt 0 ]]; then
|
|
150
|
+
IMAGES_CSV="$(IFS=,; echo "${IMAGES[*]}")"
|
|
151
|
+
fi
|
|
152
|
+
ALL_IMAGES_REMOVED=true
|
|
153
|
+
[[ "$RETAINED_IMAGE" == true ]] && ALL_IMAGES_REMOVED=false
|
|
154
|
+
|
|
155
|
+
printf 'TEAR_DOWN\tremoved\t%s\t%s\t%s\t%s\t%s\n' \
|
|
156
|
+
"$APP" "$ENVIRONMENT" "$CONTAINER" "$IMAGES_CSV" "$ALL_IMAGES_REMOVED"
|
|
100
157
|
'''
|