Skip to content

feat(scan): add --diff flag for Terraform plan JSON cost diffs - #209

Draft
glenngillen wants to merge 1 commit into
mainfrom
scan-diff-flag
Draft

feat(scan): add --diff flag for Terraform plan JSON cost diffs#209
glenngillen wants to merge 1 commit into
mainfrom
scan-diff-flag

Conversation

@glenngillen

Copy link
Copy Markdown
Contributor

Adds infracost scan --diff for Terraform plan JSON targets: the plan file is scanned normally for current costs, then a synthetic plan whose planned_values is the original's prior_state is priced through the same pipeline (no policies, telemetry, or result caching), and the two outputs are diffed. Output is JSON-only for now (--diff requires --json) and emits totals plus a Diff map grouped by resource type, listing only resources whose monthly cost changed, with a one-level Subresources breakdown. Other input formats (HCL, CloudFormation) and human/LLM rendering are deliberately out of scope for this first pass.

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 aliscott left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread internal/format/diff.go
if p.curr != nil {
currCost = resourceMonthlyCost(p.curr)
}
if prevCost.Equals(currCost) {

@aliscott aliscott Aug 26, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread internal/format/diff.go
// 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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread internal/format/diff.go

"github.com/infracost/go-proto/pkg/rat"
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants