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 +6 -0
- ecsodus/cfn.py +198 -0
- ecsodus/check/__init__.py +0 -0
- ecsodus/check/changeset.py +256 -0
- ecsodus/check/plan.py +162 -0
- ecsodus/cli.py +435 -0
- ecsodus/emit/__init__.py +0 -0
- ecsodus/emit/hcl.py +79 -0
- ecsodus/emit/patches.py +98 -0
- ecsodus/emit/report.py +164 -0
- ecsodus/emit/retain_patch.py +264 -0
- ecsodus/emit/runbook.py +495 -0
- ecsodus/emit/terraform.py +252 -0
- ecsodus/knowledge.py +783 -0
- ecsodus/mappers/__init__.py +0 -0
- ecsodus/mappers/fates.py +587 -0
- ecsodus/mappers/resolve.py +317 -0
- ecsodus/mappers/tf_compute.py +1390 -0
- ecsodus/mappers/tf_data.py +1038 -0
- ecsodus/mappers/tf_iam_dns.py +351 -0
- ecsodus/mappers/tf_network.py +637 -0
- ecsodus/mappers/tf_oob.py +173 -0
- ecsodus/mappers/tfmap.py +166 -0
- ecsodus/model.py +182 -0
- ecsodus/sources/__init__.py +0 -0
- ecsodus/sources/aws.py +104 -0
- ecsodus/sources/copilot.py +365 -0
- ecsodus/sources/live.py +431 -0
- ecsodus-0.1.0.dist-info/METADATA +126 -0
- ecsodus-0.1.0.dist-info/RECORD +33 -0
- ecsodus-0.1.0.dist-info/WHEEL +4 -0
- ecsodus-0.1.0.dist-info/entry_points.txt +3 -0
- ecsodus-0.1.0.dist-info/licenses/LICENSE +202 -0
ecsodus/__init__.py
ADDED
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
|