Skip to content

Trevor159/drift-fixer

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

36 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

drift-fixer

A CLI tool that automatically detects and corrects Terraform/OpenTofu configuration drift. It runs a plan, reads the before/after diff from the plan JSON, edits the relevant .tf files in-place using the hclwrite AST library, and validates the result with a second plan.

Written in Go. No external CLI tools required β€” HCL is edited directly via the hashicorp/hcl library.


Table of Contents


How It Works

  1. Plan β€” runs tofu plan -out <tmp> then tofu show -json <tmp> in the target directory. Reads resource_changes[*].change.{before,after} from the JSON output and diffs every attribute. Only attributes where before != after are collected into a ResourceDrift.

  2. Find β€” parses every .tf file in the project directory using the hclsyntax AST to locate which file contains each drifted resource block.

  3. Edit β€” opens the file with hclwrite, walks the block tree, and applies each drifted attribute:

    • Scalar attributes are set with SetAttributeValue / SetAttributeRaw.
    • List attributes with more than one item are formatted one-item-per-line.
    • Block-typed values (nested blocks) are recursively synced.
    • Resources deleted from infra are removed from config entirely.
    • The output is run through hclwrite.Format (equivalent to tofu fmt).
  4. Validate β€” runs a second plan. If no drift remains, exits 0. If drift persists, exits 1 with a summary.


Building

Requirements

  • Go 1.22+ (go version)
  • OpenTofu or Terraform CLI on $PATH (default: tofu)
  • GOTOOLCHAIN=local is required because go.mod pins 1.22 while some transitive dependencies were built with a newer toolchain declaration.
# Clone
git clone <repo-url>
cd drift-fixer

# Build binary to project root
cd go
GOTOOLCHAIN=local go build -o ../drift-fixer-go ./cmd/drift-fixer/

# Verify
cd ..
./drift-fixer-go -h

The resulting binary drift-fixer-go is self-contained with no runtime dependencies.


Usage

./drift-fixer-go [flags]

Flags:
  -path string
        Path to the Terraform/OpenTofu project directory (default ".")
  -tf-bin string
        Terraform/OpenTofu binary to use (default "tofu", or $DRIFT_FIXER_TF_BIN)
  -verbose
        Print every attribute and block change as it is applied
  -dry-run
        Detect and report drift without writing any files

Examples

# Fix drift in the current directory using tofu
./drift-fixer-go

# Fix a specific project directory
./drift-fixer-go -path ./infra/github

# Use terraform instead of tofu
./drift-fixer-go -tf-bin terraform

# Preview what would change without editing files
./drift-fixer-go -dry-run -verbose

# Verbose mode shows every attribute as it is applied
./drift-fixer-go -verbose

Typical output

Starting drift analysis in: /home/user/infra/github
Using CLI binary: tofu
πŸ” Running plan to detect drift...
πŸ“Š Found 2 resource(s) with drift:
  - github_repository_ruleset.main
  - github_repository.app

πŸ“ Applying fixes...

  Syncing github_repository_ruleset.main in rulesets.tf
  βœ… Fixed github_repository_ruleset.main in rulesets.tf

  Syncing github_repository.app in repos.tf
  βœ… Fixed github_repository.app in repos.tf

βœ… Validating...
βœ… Drift fixing completed successfully! No remaining drift.

Environment Variables

Variable Default Description
DRIFT_FIXER_TF_BIN tofu Terraform/OpenTofu binary to invoke
DRIFT_FIXER_COMMENT_SCRIPT (unset) Path to a script that generates inline HCL comments (see Comment Hook)

Comment Hook

When DRIFT_FIXER_COMMENT_SCRIPT is set, drift-fixer calls that script for every value it writes β€” both scalar attributes and individual list items. The script can print a comment body to stdout; drift-fixer will attach it as an inline # comment on that line.

Existing comments in the file are always preserved and take priority over hook-generated comments. The hook only fires for values that have no existing comment.

Variables passed to the script

Variable Example
DRIFT_RESOURCE_TYPE github_repository_ruleset
DRIFT_RESOURCE_NAME ruleset_main
DRIFT_ATTR_PATH conditions.ref_name.include
DRIFT_ATTR_VALUE "~DEFAULT_BRANCH" (rendered, so strings are quoted)

Script contract

  • Print a single line to stdout β€” the comment text, without #.
  • Print nothing (or exit non-zero) to add no comment.
  • Stderr is ignored (errors are logged in verbose mode only).

Example script

#!/usr/bin/env bash
# Annotate GitHub branch patterns with a human-readable description

case "$DRIFT_ATTR_VALUE" in
  '"~DEFAULT_BRANCH"') echo "the default branch" ;;
  '"~ALL"')            echo "all branches" ;;
  '"refs/heads/releases/**/*"') echo "release branches" ;;
esac

With this script active, a synced include list might look like:

include = [
  "~DEFAULT_BRANCH", # the default branch
  "refs/heads/releases/**/*", # release branches
  "refs/heads/meep",
]

Supported Features

Attribute types

Type Supported Notes
String βœ… Any content including slashes, regex, special chars
Boolean βœ…
Integer βœ… Large integers (e.g. GitHub App IDs) rendered as integers, never scientific notation
Float βœ…
String list βœ… Multi-line for >1 item, single-line for 1 item
Number list βœ… Same formatting rules
Nested block βœ… Recursively synced to arbitrary depth
Repeated blocks βœ… e.g. multiple bypass_actors {}
Null value βœ… Silently skipped

Block operations

Operation Behaviour
Add missing block If a block type is entirely absent from config, all instances from infra are added
Sync existing block Attributes inside existing blocks are updated in-place
Remove excess blocks If infra has fewer instances than config, the extra config blocks are removed
Preserve user-reduced count If config has fewer blocks than infra, count is left alone (user is intentionally deleting them via Terraform)
Remove empty block type If infra returns [] and config has blocks of that type, all are removed
Skip empty scalar list If infra returns [] and no blocks of that type exist, it is ignored (not written as = [])
Delete resource If a resource is deleted from infra (actions: ["delete"]), the entire resource {} block is removed from config

List formatting

# Single item β€” stays on one line
topics = ["terraform"]

# Multiple items β€” one per line, `tofu fmt`-compliant indentation
topics = [
  "terraform",
  "go",
  "github",
]

Comment preservation

Comments in list attributes survive drift syncs:

  • Before-line comments (# comment on its own line before a value)
  • Inline comments (# comment at the end of the same line as a value)
  • // style comments
  • Comments are keyed by rendered value content, not position β€” they follow their value even if the list is reordered or items are inserted before them.
  • Comments for removed items are dropped.
include = [
  # default branch β€” always included
  "~DEFAULT_BRANCH", # added automatically by provider
  "refs/heads/releases/**/*", # release train
]

Resources tested against (GitHub provider)

Resource Notes
github_repository description, visibility, topics, all boolean flags, security_and_analysis nested blocks, squash_merge_commit_title/message, merge_commit_title/message
github_repository_ruleset enforcement, target, conditions.ref_name.{include,exclude}, bypass_actors, all rules.* sub-blocks listed below
rules.pull_request allowed_merge_methods, required_approving_review_count, all boolean flags
rules.required_status_checks required_check repeated blocks (add, remove, sync)
rules.required_deployments required_deployment_environments list
rules.branch_name_pattern operator, pattern, name, negate
rules.tag_name_pattern same schema as branch_name_pattern
rules.commit_message_pattern same schema
rules.commit_author_email_pattern same schema
rules.committer_email_pattern same schema
rules.required_code_scanning required_code_scanning_tool repeated blocks
rules.file_path_restriction restricted_file_paths list
rules.file_extension_restriction restricted_file_extensions list
rules.max_file_size max_file_size integer
rules.max_file_path_length max_file_path_length integer
rules.merge_queue all integer and string fields

The tool is provider-agnostic β€” it works on any Terraform/OpenTofu resource. The GitHub provider has been the primary test target.


Known Limitations

  • Sensitive attributes β€” attributes marked sensitive in the plan JSON are skipped (the provider redacts their values, so drift-fixer cannot know the correct value).
  • after_unknown attributes β€” attributes whose post-change value is not known at plan time are skipped.
  • User-managed deletions β€” if you have intentionally fewer blocks in your config than exist in infra (because you want Terraform to delete the extras on the next apply), drift-fixer will not add them back. It detects this by comparing config block count vs infra block count.
  • Multiple resources with the same type+name β€” only the first match in a file is edited. This is a theoretical edge case since Terraform requires unique addresses.
  • for_each / count meta-arguments β€” resources managed with count or for_each are located by address label. The tool edits the block body directly; it does not currently modify the count or for_each expression itself.
  • Comment hook is synchronous β€” the script is exec'd once per value written. For large syncs with many list items, a slow script will slow the overall run.

Testing

All tests are in go/internal/editor/ and use only the standard library (testing package). No real cloud credentials or live API calls are made.

# Run all tests (67 tests across 2 files)
cd go
GOTOOLCHAIN=local go test ./internal/editor/ -v

# Run a specific test
GOTOOLCHAIN=local go test ./internal/editor/ -v -run TestBypassActorModeChange

# Run all packages
GOTOOLCHAIN=local go test ./...

Test files

File Count Coverage
editor_test.go 18 tests Core edit operations, multi-line lists, comment preservation, hook
editor_extended_test.go 49 tests GitHub provider shapes, list mutations, deep nesting, every comment edge case, hook path verification

Integration test (test.sh)

Requires a real GitHub token and the examples/ directory pointing at a live repository:

# Set up credentials
echo 'export GITHUB_TOKEN=ghp_...' > .env

# Run full end-to-end: build β†’ checkout clean main.tf β†’ plan β†’ fix β†’ validate
./test.sh

# Dry run
./test.sh --dry-run

The script:

  1. Builds the binary from source.
  2. Resets examples/main.tf to HEAD with git checkout.
  3. Runs drift-fixer-go -path examples/ -verbose.
  4. Exits 0 only if the second plan confirms no remaining drift.

Project Structure

drift-fixer/
β”œβ”€β”€ drift-fixer-go          # compiled binary (gitignored)
β”œβ”€β”€ test.sh                 # integration test script
β”œβ”€β”€ examples/
β”‚   └── main.tf             # test fixture (github provider resources)
└── go/
    β”œβ”€β”€ go.mod
    β”œβ”€β”€ go.sum
    β”œβ”€β”€ cmd/
    β”‚   └── drift-fixer/
    β”‚       └── main.go     # CLI entry point: flags, orchestration, validate
    └── internal/
        β”œβ”€β”€ planner/
        β”‚   └── planner.go  # runs plan+show-json, diffs before/after, returns ResourceDrift list
        β”œβ”€β”€ finder/
        β”‚   └── finder.go   # parses .tf AST to locate which file contains each resource
        └── editor/
            β”œβ”€β”€ editor.go       # hclwrite-based editor: ApplyDrift, RemoveResource, syncBody
            β”œβ”€β”€ hook.go         # CommentHook type + DRIFT_FIXER_COMMENT_SCRIPT loader
            β”œβ”€β”€ editor_test.go          # core unit tests (18)
            └── editor_extended_test.go # extended GitHub provider tests (49)

Package responsibilities

planner

  • Runs <tf-bin> plan -out <tmp> followed by <tf-bin> show -json <tmp>.
  • Iterates resource_changes in the JSON output.
  • For delete actions: returns ResourceDrift{Delete: true}.
  • For update/replace actions: walks change.before vs change.after, collects every key where the values differ, skips sensitive keys and after_unknown markers.

finder

  • Walks the project directory for *.tf files.
  • Parses each file with hclsyntax.ParseConfig (full AST, no regex).
  • Returns a map of resourceAddress β†’ filePath.

editor

  • ApplyDrift β€” main entry point; opens file, finds resource block, calls syncBody, writes formatted output.
  • RemoveResource β€” removes the entire resource {} block.
  • syncBody β€” recursive; handles empty lists, block vs scalar distinction, block count policy, path accumulation for hooks.
  • setAttributeVal β€” scalar/list writer; extracts and re-injects comments; calls hook for values with no existing comment.
  • extractItemComments β€” token-stream parser that maps rendered value string β†’ {before, inline} comment tokens. Keyed by value content so comments survive list reordering.
  • multilineListTokens β€” builds hclwrite.Tokens for a multi-line list, re-inserting preserved comments and hook-generated comments in the right spots.

hook

  • CommentHook β€” function type (rType, rName, path, value) β†’ comment.
  • LoadCommentHook β€” reads DRIFT_FIXER_COMMENT_SCRIPT; returns a hook that exec's the script with context via env vars, or nil if unset.

About

A cli tool to edit hcl in your repo to match the current terraform state

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors