Skip to main content

Custom Terraform project wrapper used to provision infrastructure.

Project description

tf-project

A thin, opinionated Terraform-project wrapper. Provides a single tf-project (aka tfp) CLI that wraps terraform init / plan / apply / refresh / destroy / fmt / output / state mv with:

  • Per-tfvars remote-state backend keys (one state per tfvars file).
  • Pluggable tfvars preprocessing — defaults to 1Password's op inject so you can keep op://... references in tfvars committed to git.
  • A small JSON state file (tmp/my_terraform_state.json) capturing which tfvars was last init'd, so subsequent plan / apply need no arguments.

Install

pip install tf-project

The CLI is exposed as both tf-project and the shorter alias tfp.

Configure

The fastest way to get a config is:

cd path/to/your-terraform-repo
tfp self init

This drops a tf_project.toml at the repo root, or — if a pyproject.toml is already present — appends a [tool.tf_project] section to it. It refuses to overwrite an existing config.

You can also write the file yourself:

[tf_project]
terraform_dir    = "terraform"            # where your <project>/ subdirs live
tfvars_dir       = "tfvars"               # used by `tfp fmt`
tmp_dir          = "tmp"                  # state file + tfplan land here
state_key_prefix = "terraform/azure/"     # remote backend key prefix

# Optional. Defaults to `shutil.which("terraform")` at config-load time.
# A value with a path separator is resolved relative to the project root;
# a bare name (e.g. `"tofu"`) is left for `subprocess` to PATH-resolve.
# terraform_binary = "bin/terraform-1.7.5"

# Optional. Static `-backend-config` k/v pairs applied to every `tfp init`.
# Banner-level `backend_config` overrides individual keys.
[tf_project.backend_config]
# resource_group_name  = "tfstate-rg"
# storage_account_name = "tfstate0001"
# container_name       = "tfstate"

# Optional. Defaults to `op inject`. Set `command = []` to disable.
[tf_project.secrets]
command = ["op", "inject", "--in-file", "{in}", "--out-file", "{out}"]

Alternatively, place the same fields under [tool.tf_project] in your pyproject.toml. tf_project.toml wins if both are present. The package walks up from cwd to find either file.

Each tfvars file should carry a one-line JSON banner identifying its project:

# {"header": "terraform", "project": "demo"}
foo = "bar"

tfp init <tfvars> reads that banner to pick the terraform_dir/<project>/ subdirectory to operate on, and persists a state record so subsequent commands take no arguments.

The banner also accepts these optional fields:

Field Purpose
state_key Full remote-state backend key. Overrides the default <state_key_prefix><tfvars-stem>.tfstate. Use to share state across files.
env JSON object of string → string env vars. Merged into the saved state on top of any previously-captured environment.
backend_config JSON object of extra -backend-config k=v pairs (e.g. resource_group_name, storage_account_name). Wins over the config-level [tf_project.backend_config] table.
# {"header":"terraform","project":"core","state_key":"shared/core.tfstate","env":{"ARM_SUBSCRIPTION_ID":"…"}}
foo = "bar"

Usage

tfp init tfvars/dev.tfvars     # init backend for this tfvars
tfp plan                        # plan using last init'd tfvars
tfp plan -t module.foo.bar      # targeted plan (repeatable)
tfp plan -r module.foo.bar      # force-replace (repeatable)
tfp apply                       # apply the saved plan
tfp refresh                     # apply directly (no saved plan)
tfp destroy -t module.foo.bar   # targeted destroy
tfp fmt                         # terraform fmt -recursive over terraform/ + tfvars/
tfp output                      # terraform output -json
tfp state-mv aws_x.a aws_x.b    # terraform state mv
tfp status                      # one-line summary of the current init

Global flags

  • --verbose — echo the terraform argv to stderr before exec.
  • --dry-run — print the argv and skip execution. Combine with any subcommand (wrapped or passthrough) to preview what would be invoked.
tfp --dry-run plan -t module.foo
tfp --verbose apply

Recovering from a stuck Azure tfstate lock

When terraform is killed mid-operation (Ctrl-C, OOM, network drop), the azurerm backend leaves an infinite blob lease on the tfstate. Subsequent runs fail with "state locked".

tfp self lock status            # lease + lock metadata (exits 2 if locked)
tfp self lock break             # break the blob lease (prompts for confirmation; -y to skip)
tfp force-unlock <LOCK_ID>      # backend-agnostic, via terraform passthrough

tfp self lock status reads the lock ID directly from the blob's terraformlockid metadata (which the azurerm backend writes as base64-encoded JSON), so you don't have to provoke a failed terraform run to discover it:

locked         = True
lease_state    = leased
lease_duration = infinite
lock_id        = 12345678-90ab-cdef-1234-567890abcdef
lock_who       = user@host
lock_operation = OperationTypePlan
lock_created   = 2026-05-14T12:00:00Z

To release via terraform: tfp force-unlock 12345678-90ab-cdef-1234-567890abcdef

tfp self lock {status,break} shells out to az storage blob and so requires the Azure CLI to be installed and authenticated. It reads the storage account / container / blob from the saved init state — so this only works after tfp init has captured [tf_project.backend_config] (storage_account_name, container_name) and the key.

Two options once you have the ID:

  • tfp force-unlock <ID> — the polite version: terraform releases the lease and deletes the lock metadata. Works for any backend.
  • tfp self lock break — the blunt version: breaks the blob lease without going through terraform. Useful when terraform itself can't reach the backend or when you don't have the ID.

Apply safety

tfp plan records a SHA-256 of the decrypted tfvars alongside the saved tfplan (<tfplan>.meta.json). tfp apply refuses to run if the tfvars content changed since the plan was generated. Pass --force to override.

Passthrough to terraform

Any subcommand not in the wrapped list above is forwarded to terraform verbatim, prefixed with -chdir=<source_root> and the environment from the last tfp init. So the full Terraform CLI surface is reachable through tfp:

tfp validate                          # terraform -chdir=... validate
tfp validate -json                    # flags pass straight through
tfp workspace list                    # terraform -chdir=... workspace list
tfp taint module.foo.bar              # terraform -chdir=... taint module.foo.bar
tfp providers schema -json            # terraform -chdir=... providers schema -json
tfp version                           # works without init (no -chdir prepended)

The wrapped subcommands also accept extra terraform flags, which are appended to the underlying invocation:

tfp plan -t module.foo -- -detailed-exitcode -compact-warnings
tfp apply -- -parallelism=20

(The -- is optional — anything Typer doesn't recognise is forwarded — but including it is the most readable way to signal "everything after this is raw terraform flags".)

Self-management

A group of tfp self ... commands manages the tool itself, not Terraform:

tfp self init                  # bootstrap tf_project.toml or [tool.tf_project]
tfp self config print          # show effective config (--json for JSON)
tfp self config path           # show which file the config came from
tfp self state show            # pretty-print the saved init state
tfp self state clear           # delete the saved state file
tfp self doctor                # sanity-check the environment (PATH, dirs, ...)
tfp self banner check <tfvars> # validate a tfvars banner; print resolved fields
tfp self lock status           # show Azure blob-lease state of the remote tfstate
tfp self lock break            # break the lease after a hard-kill left state locked
tfp force-unlock <LOCK_ID>     # backend-agnostic, via terraform passthrough

Development

pdm install
pdm run ruff check src tests
pdm run pyright src
pdm run pytest                  # unit tests
pdm run pytest -m integration   # smoke tests (need `terraform` on PATH)

Release

  1. Merge to main. CI runs; on success the Release workflow triggers.
  2. Release builds the wheel, publishes to TestPyPI then PyPI via OIDC trusted publishing, then opens a pdm bump patch commit to prepare the next version.
  3. PyPI trusted-publishing setup is a one-time step on pypi.org → Manage project → Publishing, tied to this repo and the release GitHub Environment.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

tf_project-0.1.0.tar.gz (24.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tf_project-0.1.0-py3-none-any.whl (24.6 kB view details)

Uploaded Python 3

File details

Details for the file tf_project-0.1.0.tar.gz.

File metadata

  • Download URL: tf_project-0.1.0.tar.gz
  • Upload date:
  • Size: 24.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: pdm/2.26.9 CPython/3.13.13 Linux/6.17.0-1010-azure

File hashes

Hashes for tf_project-0.1.0.tar.gz
Algorithm Hash digest
SHA256 5b0c477d999d37924816394f8c3869ac2a6f9c4a49a95a9176d3dba795701693
MD5 fec036310df4a0294e921931c9e57599
BLAKE2b-256 d3f2131ecd46058dfb55cd55b363e2b66e87f255ccb69ec3581ffb72067a832d

See more details on using hashes here.

File details

Details for the file tf_project-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: tf_project-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 24.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: pdm/2.26.9 CPython/3.13.13 Linux/6.17.0-1010-azure

File hashes

Hashes for tf_project-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2b816b5413270cf60ea1a62c91a98a812ee6dad45fcdadf947794194b81410dc
MD5 6cab704addbe7441aa6d138ae7889def
BLAKE2b-256 1c8bece42534c3b5cc5031474f0fb64d1e066e3d9c0c732e43fcf32686b6c767

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page