A first-class `tofu drift` command for detecting infrastructure changed outside of OpenTofu
Community note
[!TIP] Hi there, OpenTofu community! The OpenTofu team prioritizes issues based on upvotes. Please make sure to upvote this issue and describe how it affects you in detail in the comments to show your support.
OpenTofu Version
OpenTofu v1.14.0-dev
on darwin_arm64The problem in your OpenTofu project
I run OpenTofu drift checks on a schedule and in CI, across a lot of workspaces. I want them to fire only when something has actually changed outside of OpenTofu, and to never be able to change anything themselves.
Detection isn't the missing piece, though it's fiddly. tofu plan -refresh-only -detailed-exitcode already returns exit 2 only when there's drift (an unapplied config change doesn't trigger it), and -json emits resource_drift, but you have to know that exact flag combination and parse the JSON to see what actually changed. Two bigger things are the problem.
First, I can't tell a check to ignore drift I already know about, just for that run. There's always some noise, like a tag a scanner rewrites every hour or an attribute the provider keeps normalizing. My only knob today is ignore_changes, and it's the wrong tool for an alarm (more on that below). So I either get paged for noise or mute the whole resource and miss real drift on it.
Second, the check isn't guaranteed read-only. I'm running plan, which takes -out and is one step from something appliable. I'd be a lot more comfortable scheduling that across dozens of workspaces, or handing it to compliance, if it simply couldn't change anything.
Attempted Solutions
For finding drift the refresh-only route is fine.
tofu plan -refresh-only -detailed-exitcode # 2 on drift, 0 otherwise
tofu plan -refresh-only -json | jq '[.. | select(.type? == "resource_drift")]'It's the suppression part that breaks down. Take that scanner rewriting tags["LastScanned"] every hour, so every run lights up on it. Right now I have two options.
- Put
ignore_changes = [tags["LastScanned"]]in the config. That quiets the alarm, but nowapplywon't reconcile that tag either, it needs a review, and it's global, not scoped to this one check. - Filter the
-jsoninjqand work out the exit code myself. That's a fragile script I have to babysit per resource type.
Neither lets me ignore that one tag for this check while still failing on any other drift.
Proposal
I'd propose a tofu drift command. What makes it more than a wrapper around plan -refresh-only is a -ignore flag that hides expected drift from the report, and only the report.
tofu drift -ignore=aws_s3_bucket.demo drops a whole resource; -ignore='aws_instance.web:tags["LastScanned"]' drops one attribute. The address is just the normal resource instance address you already use in plan output and -target (count/for_each and modules work, managed resources only), and the attribute part is the same syntax as ignore_changes. It only affects what's printed, never state, never infra, never what a later plan or apply does. If it can't apply a rule cleanly, it shows the full drift instead, and anything it hides is still counted and labeled. It only masks what you name, so if the same resource drifted somewhere else you still see it and the check still fails. That's the one thing the refresh-only plus jq route can't do without a config change, and it's the part I've built.
The command itself is small.
tofu drift # human-readable report
tofu drift -json # adds a drift_summary with countsA few specifics.
- It's strictly read-only. It forces refresh-only and rejects
-destroyand-refresh=false(without a refresh it would report "no drift" regardless of the real state), and it can't apply or destroy under any arguments, which aplan-based check can't promise. - Exit codes are
0no drift,1error,2drift. The2lines up with whatplan -detailed-exitcodealready means. There's no "drift plus pending config" case to worry about, since refresh-only never looks at config changes, and if the refresh itself falls over that's just an error (1). - In
-jsonthe only new thing is adrift_summarymessage withchanged/deleted/total/ignored.resource_driftis the existing message, untouched.
tofu plan -refresh-only -detailed-exitcode -json already handles detection and gives a clean exit code, so this isn't about finding drift. It's about being able to suppress expected drift per run, and having one read-only command to reach for instead of that flag combination.
On scope, in case it matters for the decision, this is a CLI-layer change with nothing touched in the core engine or the backends. I'll share the full diff when there's a PR.
Related: #28803 asked for a way to hide expected changes from the refresh report, and #30486 handled that for the plan view by only showing changes that fed into the plan. -ignore is a different, complementary thing: explicit, under my control, per run, aimed at a standalone check rather than tidying up a full plan.
I've got a working version with tests and I've run it against a real AWS provider, so I'm happy to share the branch. But per CONTRIBUTING I wanted to sort out direction before dropping code. The real question is do you want a read-only drift command with a suppression flag at all, or would you rather the suppression just be a flag on plan -refresh-only? Since -ignore is the whole reason for the command, I'd put it in v1, though I'm happy to stage its surface (resource-level first, attribute-level as a follow-up) if you'd prefer something smaller to start. Glad to turn this into an RFC if you tag it needs-rfc. I've filed the same thing for Terraform at hashicorp/terraform#39172.
Workarounds and Alternatives
The closest thing today is tofu plan -refresh-only -detailed-exitcode plus -json | jq, which I covered above. It finds drift fine, it just can't suppress per run.
If a new command is the wrong shape, the obvious alternative is to hang -ignore (and a drift-scoped exit code) off plan -refresh-only, and honestly I'd be fine with that. The catch is plan still can't promise read-only (it takes -out), and its flag list is already long. A plan -drift-only flag has the same problem, and actually changing what plan -detailed-exitcode returns is off the table since it'd break everyone relying on today's 0/1/2. Only a separate command can actually guarantee read-only.
References
A few related Terraform issues for context, given the shared history. I'm citing them as prior art.
- hashicorp/terraform#39172 (the parallel Terraform proposal)
- hashicorp/terraform#28803 (closed, addressed by #30486): the drift-report noise problem; #30486 de-noised the plan view,
-ignoreis the per-run lever for a standalone check. - hashicorp/terraform#35226 (open): asks that
planexit0when everything matches, showing demand for drift-scoped exit semantics.
Source: opentofu/opentofu