Coverage for scripts / live_release_validation / ownership / log_groups.py: 100.00%
393 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""CloudWatch log-group identity, stability observation, and ownership checkpointing."""
3from __future__ import annotations
5import copy
6import re
7import time
8import uuid
9from collections.abc import Mapping
10from datetime import datetime
11from typing import Any, cast
13from botocore.exceptions import ClientError
15from ..constants import (
16 _EKS_LOG_GROUP_SUFFIXES,
17 _LOG_CLEANUP_TOKEN_TAG,
18 _LOG_GROUP_CHECKPOINT_STABLE_OBSERVATIONS,
19 _LOG_GROUP_CLEANUP_STABLE_OBSERVATIONS,
20 _LOG_GROUP_OBSERVATION_ATTEMPTS,
21 _LOG_GROUP_OBSERVATION_HISTORY_LIMIT,
22 _LOG_GROUP_OBSERVATION_POLL_SECONDS,
23 _LOG_GROUP_RETRYABLE_OBSERVATION_CODES,
24 _LOG_GROUP_SOURCE_TYPES,
25 _RUN_STACK_TAG,
26)
27from ..inventory import (
28 describe_stack,
29)
30from ..models import RunContext, utc_now
31from ..ownership.stacks import (
32 _owned_stack_ids,
33 _owned_stack_record,
34)
37def _derived_log_group_names(resource_type: str, physical_id: str) -> tuple[str, ...]:
38 if resource_type == "AWS::Logs::LogGroup":
39 return (physical_id,)
40 if resource_type == "AWS::Lambda::Function":
41 return (f"/aws/lambda/{physical_id}",)
42 if resource_type == "AWS::EKS::Cluster":
43 return (
44 f"/aws/eks/{physical_id}/cluster",
45 *(
46 f"/aws/containerinsights/{physical_id}/{suffix}"
47 for suffix in _EKS_LOG_GROUP_SUFFIXES
48 ),
49 )
50 return ()
53def _live_eks_cluster_identity(
54 ctx: RunContext,
55 region: str,
56 cluster_name: str,
57) -> dict[str, str]:
58 """Require the exact ACTIVE service-side cluster before deriving log authority."""
59 partition = ctx.session.get_partition_for_region(region)
60 if not partition:
61 raise RuntimeError(f"Could not resolve AWS partition for EKS cluster in {region}")
62 expected_arn = (
63 f"arn:{partition}:eks:{region}:{ctx.settings.expected_account}:cluster/{cluster_name}"
64 )
65 response = ctx.session.client("eks", region_name=region).describe_cluster(name=cluster_name)
66 cluster = response.get("cluster")
67 if not isinstance(cluster, dict):
68 raise RuntimeError(f"EKS omitted cluster identity for {region}:{cluster_name}")
69 identity = {
70 "name": str(cluster.get("name") or ""),
71 "arn": str(cluster.get("arn") or ""),
72 "status": str(cluster.get("status") or ""),
73 }
74 if identity != {"name": cluster_name, "arn": expected_arn, "status": "ACTIVE"}:
75 raise RuntimeError(
76 f"EKS cluster identity is not exact and ACTIVE for {region}:{cluster_name}"
77 )
78 return identity
81def _eks_cluster_log_authority_identity(
82 ctx: RunContext,
83 region: str,
84 cluster_name: str,
85 *,
86 allow_deleted: bool,
87) -> dict[str, str]:
88 """Resolve EKS log authority, tolerating a rolled-back (deleted) cluster.
90 A create rollback deletes the cluster itself while its control-plane and
91 Container Insights log groups survive. The DELETED tombstone identity is
92 only ever derived from this run's own stack resource record.
93 """
94 if not allow_deleted:
95 return _live_eks_cluster_identity(ctx, region, cluster_name)
96 partition = ctx.session.get_partition_for_region(region)
97 if not partition:
98 raise RuntimeError(f"Could not resolve AWS partition for EKS cluster in {region}")
99 expected_arn = (
100 f"arn:{partition}:eks:{region}:{ctx.settings.expected_account}:cluster/{cluster_name}"
101 )
102 try:
103 return _live_eks_cluster_identity(ctx, region, cluster_name)
104 except ClientError as exc:
105 if exc.response.get("Error", {}).get("Code", "") != "ResourceNotFoundException":
106 raise
107 return {"name": cluster_name, "arn": expected_arn, "status": "DELETED"}
110def _validated_owned_log_group_identity(
111 ctx: RunContext,
112 record: Mapping[str, Any],
113) -> tuple[str, str]:
114 """Validate an exact log-group name derived from a checkpointed stack resource."""
115 region = str(record.get("region") or "")
116 name = str(record.get("name") or "")
117 stack_name = str(record.get("stack_name") or "")
118 stack_id = str(record.get("stack_id") or "")
119 resource_type = str(record.get("source_resource_type") or "")
120 logical_id = str(record.get("source_logical_id") or "")
121 physical_id = str(record.get("source_physical_id") or "")
122 target_regions = ctx.checkpoint.state.get("target_stack_regions")
123 if not isinstance(target_regions, dict) or str(target_regions.get(stack_name) or "") != region:
124 raise RuntimeError(f"Log-group checkpoint target stack is invalid for {region}:{name}")
125 partition = ctx.session.get_partition_for_region(region)
126 if not partition:
127 raise RuntimeError(f"Could not resolve AWS partition for log group in {region}")
128 expected_stack_prefix = (
129 f"arn:{partition}:cloudformation:{region}:{ctx.settings.expected_account}:"
130 f"stack/{stack_name}/"
131 )
132 owned_stack_record = _owned_stack_record(ctx, region, stack_name)
133 # The source stack may be the current generation or, after an adopted
134 # in-place upgrade, one it replaced (its retained log groups outlive it).
135 owned_stack_ids = _owned_stack_ids(ctx, region, stack_name)
136 if (
137 not name
138 or not logical_id
139 or resource_type not in _LOG_GROUP_SOURCE_TYPES
140 or name not in _derived_log_group_names(resource_type, physical_id)
141 ):
142 raise RuntimeError(f"Log-group checkpoint source is invalid for {region}:{name}")
143 source_service_identity = record.get("source_service_identity")
144 if resource_type == "AWS::EKS::Cluster":
145 expected_arn = (
146 f"arn:{partition}:eks:{region}:{ctx.settings.expected_account}:cluster/{physical_id}"
147 )
148 # ACTIVE is the pre-destroy authority; DELETED is the exact tombstone
149 # recorded when a rolled-back create removed the cluster but left its
150 # control-plane and Container Insights log groups behind.
151 accepted_source_identities = tuple(
152 {"name": physical_id, "arn": expected_arn, "status": status}
153 for status in ("ACTIVE", "DELETED")
154 )
155 if source_service_identity not in accepted_source_identities:
156 raise RuntimeError(
157 f"Log-group checkpoint lacks exact live EKS identity for {region}:{name}"
158 )
159 elif source_service_identity not in (None, {}):
160 raise RuntimeError(f"Unexpected service identity for log group {region}:{name}")
161 cleanup_token = str(record.get("cleanup_token") or "")
162 expected_cleanup_token = str(ctx.checkpoint.state.get("log_group_cleanup_token") or "")
163 if (
164 not stack_id.startswith(expected_stack_prefix)
165 or stack_id not in owned_stack_ids
166 or (owned_stack_record or {}).get("run_tag") != ctx.settings.run_id
167 or record.get("run_tag") != ctx.settings.run_id
168 or record.get("ownership_authority") != "cloudformation-stack-resource-derived"
169 or record.get("authority_phase") != "pre-destroy"
170 or not cleanup_token
171 or cleanup_token != expected_cleanup_token
172 ):
173 raise RuntimeError(f"Log-group checkpoint authority is invalid for {region}:{name}")
174 return region, name
177def _describe_exact_log_group(client: Any, name: str) -> dict[str, Any] | None:
178 kwargs: dict[str, Any] = {"logGroupNamePrefix": name, "limit": 50}
179 while True:
180 response = client.describe_log_groups(**kwargs)
181 for log_group in response.get("logGroups", []):
182 if not isinstance(log_group, Mapping):
183 raise RuntimeError("CloudWatch Logs returned a non-object log-group record")
184 candidate = cast(Mapping[str, Any], log_group)
185 if str(candidate.get("logGroupName") or "") == name:
186 return {str(key): value for key, value in candidate.items()}
187 token = response.get("nextToken")
188 if not token:
189 return None
190 kwargs["nextToken"] = token
193def _log_group_identity(client: Any, region: str, name: str) -> dict[str, Any] | None:
194 log_group = _describe_exact_log_group(client, name)
195 if log_group is None:
196 return None
197 arn = str(log_group.get("logGroupArn") or log_group.get("arn") or "").removesuffix(":*")
198 creation_time = log_group.get("creationTime")
199 if not arn or not isinstance(creation_time, int):
200 raise RuntimeError(f"CloudWatch Logs omitted identity for {region}:{name}")
201 tags = client.list_tags_for_resource(resourceArn=arn).get("tags") or {}
202 return {
203 "arn": arn,
204 "creation_time": creation_time,
205 "tags": {str(key): str(value) for key, value in tags.items()},
206 }
209def _log_group_generation(identity: Mapping[str, Any]) -> dict[str, Any]:
210 """Return the immutable fields that distinguish same-name log generations."""
211 arn = str(identity.get("arn") or "")
212 creation_time = identity.get("creation_time")
213 if not arn or not isinstance(creation_time, int):
214 raise RuntimeError("Checkpointed CloudWatch log-group identity is malformed")
215 return {"arn": arn, "creation_time": creation_time}
218def _observe_log_group_stability(
219 client: Any,
220 region: str,
221 name: str,
222 *,
223 expected_identity: Mapping[str, Any] | None = None,
224 expected_tags: Mapping[str, str] | None = None,
225 required_present: int | None,
226 required_absent: int | None,
227 attempts: int = _LOG_GROUP_OBSERVATION_ATTEMPTS,
228 poll_seconds: float = _LOG_GROUP_OBSERVATION_POLL_SECONDS,
229) -> dict[str, Any]:
230 """Bound identity reads until presence/absence is stable or a fence is crossed."""
231 for label, value in (
232 ("attempts", attempts),
233 ("required_present", required_present),
234 ("required_absent", required_absent),
235 ):
236 if value is not None and (
237 isinstance(value, bool) or not isinstance(value, int) or value <= 0
238 ):
239 raise ValueError(f"{label} must be a positive integer or None")
240 if required_present is None and required_absent is None:
241 raise ValueError("At least one stable log-group outcome must be requested")
242 if poll_seconds < 0:
243 raise ValueError("poll_seconds must be non-negative")
245 expected_generation = (
246 _log_group_generation(expected_identity) if expected_identity is not None else None
247 )
248 expected_authority_tags = {str(key): str(value) for key, value in (expected_tags or {}).items()}
249 observations: list[dict[str, Any]] = []
250 seen_generations: list[dict[str, Any]] = []
251 present_streak = 0
252 absent_streak = 0
253 replacement_streak = 0
254 replacement_generation: dict[str, Any] | None = None
255 last_identity: dict[str, Any] | None = None
257 def result(status: str, **extra: Any) -> dict[str, Any]:
258 return {
259 "status": status,
260 "region": region,
261 "name": name,
262 "attempt_count": len(observations),
263 "observations": observations,
264 "identity": copy.deepcopy(last_identity),
265 **extra,
266 }
268 for attempt in range(1, attempts + 1):
269 observed_at = utc_now()
270 try:
271 identity = _log_group_identity(client, region, name)
272 except ClientError as exc:
273 code = str(exc.response.get("Error", {}).get("Code") or "")
274 if code not in _LOG_GROUP_RETRYABLE_OBSERVATION_CODES:
275 raise
276 observations.append(
277 {
278 "attempt": attempt,
279 "observed_at": observed_at,
280 "status": "retryable-error",
281 "error_code": code,
282 "error": str(exc),
283 }
284 )
285 present_streak = 0
286 absent_streak = 0
287 replacement_streak = 0
288 replacement_generation = None
289 else:
290 last_identity = copy.deepcopy(identity)
291 if identity is None:
292 if expected_generation is None and seen_generations:
293 observations.append(
294 {
295 "attempt": attempt,
296 "observed_at": observed_at,
297 "status": "replacement",
298 "observed_generation": None,
299 }
300 )
301 return result(
302 "replacement",
303 expected_generation=seen_generations[-1],
304 observed_generation=None,
305 )
306 replacement_streak = 0
307 replacement_generation = None
308 absent_streak += 1
309 present_streak = 0
310 observations.append(
311 {
312 "attempt": attempt,
313 "observed_at": observed_at,
314 "status": "absent",
315 "consecutive": absent_streak,
316 }
317 )
318 if required_absent is not None and absent_streak >= required_absent:
319 return result("absent", consecutive=absent_streak)
320 else:
321 generation = _log_group_generation(identity)
322 if generation not in seen_generations:
323 seen_generations.append(generation)
324 observations.append(
325 {
326 "attempt": attempt,
327 "observed_at": observed_at,
328 "status": "present",
329 "generation": copy.deepcopy(generation),
330 }
331 )
332 if expected_generation is not None and generation != expected_generation:
333 if generation == replacement_generation:
334 replacement_streak += 1
335 else:
336 replacement_generation = generation
337 replacement_streak = 1
338 observations[-1]["status"] = "replacement-candidate"
339 observations[-1]["consecutive"] = replacement_streak
340 present_streak = 0
341 absent_streak = 0
342 if replacement_streak >= _LOG_GROUP_CLEANUP_STABLE_OBSERVATIONS:
343 observations[-1]["status"] = "replacement"
344 return result(
345 "replacement",
346 expected_generation=expected_generation,
347 observed_generation=generation,
348 consecutive=replacement_streak,
349 )
350 if attempt < attempts:
351 time.sleep(poll_seconds)
352 continue
353 replacement_streak = 0
354 replacement_generation = None
355 if expected_generation is None and len(seen_generations) > 1:
356 observations[-1]["status"] = "replacement"
357 return result(
358 "replacement",
359 expected_generation=seen_generations[0],
360 observed_generation=generation,
361 )
362 tags = identity.get("tags") or {}
363 tag_drift = {
364 key: {"expected": value, "observed": tags.get(key)}
365 for key, value in expected_authority_tags.items()
366 if tags.get(key) != value
367 }
368 if tag_drift:
369 observations[-1]["status"] = "tag-drift"
370 observations[-1]["tag_drift"] = copy.deepcopy(tag_drift)
371 return result("tag-drift", tag_drift=tag_drift)
372 present_streak += 1
373 absent_streak = 0
374 observations[-1]["consecutive"] = present_streak
375 if required_present is not None and present_streak >= required_present:
376 return result("present", consecutive=present_streak)
377 if attempt < attempts:
378 time.sleep(poll_seconds)
380 return result(
381 "unsettled",
382 required_present=required_present,
383 required_absent=required_absent,
384 present_streak=present_streak,
385 absent_streak=absent_streak,
386 )
389def _record_log_group_observation(
390 ctx: RunContext,
391 record: dict[str, Any],
392 *,
393 phase: str,
394 outcome: Mapping[str, Any],
395) -> dict[str, Any]:
396 """Persist bounded identity evidence and any confirmed replacement generation."""
397 entry = {"phase": phase, "recorded_at": utc_now(), **copy.deepcopy(dict(outcome))}
398 with ctx.state_lock:
399 history = record.setdefault("identity_observation_history", [])
400 if not isinstance(history, list):
401 raise RuntimeError("Log-group identity_observation_history must be a list")
402 history.append(entry)
403 del history[:-_LOG_GROUP_OBSERVATION_HISTORY_LIMIT]
404 if outcome.get("status") == "replacement":
405 replacements = record.setdefault("replacement_evidence", [])
406 if not isinstance(replacements, list):
407 raise RuntimeError("Log-group replacement_evidence must be a list")
408 replacements.append(copy.deepcopy(entry))
409 ctx.persist_callback(ctx.checkpoint)
410 return entry
413def _record_log_group_checkpoint_incident(
414 ctx: RunContext,
415 candidate: Mapping[str, Any],
416 *,
417 phase: str,
418 outcome: Mapping[str, Any],
419) -> None:
420 """Preserve failed pre-authority observations without adopting the generation."""
421 with ctx.state_lock:
422 incidents = ctx.checkpoint.state.setdefault("log_group_checkpoint_incidents", [])
423 if not isinstance(incidents, list):
424 raise RuntimeError("Checkpoint log_group_checkpoint_incidents must be a list")
425 incidents.append(
426 {
427 "phase": phase,
428 "recorded_at": utc_now(),
429 "candidate": copy.deepcopy(dict(candidate)),
430 "outcome": copy.deepcopy(dict(outcome)),
431 }
432 )
433 ctx.persist_callback(ctx.checkpoint)
436def _set_log_group_disposition(
437 ctx: RunContext,
438 record: dict[str, Any],
439 *,
440 status: str,
441 phase: str,
442 outcome: Mapping[str, Any],
443) -> dict[str, Any]:
444 disposition = {
445 "status": status,
446 "phase": phase,
447 "recorded_at": utc_now(),
448 "original_identity": copy.deepcopy(record.get("observed_identity")),
449 "last_observation_status": str(outcome.get("status") or ""),
450 }
451 with ctx.state_lock:
452 record["original_generation_disposition"] = disposition
453 ctx.persist_callback(ctx.checkpoint)
454 return disposition
457#: Checkpoint list of records a recreated stack superseded (see
458#: :func:`_carry_log_group_across_generations`), kept as evidence only.
459_SUPERSEDED_LOG_GROUPS_KEY = "superseded_log_groups"
461#: Record fields a same-name log group keeps when its stack is recreated.
462_GENERATION_INVARIANT_FIELDS = (
463 "region",
464 "name",
465 "stack_name",
466 "source_resource_type",
467 "source_physical_id",
468 "ownership_authority",
469 "authority_phase",
470 "run_tag",
471 "cleanup_token",
472)
474#: Record fields that bind a log group to one stack generation. The logical ID
475#: is among them because a later release may rename the construct that owns a
476#: fixed physical name.
477_GENERATION_BINDING_FIELDS = ("stack_id", "source_logical_id", "source_service_identity")
480def _replaced_generation_record(
481 ctx: RunContext,
482 previous: Mapping[str, Any],
483 candidate: Mapping[str, Any],
484) -> bool:
485 """Whether ``previous`` was derived from a stack generation this run replaced.
487 Only run-tag adoption (the upgrade harness) records replaced generations.
488 A recreated stack keeps its fixed resource names, so the EKS cluster's and
489 each named Lambda function's log groups are derived again, under the same
490 names, from the generation that replaced it.
491 """
492 previous_stack = str(previous.get("stack_id") or "")
493 owned = _owned_stack_ids(ctx, str(candidate["region"]), str(candidate["stack_name"]))
494 return (
495 previous_stack != candidate["stack_id"]
496 and previous_stack in owned
497 and all(previous.get(field) == candidate[field] for field in _GENERATION_INVARIANT_FIELDS)
498 )
501def _carry_log_group_across_generations(
502 ctx: RunContext,
503 records: list[Any],
504 previous: dict[str, Any],
505 candidate: Mapping[str, Any],
506 *,
507 logs: Any,
508 authority_tags: Mapping[str, str],
509) -> bool:
510 """Hand a replaced generation's same-name record to the stack that replaced it.
512 Returns ``True`` when the recorded group generation outlived its stack:
513 the record is rebound to the new stack generation in place, its earlier
514 binding kept under ``stack_generations``. Returns ``False`` when that
515 generation is gone (``gco``'s teardown deletes the implicit groups of the
516 stacks it destroys): the record moves to ``superseded_log_groups`` and the
517 caller checkpoints the name afresh from the new generation. A deleted
518 log-group generation never returns, since its creation time is part of its
519 identity, so a stable different generation or stable absence proves it
520 gone. Anything else fails closed.
521 """
522 region = str(candidate["region"])
523 name = str(candidate["name"])
524 _validated_owned_log_group_identity(ctx, previous)
525 observed = previous.get("observed_identity")
526 if not isinstance(observed, dict):
527 raise RuntimeError(f"Log-group checkpoint identity is malformed for {region}:{name}")
528 phase = "checkpoint-stack-generation"
529 outcome = _observe_log_group_stability(
530 logs,
531 region,
532 name,
533 expected_identity=observed,
534 expected_tags=authority_tags,
535 required_present=_LOG_GROUP_CHECKPOINT_STABLE_OBSERVATIONS,
536 required_absent=_LOG_GROUP_CHECKPOINT_STABLE_OBSERVATIONS,
537 )
538 _record_log_group_observation(ctx, previous, phase=phase, outcome=outcome)
539 if outcome["status"] == "present":
540 with ctx.state_lock:
541 binding = {
542 field: copy.deepcopy(previous[field])
543 for field in _GENERATION_BINDING_FIELDS
544 if field in previous
545 }
546 generations = previous.setdefault("stack_generations", [])
547 if not isinstance(generations, list):
548 raise RuntimeError("Log-group stack_generations must be a list")
549 generations.append(
550 {**binding, "rebound_to": candidate["stack_id"], "rebound_at": utc_now()}
551 )
552 # The resource type is invariant, so both records carry the same
553 # binding fields (only an EKS cluster's has a service identity).
554 for field in _GENERATION_BINDING_FIELDS:
555 if field in candidate:
556 previous[field] = copy.deepcopy(candidate[field])
557 ctx.persist_callback(ctx.checkpoint)
558 return True
559 if outcome["status"] not in {"replacement", "absent"}:
560 _set_log_group_disposition(
561 ctx,
562 previous,
563 status="checkpoint-generation-not-stable",
564 phase=phase,
565 outcome=outcome,
566 )
567 raise RuntimeError(
568 f"Log-group checkpoint generation is not stable for {region}:{name}: "
569 f"{outcome['status']}"
570 )
571 _set_log_group_disposition(
572 ctx,
573 previous,
574 status="superseded-by-stack-generation",
575 phase=phase,
576 outcome=outcome,
577 )
578 with ctx.state_lock:
579 superseded = ctx.checkpoint.state.setdefault(_SUPERSEDED_LOG_GROUPS_KEY, [])
580 if not isinstance(superseded, list):
581 raise RuntimeError("Checkpoint superseded_log_groups must be a list")
582 superseded.append(
583 {
584 **copy.deepcopy(previous),
585 "superseded_by_stack_id": candidate["stack_id"],
586 "superseded_at": utc_now(),
587 }
588 )
589 records[:] = [item for item in records if item is not previous]
590 ctx.persist_callback(ctx.checkpoint)
591 return False
594def _checkpoint_owned_log_groups(ctx: RunContext) -> list[dict[str, Any]]:
595 """Fence, tag, and checkpoint exact generations while source stacks are live."""
596 target_regions = ctx.checkpoint.state.get("target_stack_regions")
597 if not isinstance(target_regions, dict):
598 raise RuntimeError("Checkpoint target_stack_regions must be an object")
599 try:
600 run_started_ms = int(datetime.fromisoformat(ctx.checkpoint.created_at).timestamp() * 1000)
601 except ValueError as exc:
602 raise RuntimeError("Checkpoint created_at is not a valid timestamp") from exc
604 with ctx.state_lock:
605 cleanup_token = str(ctx.checkpoint.state.get("log_group_cleanup_token") or "")
606 if not cleanup_token:
607 cleanup_token = uuid.uuid4().hex
608 ctx.checkpoint.state["log_group_cleanup_token"] = cleanup_token
609 if not re.fullmatch(r"[0-9a-f]{32}", cleanup_token):
610 raise RuntimeError("Checkpoint log-group cleanup token is malformed")
611 records = ctx.checkpoint.state.setdefault("owned_log_groups", [])
612 if not isinstance(records, list):
613 raise RuntimeError("Checkpoint owned_log_groups must be a list")
614 by_identity = {
615 (str(item.get("region") or ""), str(item.get("name") or "")): item
616 for item in records
617 if isinstance(item, dict)
618 }
619 authority_tags = {
620 _RUN_STACK_TAG: ctx.settings.run_id,
621 _LOG_CLEANUP_TOKEN_TAG: cleanup_token,
622 }
623 for stack_name, raw_region in sorted(target_regions.items()):
624 region = str(raw_region)
625 stack_record = _owned_stack_record(ctx, region, str(stack_name))
626 if stack_record is None:
627 continue
628 live_stack = describe_stack(ctx.session, region, stack_record["stack_id"])
629 if (
630 live_stack is None
631 or str(live_stack.get("status") or "").startswith("DELETE")
632 or (live_stack.get("tags") or {}).get(_RUN_STACK_TAG) != ctx.settings.run_id
633 ):
634 # Destructive authority is never created or completed from a
635 # deleted-stack tombstone. Existing pre-destroy records remain usable.
636 continue
637 # A rolled-back create leaves log groups behind: retained LogGroup
638 # resources, Lambda-created default groups, and EKS control-plane
639 # groups all survive resource deletion. Their stack resources read
640 # DELETE_COMPLETE while the stack itself is still describable, so
641 # rollback statuses widen the resource filter to those tombstones.
642 rolled_back = str(live_stack.get("status") or "") in {
643 "ROLLBACK_COMPLETE",
644 "ROLLBACK_FAILED",
645 "UPDATE_ROLLBACK_COMPLETE",
646 "UPDATE_ROLLBACK_FAILED",
647 }
648 allowed_resource_statuses = {"CREATE_COMPLETE", "UPDATE_COMPLETE"}
649 if rolled_back:
650 allowed_resource_statuses |= {"DELETE_COMPLETE", "DELETE_FAILED", "DELETE_SKIPPED"}
651 cfn = ctx.session.client("cloudformation", region_name=region)
652 pages = cfn.get_paginator("list_stack_resources").paginate(
653 StackName=stack_record["stack_id"]
654 )
655 resources = [
656 item
657 for page in pages
658 for item in page.get("StackResourceSummaries", [])
659 if str(item.get("ResourceType") or "") in _LOG_GROUP_SOURCE_TYPES
660 and item.get("LogicalResourceId")
661 and item.get("PhysicalResourceId")
662 and str(item.get("ResourceStatus") or "") in allowed_resource_statuses
663 ]
664 logs = ctx.session.client("logs", region_name=region)
665 lambda_client = ctx.session.client("lambda", region_name=region)
666 for resource in resources:
667 resource_type = str(resource["ResourceType"])
668 physical_id = str(resource["PhysicalResourceId"])
669 source_service_identity = None
670 if resource_type == "AWS::EKS::Cluster":
671 source_service_identity = _eks_cluster_log_authority_identity(
672 ctx,
673 region,
674 physical_id,
675 allow_deleted=rolled_back,
676 )
677 names = _derived_log_group_names(resource_type, physical_id)
678 if resource_type == "AWS::Lambda::Function":
679 default_name = f"/aws/lambda/{physical_id}"
680 try:
681 function = lambda_client.get_function_configuration(
682 FunctionName=physical_id
683 )
684 except ClientError as exc:
685 error_code = exc.response.get("Error", {}).get("Code", "")
686 if not (rolled_back and error_code == "ResourceNotFoundException"):
687 raise
688 # The rolled-back function is gone; only its default
689 # log group can remain.
690 names = (default_name,)
691 else:
692 configured_name = str(
693 (function.get("LoggingConfig") or {}).get("LogGroup") or default_name
694 )
695 names = (default_name,) if configured_name == default_name else ()
696 for name in names:
697 key = (region, name)
698 candidate = {
699 "region": region,
700 "name": name,
701 "stack_name": str(stack_name),
702 "stack_id": stack_record["stack_id"],
703 "source_resource_type": resource_type,
704 "source_logical_id": str(resource["LogicalResourceId"]),
705 "source_physical_id": physical_id,
706 "ownership_authority": "cloudformation-stack-resource-derived",
707 "authority_phase": "pre-destroy",
708 "run_tag": ctx.settings.run_id,
709 "cleanup_token": cleanup_token,
710 }
711 if source_service_identity is not None:
712 candidate["source_service_identity"] = source_service_identity
713 _validated_owned_log_group_identity(ctx, candidate)
715 previous = by_identity.get(key)
716 immutable = tuple(candidate)
717 expected_identity: Mapping[str, Any] | None = None
718 if previous is not None and any(
719 previous.get(field) != candidate[field] for field in immutable
720 ):
721 if not _replaced_generation_record(ctx, previous, candidate):
722 raise RuntimeError(f"Log-group ownership changed for {region}:{name}")
723 if _carry_log_group_across_generations(
724 ctx,
725 records,
726 previous,
727 candidate,
728 logs=logs,
729 authority_tags=authority_tags,
730 ):
731 continue
732 del by_identity[key]
733 previous = None
734 if previous is not None:
735 observed = previous.get("observed_identity")
736 if not isinstance(observed, dict):
737 raise RuntimeError(
738 f"Log-group checkpoint identity is malformed for {region}:{name}"
739 )
740 expected_identity = observed
742 initial = _observe_log_group_stability(
743 logs,
744 region,
745 name,
746 expected_identity=expected_identity,
747 expected_tags=authority_tags if previous is not None else None,
748 required_present=_LOG_GROUP_CHECKPOINT_STABLE_OBSERVATIONS,
749 required_absent=_LOG_GROUP_CHECKPOINT_STABLE_OBSERVATIONS,
750 )
751 if previous is not None:
752 _record_log_group_observation(
753 ctx,
754 previous,
755 phase="checkpoint-revalidation",
756 outcome=initial,
757 )
758 if initial["status"] != "present":
759 status = (
760 "replacement-observed-during-checkpoint"
761 if initial["status"] == "replacement"
762 else "checkpoint-generation-not-stable"
763 )
764 _set_log_group_disposition(
765 ctx,
766 previous,
767 status=status,
768 phase="checkpoint-revalidation",
769 outcome=initial,
770 )
771 raise RuntimeError(
772 f"Log-group checkpoint generation is not stable for "
773 f"{region}:{name}: {initial['status']}"
774 )
775 continue
777 if initial["status"] == "absent":
778 if resource_type == "AWS::Logs::LogGroup":
779 _record_log_group_checkpoint_incident(
780 ctx,
781 candidate,
782 phase="checkpoint-explicit-group-absence",
783 outcome=initial,
784 )
785 if rolled_back:
786 # A rolled-back create deletes non-retained
787 # LogGroup resources; the stack resource is a
788 # DELETE_COMPLETE tombstone widened in only to
789 # catch groups that DID survive (retained or
790 # recreated by late log deliveries). A group
791 # that is genuinely gone is the expected
792 # rollback outcome, not missing authority —
793 # aborting here strands every remaining stack
794 # (observed live: example-job validation run
795 # ex241-2913b044, where the Valkey subnet
796 # failure rolled back gco-us-east-1 and this
797 # guard then blocked the entire teardown).
798 continue
799 raise RuntimeError(
800 f"CloudFormation log group is absent before teardown: "
801 f"{region}:{name}"
802 )
803 try:
804 logs.create_log_group(logGroupName=name, tags=authority_tags)
805 except ClientError as exc:
806 if (
807 exc.response.get("Error", {}).get("Code")
808 != "ResourceAlreadyExistsException"
809 ):
810 raise
811 raced = _observe_log_group_stability(
812 logs,
813 region,
814 name,
815 expected_identity=None,
816 expected_tags=None,
817 required_present=_LOG_GROUP_CHECKPOINT_STABLE_OBSERVATIONS,
818 required_absent=_LOG_GROUP_CHECKPOINT_STABLE_OBSERVATIONS,
819 )
820 _record_log_group_checkpoint_incident(
821 ctx,
822 candidate,
823 phase="checkpoint-create-race",
824 outcome=raced,
825 )
826 raise RuntimeError(
827 f"Log group appeared during checkpoint creation; refusing to "
828 f"adopt or tag it: {region}:{name}"
829 ) from exc
830 initial = _observe_log_group_stability(
831 logs,
832 region,
833 name,
834 expected_identity=None,
835 expected_tags=authority_tags,
836 required_present=_LOG_GROUP_CHECKPOINT_STABLE_OBSERVATIONS,
837 required_absent=_LOG_GROUP_CHECKPOINT_STABLE_OBSERVATIONS,
838 )
839 if initial["status"] != "present":
840 _record_log_group_checkpoint_incident(
841 ctx,
842 candidate,
843 phase="checkpoint-initial-stability",
844 outcome=initial,
845 )
846 raise RuntimeError(
847 f"Log group could not be stably checkpointed: "
848 f"{region}:{name}: {initial['status']}"
849 )
851 identity = initial.get("identity")
852 if not isinstance(identity, dict):
853 raise RuntimeError(f"Log group omitted identity: {region}:{name}")
854 if identity["creation_time"] < run_started_ms:
855 raise RuntimeError(
856 f"Log group predates this validation run: {region}:{name}"
857 )
858 tags = identity.get("tags") or {}
859 conflicting_tags = {
860 key: {"expected": value, "observed": tags.get(key)}
861 for key, value in authority_tags.items()
862 if key in tags and tags.get(key) != value
863 }
864 if conflicting_tags:
865 conflict = {
866 **copy.deepcopy(initial),
867 "status": "tag-drift",
868 "tag_drift": conflicting_tags,
869 }
870 _record_log_group_checkpoint_incident(
871 ctx,
872 candidate,
873 phase="checkpoint-authority-tag-conflict",
874 outcome=conflict,
875 )
876 raise RuntimeError(f"Log-group authority tags conflict for {region}:{name}")
878 # This read is intentionally adjacent to tag_resource. A generation
879 # change after the stable reads is fenced before any authority tags
880 # can be applied. The post-tag stable reads catch the unavoidable
881 # service-side TOCTOU without ever adopting that replacement.
882 pre_tag = _observe_log_group_stability(
883 logs,
884 region,
885 name,
886 expected_identity=identity,
887 expected_tags=None,
888 required_present=1,
889 required_absent=1,
890 )
891 if pre_tag["status"] != "present":
892 _record_log_group_checkpoint_incident(
893 ctx,
894 candidate,
895 phase="checkpoint-immediate-pre-tag",
896 outcome=pre_tag,
897 )
898 raise RuntimeError(
899 f"Log-group generation changed immediately before tagging: "
900 f"{region}:{name}: {pre_tag['status']}"
901 )
902 if any(tags.get(key) != value for key, value in authority_tags.items()):
903 logs.tag_resource(resourceArn=identity["arn"], tags=authority_tags)
905 post_tag = _observe_log_group_stability(
906 logs,
907 region,
908 name,
909 expected_identity=identity,
910 expected_tags=authority_tags,
911 required_present=_LOG_GROUP_CHECKPOINT_STABLE_OBSERVATIONS,
912 required_absent=_LOG_GROUP_CHECKPOINT_STABLE_OBSERVATIONS,
913 )
914 if post_tag["status"] != "present":
915 _record_log_group_checkpoint_incident(
916 ctx,
917 candidate,
918 phase="checkpoint-post-tag-stability",
919 outcome=post_tag,
920 )
921 raise RuntimeError(
922 f"Log-group generation or authority changed while checkpointing: "
923 f"{region}:{name}: {post_tag['status']}"
924 )
925 final_identity = post_tag.get("identity")
926 if not isinstance(final_identity, dict):
927 raise RuntimeError(
928 f"Log group omitted its post-tag identity: {region}:{name}"
929 )
930 candidate["observed_identity"] = final_identity
931 candidate["checkpoint_observations"] = {
932 "initial": initial,
933 "immediate_pre_tag": pre_tag,
934 "post_tag": post_tag,
935 }
936 candidate["original_generation_disposition"] = {
937 "status": "checkpointed-present",
938 "phase": "pre-destroy",
939 "recorded_at": utc_now(),
940 "original_identity": copy.deepcopy(final_identity),
941 }
942 records.append(candidate)
943 by_identity[key] = candidate
944 ctx.persist_callback(ctx.checkpoint)
945 return copy.deepcopy(records)