Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this project is

`terraform-demux` is a wrapper around the Terraform CLI that lets a single `terraform` invocation Just Work across projects with differing `required_version` constraints. It inspects the Terraform module in the current working directory (walking up to parents if needed), picks the newest stable release that satisfies all constraints, downloads it (cached on disk), and execs it — so users never manually switch Terraform versions when moving between projects.

## Common commands

- `go build ./cmd/terraform-demux` — build the binary.
- `go test -race ./...` — run unit tests with the race detector (matches CI).
- `go test ./internal/wrapper -run TestCheckStateCommand` — run a single test.
- `./test.sh` — integration tests against `testdata/` fixtures. Uses `TF_DEMUX_CACHE_HOME` to point the wrapper at a temp dir so the user cache isn't touched.
- `goreleaser build --snapshot` — local cross-platform build check (matches CI).
- `GOOS=windows go build ./...` — quick verify the build-tagged Windows files compile.
- `go run ./cmd/terraform-demux -- -version` — exercise the launcher against the current directory's module.

## Environment variables

- `TF_DEMUX_LOG` — non-empty enables verbose logging to stderr; otherwise logs are buffered and only flushed on error.
- `TF_DEMUX_ARCH` — overrides `runtime.GOARCH` when picking which platform binary to download (commonly `amd64` on Apple Silicon when an older Terraform release has no arm64 build).
- `TF_DEMUX_ALLOW_STATE_COMMANDS` — truthy values bypass the state-command guard. The wrapper strips all `TF_DEMUX_*` from the env it passes to the child terraform.
- `TF_DEMUX_CACHE_HOME` — override the cache directory (`os.UserCacheDir()/terraform-demux` by default; useful on macOS where `XDG_CACHE_HOME` is ignored).

## Architecture

- **Entrypoint** — `cmd/terraform-demux/main.go`: gets verbose flag from `TF_DEMUX_LOG`, calls `wrapper.SetupLogging`, picks arch from `TF_DEMUX_ARCH` or `runtime.GOARCH`, then calls `wrapper.RunTerraform`. On error, calls the flush callback to dump buffered logs to stderr.
- **`internal/wrapper`**:
- `wrapper.go` — the orchestration: ensure cache dir → walk from cwd up looking for a module declaring `required_version` via `tfconfig.LoadModule` (split into `getTerraformVersionConstraints` + per-dir `loadConstraintsAt` helper) → fetch the release index → pick the newest non-prerelease version satisfying all constraints → guard state commands → download → exec. If no constraint is found anywhere up the tree, the latest stable release is used. Pre-release versions are filtered out. Parse errors in unrelated parent directories are logged and skipped; only a parse error in the user's own cwd is fatal.
- `logging.go` — `SetupLogging(verbose, errSink)` returns a `flush` func; non-verbose mode buffers log output until `flush()` is called on the error path.
- `checkargs.go` — refuses `terraform import` (≥1.5), `state mv` (≥1.1), and `state rm` (≥1.7) unless `TF_DEMUX_ALLOW_STATE_COMMANDS` is truthy. Matches positional subcommands only, so flag values like `-var=action=import` don't trigger the guard.
- `signals_unix.go` / `signals_windows.go` — list of forwarded signals. On Unix: SIGINT, SIGTERM, SIGHUP, SIGQUIT. On Windows: SIGINT only (the rest aren't delivered to console processes).
- `exitcode_unix.go` / `exitcode_windows.go` — Unix returns `128+signum` for signaled children (matches shell convention); Windows just returns the child's exit code.
- **`internal/releaseapi/client.go`** — HTTP client backed by `httpcache` + `diskcache` against `releases.hashicorp.com/terraform/index.json`, plus a 5-minute local TTL on the parsed index (avoids HTTP revalidation for back-to-back invocations). Verifies SHA256 against the published `*_SHA256SUMS` file, unzips, atomically writes the binary into the cache dir, chmods it `0700`. Per-binary `flock` (`gofrs/flock`) prevents concurrent processes from re-downloading the same archive. The download path is split into named helpers (`findBuild`, `expectedChecksumFor`, `verifyChecksum`, `openZipReader`, `extractTerraformBinary`, `writeZipEntryAsExecutable`) so each step is independently testable; tests in `client_test.go` use `httptest` via the shared `serveRelease` / `fakeReleaseServer` / `newTestClient` fixtures.
- **Cache dir** — `os.UserCacheDir()/terraform-demux/` (e.g. `~/Library/Caches/terraform-demux/` on macOS). Has two subdirs: `http/` for httpcache entries, `bin/` for the extracted `terraform_<ver>_<os>_<arch>` binaries. The local index cache lives at `index.json` in the parent.

## CI

- `.github/workflows/ci.yaml` runs two jobs:
- `unit-tests` — matrix across `ubuntu-latest`, `macos-latest`, `windows-latest`. Runs `go test -race ./...`.
- `integration` — Linux only. Runs `./test.sh` and a `goreleaser build --snapshot` smoke build.
- Go version is pinned to `1.24` in both workflows and `go.mod`. The `1.24.0` patch in `go.mod` is forced by `gofrs/flock` (which declares `go 1.24.0` in its own go.mod); Go's module rules require our directive to be ≥ the dep-closure max.

## Release / distribution

- GoReleaser (`.goreleaser.yaml`, schema `version: 2`) builds linux/windows/darwin × amd64/arm64 (no windows/arm64) on tag pushes. The workflow uses `goreleaser-action@v7`.
- `ldflags: -X main.version=v{{.Version}}` injects the release tag into `cmd/terraform-demux/main.go`'s `version` var, so released binaries report the actual version (not the package default `v0.0.1+dev`).
- Tag pushes trigger `goreleaser release --clean` in `.github/workflows/release.yaml`. Goreleaser publishes the GitHub release, uploads binaries, and pushes the updated Homebrew formula to `Formula/terraform-demux.rb` over SSH using a deploy key (secret `RELEASE_DEPLOY_KEY`). The deploy key is the bypass actor for `main`'s ruleset; it's the only identity that can write to `Formula/` while branch protection is enforced.
- The recurring `chore: regenerate homebrew formula` commits on `main` come from this flow — hand-edits to that file get clobbered on the next release.

## Workflow notes

- Don't push directly to `main`; open a PR.
- Don't mention Claude / Claude Code in commit messages or PR descriptions.
- Run `terraform fmt` on any Terraform code you modify (including `testdata/` fixtures).
98 changes: 72 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,58 +1,104 @@
# terraform-demux

A seamless launcher for Terraform.
A drop-in `terraform` that picks the right Terraform version for every project — automatically.

[![CI](https://github.com/etsy/terraform-demux/actions/workflows/ci.yaml/badge.svg)](https://github.com/etsy/terraform-demux/actions/workflows/ci.yaml)
[![Go Report Card](https://goreportcard.com/badge/github.com/etsy/terraform-demux)](https://goreportcard.com/report/github.com/etsy/terraform-demux)
[![Latest release](https://img.shields.io/github/v/release/etsy/terraform-demux)](https://github.com/etsy/terraform-demux/releases/latest)
[![Go Reference](https://pkg.go.dev/badge/github.com/etsy/terraform-demux.svg)](https://pkg.go.dev/github.com/etsy/terraform-demux)
[![License: Apache-2.0](https://img.shields.io/github/license/etsy/terraform-demux)](LICENSE)

![demo of running `terraform-demux` with different `required_version` constraints](https://user-images.githubusercontent.com/1906605/117176639-15f8d880-ad9e-11eb-9e0d-65c0bd0ce8f9.gif)

## Why terraform-demux?

Switching Terraform versions between projects is annoying. `tfenv install`, `tfswitch`, asdf shims — they all need maintenance, and you still forget to run them. `terraform-demux` reads each project's `required_version` constraint, downloads the matching Terraform release, verifies its SHA-256 against HashiCorp's published `SHA256SUMS`, caches it, and execs it — so a single `terraform` command Just Works in every project.

No per-version installs. No shims. No surprises.

## Features

## Installation
- **Zero-config** — reads `required_version` from your `terraform { ... }` block, walking parent directories until it finds one
- **Drop-in** — installs a `terraform` symlink, so existing scripts, CI, and IDE plugins keep working
- **Verified downloads** — every archive is checked against HashiCorp's published `SHA256SUMS` before it's cached or executed
- **Concurrency-safe cache** — per-binary `flock` keeps parallel invocations from re-downloading the same release
- **Apple Silicon friendly** — `TF_DEMUX_ARCH=amd64` lets you run older Terraform releases that have no `arm64` build
- **Safer state operations** — refuses `terraform import`, `state mv`, and `state rm` on Terraform versions that have config-block alternatives ([`import`](https://developer.hashicorp.com/terraform/language/import), [`moved`](https://developer.hashicorp.com/terraform/language/modules/develop/refactoring), [`removed`](https://developer.hashicorp.com/terraform/language/resources/syntax)); opt back in with one env var when you need to
- **Cross-platform** — Linux, macOS, and Windows on `amd64` and `arm64` (no `windows/arm64`)

### Homebrew
## Quick start

**Note:** installing `terraform-demux` via Homebrew will automatically create a symlink named `terraform`.
### Homebrew (recommended)

1. `brew tap etsy/terraform-demux https://github.com/etsy/terraform-demux`
2. `brew install terraform-demux`
```sh
brew tap etsy/terraform-demux https://github.com/etsy/terraform-demux
brew install terraform-demux
```

The formula installs the binary as `terraform-demux` and creates a `terraform` symlink, so you can keep typing `terraform` everywhere.

```sh
terraform -version
```

### Manual

1. Grab the latest binary from the [releases page](https://github.com/etsy/terraform-demux/releases)
2. Copy it to a location in your `$PATH` as `terraform` (or leave it as `terraform-demux` if you'd like)
1. Grab the binary for your platform from the [latest release](https://github.com/etsy/terraform-demux/releases/latest).
2. Drop it into a directory on your `$PATH`. Either keep it as `terraform-demux`, or rename/symlink it to `terraform` if you want it to be invoked as the default.

## Usage
## How does it compare?

Simply navigate to any folder that contains Terraform configuration and run `terraform` as you usually would. `terraform-demux` will attempt to locate the appropriate [version constraint](https://www.terraform.io/docs/language/expressions/version-constraints.html) by searching in the current working directory and recursively through parent directories. If `terraform-demux` cannot determine a constraint, it will default to the latest possible version.
| | reads `required_version` directly | drop-in `terraform` (no shim) | auto-installs new versions on first use |
| --- | :-: | :-: | :-: |
| **terraform-demux** | ✅ | ✅ | ✅ |
| [tfenv](https://github.com/tfutils/tfenv) | partial (via `tfenv use min-required`) | shim wrapper | ❌ (`tfenv install <ver>` first) |
| [tfswitch](https://github.com/warrensbox/terraform-switcher) | ✅ | ❌ (manages a symlink you switch) | ✅ (interactive) |

### Architecture Compatibility
`terraform-demux`'s niche: you never run a `demux` subcommand or remember to install a version. You just run `terraform`.

`terraform-demux` supports a native `arm64` build that can also run `amd64` versions of `terraform` by specifying the `TF_DEMUX_ARCH` environment variable. This might be necessary for `terraform` workspaces that need older `terraform` versions that do not have `arm64` builds, or use older providers that do not have `arm64` builds.
## Configuration

It is recommended to set up the following shell alias for handy `amd64` invocations:
| Env var | Purpose |
| --- | --- |
| `TF_DEMUX_LOG` | Any non-empty value enables verbose logging to stderr. By default logs are buffered and only printed if something goes wrong. |
| `TF_DEMUX_ARCH` | Override the architecture used to pick a Terraform binary. Common case: `TF_DEMUX_ARCH=amd64` on Apple Silicon for older Terraform releases that have no native `arm64` build. |
| `TF_DEMUX_ALLOW_STATE_COMMANDS` | `1`, `true`, or `yes` bypasses the state-command guard described below. |
| `TF_DEMUX_CACHE_HOME` | Override the cache directory (otherwise `os.UserCacheDir()/terraform-demux/`). Useful for sandboxes and CI; also handy on macOS, where `XDG_CACHE_HOME` is ignored by `os.UserCacheDir`. |

Suggested shell alias for `amd64` invocations on Apple Silicon:

```sh
alias terraform-amd64="TF_DEMUX_ARCH=amd64 terraform-demux"
alias terraform-amd64="TF_DEMUX_ARCH=amd64 terraform"
```

### Enhanced State Operations Control
`TF_DEMUX_*` variables are stripped from the environment passed to the child Terraform process, so wrapper-internal config can't accidentally leak into providers or nested invocations.

## State-command guard

We highly encourage leveraging native Terraform refactoring blocks whenever feasible, provided your Terraform version supports them. In line with this, we've implemented stricter controls over state operations to enhance security and stability. It's important to note that state operations now require the `TF_DEMUX_ALLOW_STATE_COMMANDS` environment variable to be set for execution.
Native Terraform refactoring blocks are safer and reviewable in code, so `terraform-demux` refuses these CLI commands by default on the Terraform versions where a block alternative exists:

Usage Details
- `terraform import` — refused on Terraform `>= 1.5.0` (use the [`import`](https://developer.hashicorp.com/terraform/language/import) block).
- `terraform state mv` — refused on Terraform `>= 1.1.0` (use the [`moved`](https://developer.hashicorp.com/terraform/language/modules/develop/refactoring) block).
- `terraform state rm` — refused on Terraform `>= 1.7.0` (use the [`removed`](https://developer.hashicorp.com/terraform/language/resources/syntax) block).

* For Terraform 1.1.0 and above: we recommend using Terraform's [moved](https://developer.hashicorp.com/terraform/language/modules/develop/refactoring) block instead of the `terraform state mv` command.
When you really do need to run one of them, set the override:

```sh
TF_DEMUX_ALLOW_STATE_COMMANDS=true terraform state mv ...
```

* For Terraform 1.5.0 and above: we recommend using Terraform's [import](https://developer.hashicorp.com/terraform/language/import) block instead of the `terraform import` command.
The guard matches positional Terraform subcommands only, so flag values like `-var=action=import` won't trigger it.

* For Terraform 1.7.0 and above: we recommend using Terraform's [removed](https://developer.hashicorp.com/terraform/language/resources/syntax) block instead of the `terraform state rm` command.
## Cache directory

However, if necessary, you can still utilize the Terraform CLI to manipulate states. Before proceeding, ensure to set the environment variable `TF_DEMUX_ALLOW_STATE_COMMANDS=true` to confirm your intent.
`terraform-demux` caches HashiCorp's release index and downloaded Terraform binaries under `os.UserCacheDir()/terraform-demux/` (e.g. `~/Library/Caches/terraform-demux/` on macOS), split into `http/` and `bin/` subdirectories. Each binary is checksum-verified before it lands in `bin/`.

### Logging
Set `TF_DEMUX_CACHE_HOME` to point the cache somewhere else.

Setting the `TF_DEMUX_LOG` environment variable to any non-empty value will cause `terraform-demux` to write out debug logs to `stderr`.
## Contributing

## Cache Directory
PRs and issues welcome. Run `go test ./...` for the unit tests; `./test.sh` exercises the end-to-end flow against the fixtures in `testdata/`. CI runs on Linux, macOS, and Windows on every push.

`terraform-demux` keeps a cache of HashiCorp's releases index and downloaded Terraform binaries in the directory returned by [os.UserCacheDir](https://golang.org/pkg/os/#UserCacheDir), under `terraform-demux/` (e.g. `~/Library/Caches/terraform-demux/` on macOS).
## License

Setting `TF_DEMUX_CACHE_HOME` overrides this location. This is useful for tests, sandboxes, and CI runners where you don't want the wrapper to touch the user's real cache (note that `XDG_CACHE_HOME` is ignored by `os.UserCacheDir` on macOS).
Apache-2.0 — see [LICENSE](LICENSE).
Loading