diff --git a/src/deploydiff.egg-info/PKG-INFO b/src/deploydiff.egg-info/PKG-INFO index b92bde0..034820d 100644 --- a/src/deploydiff.egg-info/PKG-INFO +++ b/src/deploydiff.egg-info/PKG-INFO @@ -33,6 +33,7 @@ Dynamic: license-file # DeployDiff CLI [![GitHub stars](https://img.shields.io/github/stars/Coding-Dev-Tools/deploydiff?style=social)](https://github.com/Coding-Dev-Tools/deploydiff/stargazers) +[![Awesome DevOps](https://img.shields.io/badge/Awesome_DevOps-Submitted-grey?logo=github)](https://github.com/wmariuss/awesome-devops) Preview infrastructure changes with human-readable diffs, cost impact estimation, and rollback commands — before you hit deploy. @@ -69,6 +70,12 @@ scoop bucket add Coding-Dev-Tools https://github.com/Coding-Dev-Tools/scoop-buck scoop install deploydiff ``` +**npm (Node.js wrapper):** +```bash +npm install -g deploydiff +``` +Then run: `deploydiff --help` + ## Usage ```bash @@ -154,13 +161,3 @@ DeployDiff is one of eight tools in the Revenue Holdings suite. One license cove ## License MIT - - - -## Install via npm - -```bash -npm install -g deploydiff -``` - -Then run: `deploydiff --help` diff --git a/src/deploydiff/__pycache__/__init__.cpython-312.pyc b/src/deploydiff/__pycache__/__init__.cpython-312.pyc index 36a7a09..4f7fcd7 100644 Binary files a/src/deploydiff/__pycache__/__init__.cpython-312.pyc and b/src/deploydiff/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/deploydiff/__pycache__/cli.cpython-312.pyc b/src/deploydiff/__pycache__/cli.cpython-312.pyc index 6be352b..801b0a5 100644 Binary files a/src/deploydiff/__pycache__/cli.cpython-312.pyc and b/src/deploydiff/__pycache__/cli.cpython-312.pyc differ diff --git a/src/deploydiff/__pycache__/cloudformation_parser.cpython-312.pyc b/src/deploydiff/__pycache__/cloudformation_parser.cpython-312.pyc index c92820e..9e4d3c4 100644 Binary files a/src/deploydiff/__pycache__/cloudformation_parser.cpython-312.pyc and b/src/deploydiff/__pycache__/cloudformation_parser.cpython-312.pyc differ diff --git a/src/deploydiff/__pycache__/cost_estimator.cpython-312.pyc b/src/deploydiff/__pycache__/cost_estimator.cpython-312.pyc index 4fa56de..badba66 100644 Binary files a/src/deploydiff/__pycache__/cost_estimator.cpython-312.pyc and b/src/deploydiff/__pycache__/cost_estimator.cpython-312.pyc differ diff --git a/src/deploydiff/__pycache__/diff_renderer.cpython-312.pyc b/src/deploydiff/__pycache__/diff_renderer.cpython-312.pyc index 144363c..9548f96 100644 Binary files a/src/deploydiff/__pycache__/diff_renderer.cpython-312.pyc and b/src/deploydiff/__pycache__/diff_renderer.cpython-312.pyc differ diff --git a/src/deploydiff/__pycache__/models.cpython-312.pyc b/src/deploydiff/__pycache__/models.cpython-312.pyc index f8cc812..3ce6c1b 100644 Binary files a/src/deploydiff/__pycache__/models.cpython-312.pyc and b/src/deploydiff/__pycache__/models.cpython-312.pyc differ diff --git a/src/deploydiff/__pycache__/pulumi_parser.cpython-312.pyc b/src/deploydiff/__pycache__/pulumi_parser.cpython-312.pyc index 306e48b..e495996 100644 Binary files a/src/deploydiff/__pycache__/pulumi_parser.cpython-312.pyc and b/src/deploydiff/__pycache__/pulumi_parser.cpython-312.pyc differ diff --git a/src/deploydiff/__pycache__/rollback.cpython-312.pyc b/src/deploydiff/__pycache__/rollback.cpython-312.pyc index c0ac662..b1bc0b5 100644 Binary files a/src/deploydiff/__pycache__/rollback.cpython-312.pyc and b/src/deploydiff/__pycache__/rollback.cpython-312.pyc differ diff --git a/src/deploydiff/__pycache__/terraform_parser.cpython-312.pyc b/src/deploydiff/__pycache__/terraform_parser.cpython-312.pyc index d78444c..621d8f3 100644 Binary files a/src/deploydiff/__pycache__/terraform_parser.cpython-312.pyc and b/src/deploydiff/__pycache__/terraform_parser.cpython-312.pyc differ diff --git a/src/deploydiff/cli.py b/src/deploydiff/cli.py index 46d8771..143259d 100644 --- a/src/deploydiff/cli.py +++ b/src/deploydiff/cli.py @@ -36,7 +36,12 @@ def main(): @click.option("--cfn", "cloudformation_file", type=click.Path(exists=True), help="CloudFormation change set JSON file") @click.option("--pulumi", "pulumi_file", type=click.Path(exists=True), help="Pulumi preview JSON file") @click.option("-v", "--verbose", is_flag=True, help="Show before/after details for each change") -def preview(terraform_file, cloudformation_file, pulumi_file, verbose): +@click.option( + "--exit-on-destroy", + is_flag=True, + help="Exit with code 1 if the plan contains destructive changes (deletes or replaces)", +) +def preview(terraform_file, cloudformation_file, pulumi_file, verbose, exit_on_destroy): """Preview infrastructure changes from a plan file.""" plan = _load_plan(terraform_file, cloudformation_file, pulumi_file) if plan is None: @@ -45,13 +50,26 @@ def preview(terraform_file, cloudformation_file, pulumi_file, verbose): render_plan(plan, console, verbose=verbose) + if exit_on_destroy and plan.destructive_changes: + console.print( + f"\n[red]Plan contains {len(plan.destructive_changes)} destructive change(s). " + f"Exiting with code 1 (--exit-on-destroy).[/red]" + ) + raise SystemExit(1) + @main.command() @click.option("--tf", "terraform_file", type=click.Path(exists=True), help="Terraform plan JSON file") @click.option("--cfn", "cloudformation_file", type=click.Path(exists=True), help="CloudFormation change set JSON file") @click.option("--pulumi", "pulumi_file", type=click.Path(exists=True), help="Pulumi preview JSON file") @click.option("--pricing", "pricing_file", type=click.Path(exists=True), help="Custom pricing JSON file") -def cost(terraform_file, cloudformation_file, pulumi_file, pricing_file): +@click.option( + "--threshold", + type=float, + default=None, + help="Exit with code 1 if total monthly cost delta exceeds this value (e.g. 500 for $500)", +) +def cost(terraform_file, cloudformation_file, pulumi_file, pricing_file, threshold): """Estimate monthly cost impact of infrastructure changes.""" plan = _load_plan(terraform_file, cloudformation_file, pulumi_file) if plan is None: @@ -61,6 +79,14 @@ def cost(terraform_file, cloudformation_file, pulumi_file, pricing_file): estimates = estimate_costs(plan, pricing_file=pricing_file) _render_costs(estimates, plan, console) + if threshold is not None and plan.total_monthly_delta > threshold: + console.print( + f"\n[red]Total monthly cost increase of ${plan.total_monthly_delta:.2f} " + f"exceeds threshold of ${threshold:.2f}. " + f"Exiting with code 1 (--threshold).[/red]" + ) + raise SystemExit(1) + @main.command() @click.option("--tf", "terraform_file", type=click.Path(exists=True), help="Terraform plan JSON file") diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc index fbeb799..aa9d6d7 100644 Binary files a/tests/__pycache__/__init__.cpython-312.pyc and b/tests/__pycache__/__init__.cpython-312.pyc differ diff --git a/tests/__pycache__/test_deploydiff.cpython-312-pytest-9.0.3.pyc b/tests/__pycache__/test_deploydiff.cpython-312-pytest-9.0.3.pyc index 2d4339e..fb025d0 100644 Binary files a/tests/__pycache__/test_deploydiff.cpython-312-pytest-9.0.3.pyc and b/tests/__pycache__/test_deploydiff.cpython-312-pytest-9.0.3.pyc differ diff --git a/tests/test_deploydiff.py b/tests/test_deploydiff.py index 404ea72..6ff30e4 100644 --- a/tests/test_deploydiff.py +++ b/tests/test_deploydiff.py @@ -481,3 +481,68 @@ def test_rollback_pulumi(self, sample_pulumi_preview, tmp_path): runner = CliRunner() result = runner.invoke(main, ["rollback", "--pulumi", str(pulumi_file)]) assert result.exit_code == 0 + + def test_preview_exit_on_destroy_no_destroy(self, tmp_path): + """--exit-on-destroy exits 0 when plan has no destructive changes.""" + # Plan with only creates and updates — no deletes/replaces + safe_plan = { + "format_version": "1.2", + "resource_changes": [ + { + "address": "aws_instance.web", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["create"], + "before": None, + "after": {"instance_type": "t3.micro"}, + }, + }, + { + "address": "aws_db_instance.primary", + "type": "aws_db_instance", + "name": "primary", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["update"], + "before": {"instance_class": "db.t3.small"}, + "after": {"instance_class": "db.t3.medium"}, + }, + }, + ], + } + tf_file = tmp_path / "safe_plan.json" + tf_file.write_text(json.dumps(safe_plan)) + runner = CliRunner() + result = runner.invoke(main, ["preview", "--tf", str(tf_file), "--exit-on-destroy"]) + assert result.exit_code == 0 + + def test_preview_exit_on_destroy_with_destroy(self, sample_terraform_plan, tmp_path): + """--exit-on-destroy exits 1 when plan has destructive changes (deletes/replaces).""" + tf_file = tmp_path / "plan.json" + tf_file.write_text(json.dumps(sample_terraform_plan)) + runner = CliRunner() + # terraform fixture has a delete + replace (destructive) + result = runner.invoke(main, ["preview", "--tf", str(tf_file), "--exit-on-destroy"]) + assert result.exit_code == 1 + assert "destructive" in result.output.lower() + + def test_cost_threshold_under(self, sample_terraform_plan, tmp_path): + """--threshold exits 0 when delta is under the threshold.""" + tf_file = tmp_path / "plan.json" + tf_file.write_text(json.dumps(sample_terraform_plan)) + runner = CliRunner() + # Total delta for fixture is $6.50, so $1000 threshold should pass + result = runner.invoke(main, ["cost", "--tf", str(tf_file), "--threshold", "1000"]) + assert result.exit_code == 0 + + def test_cost_threshold_exceeded(self, sample_terraform_plan, tmp_path): + """--threshold exits 1 when delta exceeds the threshold.""" + tf_file = tmp_path / "plan.json" + tf_file.write_text(json.dumps(sample_terraform_plan)) + runner = CliRunner() + # Total delta for fixture is $6.50, so $1 threshold should trigger + result = runner.invoke(main, ["cost", "--tf", str(tf_file), "--threshold", "1"]) + assert result.exit_code == 1 + assert "threshold" in result.output.lower()