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
tfp last                        # last terraform invocation (argv + exit code)

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 auto-detects the backend from the saved init state's backend_config:

  • azurermstorage_account_name + container_name present. Shells out to az storage blob.
  • s3bucket + dynamodb_table present. Shells out to aws dynamodb (uses the DynamoDB-locking variant of the S3 backend).

Both need the respective CLI installed and authenticated, and they read the relevant fields from the state saved by tfp init.

Two options once you have the ID:

  • tfp force-unlock <ID> or tfp self lock break (default) — the polite version: terraform releases the lease and deletes the lock metadata. Works for any backend. self lock break discovers the ID itself; force-unlock takes it as an argument.
  • tfp self lock break --blunt — skips terraform and releases the lock at the backend level (azurerm: break the blob lease; s3: delete the DynamoDB lock item). Use when terraform itself can't reach the backend.

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 snapshot              # `terraform state pull` to <tmp_dir>/snapshot-<ts>.tfstate
tfp self trace <subcommand>    # print the argv tfp would build; no exec, no op inject
tfp self lock status           # remote-state lock state (azurerm or s3+dynamodb)
tfp self lock break            # polite-by-default release; --blunt skips terraform
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.2.tar.gz (31.1 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.2-py3-none-any.whl (30.6 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: tf_project-0.1.2.tar.gz
  • Upload date:
  • Size: 31.1 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.2.tar.gz
Algorithm Hash digest
SHA256 4f91d1ec20f7f0411000fc2182fd6b40407ec7fee8344954f01a2e80b51ae453
MD5 ed973a836a4e9358a87133c2f9f3c8a7
BLAKE2b-256 91e2d8bc310b08964d1d70b04df9ed509b6edf9265aa621ece589dcad29cdb1d

See more details on using hashes here.

File details

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

File metadata

  • Download URL: tf_project-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 30.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.2-py3-none-any.whl
Algorithm Hash digest
SHA256 5fc68bfab884bccfbcddf7ad687a79e428e2438408b64dd8ca25bbc1947e9c43
MD5 97ef768a08e1c3c03a0fd9a6aadb9316
BLAKE2b-256 f57aefaa94663f4727b43fee96804441236303cd3c26cb632ff0188b66881e0a

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