cdk-diff-summary 1.1.1__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.
- cdk_diff_summary/__init__.py +3 -0
- cdk_diff_summary/cli.py +167 -0
- cdk_diff_summary/config.py +64 -0
- cdk_diff_summary/diff.py +320 -0
- cdk_diff_summary/policy.py +99 -0
- cdk_diff_summary/py.typed +1 -0
- cdk_diff_summary/render.py +129 -0
- cdk_diff_summary-1.1.1.dist-info/METADATA +170 -0
- cdk_diff_summary-1.1.1.dist-info/RECORD +13 -0
- cdk_diff_summary-1.1.1.dist-info/WHEEL +5 -0
- cdk_diff_summary-1.1.1.dist-info/entry_points.txt +2 -0
- cdk_diff_summary-1.1.1.dist-info/licenses/LICENSE +21 -0
- cdk_diff_summary-1.1.1.dist-info/top_level.txt +1 -0
cdk_diff_summary/cli.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Command-line interface for cdk-diff-summary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from os import environ
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TextIO
|
|
12
|
+
|
|
13
|
+
from cdk_diff_summary.config import DEFAULT_TITLE, parse_bool, parse_max_changed_fields
|
|
14
|
+
from cdk_diff_summary.diff import DiffSummary, parse_diff
|
|
15
|
+
from cdk_diff_summary.render import render_summary
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class CliConfig:
|
|
20
|
+
diff_json_path: str
|
|
21
|
+
title: str
|
|
22
|
+
max_changed_fields: int
|
|
23
|
+
collapse_iam_policies: bool
|
|
24
|
+
collapse_assets: bool
|
|
25
|
+
fail_on_remove: bool
|
|
26
|
+
fail_on_replace: bool
|
|
27
|
+
output_path: str
|
|
28
|
+
github_step_summary: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(argv: list[str] | None = None) -> int:
|
|
32
|
+
try:
|
|
33
|
+
config = parse_args(argv)
|
|
34
|
+
diff_summary = parse_diff_from_file(config)
|
|
35
|
+
markdown = render_summary(
|
|
36
|
+
diff_summary,
|
|
37
|
+
title=config.title,
|
|
38
|
+
max_changed_fields=config.max_changed_fields,
|
|
39
|
+
)
|
|
40
|
+
append_outputs(config, markdown)
|
|
41
|
+
return fail_status(config, diff_summary)
|
|
42
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
43
|
+
print(f"cdk-diff-summary: {exc}", file=sys.stderr)
|
|
44
|
+
return 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_args(argv: list[str] | None = None) -> CliConfig:
|
|
48
|
+
parser = argparse.ArgumentParser(
|
|
49
|
+
description="Render a compact Markdown summary from AWS CDK diff JSON.",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"diff_json_path",
|
|
53
|
+
nargs="?",
|
|
54
|
+
help="Path to JSON produced by cdk diff --json. Defaults to DIFF_JSON_PATH.",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--title",
|
|
58
|
+
default=environ.get("SUMMARY_TITLE", DEFAULT_TITLE) or DEFAULT_TITLE,
|
|
59
|
+
help="Markdown heading for the summary.",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--max-changed-fields",
|
|
63
|
+
default=environ.get("MAX_CHANGED_FIELDS", "8"),
|
|
64
|
+
help="Maximum changed field paths to show for each resource.",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--collapse-iam-policies",
|
|
68
|
+
action=argparse.BooleanOptionalAction,
|
|
69
|
+
default=parse_bool(environ.get("COLLAPSE_IAM_POLICIES"), default=True),
|
|
70
|
+
help="Collapse IAM policy document changes. Enabled by default.",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--collapse-assets",
|
|
74
|
+
action=argparse.BooleanOptionalAction,
|
|
75
|
+
default=parse_bool(environ.get("COLLAPSE_ASSETS"), default=True),
|
|
76
|
+
help="Collapse common CDK asset/hash churn. Enabled by default.",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--fail-on-remove",
|
|
80
|
+
action="store_true",
|
|
81
|
+
default=parse_bool(environ.get("FAIL_ON_REMOVE"), default=False),
|
|
82
|
+
help="Exit non-zero after writing the summary if visible removes exist.",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--fail-on-replace",
|
|
86
|
+
action="store_true",
|
|
87
|
+
default=parse_bool(environ.get("FAIL_ON_REPLACE"), default=False),
|
|
88
|
+
help="Exit non-zero after writing the summary if visible replacements exist.",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--output",
|
|
92
|
+
default=environ.get("SUMMARY_OUTPUT_PATH", "").strip(),
|
|
93
|
+
help="Optional path to append the generated Markdown summary.",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--github-step-summary",
|
|
97
|
+
default=environ.get("GITHUB_STEP_SUMMARY", "").strip(),
|
|
98
|
+
help="Optional path to append GitHub Step Summary Markdown.",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
args = parser.parse_args(argv)
|
|
102
|
+
diff_json_path = args.diff_json_path or environ.get("DIFF_JSON_PATH", "").strip()
|
|
103
|
+
if not diff_json_path:
|
|
104
|
+
raise ValueError("diff-json-path argument or DIFF_JSON_PATH is required")
|
|
105
|
+
|
|
106
|
+
return CliConfig(
|
|
107
|
+
diff_json_path=diff_json_path,
|
|
108
|
+
title=args.title,
|
|
109
|
+
max_changed_fields=parse_max_changed_fields(str(args.max_changed_fields)),
|
|
110
|
+
collapse_iam_policies=args.collapse_iam_policies,
|
|
111
|
+
collapse_assets=args.collapse_assets,
|
|
112
|
+
fail_on_remove=args.fail_on_remove,
|
|
113
|
+
fail_on_replace=args.fail_on_replace,
|
|
114
|
+
output_path=args.output,
|
|
115
|
+
github_step_summary=args.github_step_summary,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_diff_from_file(config: CliConfig) -> DiffSummary:
|
|
120
|
+
with Path(config.diff_json_path).open(encoding="utf-8") as handle:
|
|
121
|
+
document = json.load(handle)
|
|
122
|
+
return parse_diff(
|
|
123
|
+
document,
|
|
124
|
+
collapse_iam_policies=config.collapse_iam_policies,
|
|
125
|
+
collapse_assets=config.collapse_assets,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def append_outputs(config: CliConfig, markdown: str) -> None:
|
|
130
|
+
if config.github_step_summary:
|
|
131
|
+
append_file(Path(config.github_step_summary), markdown)
|
|
132
|
+
if config.output_path:
|
|
133
|
+
append_file(Path(config.output_path), markdown)
|
|
134
|
+
if not config.github_step_summary and not config.output_path:
|
|
135
|
+
sys.stdout.write(markdown)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def append_file(path: Path, markdown: str) -> None:
|
|
139
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
141
|
+
append_markdown(handle, markdown)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def append_markdown(handle: TextIO, markdown: str) -> None:
|
|
145
|
+
handle.write(markdown)
|
|
146
|
+
if not markdown.endswith("\n"):
|
|
147
|
+
handle.write("\n")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def fail_status(config: CliConfig, diff_summary: DiffSummary) -> int:
|
|
151
|
+
if config.fail_on_remove and diff_summary.removes > 0:
|
|
152
|
+
print(
|
|
153
|
+
"cdk-diff-summary: visible removes found and --fail-on-remove is set",
|
|
154
|
+
file=sys.stderr,
|
|
155
|
+
)
|
|
156
|
+
return 2
|
|
157
|
+
if config.fail_on_replace and diff_summary.replacements > 0:
|
|
158
|
+
print(
|
|
159
|
+
"cdk-diff-summary: visible replacements found and --fail-on-replace is set",
|
|
160
|
+
file=sys.stderr,
|
|
161
|
+
)
|
|
162
|
+
return 3
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Runtime configuration for the cdk-diff-summary action."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from os import environ
|
|
7
|
+
|
|
8
|
+
DEFAULT_TITLE = "CDK diff summary"
|
|
9
|
+
TRUE_VALUES = {"1", "true", "yes", "y", "on"}
|
|
10
|
+
FALSE_VALUES = {"0", "false", "no", "n", "off", ""}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Config:
|
|
15
|
+
diff_json_path: str
|
|
16
|
+
summary_title: str
|
|
17
|
+
max_changed_fields: int
|
|
18
|
+
collapse_iam_policies: bool
|
|
19
|
+
collapse_assets: bool
|
|
20
|
+
fail_on_remove: bool
|
|
21
|
+
fail_on_replace: bool
|
|
22
|
+
summary_output_path: str
|
|
23
|
+
github_step_summary: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_bool(value: str | None, *, default: bool = False) -> bool:
|
|
27
|
+
if value is None:
|
|
28
|
+
return default
|
|
29
|
+
normalized = value.strip().lower()
|
|
30
|
+
if normalized in TRUE_VALUES:
|
|
31
|
+
return True
|
|
32
|
+
if normalized in FALSE_VALUES:
|
|
33
|
+
return False
|
|
34
|
+
raise ValueError(f"expected a boolean value, got {value!r}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_max_changed_fields(value: str | None) -> int:
|
|
38
|
+
raw_value = "8" if value is None or value.strip() == "" else value.strip()
|
|
39
|
+
try:
|
|
40
|
+
parsed = int(raw_value)
|
|
41
|
+
except ValueError as exc:
|
|
42
|
+
message = "MAX_CHANGED_FIELDS must be an integer greater than or equal to 1"
|
|
43
|
+
raise ValueError(message) from exc
|
|
44
|
+
if parsed < 1:
|
|
45
|
+
raise ValueError("MAX_CHANGED_FIELDS must be greater than or equal to 1")
|
|
46
|
+
return parsed
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_config() -> Config:
|
|
50
|
+
diff_json_path = environ.get("DIFF_JSON_PATH", "").strip()
|
|
51
|
+
if not diff_json_path:
|
|
52
|
+
raise ValueError("DIFF_JSON_PATH is required")
|
|
53
|
+
|
|
54
|
+
return Config(
|
|
55
|
+
diff_json_path=diff_json_path,
|
|
56
|
+
summary_title=environ.get("SUMMARY_TITLE", DEFAULT_TITLE) or DEFAULT_TITLE,
|
|
57
|
+
max_changed_fields=parse_max_changed_fields(environ.get("MAX_CHANGED_FIELDS")),
|
|
58
|
+
collapse_iam_policies=parse_bool(environ.get("COLLAPSE_IAM_POLICIES"), default=True),
|
|
59
|
+
collapse_assets=parse_bool(environ.get("COLLAPSE_ASSETS"), default=True),
|
|
60
|
+
fail_on_remove=parse_bool(environ.get("FAIL_ON_REMOVE"), default=False),
|
|
61
|
+
fail_on_replace=parse_bool(environ.get("FAIL_ON_REPLACE"), default=False),
|
|
62
|
+
summary_output_path=environ.get("SUMMARY_OUTPUT_PATH", "").strip(),
|
|
63
|
+
github_step_summary=environ.get("GITHUB_STEP_SUMMARY", "").strip(),
|
|
64
|
+
)
|
cdk_diff_summary/diff.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Parse CDK diff JSON into a small internal model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from cdk_diff_summary.policy import collapse_paths
|
|
9
|
+
|
|
10
|
+
ADD_ACTIONS = {"add", "create", "+", "addition"}
|
|
11
|
+
REMOVE_ACTIONS = {"delete", "remove", "destroy", "-", "deletion"}
|
|
12
|
+
MODIFY_ACTIONS = {"modify", "update", "change", "~"}
|
|
13
|
+
REPLACE_ACTIONS = {"replace", "replacement", "+/-", "-/+"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ResourceChange:
|
|
18
|
+
stack: str
|
|
19
|
+
logical_id: str
|
|
20
|
+
action: str
|
|
21
|
+
resource_type: str
|
|
22
|
+
changed_fields: tuple[str, ...]
|
|
23
|
+
replacement: bool = False
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def group(self) -> str:
|
|
27
|
+
if self.replacement:
|
|
28
|
+
return "replacements"
|
|
29
|
+
normalized = normalize_action(self.action)
|
|
30
|
+
if normalized == "remove":
|
|
31
|
+
return "removes"
|
|
32
|
+
if normalized == "add":
|
|
33
|
+
return "adds"
|
|
34
|
+
if normalized == "modify":
|
|
35
|
+
return "modifies"
|
|
36
|
+
return "other"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class SecurityGroupChange:
|
|
41
|
+
stack: str
|
|
42
|
+
security_group: str
|
|
43
|
+
direction: str
|
|
44
|
+
protocol: str
|
|
45
|
+
port: str
|
|
46
|
+
action: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class DiffSummary:
|
|
51
|
+
stack_changes: int
|
|
52
|
+
resources: tuple[ResourceChange, ...]
|
|
53
|
+
security_group_changes: tuple[SecurityGroupChange, ...] = ()
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def adds(self) -> int:
|
|
57
|
+
return sum(1 for resource in self.resources if resource.group == "adds")
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def modifies(self) -> int:
|
|
61
|
+
return sum(1 for resource in self.resources if resource.group == "modifies")
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def removes(self) -> int:
|
|
65
|
+
return sum(1 for resource in self.resources if resource.group == "removes")
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def replacements(self) -> int:
|
|
69
|
+
return sum(1 for resource in self.resources if resource.group == "replacements")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_diff(
|
|
73
|
+
document: Any,
|
|
74
|
+
*,
|
|
75
|
+
collapse_iam_policies: bool = True,
|
|
76
|
+
collapse_assets: bool = True,
|
|
77
|
+
) -> DiffSummary:
|
|
78
|
+
stacks = extract_stacks(document)
|
|
79
|
+
resources: list[ResourceChange] = []
|
|
80
|
+
security_group_changes: list[SecurityGroupChange] = []
|
|
81
|
+
stack_changes = 0
|
|
82
|
+
|
|
83
|
+
for stack in stacks:
|
|
84
|
+
stack_name = str(first_present(stack, "stackName", "name", "id", "stack") or "Unknown")
|
|
85
|
+
stack_resources = extract_resources(stack)
|
|
86
|
+
stack_security_group_changes = extract_security_group_changes(stack)
|
|
87
|
+
has_differences = bool(stack.get("hasDifferences")) if isinstance(stack, dict) else False
|
|
88
|
+
if stack_resources or stack_security_group_changes or has_differences:
|
|
89
|
+
stack_changes += 1
|
|
90
|
+
for resource in stack_resources:
|
|
91
|
+
resources.append(
|
|
92
|
+
parse_resource(
|
|
93
|
+
resource,
|
|
94
|
+
stack_name=stack_name,
|
|
95
|
+
collapse_iam_policies=collapse_iam_policies,
|
|
96
|
+
collapse_assets=collapse_assets,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
for security_group_change in stack_security_group_changes:
|
|
100
|
+
security_group_changes.append(
|
|
101
|
+
parse_security_group_change(
|
|
102
|
+
security_group_change,
|
|
103
|
+
stack_name=stack_name,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return DiffSummary(
|
|
108
|
+
stack_changes=stack_changes,
|
|
109
|
+
resources=tuple(resources),
|
|
110
|
+
security_group_changes=tuple(security_group_changes),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def extract_stacks(document: Any) -> list[dict[str, Any]]:
|
|
115
|
+
if isinstance(document, dict):
|
|
116
|
+
stacks = first_present(document, "stacks", "Stacks", "stackDiffs", "differences")
|
|
117
|
+
if isinstance(stacks, list):
|
|
118
|
+
return [stack for stack in stacks if isinstance(stack, dict)]
|
|
119
|
+
if isinstance(stacks, dict):
|
|
120
|
+
return [
|
|
121
|
+
dict({"stackName": name}, **value)
|
|
122
|
+
for name, value in stacks.items()
|
|
123
|
+
if isinstance(value, dict)
|
|
124
|
+
]
|
|
125
|
+
if has_resource_collection(document):
|
|
126
|
+
return [document]
|
|
127
|
+
if isinstance(document, list):
|
|
128
|
+
return [stack for stack in document if isinstance(stack, dict)]
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def extract_resources(stack: dict[str, Any]) -> list[dict[str, Any]]:
|
|
133
|
+
raw_resources = first_present(
|
|
134
|
+
stack,
|
|
135
|
+
"resources",
|
|
136
|
+
"resourceChanges",
|
|
137
|
+
"ResourceChanges",
|
|
138
|
+
"changes",
|
|
139
|
+
)
|
|
140
|
+
if isinstance(raw_resources, list):
|
|
141
|
+
return [resource for resource in raw_resources if isinstance(resource, dict)]
|
|
142
|
+
if isinstance(raw_resources, dict):
|
|
143
|
+
resources = []
|
|
144
|
+
for logical_id, resource in raw_resources.items():
|
|
145
|
+
if isinstance(resource, dict):
|
|
146
|
+
resources.append(dict({"logicalId": logical_id}, **resource))
|
|
147
|
+
return resources
|
|
148
|
+
return []
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def extract_security_group_changes(stack: dict[str, Any]) -> list[dict[str, Any]]:
|
|
152
|
+
raw_changes = first_present(
|
|
153
|
+
stack,
|
|
154
|
+
"securityGroupChanges",
|
|
155
|
+
"securityGroups",
|
|
156
|
+
"SecurityGroupChanges",
|
|
157
|
+
)
|
|
158
|
+
if isinstance(raw_changes, list):
|
|
159
|
+
return [change for change in raw_changes if isinstance(change, dict)]
|
|
160
|
+
if isinstance(raw_changes, dict):
|
|
161
|
+
changes = []
|
|
162
|
+
for security_group, change in raw_changes.items():
|
|
163
|
+
if isinstance(change, dict):
|
|
164
|
+
changes.append(dict({"securityGroup": security_group}, **change))
|
|
165
|
+
return changes
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def parse_resource(
|
|
170
|
+
resource: dict[str, Any],
|
|
171
|
+
*,
|
|
172
|
+
stack_name: str,
|
|
173
|
+
collapse_iam_policies: bool,
|
|
174
|
+
collapse_assets: bool,
|
|
175
|
+
) -> ResourceChange:
|
|
176
|
+
logical_id = str(
|
|
177
|
+
first_present(resource, "logicalId", "logicalResourceId", "id", "name") or "Unknown"
|
|
178
|
+
)
|
|
179
|
+
resource_type = str(first_present(resource, "resourceType", "type", "resourceTypeName") or "")
|
|
180
|
+
action = normalize_action(
|
|
181
|
+
str(first_present(resource, "action", "changeType", "operation") or "other")
|
|
182
|
+
)
|
|
183
|
+
changed_fields = extract_changed_fields(resource)
|
|
184
|
+
replacement = is_replacement(resource, changed_fields)
|
|
185
|
+
if replacement:
|
|
186
|
+
action = "replace"
|
|
187
|
+
collapsed_fields = collapse_paths(
|
|
188
|
+
changed_fields,
|
|
189
|
+
collapse_iam_policies=collapse_iam_policies,
|
|
190
|
+
collapse_assets=collapse_assets,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return ResourceChange(
|
|
194
|
+
stack=stack_name,
|
|
195
|
+
logical_id=logical_id,
|
|
196
|
+
action=action,
|
|
197
|
+
resource_type=resource_type,
|
|
198
|
+
changed_fields=tuple(collapsed_fields),
|
|
199
|
+
replacement=replacement,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def parse_security_group_change(
|
|
204
|
+
change: dict[str, Any],
|
|
205
|
+
*,
|
|
206
|
+
stack_name: str,
|
|
207
|
+
) -> SecurityGroupChange:
|
|
208
|
+
security_group = first_present(
|
|
209
|
+
change,
|
|
210
|
+
"securityGroup",
|
|
211
|
+
"securityGroupId",
|
|
212
|
+
"groupId",
|
|
213
|
+
"groupName",
|
|
214
|
+
"name",
|
|
215
|
+
)
|
|
216
|
+
direction = first_present(change, "direction", "ruleType", "type")
|
|
217
|
+
protocol = first_present(change, "protocol", "ipProtocol")
|
|
218
|
+
port = first_present(change, "port", "fromPort", "toPort", "ports")
|
|
219
|
+
action = first_present(change, "action", "changeType", "operation")
|
|
220
|
+
|
|
221
|
+
return SecurityGroupChange(
|
|
222
|
+
stack=stack_name,
|
|
223
|
+
security_group=str(security_group or "Unknown"),
|
|
224
|
+
direction=str(direction or ""),
|
|
225
|
+
protocol=str(protocol or ""),
|
|
226
|
+
port=format_port(port),
|
|
227
|
+
action=normalize_action(str(action or "other")),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def extract_changed_fields(resource: dict[str, Any]) -> list[str]:
|
|
232
|
+
raw_changes = first_present(
|
|
233
|
+
resource,
|
|
234
|
+
"propertyChanges",
|
|
235
|
+
"propertyDiffs",
|
|
236
|
+
"details",
|
|
237
|
+
"changes",
|
|
238
|
+
"changedFields",
|
|
239
|
+
)
|
|
240
|
+
fields: list[str] = []
|
|
241
|
+
|
|
242
|
+
if isinstance(raw_changes, list):
|
|
243
|
+
for change in raw_changes:
|
|
244
|
+
if isinstance(change, str):
|
|
245
|
+
fields.append(change)
|
|
246
|
+
elif isinstance(change, dict):
|
|
247
|
+
field = first_present(change, "path", "propertyPath", "name", "field", "target")
|
|
248
|
+
if field:
|
|
249
|
+
fields.append(str(field))
|
|
250
|
+
elif isinstance(raw_changes, dict):
|
|
251
|
+
for key, value in raw_changes.items():
|
|
252
|
+
if isinstance(value, dict):
|
|
253
|
+
field = first_present(value, "path", "propertyPath", "name", "field")
|
|
254
|
+
fields.append(str(field or key))
|
|
255
|
+
else:
|
|
256
|
+
fields.append(str(key))
|
|
257
|
+
|
|
258
|
+
if not fields:
|
|
259
|
+
field = first_present(resource, "path", "propertyPath")
|
|
260
|
+
if field:
|
|
261
|
+
fields.append(str(field))
|
|
262
|
+
return dedupe(fields)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def is_replacement(resource: dict[str, Any], changed_fields: list[str]) -> bool:
|
|
266
|
+
replacement = first_present(resource, "replacement", "requiresReplacement", "willReplace")
|
|
267
|
+
if isinstance(replacement, bool) and replacement:
|
|
268
|
+
return True
|
|
269
|
+
action = str(first_present(resource, "action", "changeType", "operation") or "").strip().lower()
|
|
270
|
+
if action in REPLACE_ACTIONS:
|
|
271
|
+
return True
|
|
272
|
+
|
|
273
|
+
raw_changes = first_present(resource, "propertyChanges", "propertyDiffs", "details", "changes")
|
|
274
|
+
if isinstance(raw_changes, list):
|
|
275
|
+
for change in raw_changes:
|
|
276
|
+
if isinstance(change, dict) and change.get("requiresReplacement") is True:
|
|
277
|
+
return True
|
|
278
|
+
return any(field.lower() == "replacement" for field in changed_fields)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def normalize_action(action: str) -> str:
|
|
282
|
+
normalized = action.strip().lower()
|
|
283
|
+
if normalized in ADD_ACTIONS:
|
|
284
|
+
return "add"
|
|
285
|
+
if normalized in REMOVE_ACTIONS:
|
|
286
|
+
return "remove"
|
|
287
|
+
if normalized in MODIFY_ACTIONS:
|
|
288
|
+
return "modify"
|
|
289
|
+
if normalized in REPLACE_ACTIONS:
|
|
290
|
+
return "replace"
|
|
291
|
+
return normalized or "other"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def first_present(mapping: dict[str, Any], *keys: str) -> Any:
|
|
295
|
+
for key in keys:
|
|
296
|
+
if key in mapping and mapping[key] is not None:
|
|
297
|
+
return mapping[key]
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def format_port(value: Any) -> str:
|
|
302
|
+
if value is None:
|
|
303
|
+
return ""
|
|
304
|
+
if isinstance(value, list | tuple):
|
|
305
|
+
return ", ".join(str(item) for item in value)
|
|
306
|
+
return str(value)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def has_resource_collection(document: dict[str, Any]) -> bool:
|
|
310
|
+
return any(key in document for key in ("resources", "resourceChanges", "ResourceChanges"))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def dedupe(values: list[str]) -> list[str]:
|
|
314
|
+
seen: set[str] = set()
|
|
315
|
+
result: list[str] = []
|
|
316
|
+
for value in values:
|
|
317
|
+
if value not in seen:
|
|
318
|
+
seen.add(value)
|
|
319
|
+
result.append(value)
|
|
320
|
+
return result
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Noise reduction helpers for changed field paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
IAM_POLICY_ROOTS = (
|
|
8
|
+
"PolicyDocument",
|
|
9
|
+
"AssumeRolePolicyDocument",
|
|
10
|
+
"Policies[].PolicyDocument",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
ASSET_PATTERNS = tuple(
|
|
14
|
+
re.compile(pattern, re.IGNORECASE)
|
|
15
|
+
for pattern in (
|
|
16
|
+
r"assetparameters.*artifacthash",
|
|
17
|
+
r"assethash",
|
|
18
|
+
r"sourcehash",
|
|
19
|
+
r"codehash",
|
|
20
|
+
r"imageuri",
|
|
21
|
+
r"imageasset",
|
|
22
|
+
r"docker.*asset",
|
|
23
|
+
r"s3(object)?key",
|
|
24
|
+
r"sourceobjectkey",
|
|
25
|
+
r"metadata.*aws:cdk:path",
|
|
26
|
+
r"metadata.*asset",
|
|
27
|
+
r"code\.s3key",
|
|
28
|
+
r"code\.zipfile",
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def normalize_path(path: str) -> str:
|
|
34
|
+
normalized = path.replace("/", ".")
|
|
35
|
+
normalized = re.sub(r"\[[0-9]+\]", "[]", normalized)
|
|
36
|
+
normalized = re.sub(r"\.+", ".", normalized)
|
|
37
|
+
return normalized.strip(".")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def collapse_iam_policy_path(path: str) -> str:
|
|
41
|
+
normalized = normalize_path(path)
|
|
42
|
+
if normalized.startswith("PolicyDocument."):
|
|
43
|
+
return "PolicyDocument"
|
|
44
|
+
if normalized == "PolicyDocument":
|
|
45
|
+
return "PolicyDocument"
|
|
46
|
+
if normalized.startswith("AssumeRolePolicyDocument."):
|
|
47
|
+
return "AssumeRolePolicyDocument"
|
|
48
|
+
if normalized == "AssumeRolePolicyDocument":
|
|
49
|
+
return "AssumeRolePolicyDocument"
|
|
50
|
+
if ".PolicyDocument." in normalized:
|
|
51
|
+
return normalized.split(".PolicyDocument.", maxsplit=1)[0] + ".PolicyDocument"
|
|
52
|
+
return path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def collapse_asset_path(path: str) -> str | None:
|
|
56
|
+
normalized = normalize_path(path)
|
|
57
|
+
compact = re.sub(r"[^a-z0-9:]", "", normalized.lower())
|
|
58
|
+
for pattern in ASSET_PATTERNS:
|
|
59
|
+
if pattern.search(compact) or pattern.search(normalized):
|
|
60
|
+
return asset_bucket(normalized)
|
|
61
|
+
return path
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def asset_bucket(path: str) -> str | None:
|
|
65
|
+
lowered = path.lower()
|
|
66
|
+
if "metadata" in lowered:
|
|
67
|
+
return "Metadata.Asset"
|
|
68
|
+
if "s3" in lowered or "objectkey" in lowered:
|
|
69
|
+
return "Code.S3Key"
|
|
70
|
+
if "image" in lowered or "docker" in lowered:
|
|
71
|
+
return "Image.Asset"
|
|
72
|
+
if "hash" in lowered or "assetparameters" in lowered:
|
|
73
|
+
return "Asset.Hash"
|
|
74
|
+
if "zipfile" in lowered:
|
|
75
|
+
return "Code.ZipFile"
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def collapse_paths(
|
|
80
|
+
paths: list[str],
|
|
81
|
+
*,
|
|
82
|
+
collapse_iam_policies: bool,
|
|
83
|
+
collapse_assets: bool,
|
|
84
|
+
) -> list[str]:
|
|
85
|
+
collapsed: list[str] = []
|
|
86
|
+
seen: set[str] = set()
|
|
87
|
+
for path in paths:
|
|
88
|
+
next_path = path
|
|
89
|
+
if collapse_iam_policies:
|
|
90
|
+
next_path = collapse_iam_policy_path(next_path)
|
|
91
|
+
if collapse_assets:
|
|
92
|
+
asset_path = collapse_asset_path(next_path)
|
|
93
|
+
if asset_path is None:
|
|
94
|
+
continue
|
|
95
|
+
next_path = asset_path
|
|
96
|
+
if next_path not in seen:
|
|
97
|
+
seen.add(next_path)
|
|
98
|
+
collapsed.append(next_path)
|
|
99
|
+
return collapsed
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Render compact Markdown summaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from cdk_diff_summary.diff import DiffSummary, ResourceChange, SecurityGroupChange
|
|
8
|
+
|
|
9
|
+
GROUP_TITLES = (
|
|
10
|
+
("replacements", "Replacements"),
|
|
11
|
+
("removes", "Removes"),
|
|
12
|
+
("adds", "Adds"),
|
|
13
|
+
("modifies", "Modifies"),
|
|
14
|
+
("other", "Other changes"),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def render_summary(summary: DiffSummary, *, title: str, max_changed_fields: int) -> str:
|
|
19
|
+
lines = [
|
|
20
|
+
f"## {escape_markdown(title)}",
|
|
21
|
+
"",
|
|
22
|
+
"| Metric | Count |",
|
|
23
|
+
"| --- | ---: |",
|
|
24
|
+
f"| Stack changes | {summary.stack_changes} |",
|
|
25
|
+
f"| Resource changes | {len(summary.resources)} |",
|
|
26
|
+
f"| Adds | {summary.adds} |",
|
|
27
|
+
f"| Modifies | {summary.modifies} |",
|
|
28
|
+
f"| Removes | {summary.removes} |",
|
|
29
|
+
f"| Replacements | {summary.replacements} |",
|
|
30
|
+
f"| Security group changes | {len(summary.security_group_changes)} |",
|
|
31
|
+
f"| Changes shown below | {len(summary.resources) + len(summary.security_group_changes)} |",
|
|
32
|
+
"",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
for group, group_title in GROUP_TITLES:
|
|
36
|
+
resources = [resource for resource in summary.resources if resource.group == group]
|
|
37
|
+
if not resources:
|
|
38
|
+
continue
|
|
39
|
+
lines.extend(render_group(group_title, resources, max_changed_fields=max_changed_fields))
|
|
40
|
+
lines.append("")
|
|
41
|
+
|
|
42
|
+
if summary.security_group_changes:
|
|
43
|
+
lines.extend(render_security_group_changes(summary.security_group_changes))
|
|
44
|
+
lines.append("")
|
|
45
|
+
|
|
46
|
+
if not summary.resources and not summary.security_group_changes:
|
|
47
|
+
lines.append("No resource changes found in the CDK diff JSON.")
|
|
48
|
+
lines.append("")
|
|
49
|
+
|
|
50
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render_group(
|
|
54
|
+
title: str,
|
|
55
|
+
resources: Iterable[ResourceChange],
|
|
56
|
+
*,
|
|
57
|
+
max_changed_fields: int,
|
|
58
|
+
) -> list[str]:
|
|
59
|
+
lines = [
|
|
60
|
+
f"### {title}",
|
|
61
|
+
"",
|
|
62
|
+
"| Stack | Logical ID | Action | Resource type | Changed fields |",
|
|
63
|
+
"| --- | --- | --- | --- | --- |",
|
|
64
|
+
]
|
|
65
|
+
for resource in resources:
|
|
66
|
+
fields = format_changed_fields(
|
|
67
|
+
resource.changed_fields,
|
|
68
|
+
max_changed_fields=max_changed_fields,
|
|
69
|
+
)
|
|
70
|
+
lines.append(
|
|
71
|
+
"| "
|
|
72
|
+
+ " | ".join(
|
|
73
|
+
escape_table_cell(value)
|
|
74
|
+
for value in (
|
|
75
|
+
resource.stack,
|
|
76
|
+
resource.logical_id,
|
|
77
|
+
resource.action,
|
|
78
|
+
resource.resource_type,
|
|
79
|
+
fields,
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
+ " |"
|
|
83
|
+
)
|
|
84
|
+
return lines
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def render_security_group_changes(
|
|
88
|
+
changes: Iterable[SecurityGroupChange],
|
|
89
|
+
) -> list[str]:
|
|
90
|
+
lines = [
|
|
91
|
+
"### Security group changes",
|
|
92
|
+
"",
|
|
93
|
+
"| Stack | Security group | Direction | Protocol | Port | Action |",
|
|
94
|
+
"| --- | --- | --- | --- | --- | --- |",
|
|
95
|
+
]
|
|
96
|
+
for change in changes:
|
|
97
|
+
lines.append(
|
|
98
|
+
"| "
|
|
99
|
+
+ " | ".join(
|
|
100
|
+
escape_table_cell(value)
|
|
101
|
+
for value in (
|
|
102
|
+
change.stack,
|
|
103
|
+
change.security_group,
|
|
104
|
+
change.direction,
|
|
105
|
+
change.protocol,
|
|
106
|
+
change.port,
|
|
107
|
+
change.action,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
+ " |"
|
|
111
|
+
)
|
|
112
|
+
return lines
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def format_changed_fields(fields: tuple[str, ...], *, max_changed_fields: int) -> str:
|
|
116
|
+
if not fields:
|
|
117
|
+
return "_n/a_"
|
|
118
|
+
visible = list(fields[:max_changed_fields])
|
|
119
|
+
if len(fields) > max_changed_fields:
|
|
120
|
+
visible.append("...")
|
|
121
|
+
return "`" + "`, `".join(visible) + "`"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def escape_table_cell(value: str) -> str:
|
|
125
|
+
return escape_markdown(value).replace("|", "\\|").replace("\n", "<br>")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def escape_markdown(value: str) -> str:
|
|
129
|
+
return str(value).replace("\r", "")
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cdk-diff-summary
|
|
3
|
+
Version: 1.1.1
|
|
4
|
+
Summary: Summarize AWS CDK diff JSON as compact Markdown.
|
|
5
|
+
Author: cdk-diff-summary contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jalcock501/cdk-diff-summary-pypi
|
|
8
|
+
Project-URL: Repository, https://github.com/jalcock501/cdk-diff-summary-pypi
|
|
9
|
+
Project-URL: Documentation, https://github.com/jalcock501/cdk-diff-summary-pypi#readme
|
|
10
|
+
Project-URL: Issues, https://github.com/jalcock501/cdk-diff-summary-pypi/issues
|
|
11
|
+
Keywords: aws,cdk,cloudformation,github-actions,diff
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
20
|
+
Classifier: Topic :: System :: Systems Administration
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: build<2.0,>=1.2; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest<10.0,>=8.3; extra == "dev"
|
|
28
|
+
Requires-Dist: ruff<0.16,>=0.8; extra == "dev"
|
|
29
|
+
Requires-Dist: twine<7.0,>=5.0; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# cdk-diff-summary
|
|
33
|
+
|
|
34
|
+
`cdk-diff-summary` reads AWS CDK diff JSON and renders a compact Markdown summary.
|
|
35
|
+
|
|
36
|
+
It is useful locally, in CI systems, and in GitHub Actions workflows where raw CDK or CloudFormation diffs are too noisy. It groups adds, modifies, removes, replacements, security group rule changes, and other changes while reducing common churn from IAM policy documents and CDK asset hashes.
|
|
37
|
+
|
|
38
|
+
The tool deliberately shows changed field paths only, not before/after values, to avoid exposing sensitive infrastructure values in summaries.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pipx install cdk-diff-summary
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
or:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
python -m pip install cdk-diff-summary
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
Generate CDK diff JSON:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx cdk diff --json > cdk-diff.json
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Render Markdown to stdout:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
cdk-diff-summary cdk-diff.json
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Append Markdown to a file:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
cdk-diff-summary cdk-diff.json --output cdk-diff-summary.md
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Use a custom title and field limit:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
cdk-diff-summary cdk-diff.json \
|
|
76
|
+
--title "Production CDK diff" \
|
|
77
|
+
--max-changed-fields 5
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Fail when visible removals or replacements exist:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
cdk-diff-summary cdk-diff.json --fail-on-remove --fail-on-replace
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## CLI Options
|
|
87
|
+
|
|
88
|
+
| Option | Description |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| `diff-json-path` | Path to JSON produced by `cdk diff --json`. May also be set with `DIFF_JSON_PATH`. |
|
|
91
|
+
| `--title` | Markdown heading for the summary. Defaults to `CDK diff summary`. |
|
|
92
|
+
| `--max-changed-fields` | Maximum changed field paths shown per resource. Defaults to `8`. |
|
|
93
|
+
| `--collapse-iam-policies` / `--no-collapse-iam-policies` | Collapse large IAM policy document diffs to compact paths. Enabled by default. |
|
|
94
|
+
| `--collapse-assets` / `--no-collapse-assets` | Collapse common CDK asset/hash churn. Enabled by default. |
|
|
95
|
+
| `--fail-on-remove` | Write the summary, then exit non-zero if visible resource removes exist. |
|
|
96
|
+
| `--fail-on-replace` | Write the summary, then exit non-zero if visible resource replacements exist. |
|
|
97
|
+
| `--output` | Optional path to append the generated Markdown summary. |
|
|
98
|
+
| `--github-step-summary` | Optional path to append GitHub Step Summary Markdown. Defaults to `$GITHUB_STEP_SUMMARY`. |
|
|
99
|
+
|
|
100
|
+
Environment variables compatible with the GitHub Action wrapper are also supported:
|
|
101
|
+
|
|
102
|
+
- `DIFF_JSON_PATH`
|
|
103
|
+
- `SUMMARY_TITLE`
|
|
104
|
+
- `MAX_CHANGED_FIELDS`
|
|
105
|
+
- `COLLAPSE_IAM_POLICIES`
|
|
106
|
+
- `COLLAPSE_ASSETS`
|
|
107
|
+
- `FAIL_ON_REMOVE`
|
|
108
|
+
- `FAIL_ON_REPLACE`
|
|
109
|
+
- `SUMMARY_OUTPUT_PATH`
|
|
110
|
+
- `GITHUB_STEP_SUMMARY`
|
|
111
|
+
|
|
112
|
+
CLI arguments take precedence over environment variables.
|
|
113
|
+
|
|
114
|
+
## Example Output
|
|
115
|
+
|
|
116
|
+
```markdown
|
|
117
|
+
## CDK diff summary
|
|
118
|
+
|
|
119
|
+
| Metric | Count |
|
|
120
|
+
| --- | ---: |
|
|
121
|
+
| Stack changes | 1 |
|
|
122
|
+
| Resource changes | 3 |
|
|
123
|
+
| Adds | 1 |
|
|
124
|
+
| Modifies | 1 |
|
|
125
|
+
| Removes | 0 |
|
|
126
|
+
| Replacements | 1 |
|
|
127
|
+
| Security group changes | 1 |
|
|
128
|
+
| Changes shown below | 4 |
|
|
129
|
+
|
|
130
|
+
### Replacements
|
|
131
|
+
|
|
132
|
+
| Stack | Logical ID | Action | Resource type | Changed fields |
|
|
133
|
+
| --- | --- | --- | --- | --- |
|
|
134
|
+
| PaymentsStack | Worker | replace | AWS::Lambda::Function | `Architectures[]`, `Layers[]` |
|
|
135
|
+
|
|
136
|
+
### Security group changes
|
|
137
|
+
|
|
138
|
+
| Stack | Security group | Direction | Protocol | Port | Action |
|
|
139
|
+
| --- | --- | --- | --- | --- | --- |
|
|
140
|
+
| PaymentsStack | AppSecurityGroup | ingress | tcp | 443 | add |
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Local Development
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
python -m pip install -e ".[dev]"
|
|
147
|
+
python -m pytest
|
|
148
|
+
ruff check .
|
|
149
|
+
python -m build
|
|
150
|
+
twine check dist/*
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Run from source:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
cdk-diff-summary example_cdk_diff_json/cdk-diff-json-tiny.json
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Publishing
|
|
160
|
+
|
|
161
|
+
This package is ready for PyPI trusted publishing. Create a PyPI project named `cdk-diff-summary`, configure a trusted publisher for this repository and the `publish.yml` workflow, then create a GitHub release.
|
|
162
|
+
|
|
163
|
+
For a manual dry run:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
python -m build
|
|
167
|
+
twine check dist/*
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
CDK diff JSON shape can vary by CDK version. If parsing fails, please open an issue with a sanitized example of the JSON shape that failed.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
cdk_diff_summary/__init__.py,sha256=hLZXtj9mBFhZV-3WKIcp25WxzObT_qjPg-mlmApdy4k,78
|
|
2
|
+
cdk_diff_summary/cli.py,sha256=KLhoTskGdLaV6-SgXqXW8IdkPDeDF_DC5hM0FJMrQN8,5650
|
|
3
|
+
cdk_diff_summary/config.py,sha256=WbLw8qw5lTbqYsVsL-YpSnDwWAA78UDJNToRNx9XAuE,2261
|
|
4
|
+
cdk_diff_summary/diff.py,sha256=-BClnXze3c0uvFJ1V5CylsWA1Hb_F11dgtv6OAMVAh0,10254
|
|
5
|
+
cdk_diff_summary/policy.py,sha256=JQHgqAxTTS9N6E1XcCylrIZ6B4wivET7x6-kOwHKweE,2842
|
|
6
|
+
cdk_diff_summary/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
7
|
+
cdk_diff_summary/render.py,sha256=sMdzMecdif5mnA-Dku8hKizKvcLKqlDHogoYLHYZGaQ,3871
|
|
8
|
+
cdk_diff_summary-1.1.1.dist-info/licenses/LICENSE,sha256=rbiCJSp5zGpy3nb6XprOxrr6dMv4FNADz65obU3Y0EE,1086
|
|
9
|
+
cdk_diff_summary-1.1.1.dist-info/METADATA,sha256=_4CB2KyRp75OsjdPvhQ5CteJqlCqjFKMMSO9B3DwvKw,5176
|
|
10
|
+
cdk_diff_summary-1.1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
cdk_diff_summary-1.1.1.dist-info/entry_points.txt,sha256=tr-Ta0dOJTLZlEYRT717Op_RrzRvleJzWwRCxWqJEVA,63
|
|
12
|
+
cdk_diff_summary-1.1.1.dist-info/top_level.txt,sha256=dRfbg05BhDK7GtGdImSOpBonIzdoGvrnwICnqVcv9JI,17
|
|
13
|
+
cdk_diff_summary-1.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cdk-diff-summary contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cdk_diff_summary
|