Skip to content

Commit 529a42d

Browse files
author
DevForge Engineer
committed
Merge remote-tracking branch 'origin/main' into tmp-improve
2 parents 47ce438 + 3e1c42e commit 529a42d

16 files changed

Lines changed: 354 additions & 103 deletions

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* @Coding-Dev-Tools

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ Thumbs.db
7171
research/
7272
fixtures/generated/
7373
.ruff_cache/
74+
.secrets.baseline
7475

7576
# Merge artifacts and cache (added by workspace stabilization)
7677
*.pyc

AGENTS.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# deploydiff
2+
3+
## Purpose
4+
Compare deployment configurations across environments. Detect drift between staging and production configs. Preview infrastructure changes with human-readable diffs, cost impact estimation, and rollback commands.
5+
6+
## Build & Test Commands
7+
- Install: `pip install -e .` or `pip install deploydiff`
8+
- Test: `pytest tests/` (or `python -m pytest tests/ -v --tb=short`)
9+
- Lint: `ruff check .`
10+
- Build: `pip install build twine && python -m build && twine check dist/*`
11+
- CLI check: `deploydiff --help`
12+
13+
## Architecture
14+
Key directories:
15+
- `src/deploydiff/` — Main package (CLI, diff engine, cost estimator, rollback generator)
16+
- `tests/` — Test suite
17+
- `.github/workflows/` — CI/CD (auto-code-review.yml, ci.yml, pages.yml, publish.yml)
18+
- `dist/` — Built distributions
19+
- `scripts/` — Automation scripts
20+
21+
## Conventions
22+
- Language: Python 3.10+
23+
- Test framework: pytest
24+
- CI: GitHub Actions (auto-code-review.yml, ci.yml, pages.yml, publish.yml)
25+
- Linting: ruff
26+
- Build system: setuptools
27+
- Package layout: src/ layout
28+
- Dependencies: click, rich, pyyaml, tomli, jinja2
29+
- CLI entry point: deploydiff.cli:cli
30+
- Default branch: main
31+
- Versioning: Semantic versioning (semver)
32+
- Documentation: Markdown
33+
34+
## Contributing
35+
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed contribution guidelines and development workflow.

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ description = "Preview infrastructure changes (Terraform, CloudFormation, Pulumi
99
readme = "README.md"
1010
requires-python = ">=3.10"
1111
license = "MIT"
12-
authors = [{name = "Revenue Holdings"}]
12+
authors = [{name = "DevForge"}]
1313

1414
dependencies = [
1515
"click>=8.1",
1616
"rich>=13.0",
1717
"pyyaml>=6.0",
18+
"tomli>=2.0",
19+
"jinja2>=3.1",
1820
]
1921
keywords = ["infrastructure", "terraform", "cloudformation", "pulumi", "cost", "diff", "cli"]
2022
classifiers = [
@@ -25,6 +27,7 @@ classifiers = [
2527
"Programming Language :: Python :: 3.10",
2628
"Programming Language :: Python :: 3.11",
2729
"Programming Language :: Python :: 3.12",
30+
"Programming Language :: Python :: 3.13",
2831
]
2932

3033
[project.optional-dependencies]

src/deploydiff/__main__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Allow running as python -m deploydiff."""
2+
23
from deploydiff.cli import main
34

45
main()

src/deploydiff/cli.py

Lines changed: 104 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
try:
1717
from revenueholdings_license import require_license
18+
1819
_HAS_RH_LICENSE = True
1920
except ImportError:
2021
_HAS_RH_LICENSE = False
@@ -25,26 +26,64 @@
2526
@click.group()
2627
@click.version_option(package_name="deploydiff")
2728
@click.option("--no-gate", is_flag=True, help="Skip license gating check.")
29+
@click.option(
30+
"--require-license",
31+
"require_license_flag",
32+
is_flag=True,
33+
envvar="REVENUEHOLDINGS_REQUIRE_LICENSE",
34+
help=(
35+
"Exit with an error if revenueholdings-license is not installed "
36+
"or if the license check fails. "
37+
"Also enabled via REVENUEHOLDINGS_REQUIRE_LICENSE=1."
38+
),
39+
)
2840
@click.pass_context
29-
def main(ctx, no_gate) -> None:
41+
def main(ctx, no_gate, require_license_flag) -> None:
3042
"""DeployDiff - Preview infrastructure changes with cost impact and rollback."""
3143
ctx.ensure_object(dict)
3244
ctx.obj["no_gate"] = no_gate
33-
if _HAS_RH_LICENSE and not no_gate:
34-
require_license("deploydiff")
45+
ctx.obj["require_license_flag"] = require_license_flag
46+
if not no_gate:
47+
if _HAS_RH_LICENSE:
48+
require_license("deploydiff")
49+
elif require_license_flag:
50+
console.print(
51+
"[bold red]Error:[/bold red] revenueholdings-license is not installed. "
52+
"Install it with: pip install revenueholdings-license"
53+
)
54+
raise SystemExit(1)
3555

3656

3757
@main.command()
38-
@click.option("--tf", "terraform_file", type=click.Path(exists=True), help="Terraform plan JSON file")
39-
@click.option("--cfn", "cloudformation_file", type=click.Path(exists=True), help="CloudFormation change set JSON file")
40-
@click.option("--pulumi", "pulumi_file", type=click.Path(exists=True), help="Pulumi preview JSON file")
41-
@click.option("-v", "--verbose", is_flag=True, help="Show before/after details for each change")
58+
@click.option(
59+
"--tf",
60+
"terraform_file",
61+
type=click.Path(exists=True),
62+
help="Terraform plan JSON file",
63+
)
64+
@click.option(
65+
"--cfn",
66+
"cloudformation_file",
67+
type=click.Path(exists=True),
68+
help="CloudFormation change set JSON file",
69+
)
70+
@click.option(
71+
"--pulumi",
72+
"pulumi_file",
73+
type=click.Path(exists=True),
74+
help="Pulumi preview JSON file",
75+
)
76+
@click.option(
77+
"-v", "--verbose", is_flag=True, help="Show before/after details for each change"
78+
)
4279
@click.option(
4380
"--exit-on-destroy",
4481
is_flag=True,
4582
help="Exit with code 1 if the plan contains destructive changes (deletes or replaces)",
4683
)
47-
def preview(terraform_file, cloudformation_file, pulumi_file, verbose, exit_on_destroy) -> None:
84+
def preview(
85+
terraform_file, cloudformation_file, pulumi_file, verbose, exit_on_destroy
86+
) -> None:
4887
"""Preview infrastructure changes from a plan file."""
4988
plan = _load_plan(terraform_file, cloudformation_file, pulumi_file)
5089
if plan is None:
@@ -62,20 +101,43 @@ def preview(terraform_file, cloudformation_file, pulumi_file, verbose, exit_on_d
62101

63102

64103
@main.command()
65-
@click.option("--tf", "terraform_file", type=click.Path(exists=True), help="Terraform plan JSON file")
66-
@click.option("--cfn", "cloudformation_file", type=click.Path(exists=True), help="CloudFormation change set JSON file")
67-
@click.option("--pulumi", "pulumi_file", type=click.Path(exists=True), help="Pulumi preview JSON file")
68-
@click.option("--pricing", "pricing_file", type=click.Path(exists=True), help="Custom pricing JSON file")
104+
@click.option(
105+
"--tf",
106+
"terraform_file",
107+
type=click.Path(exists=True),
108+
help="Terraform plan JSON file",
109+
)
110+
@click.option(
111+
"--cfn",
112+
"cloudformation_file",
113+
type=click.Path(exists=True),
114+
help="CloudFormation change set JSON file",
115+
)
116+
@click.option(
117+
"--pulumi",
118+
"pulumi_file",
119+
type=click.Path(exists=True),
120+
help="Pulumi preview JSON file",
121+
)
122+
@click.option(
123+
"--pricing",
124+
"pricing_file",
125+
type=click.Path(exists=True),
126+
help="Custom pricing JSON file",
127+
)
69128
@click.option(
70129
"--threshold",
71130
type=float,
72131
default=None,
73132
help="Exit with code 1 if total monthly cost delta exceeds this value (e.g. 500 for $500)",
74133
)
75-
def cost(terraform_file, cloudformation_file, pulumi_file, pricing_file, threshold) -> None:
134+
def cost(
135+
terraform_file, cloudformation_file, pulumi_file, pricing_file, threshold
136+
) -> None:
76137
"""Estimate monthly cost impact of infrastructure changes. (Pro feature)"""
77138
if _HAS_RH_LICENSE:
78139
from revenueholdings_license import require_tier
140+
79141
require_tier("pro", "deploydiff cost")
80142
plan = _load_plan(terraform_file, cloudformation_file, pulumi_file)
81143
if plan is None:
@@ -95,13 +157,29 @@ def cost(terraform_file, cloudformation_file, pulumi_file, pricing_file, thresho
95157

96158

97159
@main.command()
98-
@click.option("--tf", "terraform_file", type=click.Path(exists=True), help="Terraform plan JSON file")
99-
@click.option("--cfn", "cloudformation_file", type=click.Path(exists=True), help="CloudFormation change set JSON file")
100-
@click.option("--pulumi", "pulumi_file", type=click.Path(exists=True), help="Pulumi preview JSON file")
160+
@click.option(
161+
"--tf",
162+
"terraform_file",
163+
type=click.Path(exists=True),
164+
help="Terraform plan JSON file",
165+
)
166+
@click.option(
167+
"--cfn",
168+
"cloudformation_file",
169+
type=click.Path(exists=True),
170+
help="CloudFormation change set JSON file",
171+
)
172+
@click.option(
173+
"--pulumi",
174+
"pulumi_file",
175+
type=click.Path(exists=True),
176+
help="Pulumi preview JSON file",
177+
)
101178
def rollback(terraform_file, cloudformation_file, pulumi_file) -> None:
102179
"""Generate rollback commands for infrastructure changes. (Pro feature)"""
103180
if _HAS_RH_LICENSE:
104181
from revenueholdings_license import require_tier
182+
105183
require_tier("pro", "deploydiff rollback")
106184
plan = _load_plan(terraform_file, cloudformation_file, pulumi_file)
107185
if plan is None:
@@ -113,7 +191,6 @@ def rollback(terraform_file, cloudformation_file, pulumi_file) -> None:
113191
console.print(cmd)
114192

115193

116-
117194
def _load_plan(
118195
terraform_file: str | None,
119196
cloudformation_file: str | None,
@@ -126,7 +203,9 @@ def _load_plan(
126203
if len(provided) == 0:
127204
return None
128205
if len(provided) > 1:
129-
console.print("[red]Error: Provide only one source file (--tf, --cfn, or --pulumi)[/red]")
206+
console.print(
207+
"[red]Error: Provide only one source file (--tf, --cfn, or --pulumi)[/red]"
208+
)
130209
raise SystemExit(1)
131210

132211
if terraform_file:
@@ -139,7 +218,9 @@ def _load_plan(
139218
return None
140219

141220

142-
def _render_costs(estimates: list[CostEstimate], plan: DeployPlan, console: Console) -> None:
221+
def _render_costs(
222+
estimates: list[CostEstimate], plan: DeployPlan, console: Console
223+
) -> None:
143224
"""Render cost estimates to the console."""
144225
from rich import box
145226
from rich.table import Table
@@ -172,7 +253,9 @@ def _render_costs(estimates: list[CostEstimate], plan: DeployPlan, console: Cons
172253
if total > 0:
173254
console.print(f"\n[bold red]Total monthly increase: +${total:.2f}[/bold red]")
174255
elif total < 0:
175-
console.print(f"\n[bold green]Total monthly decrease: -${abs(total):.2f}[/bold green]")
256+
console.print(
257+
f"\n[bold green]Total monthly decrease: -${abs(total):.2f}[/bold green]"
258+
)
176259
else:
177260
console.print("\n[bold]Total monthly change: $0.00[/bold]")
178261

@@ -188,8 +271,7 @@ def mcp() -> None:
188271
from .mcp_server import run_for_app
189272
except ImportError as exc:
190273
console.print(
191-
"[red]Error: click-to-mcp is not installed.[/red]\n"
192-
"Install it with: [bold]pip install click-to-mcp[/bold]"
274+
"[red]Error: click-to-mcp is not installed.[/red]\nInstall it with: [bold]pip install click-to-mcp[/bold]"
193275
)
194276
raise SystemExit(1) from exc
195277

src/deploydiff/cloudformation_parser.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,12 @@ def parse_cloudformation_changeset(changeset_json: str | dict[str, Any]) -> Depl
5050
changes_list = data.get("Changes", data.get("changes", []))
5151

5252
for change_entry in changes_list:
53-
resource_change_data = change_entry.get("ResourceChange", change_entry.get("resource_change", {}))
54-
action_str = change_entry.get("Action", resource_change_data.get("Action", "Modify"))
53+
resource_change_data = change_entry.get(
54+
"ResourceChange", change_entry.get("resource_change", {})
55+
)
56+
action_str = change_entry.get(
57+
"Action", resource_change_data.get("Action", "Modify")
58+
)
5559

5660
action = CFN_ACTION_MAP.get(action_str, ChangeAction.UPDATE)
5761

@@ -60,11 +64,16 @@ def parse_cloudformation_changeset(changeset_json: str | dict[str, Any]) -> Depl
6064
if CFN_REPLACEMENT_MAP.get(str(replacement), False):
6165
action = ChangeAction.REPLACE
6266

63-
resource_type = resource_change_data.get("Type", resource_change_data.get("ResourceType", "unknown"))
67+
resource_type = resource_change_data.get(
68+
"Type", resource_change_data.get("ResourceType", "unknown")
69+
)
6470
resource_name = resource_change_data.get(
65-
"LogicalResourceId", resource_change_data.get("PhysicalResourceId", "unknown")
66-
)
67-
address = resource_change_data.get("LogicalResourceId", f"{resource_type}.{resource_name}")
71+
"LogicalResourceId",
72+
resource_change_data.get("PhysicalResourceId", "unknown"),
73+
)
74+
address = resource_change_data.get(
75+
"LogicalResourceId", f"{resource_type}.{resource_name}"
76+
)
6877

6978
# Scope details for update changes
7079
resource_change_data.get("Scope", [])

src/deploydiff/cost_estimator.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,9 @@
153153
}
154154

155155

156-
def estimate_costs(plan: DeployPlan, pricing_file: str | Path | None = None) -> list[CostEstimate]:
156+
def estimate_costs(
157+
plan: DeployPlan, pricing_file: str | Path | None = None
158+
) -> list[CostEstimate]:
157159
"""Estimate monthly cost impact for each resource change in a plan.
158160
159161
Args:
@@ -197,7 +199,10 @@ def _estimate_resource_cost(
197199
# If deleting, after cost is 0; if creating, before cost is 0
198200
if before and change.action == ChangeAction.CREATE:
199201
return 0.0
200-
if not before and change.action in (ChangeAction.DELETE, ChangeAction.DELETE_BEFORE_CREATE):
202+
if not before and change.action in (
203+
ChangeAction.DELETE,
204+
ChangeAction.DELETE_BEFORE_CREATE,
205+
):
201206
return 0.0
202207

203208
resource_type = change.resource_type
@@ -206,7 +211,14 @@ def _estimate_resource_cost(
206211
# Try to find an instance type / size key in the resource config
207212
data = change.before if before else change.after
208213
if data and isinstance(data, dict):
209-
for field in ("instance_type", "InstanceType", "node_type", "NodeType", "volume_type", "engine"):
214+
for field in (
215+
"instance_type",
216+
"InstanceType",
217+
"node_type",
218+
"NodeType",
219+
"volume_type",
220+
"engine",
221+
):
210222
val = data.get(field, "")
211223
if val and str(val) in type_pricing:
212224
return type_pricing[str(val)]
@@ -224,7 +236,9 @@ def _build_cost_description(change: ResourceChange, before: float, after: float)
224236
return "no change"
225237

226238

227-
def _load_pricing(pricing_file: str | Path | None = None) -> dict[str, dict[str, float]]:
239+
def _load_pricing(
240+
pricing_file: str | Path | None = None,
241+
) -> dict[str, dict[str, float]]:
228242
"""Load pricing data from a custom file, falling back to defaults."""
229243
if pricing_file is None:
230244
return DEFAULT_PRICING.copy()
@@ -233,7 +247,7 @@ def _load_pricing(pricing_file: str | Path | None = None) -> dict[str, dict[str,
233247
if not path.exists():
234248
return DEFAULT_PRICING.copy()
235249

236-
with open(path) as f:
250+
with path.open() as f:
237251
custom = json.load(f)
238252

239253
# Merge with defaults (custom overrides)

0 commit comments

Comments
 (0)