Docs

Terraform pull requests

A Terraform-managed resource is fixed by a pull request that changes its block. A CLI delete would be undone, or turned into a failed apply, by the next terraform apply.

Built, not hosted Ownership detection and pull requests run in the self-hosted application, tested with a fake GitHub client. The hosted service does not open pull requests yet, and there is no public Spendsaw GitHub App.

How ownership is detected

Detectors run in this order, and the first match wins:

  1. Tags. A key starting terraform:, a tag such as ManagedBy=terraform, or Terraform=true marks a resource as Terraform-owned. Any aws:cloudformation: tag marks it as CloudFormation-owned. A Repo tag can point at one of your linked repositories.
  2. Terraform state. If you configure state locations, the resource's ARN or id is looked up in your state files.
  3. Code search. If the GitHub App is installed, the resource id is searched for in .tf, .tf.json and .tfvars files of the linked repositories.

Click-ops is a positive conclusion, because it sends a resource to CLI deletion. It is only reached when the state detector read every configured file and, by default, the code search also ran and found nothing. A detector that is not configured or fails leaves the finding unknown.

Reading state

State reading is opt-in and uses its own role, CostAgentStateReader, limited to .tfstate objects in one bucket. Only identifiers are kept from each file; every other attribute is discarded. The details are on the trust page.

IAC_STATE_LOCATIONS=[{"bucket": "acme-tfstate", "key": "envs/prod/terraform.tfstate",
  "account_id": "111122223333", "repo": "acme/infra", "path": "envs/prod"}]

Use prefix in place of key to read every .tfstate under a prefix. account_id is the account that owns the bucket, and path is the Terraform root inside the repository.

The GitHub App

  • Spendsaw authenticates as an installation of a GitHub App. There are no personal access tokens.
  • Permissions: contents write, pull requests write, checks read.
  • It only reads and writes repositories you both installed the app on and listed in GITHUB_REPOS. A resource tag cannot point it anywhere else.
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY_PATH=
GITHUB_INSTALLATION_ID=
GITHUB_REPOS=acme/infra

There is no public Spendsaw GitHub App to install yet. Self-hosting means registering your own app with those permissions.

The edits

The change is a deterministic text edit. Everything outside the edited span is preserved byte for byte: no reformatting, comments intact. The result is parsed afterwards to confirm that nothing but the target resource changed.

FindingEditExpected plan
Unassociated Elastic IP, unattached volume, orphaned snapshot, stale AMIDelete the resource block0 to add, 0 to change, 1 to destroy
Log group with no retentionSet retention_in_days0 to add, 1 to change, 0 to destroy
gp2 volumeChange type = "gp2" to "gp3"0 to add, 1 to change, 0 to destroy
Lambda on x86Set architectures to ["arm64"]0 to add, 1 to change, 0 to destroy

High-risk findings have no edit and stay recommendations. Note that deleting a volume through Terraform takes no snapshot; the pull request says so.

When no pull request is opened

An edit that cannot be shown to touch exactly one resource is refused, and the finding falls back to a recommendation with the reason. Examples:

  • The resource is one instance of a count or for_each block.
  • The value to change is an expression or variable, not a literal.
  • Another resource still references the block that would be deleted.
  • The resource lives in a module with a remote source, or in a local module used by more than one caller.
  • The block cannot be found, or the repository is not linked.

The pull request

  • Branch: cost-agent/<finding_id>, from the default branch. Opening the same finding twice returns the existing pull request.
  • The title and the facts in the body are written by code from the finding: the resource, the saving and its cost source, the expected plan, and any caution.
  • The model is not used to make the edit.
POST /api/findings/<finding_id>/pull-request
GET  /api/findings/<finding_id>/plan-check

The plan check

Spendsaw never runs Terraform. The pull request asks your existing CI to run terraform plan, then reads that check run's output and confirms the plan changes exactly the target resource in the expected way.

VerdictMeaning
okThe plan changes only the target resource, as expected
flaggedThe plan touches something else, or does something different. A person should look
pendingThe check is missing or has not finished. Never treated as ok
unreadableThe check output holds no plan that could be parsed

The check run is found by name, terraform plan by default. Change it with TERRAFORM_PLAN_CHECK_NAME. Your CI has to publish the plan text in the check run's output for this to work.

Sources: iac/ownership.py, iac/state.py, iac/config.py, iac/github_app.py, iac/hcl_edit.py, iac/pr.py, iac/plan_check.py, findings/builder.py.