ecsodus 0.1.0__py3-none-any.whl

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.
ecsodus/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """ecsodus: safely migrate AWS Copilot CLI apps to Terraform-managed ECS.
2
+
3
+ ecsodus is read-only: it inventories, reports and generates files. It never changes AWS.
4
+ """
5
+
6
+ __version__ = "0.1.0"
ecsodus/cfn.py ADDED
@@ -0,0 +1,198 @@
1
+ """CloudFormation template parsing.
2
+
3
+ Two views of a template are needed:
4
+
5
+ * a *semantic* view (plain dicts, short-form tags such as ``!Ref`` normalised to the long form
6
+ ``{"Ref": ...}``), used for analysis and for verifying patches; and
7
+ * *source positions* of each resource, used by the retain patcher to edit the original text
8
+ without re-serialising it (re-serialising YAML changes indentation and quoting, which would
9
+ break the "byte-stable apart from the patch" rule).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import io
15
+ import json
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ from ruamel.yaml import YAML
20
+ from ruamel.yaml.constructor import SafeConstructor
21
+ from ruamel.yaml.nodes import MappingNode, ScalarNode, SequenceNode
22
+
23
+ _SHORT_TAGS = {
24
+ "Ref": "Ref",
25
+ "Condition": "Condition",
26
+ "GetAtt": "Fn::GetAtt",
27
+ }
28
+
29
+
30
+ class TemplateError(ValueError):
31
+ """The template cannot be parsed or has a shape ecsodus refuses to handle."""
32
+
33
+
34
+ def _construct_tagged(loader: Any, tag_suffix: str, node: Any) -> dict[str, Any]:
35
+ name = _SHORT_TAGS.get(tag_suffix, f"Fn::{tag_suffix}")
36
+ if isinstance(node, ScalarNode):
37
+ value: Any = loader.construct_scalar(node)
38
+ if tag_suffix == "GetAtt" and isinstance(value, str):
39
+ head, _, tail = value.partition(".")
40
+ value = [head, tail]
41
+ elif isinstance(node, SequenceNode):
42
+ value = loader.construct_sequence(node, deep=True)
43
+ elif isinstance(node, MappingNode):
44
+ value = loader.construct_mapping(node, deep=True)
45
+ else: # pragma: no cover - ruamel only produces the three node kinds
46
+ raise TemplateError(f"unsupported node for !{tag_suffix}")
47
+ return {name: _plain(value)}
48
+
49
+
50
+ def _plain(value: Any) -> Any:
51
+ """Convert ruamel containers to plain dict/list recursively."""
52
+ if isinstance(value, dict):
53
+ return {str(k): _plain(v) for k, v in value.items()}
54
+ if isinstance(value, list):
55
+ return [_plain(v) for v in value]
56
+ return value
57
+
58
+
59
+ class _CfnConstructor(SafeConstructor):
60
+ """The semantic view's constructor.
61
+
62
+ ruamel registers constructors on the *class*. Registering on the shared SafeConstructor
63
+ leaked the ``!`` multi-constructor into the round-trip loader that :func:`yaml_positions`
64
+ uses, so after any :func:`load` a tagged mapping (``!GetAZs {Ref: AWS::Region}``) broke
65
+ retain patching. A private subclass keeps both registrations to this view.
66
+ """
67
+
68
+
69
+ _CfnConstructor.add_multi_constructor("!", _construct_tagged)
70
+ # CloudFormation treats unquoted dates as strings (AWSTemplateFormatVersion: 2010-09-09).
71
+ _CfnConstructor.add_constructor("tag:yaml.org,2002:timestamp", SafeConstructor.construct_yaml_str)
72
+
73
+
74
+ def _semantic_yaml() -> YAML:
75
+ y = YAML(typ="safe", pure=True)
76
+ y.Constructor = _CfnConstructor
77
+ return y
78
+
79
+
80
+ def is_json(text: str) -> bool:
81
+ return text.lstrip().startswith("{")
82
+
83
+
84
+ def load(text: str) -> dict[str, Any]:
85
+ """Parse a template (JSON or YAML) into plain dicts with long-form intrinsics."""
86
+ try:
87
+ data = json.loads(text) if is_json(text) else _semantic_yaml().load(io.StringIO(text))
88
+ except Exception as exc: # noqa: BLE001 - surface any parser error uniformly
89
+ raise TemplateError(f"cannot parse template: {exc}") from exc
90
+ data = _plain(data)
91
+ if not isinstance(data, dict) or not isinstance(data.get("Resources"), dict):
92
+ raise TemplateError("template has no Resources mapping")
93
+ return data
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class ResourcePosition:
98
+ """Where one resource's mapping sits in the template source (0-based lines)."""
99
+
100
+ logical_id: str
101
+ key_line: int # the "LogicalId:" line
102
+ body_line: int # first line of the resource body
103
+ body_col: int # indentation of the resource body keys
104
+ keys: dict[str, tuple[int, int]] # attribute key -> (line, col)
105
+ flow_style: bool
106
+ template_url: tuple[int, int] | None = None # Properties.TemplateURL key (line, col)
107
+ metadata: tuple[int, int, bool] | None = None # Metadata body (line, col, is_block_map)
108
+
109
+
110
+ def yaml_positions(text: str) -> dict[str, ResourcePosition]:
111
+ """Source positions of every resource in a block-style YAML template."""
112
+ y = YAML(typ="rt", pure=True)
113
+ try:
114
+ doc = y.load(io.StringIO(text))
115
+ except Exception as exc: # noqa: BLE001
116
+ raise TemplateError(f"cannot parse template: {exc}") from exc
117
+ resources = doc.get("Resources") if hasattr(doc, "get") else None
118
+ if resources is None:
119
+ raise TemplateError("template has no Resources mapping")
120
+ out: dict[str, ResourcePosition] = {}
121
+ for lid, body in resources.items():
122
+ key_line, _ = resources.lc.key(lid)
123
+ if not hasattr(body, "lc"):
124
+ raise TemplateError(f"resource {lid} is not a mapping")
125
+ flow = body.fa.flow_style() if hasattr(body, "fa") else False
126
+ keys = {str(k): tuple(body.lc.key(k)) for k in body}
127
+ template_url = None
128
+ props = body.get("Properties")
129
+ if hasattr(props, "lc") and "TemplateURL" in props:
130
+ template_url = tuple(props.lc.key("TemplateURL"))
131
+ metadata = None
132
+ meta = body.get("Metadata")
133
+ if meta is not None:
134
+ is_block = hasattr(meta, "lc") and not (hasattr(meta, "fa") and meta.fa.flow_style())
135
+ if is_block and len(meta):
136
+ metadata = (meta.lc.line, meta.lc.col, True)
137
+ else:
138
+ metadata = (*keys["Metadata"], False)
139
+ out[str(lid)] = ResourcePosition(
140
+ logical_id=str(lid),
141
+ key_line=key_line,
142
+ body_line=body.lc.line,
143
+ body_col=body.lc.col,
144
+ keys=keys, # type: ignore[arg-type]
145
+ flow_style=bool(flow),
146
+ template_url=template_url, # type: ignore[arg-type]
147
+ metadata=metadata, # type: ignore[arg-type]
148
+ )
149
+ return out
150
+
151
+
152
+ def resources(template: dict[str, Any]) -> dict[str, dict[str, Any]]:
153
+ return template.get("Resources", {})
154
+
155
+
156
+ def references(value: Any) -> set[str]:
157
+ """Logical IDs referenced by Ref / GetAtt / Sub / DependsOn inside ``value``."""
158
+ found: set[str] = set()
159
+
160
+ def walk(v: Any) -> None:
161
+ if isinstance(v, dict):
162
+ for k, inner in v.items():
163
+ if k == "Ref" and isinstance(inner, str):
164
+ found.add(inner)
165
+ elif k == "Fn::GetAtt":
166
+ if isinstance(inner, list) and inner and isinstance(inner[0], str):
167
+ found.add(inner[0])
168
+ elif isinstance(inner, str):
169
+ found.add(inner.split(".", 1)[0])
170
+ elif k == "Fn::Sub":
171
+ tpl = inner[0] if isinstance(inner, list) else inner
172
+ if isinstance(tpl, str):
173
+ found.update(_sub_vars(tpl))
174
+ walk(inner)
175
+ continue
176
+ walk(inner)
177
+ elif isinstance(v, list):
178
+ for inner in v:
179
+ walk(inner)
180
+
181
+ walk(value)
182
+ return found
183
+
184
+
185
+ def _sub_vars(template: str) -> set[str]:
186
+ out: set[str] = set()
187
+ i = 0
188
+ while True:
189
+ start = template.find("${", i)
190
+ if start < 0:
191
+ return out
192
+ end = template.find("}", start)
193
+ if end < 0:
194
+ return out
195
+ name = template[start + 2 : end].strip()
196
+ if not name.startswith("!"):
197
+ out.add(name.split(".", 1)[0])
198
+ i = end + 1
File without changes
@@ -0,0 +1,256 @@
1
+ """CloudFormation change-set acceptance rule for retain patches (PLAN §2.4, §13.2–3).
2
+
3
+ Input: the JSON of ``aws cloudformation describe-change-set`` for the root change set and every
4
+ nested change set (created with ``--include-nested-stacks``; describe each nested one by the
5
+ ``ChangeSetId`` shown on its ``AWS::CloudFormation::Stack`` resource change). Create change sets
6
+ with ``--include-property-values`` so Metadata before/after values can be checked.
7
+
8
+ Every resource change must be a policy-only ``Modify`` with ``Replacement: False``. Allowed
9
+ detail targets:
10
+
11
+ * ``Attribute`` DeletionPolicy / UpdateReplacePolicy;
12
+ * ``Properties``/``TemplateURL`` with ``RequiresRecreation: Never`` on an
13
+ ``AWS::CloudFormation::Stack`` that ``generate`` patched (listed in the manifest);
14
+ * ``Metadata`` whose only change is the ``ecsodus:retain`` key, when the Metadata fallback is on;
15
+ * optionally, ``Dynamic`` ``ResourceAttribute`` entries caused by a patched nested stack's
16
+ outputs, only when explicitly allowed and that nested change set itself passes.
17
+
18
+ An empty change set is not a pass: it means either the stack is already retained (verify with
19
+ ``ecsodus verify-retain`` and skip it) or CloudFormation treated the policy-only change as a no-op
20
+ (use the Metadata fallback). The result distinguishes this case.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from collections.abc import Iterable, Mapping
27
+ from dataclasses import dataclass, field
28
+ from typing import Any
29
+
30
+ from ecsodus.emit.retain_patch import METADATA_KEY, NESTED_STACK_TYPE
31
+
32
+ POLICY_ATTRIBUTES = {"DeletionPolicy", "UpdateReplacePolicy"}
33
+ NESTED_UNAVAILABLE_REASON = "Only executable from the root change set."
34
+ NO_CHANGES_REASONS = ("didn't contain changes", "No updates are to be performed")
35
+
36
+ PASS = "pass"
37
+ FAIL = "fail"
38
+ EMPTY = "empty"
39
+
40
+
41
+ @dataclass
42
+ class ChangeSetResult:
43
+ verdict: str
44
+ errors: list[str] = field(default_factory=list)
45
+ accepted: int = 0
46
+ change_sets: int = 0
47
+
48
+ @property
49
+ def ok(self) -> bool:
50
+ return self.verdict == PASS
51
+
52
+
53
+ def _is_empty(cs: dict[str, Any]) -> bool:
54
+ if cs.get("Changes"):
55
+ return False
56
+ reason = cs.get("StatusReason") or ""
57
+ if cs.get("Status") == "FAILED":
58
+ return any(r in reason for r in NO_CHANGES_REASONS)
59
+ return cs.get("Status") == "CREATE_COMPLETE"
60
+
61
+
62
+ def _metadata_only_ecsodus(detail: dict[str, Any]) -> bool:
63
+ target = detail.get("Target") or {}
64
+ before, after = target.get("BeforeValue"), target.get("AfterValue")
65
+ if after is None:
66
+ return False # without --include-property-values the change cannot be verified
67
+ try:
68
+ b = json.loads(before) if before else {}
69
+ a = json.loads(after)
70
+ except (TypeError, ValueError):
71
+ return False
72
+ if not isinstance(a, dict) or not isinstance(b, dict):
73
+ return False
74
+ a = dict(a)
75
+ if a.pop(METADATA_KEY, None) not in ("true", True):
76
+ return False
77
+ b.pop(METADATA_KEY, None)
78
+ return a == b
79
+
80
+
81
+ def check_change_sets(
82
+ change_sets: Iterable[dict[str, Any]],
83
+ patched_nested: Iterable[str] | Mapping[str, str] = (),
84
+ allow_metadata_key: bool = False,
85
+ allow_nested_dynamic: bool = False,
86
+ ) -> ChangeSetResult:
87
+ """Apply the acceptance rule to a root change set and all of its nested change sets.
88
+
89
+ ``patched_nested`` maps each patched nested-stack wrapper's logical ID to the S3 URL of its
90
+ patched child template (from the manifest); a plain list of IDs is accepted but then the
91
+ ``TemplateURL`` value cannot be verified and the rule fails closed.
92
+
93
+ Requirements beyond the per-change rule:
94
+
95
+ * every change set is ``CREATE_COMPLETE`` and executable (``ExecutionStatus: AVAILABLE``);
96
+ anything still in progress fails;
97
+ * every nested change set referenced by a stack-resource change is supplied, and every
98
+ supplied change set is the root or referenced by one;
99
+ * a ``TemplateURL`` change's new value (``describe-change-set --include-property-values``)
100
+ equals the manifest URL of the patched child.
101
+ """
102
+ sets = list(change_sets)
103
+ nested_urls: dict[str, str | None] = (
104
+ dict(patched_nested)
105
+ if isinstance(patched_nested, Mapping)
106
+ else dict.fromkeys(patched_nested)
107
+ )
108
+ result = ChangeSetResult(verdict=PASS, change_sets=len(sets))
109
+ if not sets:
110
+ return ChangeSetResult(verdict=FAIL, errors=["no change sets given"])
111
+ if len(sets) == 1 and _is_empty(sets[0]):
112
+ return ChangeSetResult(verdict=EMPTY, change_sets=1)
113
+
114
+ ids = {cs.get("ChangeSetId") for cs in sets if cs.get("ChangeSetId")}
115
+ roots = [cs for cs in sets if not cs.get("ParentChangeSetId")]
116
+ if len(roots) != 1:
117
+ result.errors.append(f"expected exactly one root change set, got {len(roots)}")
118
+ referenced: set[str] = set()
119
+ for cs in sets:
120
+ for change in cs.get("Changes") or []:
121
+ child = (change.get("ResourceChange") or {}).get("ChangeSetId")
122
+ if child:
123
+ referenced.add(child)
124
+ for child in sorted(referenced - ids):
125
+ result.errors.append(f"nested change set {child} was not supplied (describe it too)")
126
+ root_id = roots[0].get("ChangeSetId") if len(roots) == 1 else None
127
+ by_id = {cs.get("ChangeSetId"): cs for cs in sets}
128
+ for cs in sets:
129
+ cid = cs.get("ChangeSetId")
130
+ parent = cs.get("ParentChangeSetId")
131
+ if parent and cid not in referenced:
132
+ result.errors.append(f"change set {cid} is not referenced by the root")
133
+ if parent and (parent not in by_id or parent == cid):
134
+ result.errors.append(f"change set {cid} has parent {parent}, which was not supplied")
135
+ if cs.get("RootChangeSetId") and root_id and cs["RootChangeSetId"] != root_id:
136
+ result.errors.append(f"change set {cid} belongs to another root")
137
+ # Wrapper logical IDs whose child change set was actually supplied (for Dynamic entries),
138
+ # and each child's claimed parent and stack must match the change that references it.
139
+ supplied_children: set[str] = set()
140
+ for cs in sets:
141
+ for change in cs.get("Changes") or []:
142
+ rcx = change.get("ResourceChange") or {}
143
+ child_id = rcx.get("ChangeSetId")
144
+ if child_id not in ids:
145
+ continue
146
+ supplied_children.add(rcx.get("LogicalResourceId", ""))
147
+ child = by_id[child_id]
148
+ if child.get("ParentChangeSetId") != cs.get("ChangeSetId"):
149
+ result.errors.append(f"change set {child_id} does not name its referencing parent")
150
+ phys = rcx.get("PhysicalResourceId")
151
+ if phys and child.get("StackId") and child["StackId"] != phys:
152
+ result.errors.append(
153
+ f"change set {child_id} is for stack {child['StackId']}, not {phys}"
154
+ )
155
+
156
+ for cs in sets:
157
+ name = cs.get("StackName") or cs.get("ChangeSetName") or "?"
158
+ status, execution = cs.get("Status"), cs.get("ExecutionStatus")
159
+ # Nested change sets are never executable on their own: AWS reports them
160
+ # UNAVAILABLE with this exact reason (observed in the AWS end-to-end run).
161
+ nested_ok = (
162
+ bool(cs.get("ParentChangeSetId"))
163
+ and execution == "UNAVAILABLE"
164
+ and cs.get("StatusReason") == NESTED_UNAVAILABLE_REASON
165
+ )
166
+ if status != "CREATE_COMPLETE" or (execution != "AVAILABLE" and not nested_ok):
167
+ result.errors.append(
168
+ f"{name}: change set is {status}/{execution}, not CREATE_COMPLETE/AVAILABLE"
169
+ + (f" ({cs.get('StatusReason')})" if cs.get("StatusReason") else "")
170
+ )
171
+ continue
172
+ for change in cs.get("Changes") or []:
173
+ if change.get("Type") not in (None, "Resource"):
174
+ result.errors.append(f"{name}: unexpected change type {change.get('Type')}")
175
+ continue
176
+ rc = change.get("ResourceChange") or {}
177
+ where = f"{name}/{rc.get('LogicalResourceId', '?')}"
178
+ problems = _check_resource_change(
179
+ rc, nested_urls, allow_metadata_key, allow_nested_dynamic, supplied_children
180
+ )
181
+ if problems:
182
+ result.errors.extend(f"{where}: {p}" for p in problems)
183
+ else:
184
+ result.accepted += 1
185
+ if result.errors:
186
+ result.verdict = FAIL
187
+ elif result.accepted == 0:
188
+ result.verdict = EMPTY
189
+ return result
190
+
191
+
192
+ def _check_resource_change(
193
+ rc: dict[str, Any],
194
+ nested_ok: Mapping[str, str | None],
195
+ allow_metadata_key: bool,
196
+ allow_nested_dynamic: bool,
197
+ supplied_children: set[str] = frozenset(), # type: ignore[assignment]
198
+ ) -> list[str]:
199
+ problems: list[str] = []
200
+ action = rc.get("Action")
201
+ if action != "Modify":
202
+ return [f"action {action} is not allowed (only policy-only Modify)"]
203
+ replacement = rc.get("Replacement")
204
+ if replacement != "False":
205
+ problems.append(f"Replacement is {replacement!r}, must be 'False'")
206
+ rtype = rc.get("ResourceType", "")
207
+ lid = rc.get("LogicalResourceId", "")
208
+ details = rc.get("Details") or []
209
+ if not details:
210
+ scope = set(rc.get("Scope") or [])
211
+ if not scope or scope - POLICY_ATTRIBUTES:
212
+ problems.append(f"Modify with no details and scope {sorted(scope)}")
213
+ return problems
214
+ for d in details:
215
+ target = d.get("Target") or {}
216
+ attr = target.get("Attribute")
217
+ evaluation = d.get("Evaluation", "Static")
218
+ if evaluation == "Dynamic":
219
+ causing = d.get("CausingEntity") or ""
220
+ if (
221
+ allow_nested_dynamic
222
+ and d.get("ChangeSource") == "ResourceAttribute"
223
+ and causing.split(".", 1)[0] in nested_ok
224
+ and causing.split(".", 1)[0] in supplied_children
225
+ ):
226
+ continue
227
+ problems.append(
228
+ f"dynamic change to {attr}/{target.get('Name')} (caused by {causing or '?'})"
229
+ )
230
+ continue
231
+ if attr in POLICY_ATTRIBUTES:
232
+ continue
233
+ if (
234
+ attr == "Properties"
235
+ and target.get("Name") == "TemplateURL"
236
+ and rtype == NESTED_STACK_TYPE
237
+ and lid in nested_ok
238
+ ):
239
+ if target.get("RequiresRecreation") != "Never":
240
+ problems.append(
241
+ f"TemplateURL change has RequiresRecreation={target.get('RequiresRecreation')}"
242
+ )
243
+ expected = nested_ok.get(lid)
244
+ after = target.get("AfterValue")
245
+ if expected is None or after is None:
246
+ problems.append(
247
+ "TemplateURL new value cannot be verified: describe the change set with "
248
+ "--include-property-values and pass the manifest"
249
+ )
250
+ elif after.strip('"') != expected:
251
+ problems.append(f"TemplateURL points at {after!r}, not the patched child")
252
+ continue
253
+ if attr == "Metadata" and allow_metadata_key and _metadata_only_ecsodus(d):
254
+ continue
255
+ problems.append(f"change to {attr}/{target.get('Name') or '-'} is not policy-only")
256
+ return problems
ecsodus/check/plan.py ADDED
@@ -0,0 +1,162 @@
1
+ """Terraform plan and state gates (PLAN §2.6, §13.5-6).
2
+
3
+ Input is ``terraform show -json <planfile>``. An import is marked by a non-null
4
+ ``change.importing`` (its ``id`` is the physical ID being adopted); the ``actions`` array only
5
+ ever holds no-op / create / read / update / delete / forget, so imports must be detected through
6
+ ``importing`` (council note §13.6).
7
+
8
+ Both phases require the plan to cover exactly the manifest's import set:
9
+
10
+ * **import**: every managed change is a pure import (``importing`` set, actions ``["no-op"]``) of
11
+ the physical ID the manifest expects at that address; nothing else.
12
+ * **steady**: every expected address is present as a plain no-op (so a targeted or empty plan
13
+ cannot pass), and nothing else changes.
14
+
15
+ ecsodus generates no data sources, so any data source in the plan fails: a data source can read
16
+ secret material into state (e.g. ``aws_secretsmanager_secret_version``).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ from collections.abc import Iterable, Mapping
24
+ from dataclasses import dataclass, field
25
+ from typing import Any
26
+
27
+ IMPORT = "import"
28
+ STEADY = "steady"
29
+ AWS_PROVIDER = "registry.terraform.io/hashicorp/aws"
30
+
31
+
32
+ def _identity_matches(import_id: str, before: Any) -> bool:
33
+ """Does the refreshed object carry the physical ID it was imported from?
34
+
35
+ Import IDs are either a plain ID/ARN/name or a composite of several (``cluster/service``,
36
+ ``ZONE_name_TYPE``); every component must appear among the object's attribute values.
37
+ Without ``before`` (not in state) there is nothing to prove, which is a failure.
38
+ """
39
+ if not isinstance(before, dict):
40
+ return False
41
+ text = json.dumps(before, sort_keys=True).lower()
42
+ if import_id.lower() in text:
43
+ return True
44
+ parts = [p for p in re.split(r"[/_:|,]", import_id.lower()) if len(p) > 2]
45
+ return bool(parts) and all(p in text for p in parts)
46
+
47
+
48
+ @dataclass
49
+ class CheckResult:
50
+ ok: bool
51
+ errors: list[str] = field(default_factory=list)
52
+ warnings: list[str] = field(default_factory=list)
53
+ summary: dict[str, int] = field(default_factory=dict)
54
+
55
+
56
+ def _changes(plan: dict[str, Any]) -> list[dict[str, Any]]:
57
+ if "resource_changes" not in plan and "format_version" not in plan:
58
+ raise ValueError("not a `terraform show -json` plan document")
59
+ return list(plan.get("resource_changes") or [])
60
+
61
+
62
+ def check_plan(
63
+ plan: dict[str, Any],
64
+ expected_imports: Mapping[str, str] | Iterable[str],
65
+ phase: str,
66
+ forgotten: Iterable[str] = (),
67
+ ) -> CheckResult:
68
+ """Gate a plan for ``phase``. ``expected_imports`` maps address -> physical import ID.
69
+
70
+ ``forgotten`` lists addresses being handed off with a ``removed { destroy = false }`` block
71
+ (the imported task-definition revision): in the steady phase exactly those may show the
72
+ ``forget`` action, and they are no longer required to be present.
73
+ """
74
+ forgotten_set = {a for a in forgotten if a.split(".", 1)[0] == "aws_ecs_task_definition"}
75
+ res_pre = [a for a in forgotten if a not in forgotten_set]
76
+ forget_seen: set[str] = set()
77
+ if phase not in (IMPORT, STEADY):
78
+ raise ValueError(f"unknown phase {phase!r}")
79
+ expected: dict[str, str | None] = (
80
+ dict(expected_imports)
81
+ if isinstance(expected_imports, Mapping)
82
+ else dict.fromkeys(expected_imports)
83
+ )
84
+ res = CheckResult(ok=True)
85
+ for a in res_pre:
86
+ res.errors.append(f"{a}: only aws_ecs_task_definition addresses can be --forgotten")
87
+ counts: dict[str, int] = {}
88
+ importing_seen: set[str] = set()
89
+ noop_seen: set[str] = set()
90
+ if plan.get("errored"):
91
+ res.errors.append("the plan errored")
92
+ for rc in _changes(plan):
93
+ address = rc.get("address", "?")
94
+ change = rc.get("change") or {}
95
+ actions = list(change.get("actions") or [])
96
+ importing = change.get("importing")
97
+ mode = rc.get("mode", "managed")
98
+ key = "+".join(actions) + ("+import" if importing else "")
99
+ counts[key] = counts.get(key, 0) + 1
100
+ if mode == "data":
101
+ res.errors.append(f"{address}: data sources are not allowed (ecsodus generates none)")
102
+ continue
103
+ if actions == ["forget"] and phase == STEADY and address in forgotten_set:
104
+ forget_seen.add(address)
105
+ continue
106
+ provider = rc.get("provider_name", AWS_PROVIDER)
107
+ if provider != AWS_PROVIDER:
108
+ res.errors.append(f"{address}: provider {provider} is not {AWS_PROVIDER}")
109
+ continue
110
+ if rc.get("type") and rc["type"] != address.split(".", 1)[0].split("[", 1)[0]:
111
+ res.errors.append(f"{address}: resource type {rc['type']} does not match the address")
112
+ continue
113
+ if actions != ["no-op"]:
114
+ verb = "/".join(actions) or "?"
115
+ what = "pure imports and no-ops" if phase == IMPORT else "a zero-change plan"
116
+ res.errors.append(f"{address}: {verb} — the {phase} phase allows only {what}")
117
+ continue
118
+ if importing:
119
+ if phase == STEADY:
120
+ res.errors.append(f"{address}: still importing in the steady phase")
121
+ continue
122
+ importing_seen.add(address)
123
+ want = expected.get(address)
124
+ got = (importing or {}).get("id")
125
+ if address in expected and want is not None and got != want:
126
+ res.errors.append(f"{address}: imports {got!r}, but the manifest expects {want!r}")
127
+ else:
128
+ want = expected.get(address)
129
+ if phase == STEADY and want and not _identity_matches(want, change.get("before")):
130
+ res.errors.append(
131
+ f"{address}: the resource in state does not carry the manifest's ID {want!r}"
132
+ )
133
+ continue
134
+ noop_seen.add(address)
135
+ if phase == IMPORT:
136
+ for address in sorted(set(expected) - importing_seen):
137
+ res.errors.append(f"{address}: expected import is missing from the plan")
138
+ for address in sorted(importing_seen - set(expected)):
139
+ res.errors.append(f"{address}: plan imports an address ecsodus did not generate")
140
+ else:
141
+ for address in sorted(forgotten_set - forget_seen):
142
+ res.errors.append(f"{address}: --forgotten but the plan does not forget it")
143
+ for address in sorted(set(expected) - noop_seen - forget_seen):
144
+ res.errors.append(
145
+ f"{address}: missing from the steady plan (targeted or incomplete plan?)"
146
+ )
147
+ for dd in plan.get("resource_drift") or []:
148
+ res.warnings.append(f"{dd.get('address', '?')}: drift detected during refresh")
149
+ res.summary = counts
150
+ res.ok = not res.errors
151
+ return res
152
+
153
+
154
+ def check_state(state_addresses: Iterable[str], expected_imports: Iterable[str]) -> CheckResult:
155
+ """Every import-fated address must be in ``terraform state list`` output."""
156
+ present = {a.strip() for a in state_addresses if a.strip()}
157
+ wanted = set(expected_imports)
158
+ missing = sorted(wanted - present)
159
+ res = CheckResult(ok=not missing)
160
+ res.errors = [f"{a}: not in Terraform state" for a in missing]
161
+ res.summary = {"expected": len(wanted), "present": len(present)}
162
+ return res