osmo

module
v1.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 12, 2026 License: Apache-2.0

README

image

GitHub Stars Latest Release CI Go Report Card License

osmo

Osmosis for your Terraform. Reality seeps into your config until both sides equalize.

osmo detects Terraform drift and rewrites your .tf files so that configuration follows real-world reality — the absorb direction.

If you find osmo useful, a ⭐ on GitHub helps others discover it.


Why absorb, not revert

Production incidents get hotfixed directly in the cloud console. Those fixes can't wait for a terraform apply cycle. So the source of truth should follow reality, not overwrite it. osmo rewrites your .tf files to match what's actually running; you review the diff and verify with a normal terraform plan.


Quick start

# Preview changes — writes nothing, prints unified diff
osmo -dir ./infra

# Apply changes to disk
osmo -dir ./infra -write

# Apply, then prove it: re-plan and roll back if drift remains
osmo -dir ./infra -write -verify
Flag Default Meaning
-dir . Terraform working directory
-terraform auto-detect Terraform/OpenTofu binary path (auto-detects tofu then terraform; overridden by OSMO_TF_BINARY env)
-write false Write changes to disk (else dry-run diff only)
-verify false After writing, run a normal plan; roll back files if any absorbed resource still has a planned change (requires -write; not usable with -plan-json)
-approve false Interactively approve each file change before writing (requires -write and a TTY)
-json false Emit a single JSON object to stdout instead of human-readable output
-target `` Only absorb drift on this resource address (repeatable / comma-separated)
-exclude `` Skip drift on this resource address (repeatable / comma-separated; wins over -target)
-plan-json `` Path to pre-generated terraform show -json output (skips detection)

Interactive UI

osmo ui -dir ./infra          # detect drift, open interactive TUI
osmo ui -plan-json plan.json  # TUI from saved plan
osmo ui · 3 resource(s) · ./infra

╭────────────────────────────────────────────────────────────────────────╮
│   ✅ aws_instance.web                safe    instance_type    absorb   │
│   ⚠️  aws_autoscaling_group.api      review  desired_capacity skip      │
│ ▶ 🚩 aws_security_group.public      flag    ingress          skip      │
╰────────────────────────────────────────────────────────────────────────╯
╭─ aws_security_group.public ────────────────────────────────────────────╮
│                                                                         │
│  - ingress                  = []                                        │
│  + ingress                  = [{"cidr":"0.0.0.0/0","from_port":8080}]  │
│                                                                         │
│  ⓘ aws_security_group: controls network ingress/egress                 │
│  💡 investigate before absorbing                                        │
╰────────────────────────────────────────────────────────────────────────╯
[↑↓/jk] navigate  [a] absorb  [s] skip  [A] absorb-all-safe  [x] execute  [q] quit

Press x — osmo runs immediately with the right -target/-exclude flags for your selections.


Drift triage — know what's safe before absorbing

osmo triage -dir ./infra          # detect drift and classify it
osmo -dir ./infra -json | osmo triage   # pipe from existing run
osmo triage -plan-json plan.json  # use saved plan

Output:

osmo triage: 4 resource(s) analysed

✅ SAFE (2) — absorb freely
   aws_instance.web                              instance_type, tags
   aws_s3_bucket.logs                            tags

⚠️  REVIEW (1) — verify intent before absorbing
   aws_autoscaling_group.api                     desired_capacity
                                                 reason: capacity/autoscaler attribute — may be externally managed
                                                 tip:    if autoscaler-managed, consider: lifecycle { ignore_changes = [desired_capacity] }

🚩 SECURITY FLAG (1) — investigate before absorbing
   aws_security_group.public                     ingress
                                                 reason: aws_security_group: controls network ingress/egress
                                                 tip:    investigate before absorbing — if intentional: osmo -write -target aws_security_group.public -approve

Suggested command (safe resources only):
  osmo -dir ./infra -write \
    -target aws_instance.web \
    -target aws_s3_bucket.logs \
    -exclude aws_security_group.public

No API key. No network calls. Fully offline. Classification is deterministic rule-based:

Verdict Rule
safe tags, descriptions, scalar size/type attrs
review capacity/autoscaler attrs (desired_capacity, replica_count, …) — consider lifecycle.ignore_changes
flag security-sensitive resource types (IAM, security groups, KMS, public access blocks, …) or attributes (cidr, policy, ingress, egress, …)

Rules are extensible via .osmo.json:

{
  "triage": {
    "flag_resources": ["my_custom_firewall", "vault_policy"],
    "flag_attrs":     ["secret_arn", "trust_relationship"],
    "safe_attrs":     ["cost_center"]
  }
}

Exit codes: 0 all safe · 2 any review/flag · 1 error.


OpenTofu support

osmo is compatible with both Terraform and OpenTofu. Binary selection priority:

  1. OSMO_TF_BINARY env var — always wins
  2. -terraform CLI flag
  3. .osmo.json defaults.terraform field
  4. Auto-detect: tofu if found on PATH, otherwise terraform
# Explicit: use tofu
osmo -dir ./infra -terraform tofu

# Env var (useful in CI)
OSMO_TF_BINARY=tofu osmo -dir ./infra

# Auto-detect: no flags needed if tofu is on PATH
osmo -dir ./infra

All flags, -verify, -json, and Terraform Cloud support work identically with OpenTofu.


Exit codes

osmo follows the Terraform detailed-exitcode convention:

Code Meaning
0 No drift detected (or -target/-exclude matched nothing)
1 Execution error
2 Drift found — changes proposed, written, or unresolved drift reported

Use exit code 2 as the CI gate: if osmo exits 2, the diff needs review.

JSON output for CI / PR bots

-json emits a single JSON object to stdout, all human text suppressed:

osmo -dir ./infra -json | jq .result
osmo -dir ./infra -write -json > osmo.json
{
  "osmo_version": "0.1.4",
  "result": "proposed",
  "drift_count": 2,
  "changes": [
    {
      "path": "infra/main.tf",
      "edits": [{ "address": "aws_instance.web", "attrs": ["instance_type"] }],
      "diff": "--- a/infra/main.tf\n+++ b/infra/main.tf\n..."
    }
  ],
  "unresolved": []
}

result values: no_drift · no_match · proposed · absorbed · nothing_absorbable · verify_failed · error

GitHub Actions example

Full workflow — detects drift on a schedule, absorbs it, opens a PR, and posts a summary comment:

# .github/workflows/drift.yml
name: Terraform drift absorption

on:
  schedule:
    - cron: "0 6 * * *"   # daily at 06:00 UTC
  workflow_dispatch:       # manual trigger

permissions:
  contents: write
  pull-requests: write

jobs:
  absorb:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "~1"
          cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}

      - name: Install osmo
        run: |
          brew install pandey-raghvendra/tap/osmo
          # or: go install github.com/pandey-raghvendra/osmo/cmd/osmo@latest

      - name: Terraform init
        run: terraform init
        working-directory: ./infra

      - name: Absorb drift
        id: osmo
        run: |
          osmo -dir ./infra -write -json > osmo.json
          echo "exit_code=$?" >> $GITHUB_OUTPUT
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: Open PR for absorbed drift
        if: steps.osmo.outputs.exit_code == '2'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          BRANCH="osmo/absorb-$(date +%Y%m%d-%H%M)"
          git checkout -b "$BRANCH"
          git config user.name  "osmo-bot"
          git config user.email "osmo-bot@users.noreply.github.com"
          git add -A
          git commit -m "chore: absorb Terraform drift $(date +%Y-%m-%d)"
          git push origin "$BRANCH"

          DRIFT_COUNT=$(jq '.drift_count' osmo.json)
          SUMMARY=$(jq -r '
            "## Drift absorbed\n\n" +
            "**Resources drifted:** \(.drift_count)\n\n" +
            (if (.unresolved | length) > 0
             then "**Unresolved (\(.unresolved | length)):** " +
                  ([.unresolved[] | "`\(.address).\(.attr)`: \(.reason)"] | join("\n")) + "\n\n"
             else "" end) +
            "### Diffs\n\n" +
            ([.changes[] | "**`\(.path)`**\n```hcl\n\(.diff)\n```"] | join("\n\n"))
          ' osmo.json)

          gh pr create \
            --title "chore: absorb Terraform drift ($DRIFT_COUNT resource(s))" \
            --body "$SUMMARY" \
            --label "terraform,drift"

      - name: Post drift summary as step summary
        if: always()
        run: |
          if [ -f osmo.json ]; then
            echo "## osmo drift report" >> $GITHUB_STEP_SUMMARY
            echo '```json' >> $GITHUB_STEP_SUMMARY
            jq '{result, drift_count, unresolved_count: (.unresolved | length)}' osmo.json >> $GITHUB_STEP_SUMMARY
            echo '```' >> $GITHUB_STEP_SUMMARY
          fi

Exit codes in CI:

Code Meaning Action
0 No drift Pass
1 Error Fail the job
2 Drift found (changes proposed or written) Open PR / notify

Verify: closed-loop convergence

osmo rewrites your source of truth, so it should prove the rewrite actually resolved the drift. -verify runs a normal terraform plan after writing — absorb edits config to match reality, so a converged resource has no planned change:

  • no absorbed resource has a planned change → success
  • a planned change remains on any absorbed resource → all written files are rolled back to their pre-absorb content and osmo exits non-zero
osmo -dir ./infra -write -verify

This guards against a wrong provenance trace or block match silently corrupting config — osmo never leaves you with edits it can't prove converge. -verify needs a live plan, so it is incompatible with -plan-json.


Selective absorption: triage before you codify

Drift is not always a legitimate hotfix — it can be an unauthorized or malicious change. Absorbing it blindly would launder that change into your source of truth. Use selection + approval to keep a human in the loop:

# Only absorb a known-good resource
osmo -dir ./infra -write -target aws_instance.web

# Absorb everything except a suspicious change you want to investigate/revert
osmo -dir ./infra -write -exclude aws_security_group.public

# Review and approve each file change interactively
osmo -dir ./infra -write -approve

-target/-exclude match modules and indexed instances by prefix: module.app matches module.app.aws_instance.web, and aws_instance.web matches aws_instance.web[0]. -exclude always wins over -target.


Project config: .osmo.json

Place .osmo.json in the Terraform working directory to set per-project defaults (so you don't repeat flags every run) and to extend or override built-in block identity keys.

{
  "defaults": {
    "dir":       "./infra",
    "terraform": "/usr/local/bin/terraform",
    "targets":   ["module.app"],
    "excludes":  ["aws_instance.bastion"],
    "write":     false,
    "verify":    false,
    "json":      false
  },
  "block_identity": {
    "google_compute_firewall.allow": ["protocol"],
    "azurerm_lb.backend_address_pool": ["name"],
    "my_custom_resource.my_block": ["id", "name"]
  }
}

CLI flags always win over defaults. Unset flags inherit from defaults. block_identity map key format: "<resource_type>.<block_type>". User entries override built-ins.

Built-in registry (no config needed):

Resource Block Identity key
azurerm_application_gateway all named sub-blocks name
azurerm_lb frontend_ip_configuration name
google_compute_firewall allow, deny protocol
google_compute_backend_service backend group
google_container_cluster node_pool name

How it works

terraform plan -refresh-only -out=plan
terraform show -json plan
  └─ resource_drift[]: before / after / after_sensitive
        │
        ├─ scalar attrs  ──► provenance trace through configuration tree
        │                         resolves to the single literal: resource attr,
        │                         module call arg, or variable default
        │
        ├─ nested blocks ──► HCL AST navigation (any depth)
        │                         literal attr → SetAttributeValue
        │                         var.x attr   → provenance trace (same chain)
        │                         add/remove   → AppendBlock / RemoveBlock
        │
        └─ dynamic blocks ──► extract for_each var from HCL tokens
                                   trace var chain → update collection literal

osmo emits unified diff  (or writes files with -write)
you run terraform plan   → 0 diff = drift fully resolved

What it absorbs

Drift pattern Example Status
Scalar attr — literal instance_type = "t3.micro""t3.large"
Scalar attr — var.x in module root passes size = 20; resource uses var.size
Scalar attr — deep module chain module.a → module.b → resource
Scalar attr — for_each map-of-objects (each.value.X) one map entry's attr updated, siblings intact
Scalar attr — for_each map-of-scalars (each.value) scalar entry updated, siblings intact
Nested block attr — literal (any depth) ebs_block_device { volume_size = 20 }
Nested block attr — var.x ref root_block_device { volume_size = var.size }
Nested block add new ingress {} block added out-of-band
Nested block remove ingress {} block removed out-of-band
Multi-instance block matching two ebs_block_device blocks; correct one updated
Deep nesting (3+ levels) server_side_encryption_configuration > rule > ...
dynamic block — for_each = var.x collection variable updated to full after-state
Sensitive attr (after_sensitive = true) skipped, reported — never written to plain text
Scalar attr removed from reality (null or absent in after) literal removed from resource block
Nested block attr removed from reality literal removed from nested block body
Safely reported, never silently wrong

Each unresolvable drift prints ! <address>.<attr>: <reason>. Nothing is guessed.

Pattern Reason reported
local.* locals not present in plan JSON
each.value.X / each.value (scalar) single-instance for_each map patched automatically — see above
each.key / count.* meta-argument derived from instance identity; cannot drift independently
Composed expression ("${var.a}-${var.b}") multiple references
Remote module constant cannot edit registry/git source in place
Shared constant across for_each instances cannot isolate one instance
dynamic block with for_each = local.x local not traceable
dynamic block with map for_each map reconstruction not supported
Sensitive attr never writes secrets to plain-text config
Null after-value removal from config not auto-applied

Module-aware provenance

osmo is provenance-driven, not a naive HCL grep. Each drifted attribute is traced through the plan's configuration tree to the single literal that controls it:

module.app.aws_instance.web   root_block_device.volume_size drifted

configuration tree:
  module_calls.app.expressions.vol_size  = { constant_value: 20 }   ← edit here
  module.app.resources[aws_instance.web]
    .expressions.root_block_device[0]
      .volume_size = { references: ["var.vol_size"] }               ← traced through

The edit lands at the root call argument — correct blast radius. The shared module source is never touched.


Dynamic blocks

dynamic blocks are supported when for_each is a direct var.x reference:

# modules/sg/main.tf
variable "rules" {}

resource "aws_security_group" "sg" {
  dynamic "ingress" {
    for_each = var.rules          # ← osmo traces this
    content {
      from_port = ingress.value.from_port
      to_port   = ingress.value.to_port
      protocol  = ingress.value.protocol
    }
  }
}
# main.tf (root)
module "sg" {
  source = "./modules/sg"
  rules  = [                      # ← osmo rewrites this
    { from_port = 80,  to_port = 80,  protocol = "tcp" },
    { from_port = 443, to_port = 443, protocol = "tcp" },   # absorbed
  ]
}

Drift that added the 443 rule out-of-band causes osmo to update the rules list in the root module call — no literal ingress {} block is injected.


Terraform Cloud / Remote execution

osmo detects the TFC backend from .terraform/terraform.tfstate automatically. For drift detection, run terraform init first, then use -plan-json with a plan downloaded from TFC. For -verify, osmo creates a speculative plan via the TFC API — no extra flags needed:

export TFE_TOKEN=<your-team-token>   # standard Terraform env var

# Drift detection: pass a pre-generated plan JSON
osmo -dir ./infra -plan-json plan.json

# -verify: osmo auto-detects TFC and creates a speculative plan via API
osmo -dir ./infra -plan-json plan.json -write -verify

Tag-based workspace selection (cloud { workspaces { tags = [...] } }): run terraform workspace select <name> first so osmo knows which workspace to use for verify.

Getting the plan JSON from TFC
# Option A: TFC web UI
# Plans → <run> → Download JSON → save as plan.json

# Option B: TFC API
TFC_ORG=my-org
TFC_WORKSPACE=my-workspace
WS_ID=$(curl -sS -H "Authorization: Bearer $TFE_TOKEN" \
  "https://app.terraform.io/api/v2/organizations/$TFC_ORG/workspaces/$TFC_WORKSPACE" \
  | jq -r '.data.id')
RUN_ID=$(curl -sS -H "Authorization: Bearer $TFE_TOKEN" \
  -H "Content-Type: application/vnd.api+json" \
  -d "{\"data\":{\"attributes\":{\"refresh-only\":true,\"plan-only\":true},\"type\":\"runs\",\"relationships\":{\"workspace\":{\"data\":{\"type\":\"workspaces\",\"id\":\"$WS_ID\"}}}}}" \
  https://app.terraform.io/api/v2/runs | jq -r '.data.id')
# poll until status = planned_and_finished, then:
PLAN_ID=$(curl -sS -H "Authorization: Bearer $TFE_TOKEN" \
  "https://app.terraform.io/api/v2/runs/$RUN_ID" | jq -r '.data.relationships.plan.data.id')
curl -sS -H "Authorization: Bearer $TFE_TOKEN" \
  "https://app.terraform.io/api/v2/plans/$PLAN_ID/json-output" > plan.json

Local execution mode: if you use TFC only for remote state (not remote execution), switch the workspace execution mode to Local — then osmo -dir ./infra works with no extra steps.


Provider support

osmo is provider-agnostic: it reads terraform show -json (or tofu show -json) output and edits HCL. Any provider whose resources appear in resource_drift[] is supported. Tested against:

  • AWS (aws_*)
  • Azure (azurerm_*)
  • Custom local modules

Works with both Terraform and OpenTofu — see OpenTofu support.


Requirements

  • Go 1.21+ (to build from source)
  • Terraform ≥ 1.0 or OpenTofu ≥ 1.6 (for show -json with resource_drift)

Install

Homebrew (macOS / Linux)
brew install pandey-raghvendra/osmo/osmo
Go install
go install github.com/pandey-raghvendra/osmo/cmd/osmo@latest
Build from source
git clone https://github.com/pandey-raghvendra/osmo
cd osmo
go build -o osmo ./cmd/osmo
GitHub Actions
- uses: pandey-raghvendra/osmo@v1
  with:
    dir: ./infra
    write: 'true'
    json: 'true'
  # outputs: result, drift_count, exit_code, json_path

All flags are available as inputs. OSMO_TF_BINARY is set automatically when you pass terraform_binary: tofu. See action.yml for the full input/output reference.


Known limitations

Gap Notes
local.* provenance locals absent from plan JSON — fundamental Terraform limit, cannot be fixed
Composed expressions "${var.a}-${var.b}" has multiple references — reported as unresolved
dynamic + map for_each map reconstruction from expanded blocks not supported
Out-of-band created resources resources that exist in Azure/AWS but not in config are invisible to resource_drift — use terraform import then osmo
Module arg / var default deletion when a removed attr traces through a module arg or variable default, osmo reports it unresolved; only resource-block literals are auto-removed
Terraform Cloud remote execution -verify uses the TFC API (speculative plan); -plan-json mode requires downloading the plan JSON from TFC first
Windows line endings hclwrite outputs LF; files with CRLF line endings may show larger diffs

Troubleshooting

Enable debug output

OSMO_DEBUG=1 osmo -dir ./infra
# or: osmo -dir ./infra -debug

Debug output shows: how many resources drifted, which file each attribute resolved to, every Unresolved with its reason, and what the verify plan returned. Always include this output when filing a bug report.


terraform plan -refresh-only fails inside osmo

osmo runs plan in a subprocess; all the usual causes apply — missing credentials, wrong workspace, version mismatch. Run terraform plan -refresh-only yourself first to isolate the issue.

resource not found in configuration

The resource is in state but osmo can't find its configuration block in the plan JSON. Common cause: the resource is inside a module sourced from a registry/Git URL rather than a local path — osmo cannot edit remote sources.

traces to root var.X which has no literal default

The drifted attribute traces to a variable with no default value (it's required input). Edit the module call's argument or the variable default manually.

ambiguous nested block match

Two or more blocks inside the resource have the same score — osmo can't identify which one changed without risking a wrong edit. Add a block_identity entry to .osmo.json to tell osmo which attribute uniquely identifies blocks of that type.

-verify rolls everything back unexpectedly

-verify runs a full terraform plan after writing and checks whether the absorbed resources still have planned changes. If your config has unrelated pending changes (i.e. things terraform would change anyway), those planned changes will trigger rollback. Use -target to scope both osmo and verify to the drifted resource only.

-approve requires an interactive TTY

You ran osmo with -approve in CI or a non-interactive shell. Use -target/-exclude for CI-safe scoping instead.

Diff looks right but terraform plan still shows changes

Most likely the drifted attribute was traced to a variable/module arg that controls multiple resources — osmo updates the literal, which may trigger changes on siblings. Review the diff carefully and use -target to absorb one resource at a time.


License

Apache License 2.0 — permissive, with explicit patent grant; suitable for use inside corporate infrastructure pipelines.

Directories

Path Synopsis
cmd
osmo command
Command osmo detects Terraform drift and proposes HCL changes that make configuration follow real-world reality (the "absorb" direction).
Command osmo detects Terraform drift and proposes HCL changes that make configuration follow real-world reality (the "absorb" direction).
internal
absorb
Package absorb rewrites Terraform HCL so that configuration follows real-world drift (the "absorb" direction).
Package absorb rewrites Terraform HCL so that configuration follows real-world drift (the "absorb" direction).
address
Package address parses Terraform resource addresses such as
Package address parses Terraform resource addresses such as
blockid
Package blockid provides the identity-key registry used to match set-typed nested blocks by a stable attribute (e.g.
Package blockid provides the identity-key registry used to match set-typed nested blocks by a stable attribute (e.g.
config
Package config parses the `configuration` block of `terraform show -json` output.
Package config parses the `configuration` block of `terraform show -json` output.
diff
Package diff renders unified text diffs for proposed file changes.
Package diff renders unified text diffs for proposed file changes.
inspect
Package inspect analyses a regular terraform plan (resource_changes) and classifies nested block-level diffs as provider noise (optional attrs echoed back by the API, computed IDs) versus intentional semantic changes.
Package inspect analyses a regular terraform plan (resource_changes) and classifies nested block-level diffs as provider noise (optional attrs echoed back by the API, computed IDs) versus intentional semantic changes.
provenance
Package provenance traces a drifted resource attribute back to the single HCL literal that should be edited to absorb the drift.
Package provenance traces a drifted resource attribute back to the single HCL literal that should be edited to absorb the drift.
tfc
Package tfc implements Terraform Cloud / Enterprise support for osmo.
Package tfc implements Terraform Cloud / Enterprise support for osmo.
tfplan
Package tfplan runs a refresh-only Terraform plan and extracts drift.
Package tfplan runs a refresh-only Terraform plan and extracts drift.
triage
Package triage classifies Terraform drift by risk level using a deterministic rule engine.
Package triage classifies Terraform drift by risk level using a deterministic rule engine.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL