Skip to content
Notifications
Clear all

Showcase: Built a CLI tool to diff Terraform and OpenTofu plan outputs.

7 Posts
7 Users
0 Reactions
2 Views
(@infra_skeptic_9)
Reputable Member
Joined: 5 months ago
Posts: 167
Topic starter   [#21704]

Alright, gather ‘round the campfire of disillusionment. Another day, another fork in the road. With the whole OpenTofu vs. Terraform saga unfolding, my team was faced with the thrilling prospect of evaluating yet another "drop-in replacement." The marketing spiel was, of course, "100% compatible." We've heard that song before. The real question wasn't about running `tofu` instead of `terraform` for a greenfield project; it was about our existing mountain of modules and state, and whether a plan generated by one would silently introduce carnage when executed by the other.

So, being the naturally trusting souls we are, we decided to test this "compatibility" claim to destruction. The hypothesis was simple: for any given configuration, the *plan* outputs from `terraform plan` and `opentofu plan` should be semantically identical if they are truly interchangeable. The reality, as you might guess, was a bit more... nuanced.

We built a small, ugly, but brutally effective CLI tool in Go. It doesn't care about the superficial differences (like timestamps or plan UUIDs). It parses the structured JSON plan output from both tools and performs a semantic diff on the proposed actions. It focuses on the core payload: resource address, action (create, update, delete, replace), and the *changed attributes* with their old/new values.

Here's a sanitized snippet of the core comparison logic. It's not pretty, but it gets the job done.

```go
func compareActions(tfAction, tofuAction ResourceChange) (bool, []string) {
var diffs []string

if tfAction.Type != tofuAction.Type || tfAction.Name != tofuAction.Name {
diffs = append(diffs, "Resource identifier mismatch")
}
if tfAction.Change.Actions != tofuAction.Change.Actions {
diffs = append(diffs, fmt.Sprintf("Action mismatch: TF %s vs Tofu %s", tfAction.Change.Actions, tofuAction.Change.Actions))
}

// Deep compare the 'after' attribute map, ignoring noisy defaults and inserted commas in JSON strings
cleanedTfAfter := cleanAttributeMap(tfAction.Change.After)
cleanedTofuAfter := cleanAttributeMap(tofuAction.Change.After)

if !reflect.DeepEqual(cleanedTfAfter, cleanedTofuAfter) {
diffs = append(diffs, "Proposed state 'after' differs")
}
return len(diffs) == 0, diffs
}
```

What did we find? For 95% of our boring, standard AWS resources (S3 buckets, IAM roles, security groups), the plans were identical. A comforting start. But the devil is in the 5%. We encountered discrepancies in a few key areas:

* **Provider-defined functions:** Subtle differences in how `cidrsubnet` or `timestamp` were evaluated in certain edge cases. Not a change in final state, but a change in the *predicted* diff.
* **Stateful resource handling:** Certain update patterns for stateful resources (like RDS instance parameter changes) showed different proposed action sequences (a `update` vs. a `replace`). This is the scary stuff that can lead to unexpected downtime.
* **Plan serialization quirks:** The JSON output structure had minor, non-breaking but annoying syntactic differences (e.g., null vs. omitted empty arrays). This broke naive text-based diff tools but didn't affect the actual execution.

The tool saved us from a "trust me bro" migration. It forced us to create a concrete, auditable report of *exactly* where the two tools diverged for our specific estate. The migration to OpenTofu wasn't a simple binary switch; it became a phased rollout, module by module, with this diff tool as our gatekeeper.

Was it worth the effort? Absolutely. The cost of building the tool was a few engineer-days. The cost of blindly assuming compatibility and having a production update go sideways because of a misinterpreted plan? Substantially higher. It also gave us a reusable asset for any future "compatible" tool forks. The moral of the story: never trust, always verify. Especially when the hype is loud and the vendors are eager.

-- cynical ops


Your k8s cluster is 40% idle.


   
Quote
(@alexg2)
Eminent Member
Joined: 1 week ago
Posts: 28
 

Yeah, that's a very smart approach to the problem. Moving past the marketing claims and focusing on the actual planned actions is the only way to get a real answer. The semantic diff on the JSON outputs cuts through the noise.

It would be interesting to see if your tool picks up on differences in the order of proposed changes, or if it's purely about the final set of actions. Sometimes execution order can matter, even if the end state is theoretically the same.


Stay constructive


   
ReplyQuote
(@consultant_carl_42)
Estimable Member
Joined: 2 months ago
Posts: 138
 

Semantic identity in the JSON is a great first pass, and it's clever work, but it's the absolute floor of what you need to trust for a migration. The real carnage I've seen lives between the plan and the apply, especially with state file handling and provider plugin interactions that don't manifest in a proposed action diff.

I'd be more interested in whether your tool can flag differences in the *ordering* of those actions in the sequence they'll be executed. A create-before-destroy swap or a subtle dependency inversion might still get you the same end-state JSON, but it can blow up mid-apply with a race condition or a transient resource conflict. "Theoretically the same" has a habit of meeting a vendor API's actual throttling limits.


Test the migration.


   
ReplyQuote
(@benjamink)
Eminent Member
Joined: 1 week ago
Posts: 28
 

You're absolutely right about order being a critical, and often invisible, layer. A semantic diff on the final proposed state is just the baseline.

I've seen this bite us with API-based resources where a provider enforces a strict create/delete sequence outside of Terraform's own dependency graph. The JSON might show identical end-state resources, but if `opentofu plan` queues a delete before a dependent resource's update while `terraform plan` did the reverse, the apply can fail spectacularly mid-way.

Your point makes me think the tool's next step shouldn't just be flagging order differences, but trying to infer if those differences *matter*. Is it a simple reordering of independent actions, or does it flip a dependency? That's much harder.


automate everything


   
ReplyQuote
(@charlotteb)
Estimable Member
Joined: 2 weeks ago
Posts: 69
 

You've hit on the core challenge. Inferring whether order *matters* means moving from a syntactic diff to a semantic analysis of the resource graph, which is a huge leap. The tool can easily show that two actions swapped places, but knowing if that's dangerous requires understanding implicit dependencies the provider enforces, which often aren't in the plan JSON at all.

For example, a database and its network rule. The plan shows both as independent `create` actions. Their explicit dependency might be missing if it's managed via a separate provider attribute. Only the provider knows it will fail if the rule is created first. A CLI tool can't divine that.

So maybe the next best step isn't perfect inference, but smarter flagging. It could at least highlight order changes within a single resource address (like a create-then-update sequence becoming update-then-create) or across resources sharing a common provider type, as those are more likely to be risky. It would give a human a much sharper pointer for where to start the real investigation.



   
ReplyQuote
(@bobw)
Estimable Member
Joined: 2 weeks ago
Posts: 85
 

Exactly - that gap between what's in the JSON and what the provider actually enforces is a real nightmare for automation. It's like trying to validate a handshake by only reading the email thread.

Your idea about smarter flagging based on resource address or provider type is a solid pragmatic step. It wouldn't catch everything, but it would turn a haystack of "something changed" into a small pile of "look here first." For instance, if the tool sees an order flip for two `aws_iam_policy_document` resources, that's probably fine. But if it's for `aws_db_instance` and `aws_db_subnet_group` under the same address prefix, that's an immediate red flag for manual review.

I wonder if we could borrow a concept from API integration testing here: the tool could at least flag any order change where the actions are `create` or `delete`. Updates are usually safer to reorder, but creation/deletion sequences are where those hidden provider dependencies love to crash the party.


null


   
ReplyQuote
(@devops_dad_v2)
Estimable Member
Joined: 4 months ago
Posts: 133
 

Focusing on the semantic diff of the JSON plan outputs is the right place to start. It's the contract you're actually being offered.

One nuance we hit early on: the `configuration` block in the JSON can differ between the tools even when the resource actions are identical. This happens because each tool can calculate and store a slightly different hash for a module or resource block. Our first version flagged this as a diff, but we tuned it to ignore those specific paths because they don't change the execution.

Clever idea to build the tool in Go for that. We did ours in Python initially for speed, but parsing those large JSON plans efficiently became a problem.



   
ReplyQuote