@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.
@@ -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": 2,
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) == 5:
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 = mutated or route["mutated"] or runtime["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": 2,
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": 2,
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
- if missing_general or missing_production:
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
- printf 'TEAR_DOWN\tremoved\t%s\t%s\t%s\n' "$APP" "$ENVIRONMENT" "$CONTAINER"
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
  '''
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.8.1",
3
+ "version": "4.10.0",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {