Coverage for scripts / live_release_validation / ownership / dynamodb_streams.py: 100.00%
41 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1"""Accepted-residue accounting for DynamoDB streams of deleted tables.
3Deleting a DynamoDB table does not delete its stream: the stream object stays
4readable (``DISABLED``) for roughly 24 hours so consumers can drain it, there
5is no delete API for it, and the Resource Groups Tagging API keeps returning
6its ARN for as long as the index remembers it — with or without tags. The
7project scanners flag such an ARN by name alone (``.../table/gco-.../stream/...``),
8so a run that correctly destroyed its vector-store table still presents a
9"project resource" that nothing can remove. Observed live on 2026-08-20: the
10previous run's two ``gco-vector-store`` stream ARNs failed the next run's
11``baseline`` gate 4.5 hours after their tables were destroyed, with the
12streams still describable and ``DISABLED``.
14``_strip_expired_table_streams`` accepts exactly that shape and nothing else:
15a ``tagged_resources`` entry whose ARN parses as a table stream in the same
16region and expected account is stripped **only after** DynamoDB itself
17confirms the parent table does not exist. A live table keeps its stream entry
18in the inventory (genuine residue — and the table scanner reports the table
19itself too). Every accepted entry is returned as evidence (region, table,
20stream status, the check performed) so ``baseline`` and ``final-inventory``
21disclose what they tolerated rather than silently ignoring it, mirroring the
22pending-deletion KMS precedent in ``ownership/kms.py``.
23"""
25from __future__ import annotations
27import copy
28import re
29from typing import Any
31from botocore.exceptions import ClientError
33from ..models import RunContext
35_TABLE_STREAM_ARN = re.compile(
36 r"^arn:[^:]+:dynamodb:(?P<region>[a-z0-9-]+):(?P<account>\d{12})"
37 r":table/(?P<table>[^/]+)/stream/(?P<label>.+)$"
38)
41def _stream_status(ctx: RunContext, region: str, stream_arn: str) -> str:
42 """Return the live stream status, or ``ABSENT`` once fully expired."""
43 streams = ctx.session.client("dynamodbstreams", region_name=region)
44 try:
45 description = streams.describe_stream(StreamArn=stream_arn).get("StreamDescription", {})
46 except ClientError as exc:
47 if exc.response.get("Error", {}).get("Code") != "ResourceNotFoundException":
48 raise
49 return "ABSENT"
50 return str(description.get("StreamStatus") or "UNKNOWN")
53def _strip_expired_table_streams(
54 ctx: RunContext,
55 project_inventory: dict[str, Any],
56) -> tuple[dict[str, Any], list[dict[str, Any]]]:
57 """Strip tagged stream ARNs whose parent table DynamoDB proves absent."""
58 inventory = copy.deepcopy(project_inventory)
59 accepted: list[dict[str, Any]] = []
60 for region, resources in list(inventory.get("regional", {}).items()):
61 kept: list[dict[str, Any]] = []
62 for entry in resources.get("tagged_resources", []):
63 arn = str(entry.get("arn") or "")
64 match = _TABLE_STREAM_ARN.match(arn)
65 if (
66 match is None
67 or match.group("region") != region
68 or match.group("account") != ctx.settings.expected_account
69 ):
70 kept.append(entry)
71 continue
72 table_name = match.group("table")
73 dynamodb = ctx.session.client("dynamodb", region_name=region)
74 try:
75 dynamodb.describe_table(TableName=table_name)
76 except ClientError as exc:
77 if exc.response.get("Error", {}).get("Code") != "ResourceNotFoundException":
78 raise
79 else:
80 # The table is live, so the stream is real project residue —
81 # keep it (the table scanner reports the table itself too).
82 kept.append(entry)
83 continue
84 accepted.append(
85 {
86 "region": region,
87 "arn": arn,
88 "table_name": table_name,
89 "table_absent": True,
90 "authority": "dynamodb:DescribeTable ResourceNotFoundException",
91 "stream_status": _stream_status(ctx, region, arn),
92 "tags": dict(entry.get("tags") or {}),
93 "note": (
94 "streams of deleted tables have no delete API and "
95 "expire on their own roughly 24h after table deletion"
96 ),
97 }
98 )
99 resources["tagged_resources"] = kept
100 if not any(resources.values()):
101 inventory["regional"].pop(region)
102 return inventory, accepted