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:
- Tags. A key starting
terraform:, a tag such asManagedBy=terraform, orTerraform=truemarks a resource as Terraform-owned. Anyaws:cloudformation:tag marks it as CloudFormation-owned. ARepotag can point at one of your linked repositories. - Terraform state. If you configure state locations, the resource's ARN or id is looked up in your state files.
- Code search. If the GitHub App is installed, the resource id is searched for in
.tf,.tf.jsonand.tfvarsfiles 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.
| Finding | Edit | Expected plan |
|---|---|---|
| Unassociated Elastic IP, unattached volume, orphaned snapshot, stale AMI | Delete the resource block | 0 to add, 0 to change, 1 to destroy |
| Log group with no retention | Set retention_in_days | 0 to add, 1 to change, 0 to destroy |
| gp2 volume | Change type = "gp2" to "gp3" | 0 to add, 1 to change, 0 to destroy |
| Lambda on x86 | Set 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
countorfor_eachblock. - 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.
| Verdict | Meaning |
|---|---|
ok | The plan changes only the target resource, as expected |
flagged | The plan touches something else, or does something different. A person should look |
pending | The check is missing or has not finished. Never treated as ok |
unreadable | The 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.