Back to the project in the portfolio

Terraform Drift Detection — comparing plans across two branches

A Jenkins job that runs scripts/check-drift.sh to compare the Terraform plan of a reference branch against that of a candidate branch, layer by layer and stage by stage. The goal: prove that a refactor produces exactly the same infrastructure plan — so no resource is created, destroyed or changed by accident.

Generic diagram: instances, repositories, channels and modules are named by function. The architecture is shown for illustration, independently of any organisation.

Script step Compared artefact · external service Secret or decision Validated layer Drift detected Jenkins agent
01 — trigger, workspace preparation, execution

Jenkins pipeline

Everything happens on an ephemeral agent: both branches are brought side by side there, secrets are injected then deleted, and each stage is processed layer by layer before Slack gets the verdict.

job parameters FEATURE_BRANCH REF_BRANCH STAGES ENV_ID VERBOSE / KEEP_PLANS / SKIP_INIT branch under test reference (integration) integration, test or all required — fails if empty Jenkins job Terraform Drift Detection jenkins agent workspace preparation Checkout candidate branch git fetch reference branch Secrets file generated then injected running the comparison script SSH agent per stage AWS credentials Scaleway credentials Vault credentials One distinct SSH key per stage. Credentials never leave the credentials wrapper. ./check-drift.sh --ref-branch REF --stages LIST --env-id ID --secrets PATH for each stage, three layers 1 Stage tooling plan compared 2 Environment list apply, ID selection 3 Environment stacks generate then plan Comparison detail: diagram 2 below. GitHub infrastructure repository checkout + git fetch REF_BRANCH full clone, wipe before checkout build result Slack always notified green / red / grey by outcome job environment AWS_DEFAULT_REGION = eu-west-1 SCW_DEFAULT_ZONE = fr-par-2 TFENV_TERRAFORM_VERSION = 1.13.4 TF_PLUGIN_CACHE_DIR (shared) timeout: 3 hours concurrent builds disabled (the previous build is aborted) post-build Secrets file deleted Full workspace cleanup Slack notification whatever the result Runs even on failure or abort.
The secrets file only exists for the duration of the build: it is generated in the workspace, injected into Terraform, then deleted by the post-build block — which runs even if the job fails.
02 — generating both plans, normalisation, verdict

Comparing one layer

Both branches each produce a JSON plan against the same state, then jq strips everything that is not deterministic. What remains is the infrastructure intent: that is the only thing compared, and the comparison is strict.

layers involved 1 Stage tooling 2 Environment list 3 Environment stacks one stack per environment Layers 1 and 3 go through the cycle shown opposite. Layer 2 compares nothing: it applies, exposes the list of environments and sets ENV_ID. reference branch git checkout REF_BRANCH terraform init (shared cache) terraform plan -out terraform show -json → ref.json Layer 3: make generate runs before the plan. candidate branch git checkout FEATURE_BRANCH terraform init (shared cache) terraform plan -out terraform show -json → feat.json Same environment and same state targeted on both sides. normalising both plans (jq) .resource_changes | sort_by(.address) map(del(.deposed)) .change |= del(.before_sensitive, .after_sensitive, .after_unknown) .before / .after |= del(.latest_restorable_time) walk(del(.Authorization)) Strips non-deterministic fields: what remains is the infrastructure intent. is ref.json identical to feat.json? strict comparison, string by string yes [ OK ] Layer validated No plan difference between the branches. Validated-layer counter incremented. The refactor is neutral for the infrastructure. no [ FAIL ] Drift detected Report per resource address: resources present on one side only, and a diff of the .change block for shared addresses whose plan diverges. Per-stage summary: layers validated, failed, skipped → script exit code → Jenkins build status → Slack notification.
The normalisation is what makes the verdict usable: without it, non-deterministic fields would fail the comparison on every run.

All architecture diagrams