tf-snag

Your plan knows.
Your team doesn’t.

Every Terraform plan reports what drifted and what’s deprecated, then buries it in a job log. It says nothing at all about which of your resources Azure has already put a retirement date on. tf-snag reports all three, tracks them across runs, and surfaces them where your team will actually see them.

Four questions nobody’s answering

Drift is change nobody recorded

Someone loosened a storage account in the portal. A script stripped a tag. An NSG rule was added by hand. Your state file has no idea, and neither do you until something breaks.

Deprecations are decay nobody actioned

Terraform prints the warning on every plan, into a log nobody reads. Nobody aggregates them. Nobody ages them. Then a provider major drops and the upgrade is a wall instead of a backlog.

Retirements are deadlines nobody diarised

Azure announced the date years ago, in a blog post, to a distribution list that has since moved on. Terraform will plan a Basic SKU public IP quite happily, right up to the day Azure stops carrying it.

Every run leaves a trail

When something breaks, the first question is what changed, and by then the pipeline logs have rolled off. Every tf-snag run is a dated, fingerprinted record that hasn’t.

What it reports

-check drift

Reads the resource_drift Terraform recorded while refreshing state against the real world. In-place changes get an attribute-level before → after diff. Resources created or destroyed outside Terraform are named by the identity they were found by. Refresh round-trip noise like tags: null → {} is dropped automatically.

Exit 2 on any drift, so CI can gate on it.

tf-snag -check drift
tf-snag — 2 resource(s) changed outside Terraform  (1 deleted, 1 updated)

  ~ azurerm_key_vault.app
      public_network_access_enabled  false => true
  - azurerm_subnet.legacy
      deleted outside Terraform (name=legacy-snet)

-check deprecations

One finding per distinct notice, with every source location that trips it listed underneath, instead of the same warning re-emitted once per resource instance.

The richer surfaces carry more: the provider’s full explanation, a link to the exact line in your .tf, and how long the finding has been there.

tf-snag -check deprecations
deprecation warning(s): 1
  Argument is deprecated
      azurerm_signalr_service.hub (terraform/main.tf:46)
      azurerm_signalr_service.hub_01 (terraform/main.tf:59)
      azurerm_signalr_service.hub_02 (terraform/main.tf:73)

-check retirements New

Drift is a problem you already have. A retirement is one with a date on it. tf-snag matches your estate against a catalogue of published Azure retirement notices — Basic SKU public IPs and load balancers, Application Gateway v1, Front Door classic, the Log Analytics agent — and tells you which of your resources are on one, and how long is left.

The catalogue is compiled into the binary, so the check still makes no outbound call and needs no Azure permissions. Findings are grouped per retirement, not per resource: “stop using Basic public IPs” is one piece of work however many resources carry it, and one work item.

Urgency is worked out against the date at run time — retired, imminent (90 days), approaching (180 days) or scheduled — so the same entry is a note a year out and a blocker the week before. In SARIF, retired and imminent are errors.

tf-snag -check retirements
retirement(s): 2
  ! azurerm_public_ip — Basic SKU public IP addresses retire (2025-09-30), 3 instance(s)
      azurerm_public_ip.bastion
      module.network.azurerm_public_ip.egress[0]
      module.network.azurerm_public_ip.egress[1]
      retired 352 days ago — Move to Standard SKU. Basic cannot be upgraded in place while attached, so set the address to static, detach it, upgrade, then reattach.
      https://learn.microsoft.com/en-us/azure/virtual-network/ip-services/public-ip-basic-upgrade-guidance
  ! azurerm_frontdoor — Azure Front Door (classic) retires (2027-03-31), 1 instance(s)
      azurerm_frontdoor.edge
      in 195 days — Migrate to Front Door Standard or Premium (azurerm_cdn_frontdoor_profile and friends) with the zero-downtime tier migration tool.
      https://learn.microsoft.com/en-us/azure/frontdoor/classic-retirement-faq

-retirements-fail-within

By default every un-suppressed retirement trips the exit code, which on a ten-year-old estate is a wall on day one. Narrow the gate to what is close: everything is still reported, and only the deadlines inside the window fail the run.

-retirements <file> replaces the built-in catalogue outright, so you can add your own internal deadlines, trial an entry before it ships, or pin a catalogue in an air-gapped environment.

The catalogue ships with the release, so unlike every other check it can go stale. Reports carry its date and warn once it is over 90 days old — a clean result from a stale catalogue is a claim tf-snag hasn’t really checked.

tf-snag -retirements-fail-within 90d
retirement(s): 2
  ! azurerm_public_ip — Basic SKU public IP addresses retire (2025-09-30), 3 instance(s)
      azurerm_public_ip.bastion
      module.network.azurerm_public_ip.egress[0]
      module.network.azurerm_public_ip.egress[1]
      retired 352 days ago — Move to Standard SKU. Basic cannot be upgraded in place while attached, so set the address to static, detach it, upgrade, then reattach.
      https://learn.microsoft.com/en-us/azure/virtual-network/ip-services/public-ip-basic-upgrade-guidance
  · azurerm_frontdoor — Azure Front Door (classic) retires (2027-03-31), 1 instance(s) — beyond the 90-day fail window

-ignore

A finding you’ve accepted (a tag a deploy script owns, a deprecation already in the backlog, a retirement you hold an exemption for) stays on the record but stops gating the build. Rules live in a .tf-snag-ignore.yml (found automatically) or in # tf-snag:ignore comments next to the resource.

Suppressed, not deleted: still in the SARIF, still in the digest, with the reason attached. Just not a failure.

.tf-snag-ignore.yml
deprecations:
  - match: live_trace_enabled
    reason: provider 4.0 upgrade, PLAT-214

retirements:
  - id: azure-public-ip-basic-sku
    reason: CSES exemption, PLAT-318

# … or inline in main.tf:
# tf-snag:ignore-drift  reason: tag owned by the deploy script

Nothing to adopt

Adopting a run platform means moving execution onto someone’s SaaS, handing them cloud credentials, migrating state, passing a security review, and finding budget. That’s a programme of work.

tf-snag is one step in the pipeline you already have. A single binary. No platform, no SaaS, no credentials leaving the tenant, no procurement. It never applies anything; it reads the plan Terraform already produced.

“I could write that in an afternoon”

The real alternative isn’t a platform. It’s fifty lines of plan -refresh-only, piped to jq. The first version is an afternoon. Then it meets week two.

The afternoon version

  • Re-reports the same drift every night, because nothing is fingerprinted.
  • Dumped 200 findings on day one, with no baseline to diff against.
  • Still screaming about tags.LastModified. Nothing ages or clears.
  • Output lands at line 8,000 of the job log, read once.

What tf-snag adds

  • Stable per-finding fingerprints, so last night’s noise stays quiet.
  • A baseline, so day one is a diff and not a wall.
  • First-seen dates and new / updated / absent state on every finding.
  • An ignore file and inline # tf-snag:ignore comments, so accepted findings stay on record without gating CI.
  • An exceptions register of every waiver and its reason, which flags the rules that no longer suppress anything.
  • A dated catalogue of Azure retirement notices, matched against your estate on every run, researched so you don’t have to.
  • Findings land in the tf-snag tab, a Teams channel and the board, as well as the log.

On Azure DevOps

Drops into the pipeline you already run

Terraform already reports drift and deprecations. It puts them at line 8,000 of a job log, after a green check, where nobody looks again. Retirements it never mentions at all. tf-snag writes all three to the places people already look: the tf-snag tab, the team’s Teams channel, the board, the project wiki. One step produces them all.

Try it outside CI first

shell
# linux/amd64 (tf-snag) or windows/amd64 (tf-snag.exe)
curl -sSL https://github.com/wes-key/tf-snag/releases/latest/download/tf-snag \
  -o tf-snag && chmod +x tf-snag

terraform plan -out tfplan
terraform show -json tfplan | ./tf-snag -check drift
Recommended: install the tf-snag Azure DevOps extension once in the organisation. It adds the tf-snag tab and two pipeline tasks that wire up Teams alerts, Azure Boards work items and the exceptions register as task inputs. Everything still works without it: the CLI does all of the above from a script step, a markdown digest lands on the run Summary, and the SARIF renders in the generic Scans tab if you run sariftools.scans.

The extension

Optional, but it’s where this data looks its best. Install it once and every run gets:

The tf-snag tab
A purpose-built view of the run: drift, deprecations, retirements and pending changes on their own tabs, each finding badged new or dated by when it was first seen, and the ignored findings with their reasons. The clearest surface for this by a distance.
The tf-snag tab. Drift, deprecations and pending changes on their own tabs, with every finding stamped new or dated by when it was first seen, all from one pipeline run. Drag and zoom to read it.

Teams alerts

An Adaptive Card posted to a channel through a Power Automate Workflows webhook: a coloured verdict banner, the counts, and drift and deprecations folded into groups that expand in place, with new findings sorted to the top.

By default it only posts when something is new against the baseline, so a nightly run doesn’t re-announce the same drift every morning.

A tf-snag Adaptive Card in a Teams channel: a red banner reading '8 resources changed outside Terraform, 2 deprecations' with the pipeline, branch and run number, a fact list of drift, deprecations, new, ignored and pending counts, and an expanded Changed Outside Terraform group listing resources with Delete and Update badges, a New badge and first-seen dates.

Azure Boards work items

One work item per finding, tagged with the finding’s stable id, so Azure DevOps itself is the record of what’s been raised and no run opens a duplicate. By default only new findings get one, so day one isn’t fifty items. Items close when their finding goes away.

Ignoring a finding doesn’t close its item: tf-snag comments on it and leaves that call to a person. Start with dry-run, which checks the token and permissions and writes nothing.

An Azure Boards task raised by tf-snag, titled 'Terraform drift: module.resource_group[6].azurerm_resource_group.rg changed outside Terraform', tagged drift, tf-snag and a tf-snag-id tag, with a description showing the resource, its source file and line, Update and No longer ignored badges, the first-detected date, and a tags attribute changing from null to a new value.

An exceptions register

A generated wiki page listing every ignore rule: what it waives, why, whether it lives in the ignore file or inline in the .tf, and what it’s suppressing right now. Rules that have stopped suppressing anything get their own table, so stale waivers get removed instead of living forever.

It only commits when something has changed, so the page history stays meaningful.

The pipeline

With the extension it’s two tasks: one installs the binary, one does everything else:

azure-pipelines.yml
- script: |
    terraform -chdir=terraform plan -out tfplan -json | tee plan.jsonl
    terraform -chdir=terraform show -json tfplan > plan.json
  displayName: terraform plan

- task: tf-snag-install@0           # latest release, onto PATH

- task: tf-snag@0
  inputs:
    checks: all,retirements        # all is drift + deprecations
    planLogDir: terraform           # plan ran with -chdir=terraform
    retirementsFailWithin: 90       # only fail on what is close
    teamsWebhook: $(teamsWebhook)   # secret variable
    teamsNotify: new                # only when something appears
    workItems: dry-run              # off | dry-run | on
    adoType: Bug
    adoArea: Platform\Terraform
    wikiPage: /tf-snag/Exceptions   # the exceptions register

The baseline is the previous run’s tf-snag artifact, which the task publishes for you. Download it into $(Pipeline.Workspace)/prev and findings are tracked across runs. Publishing the register needs the pipeline to reference the wiki’s repository, and the build service needs Contribute on it.

Without it, a script step and a published artifact do the same job:

azure-pipelines.yml
variables:
  TF_SNAG_VERSION: v0.1.0

steps:
- script: |
    # public repo, so gh needs no token; a curl to the
    # release asset URL works just as well
    gh release download "$(TF_SNAG_VERSION)" --repo wes-key/tf-snag \
      --pattern tf-snag --output tf-snag
    chmod +x tf-snag
  displayName: Get tf-snag

- script: |
    terraform -chdir=terraform plan -out tfplan -json | tee plan.jsonl
    terraform -chdir=terraform show -json tfplan > plan.json
    ./tf-snag -check all,retirements \
      -plan plan.json -plan-log plan.jsonl -plan-log-dir terraform \
      -source "$(Build.SourcesDirectory)" \
      -format sarif -exit-code=false > tf-snag.sarif
  displayName: tf-snag drift check

- task: PublishBuildArtifacts@1
  inputs:
    PathtoPublish: tf-snag.sarif
    ArtifactName: CodeAnalysisLogs   # the Scans tab reads this

What lands where

SurfaceNeedsWhat you get
tf-snag tabthe extension The whole run in one view: drift, deprecations, retirements and pending changes on their own tabs, first-seen dates, ignored findings and their reasons, and the linked work item. The clearest surface.
Teamsa Workflows webhook An Adaptive Card when a run turns up something new, with a link back to the run.
Work itemsa token with Work Items (Read & Write) One Azure Boards item per finding — one per retirement, not per affected resource — never duplicated, closed automatically when it clears.
WikiContribute on the wiki repo The exceptions register: every ignore rule, its reason, what it suppresses, and which rules have gone stale.
Run Summarynothing A markdown digest on the Summary tab (drift, deprecations, retirements, an Ignored section), posted with task.uploadsummary. The zero-install fallback.
Scans tabsariftools.scans tf-snag’s SARIF in the generic third-party Scans tab, each finding linked to the line in your .tf, if you already standardise on it.
Tests tabjunit + PublishTestResults@2 Optional: one test per check, red on failure.
Build outcomenothing -exit-code=false plus a warning log issue marks the run SucceededWithIssues: visible, non-blocking. Drop the flag to fail the stage on any un-suppressed finding.

The flags that matter

-plan
Path to terraform show -json output, or - to read stdin.
-plan-log  /  -plan-log-dir
The terraform plan -json log and the directory its paths resolve against. This is what makes deprecation findings point at real files instead of nothing.
-source
Repo root. Turns SARIF locations into links to the exact .tf line, and picks up inline # tf-snag:ignore comments.
-baseline
Last run’s tf-snag.sarif. Diffs against it so each finding is stamped new / updated / absent with a first-seen date. That is the movement the tf-snag tab shows.
-ignore
A .tf-snag-ignore.yml (auto-discovered in the repo root) of accepted findings. Matched findings — drift, deprecations and retirements alike — stay in the report but don’t trip the exit code.
-teams-webhook  /  -teams-notify
Post the Adaptive Card to a Workflows URL (or set $TF_SNAG_TEAMS_WEBHOOK, which keeps it off the command line), on new, findings or always.
-ado-url  /  -ado-raise  /  -ado-close
Raise one work item per finding in that project, for new findings or all of them, and close the ones that have cleared. -ado-dry-run shows what would change.
-wiki-page
Publish the exceptions register to this wiki path, e.g. /tf-snag/Exceptions. -wiki-dry-run prints the page instead.
-retirements  /  -retirements-fail-within
Use your own catalogue instead of the built-in one, and narrow what fails the build to deadlines inside a window (90d). Everything outside it is still reported, marked as beyond the window.
-check  /  -format
drift | deprecations | retirements | all, and text | json | markdown | junit | sarif. all is drift and deprecations; retirements are asked for by name (-check all,retirements), because they lean on a shipped catalogue rather than the plan alone.
-exit-code
Default true: exit 2 on un-suppressed findings. Set false in CI when you want a warning, not a failed stage.

Hosted or self-hosted agents both work; the binary is linux-amd64 or windows-amd64.

Your plan already knows.
Tomorrow, so does your team.

Add tf-snag to the pipeline you already run, and tonight’s drift, deprecations and retirement deadlines land in the run, the channel and on the board, with nothing new to adopt.