feat(scan): add --diff flag for Terraform plan JSON cost diffs - #209
feat(scan): add --diff flag for Terraform plan JSON cost diffs#209glenngillen wants to merge 1 commit into
Conversation
Adds `infracost scan --diff` for Terraform plan JSON targets (the plan carries both prior and planned state, so no other input types are needed yet). The plan file is scanned through the normal pipeline for current costs, then a synthetic plan whose planned_values is the original's prior_state is priced through the same scanner (zero RunParameters: no policies, telemetry or result caching), and the two outputs are diffed. The diff is JSON-only for now (--diff requires --json) and emits the agreed shape: totals plus a Diff map grouped by resource type, listing only resources whose monthly cost changed, with a one-level Subresources breakdown by cost component and child resource. Monetary values are fixed two-decimal strings; percentage changes are numbers, null when the previous cost was zero.
aliscott
left a comment
There was a problem hiding this comment.
A note on what we will probably need for non-self-hosted deploys:
Right now, the prior scan uses empty RunParameters. This works because Scan also keeps them at zero values in self-hosted mode. When Cloud is enabled, Scan gets real values. Then the prior and current scans no longer use the same settings.
RunParameters includes UsageDefaults. These provide usage for each project. It also includes ConfigTemplate and ProductionFilters. Because of this, an unchanged S3 bucket or NAT gateway could use real usage in the current scan but zero usage in the prior scan. It would then appear as a cost increase even though nothing changed.
To support Cloud, the prior scan must use the same RunParameters and repo config as the current scan. It should skip only policies, guardrails, and budgets, instead of skipping everything. Until that is done, should validateDiffFlags reject --diff when cfg.SelfHostedPricing() is not true? That would show a clear error instead of quietly returning wrong numbers.
| } | ||
| defer func() { _ = os.RemoveAll(tempDir) }() | ||
|
|
||
| priorPath := filepath.Join(tempDir, "prior-plan.json") |
There was a problem hiding this comment.
The prior scan runs in a temp directory. Because of this, it never sees the target’s infracost.yml.
LoadOrGenerateRepositoryConfig loads the repo config from the scan target’s directory. This means the prior and current scans use different settings.
The biggest issue is repoConfig.UsageFilePath. Its path is resolved from the temp directory. As a result, only the current scan gets infracost-usage.yml.
For self-hosted deployments, this file is the only way to set usage. So resources with usage-based costs would show an increase that is not real.
Currency has the same problem. result.Config.Currency defaults to "USD". If a repo uses EUR, BuildScanDiff would subtract a USD value from a EUR value, then label the result as EUR.
| } | ||
| } | ||
|
|
||
| priorPlan := map[string]json.RawMessage{ |
There was a problem hiding this comment.
priorPlanJSON does not copy resource_changes. I think this breaks -target plans.
The legacy CLI has stripNonTargetResources for this exact problem. Its comment explains why.
With -target, every resource still appears in prior_state. But only the targeted resources appear in planned_values. Without this filter, the prior side contains the whole infrastructure. The current side contains only the target. So --diff reports every other resource as deleted.
Should resource_changes be copied too?
| if p.curr != nil { | ||
| currCost = resourceMonthlyCost(p.curr) | ||
| } | ||
| if prevCost.Equals(currCost) { |
There was a problem hiding this comment.
A resource drops out of the diff entirely if one component goes up $10 and another goes down $10, since only the total is compared. The same happens if a price moves but rounds to the same monthly figure.
v1's diffCostComponents compared quantity, price and discount too. Should we match the old CLI functionality?
| // each cost component by name, and each child resource by name with its | ||
| // recursive total. Entries whose cost did not change are dropped. Either side | ||
| // may be nil (added/removed resource). | ||
| func diffSubcosts(prev, curr *ResourceOutput) map[string]*CostDiff { |
There was a problem hiding this comment.
diffSubcosts only matches cost components when their names are exactly the same. The old CLI also tries matching the text before the bracket. See findMatchingCostComponent.
Because of this, Instance usage (..., t2.small) matches Instance usage (..., t2.medium). The result is one component with a changed price.
Here, resizing an instance creates two entries instead. One drops to 0.00. The other rises from 0.00. TestBuildScanDiff_ComponentRename makes this the expected behavior.
Resizing is one of the most common types of diff. Should this work like the old CLI?
|
|
||
| "github.com/infracost/go-proto/pkg/rat" | ||
| ) | ||
|
|
There was a problem hiding this comment.
This should use snake_case to match Output in this repo, so scan --json and scan --diff --json do not emit two different casings.
The comment above says the casing follows a published contract. I could not find one in any casing. Is there a specific consumer in mind here?
Adds
infracost scan --difffor Terraform plan JSON targets: the plan file is scanned normally for current costs, then a synthetic plan whoseplanned_valuesis the original'sprior_stateis priced through the same pipeline (no policies, telemetry, or result caching), and the two outputs are diffed. Output is JSON-only for now (--diffrequires--json) and emits totals plus aDiffmap grouped by resource type, listing only resources whose monthly cost changed, with a one-levelSubresourcesbreakdown. Other input formats (HCL, CloudFormation) and human/LLM rendering are deliberately out of scope for this first pass.