Terraform provider
Manage Infrawrench's own configuration — budgets and cost policy, probes and alerts, schedules and freezes, accounts, roles and alert routing — as Terraform resources.
The Infrawrench Terraform provider manages Infrawrench’s own configuration as Terraform resources: cost allocation and reporting, monitoring, lifecycle governance, connected accounts and access control, and alert delivery. 47 resources and 6 data sources, each with its own plan, its own drift detection, and its own terraform import.
It is for teams who already keep infrastructure in Terraform and want the rest of their platform configuration to arrive the same way — through a pull request, reviewed, with a plan that says exactly what will change.
It does not manage your cloud resources. Your cloud provider’s own Terraform provider creates the database; this one manages the budget that watches what the database costs, the probe that checks it is up, the schedule that powers it down at night, and the rule that decides who gets paged when it is not.
Which Terraform feature is this?
Three features have Terraform in the name. They point in different directions, and picking the wrong one costs you an afternoon.
| Feature | What it manages | Use it when |
|---|---|---|
| Terraform export | Your cloud resources, written out as HCL | You want to adopt existing resources into Terraform, or leave |
| Config as code | A whole organization’s configuration, as one document | Cloning an org, seeding staging, disaster recovery |
| Terraform provider (this page) | Individual Infrawrench objects, as Terraform resources | Budgets and cost policy belong in your Terraform repo, under review |
The short version: Terraform export is about getting your resources out. Config as code moves a whole org at once. The provider manages one object at a time, continuously.
They compose. Config as code is still the right tool for “make staging look like production in one shot”. The provider is the right tool for “the platform team’s budget lives in git and changes go through review”.
Why not just wrap config as code?
A fair question, and it was the first design considered. Config as code already exports, plans and applies configuration, so wrapping it would have meant one implementation of diffing and validation instead of two.
It does not fit, for reasons that are worth knowing because they also tell you when to use each feature:
- The document doesn’t carry most of these objects. It has sections for budgets, cost centres, tag policy and metric alerts — but not for saved filters, cost reports, cost alerts, scenario models, billing rules, cost exports, business metrics, accounts, roles, API keys, bastions, status pages, schedules, freezes, the alert routing table, or most of the rest.
- The document addresses things by name, not by id. That’s exactly what makes one document apply cleanly to a fresh organization. It’s also what makes
terraform importimpossible, and what would turn renaming a budget into destroying and recreating it. - Apply is all-or-nothing. It takes the whole document in one transaction. Terraform walks its graph in parallel, so ten resources applying at once would mean ten overlapping rewrites of one document.
- Deleting one object would mean owning all of them. The document’s
replacemode deletes anything it doesn’t name, so removing one budget from Terraform would have destroyed every budget created outside it.
So the provider talks to the API routes directly, one object at a time. That is what buys you per-resource plans, real imports, and deletions that affect exactly one thing.
Install
terraform {
required_providers {
infrawrench = {
source = "Infrawrench/infrawrench"
version = "~> 0.1"
}
}
}
Authenticate
provider "infrawrench" {
organization_id = "org_01HXYZABCDEF"
# api_key comes from INFRAWRENCH_API_KEY
}
Every argument falls back to an environment variable, which is how you should run it in CI so the credential never lands in a .tf file or a saved plan:
| Argument | Environment variable | Default |
|---|---|---|
base_url | INFRAWRENCH_BASE_URL | https://app.infrawrench.com |
api_key | INFRAWRENCH_API_KEY | required |
organization_id | INFRAWRENCH_ORG_ID | required |
The organization id is the one in your URL when you’re signed in — it starts with org_.
Create the credential on Settings → API keys. The scope picker lists every permission the server recognises, grouped, with a filter box that matches the permission string itself — so you can paste costs: from the table below and tick what you need. The permissions each object needs are the same ones the UI enforces, so a key that can’t edit budgets in the app can’t edit them through Terraform either:
| Objects | Read | Write |
|---|---|---|
| Budgets | budgets:read | budgets:write |
| Cost centres, allocation rules, saved filters, reports, folders, alerts, scenario models | costs:read | costs:write |
| Tag policy | resources:read | org:settings:write |
| Billing rules, cost exports | costs:read | org:settings:write |
Billing rules and cost exports are the odd ones: reading them needs only costs:read, but changing them needs org settings. A key scoped to costs:write will read them and fail to write them.
Beyond cost, each area uses the permission the matching page in the app uses — resources:write for probes, status pages, sleep schedules and log queries; metric-alerts:write for metric alerts; dashboards:write for custom graphs; freezes:write for change freezes; bastions:write, ssh-keys:write, apikeys:write and team:role:write for the access resources; and org:settings:write for alert routing, Slack, Teams and the weekly digest. The full table is in the provider’s README.
Two of those need care. Alert routing, Slack, Teams and the digest have no separate read permission — their GET is gated on org:settings:write too, so a read-only key cannot even refresh them. And an account needs three: accounts:write to connect, secrets:write to rotate its credentials, accounts:delete to disconnect.
apikeys:write and team:role:write are the two the picker does not offer, because a key holding them would be refused anyway — see below.
Two resources need a signed-in credential
An API key reaches everything in the provider except two things, whatever scopes you give it:
| Resource | With an API key | Why |
|---|---|---|
infrawrench_api_key | Closed entirely | A key that can mint keys can mint a longer-lived one and outlive its own revocation |
infrawrench_role | Readable, not writable | A key shouldn’t manufacture durable authority for other principals |
If you manage either, keep them in a separate Terraform root that a person applies with a WorkOS access token — which is the separation the restriction is arguing for anyway. The provider recognises this particular 403 and tells you which resource is affected, rather than leaving you staring at a permission error that isn’t about permissions.
A worked example
One file: a saved filter, the cost centre it feeds, the rule that allocates to it, a budget, and an alert.
resource "infrawrench_saved_filter" "platform" {
name = "Platform team"
description = "Everything tagged team=platform, across every provider."
filter {
dimension = "tag"
tag_key = "team"
op = "in"
values = ["platform"]
}
}
resource "infrawrench_cost_centre" "platform" {
name = "Platform"
description = "Shared infrastructure owned by the platform team."
}
resource "infrawrench_allocation_rule" "platform_tag" {
cost_centre_id = infrawrench_cost_centre.platform.id
priority = 100
match {
tag_key = "team"
tag_value = "platform"
}
}
resource "infrawrench_budget" "platform" {
name = "Platform monthly"
amount_cents = 4500000
currency = "USD"
saved_filter_id = infrawrench_saved_filter.platform.id
cost_basis = "amortized"
threshold {
type = "actual"
percent = 80
}
threshold {
type = "forecast"
percent = 100
}
}
resource "infrawrench_cost_alert" "platform_spike" {
name = "Platform week-on-week spike"
cadence = "weekly"
direction = "increase"
threshold_percent = 25
filter {
dimension = "tag"
tag_key = "team"
op = "in"
values = ["platform"]
}
}
Referencing account ids by hand is a mistake waiting to happen, so read them instead:
data "infrawrench_accounts" "aws" {
plugin_id = "aws"
}
resource "infrawrench_allocation_rule" "first_aws_account" {
cost_centre_id = infrawrench_cost_centre.platform.id
priority = 200
match {
account_id = data.infrawrench_accounts.aws.accounts[0].id
}
}
Beyond cost
The same file can carry the monitoring, governance and alert-delivery configuration a platform team would otherwise click together by hand.
# One rule written once covers every instance the team creates afterwards:
# resources are selected by query, never by id.
resource "infrawrench_metric_alert" "cpu" {
name = "Platform CPU sustained"
tag_key = "team"
tag_value = "platform"
metric_key = "CPU %"
comparator = ">"
threshold = 90
for_minutes = 20
}
resource "infrawrench_probe" "api" {
name = "API health"
url = "https://api.example.com/health"
}
resource "infrawrench_status_page" "public" {
title = "Acme status"
published = true
component {
probe_id = infrawrench_probe.api.id
label = "API"
}
}
# A custom graph is source code, so it lives beside the rest of your source code.
resource "infrawrench_custom_graph" "burn" {
name = "Platform burn"
source = file("${path.module}/graphs/burn.ts")
}
# A permission set whose diff is the point: adding a grant is a reviewed line.
resource "infrawrench_role" "finance" {
name = "Finance"
description = "Read spend, own budgets, touch nothing else."
permissions = ["costs:read", "budgets:read", "budgets:write", "invoices:read"]
}
Powering a development machine down outside working hours is the cheapest saving there is, and the one nobody remembers to apply by hand. Resolve the resource rather than hard-coding its id:
data "infrawrench_resources" "api" {
account_id = data.infrawrench_accounts.aws.accounts[0].id
resource_type_id = "ec2_instance"
name_contains = "api"
}
resource "infrawrench_schedule" "api_nights" {
resource_id = data.infrawrench_resources.api.resources[0].id
account_id = data.infrawrench_resources.api.resources[0].account_id
days_of_week = [1, 2, 3, 4, 5]
stop_time = "19:00"
start_time = "08:00"
timezone = "Europe/Berlin"
}
Alert routing is one resource, in order
Alert routing is a single ordered table rather than one resource per rule, because order is the semantics: the list is evaluated top to bottom and is first-match-wins unless a rule sets continue_on_match. A rule cannot meaningfully be written without saying where it sits, so per-rule resources would have needed a position attribute and then a way to stop two configurations claiming the same slot.
data "infrawrench_slack_installations" "workspace" {}
resource "infrawrench_slack_channel" "platform" {
installation_id = data.infrawrench_slack_installations.workspace.installations[0].id
channel_id = "C0123456789"
channel_name = "platform-alerts"
}
resource "infrawrench_alert_routing" "org" {
rule {
name = "Spend goes to the platform channel"
condition {
field = "trigger"
op = "in"
values = ["budgetAlerts", "anomalyAlerts", "costChangeAlerts"]
}
destination {
kind = "slack"
channel_id = infrawrench_slack_channel.platform.id
}
}
rule {
name = "Anything critical also wakes phones"
condition {
field = "severity"
op = "gte"
severity = "critical"
}
destination {
kind = "push"
}
# Held overnight, not dropped — a held alert is delivered when the window
# closes, and critical is exempt from the hold entirely.
quiet_hours {
timezone = "Europe/Berlin"
start_minute = 1320 # 22:00
end_minute = 420 # 07:00 — an overnight window
days = [1, 2, 3, 4, 5]
urgent_override = "critical"
}
# Nobody acknowledged in fifteen minutes? Widen it.
escalation {
after_minutes = 15
destination {
kind = "slack"
channel_id = infrawrench_slack_channel.platform.id
}
}
}
rule {
name = "Swallow drift chatter"
condition {
field = "trigger"
op = "in"
values = ["resourceDrift"]
}
# No destination: an enabled rule with nowhere to go silences the category
# without deleting the rules that would otherwise catch it.
}
}
Connecting the Slack workspace itself is an OAuth flow, which a Terraform provider cannot perform. Install the app once on Settings → Alerts, then read the installation with data.infrawrench_slack_installations.
Because the write replaces the whole table, the resource has to carry every field a rule can hold — including quiet hours and escalation policies you may have set up in the app before adopting Terraform. Import the resource first and read what comes back: whatever the configuration does not say, the next apply clears.
Adopting what you already have
Nobody starts from an empty organization, so every object with an id imports:
terraform import infrawrench_budget.platform b1f2c3d4-...
terraform import infrawrench_cost_centre.platform 9a8b7c6d-...
terraform import infrawrench_probe.api 2c3d4e5f-...
terraform import infrawrench_role.finance 1a2b3c4d-...
The id is in the URL when you open the object in the app, and in the --json output of the CLI.
Organization singletons — the ones there is only ever one of, with no id of their own — import under the organization id:
terraform import infrawrench_tag_policy.this org_01HXYZABCDEF
terraform import infrawrench_alert_routing.org org_01HXYZABCDEF
That covers tag policy, alert routing, currency settings, the anomaly and efficiency alert settings, the drift, expiry and posture alert settings, session recording, the weekly digest, and the Jira and Linear connections.
Two resources import under something other than their own id. A report notification hangs off its report, so it takes <report-id>/<notification-id>; a workflow schedule takes the id of the workflow it belongs to.
Secrets don’t come back
Several resources hold write-only material that no route returns. Importing them works — recovering the secret does not. After importing, put it back in your configuration; where the API accepts an omitted credential as “keep the stored one”, you can leave it out instead.
| Resource | Not recoverable | What you can still read |
|---|---|---|
infrawrench_cost_export | access key, secret, webhook URL | has_credentials, credential_hint |
infrawrench_account | the whole credentials map | nothing |
infrawrench_api_key | key — returned once, at creation | prefix |
infrawrench_ssh_key | private_key — returned once | public_key, fingerprint |
infrawrench_bastion | token — returned once | token_prefix |
infrawrench_msteams_webhook | url | url_hint |
infrawrench_jira_integration | api_token | token_hint |
infrawrench_linear_integration | api_key | key_hint |
infrawrench_deploy_trigger | answers | nothing |
The provider can’t detect drift on any of these. That’s a property of the API rather than a limitation of the provider — the values genuinely aren’t returned, deliberately.
One warning about state files
infrawrench_api_key, infrawrench_ssh_key in generate mode, and infrawrench_bastion each write a credential into your Terraform state in plaintext, because the API returns it exactly once and never again.
Use them only with a state backend you’d put any other secret in — encrypted, access-controlled, not a local file in a repository. Prefer piping the value straight into the secret store that consumes it rather than into a Terraform output.
What’s managed
Cost allocation and reporting
| Resource | Manages |
|---|---|
infrawrench_budget | Budgets and their alert thresholds |
infrawrench_cost_centre | Cost centres |
infrawrench_allocation_rule | The rules that map spend onto cost centres |
infrawrench_tag_policy | Required tags and their enforcement |
infrawrench_saved_filter | Saved cost filters |
infrawrench_cost_report | Cost reports |
infrawrench_cost_report_folder | Report folders |
infrawrench_cost_report_notification | Scheduled delivery of a report to Slack, Teams or email |
infrawrench_cost_alert | Cost change alerts |
infrawrench_cost_annotation | Notes pinned to a date on cost charts |
infrawrench_scenario_model | Scenario models |
infrawrench_billing_rule | Billing rules |
infrawrench_cost_export | Scheduled cost exports |
infrawrench_business_metric | Unit-cost denominators — the definition, not the values |
infrawrench_managed_account | Managed accounts an MSP bills |
infrawrench_currency_settings | The organization’s display currency |
infrawrench_exchange_rate | One stated rate, effective from a day |
infrawrench_anomaly_settings | Anomaly detection thresholds |
infrawrench_efficiency_alert_settings | Commitment and unit-cost alert thresholds |
Monitoring
| Resource | Manages |
|---|---|
infrawrench_probe | Synthetic probes |
infrawrench_status_page | Public status pages and their components |
infrawrench_metric_alert | Metric threshold alerts |
infrawrench_log_query | Log workspace saved queries and match alerts |
infrawrench_custom_graph | Custom graphs, source and all |
Lifecycle governance
| Resource | Manages |
|---|---|
infrawrench_schedule | Sleep schedules |
infrawrench_change_freeze | Change freezes |
infrawrench_drift_alert_settings | What the change timeline notifies about |
infrawrench_expiry_alert_settings | Expiry radar lead time |
infrawrench_posture_alert_settings | Whether posture findings notify |
infrawrench_session_recording_settings | Session recording and retention |
infrawrench_network_flow_settings | Whether network flow costs are collected |
Accounts and access
| Resource | Manages |
|---|---|
infrawrench_account | Connected cloud accounts |
infrawrench_bastion | Bastion agent enrollments |
infrawrench_role | Custom roles and permissions |
infrawrench_api_key | API keys |
infrawrench_ssh_key | SSH keys, imported or generated |
infrawrench_ssh_snippet | Saved SSH fan-out commands |
infrawrench_deploy_trigger | Redeploy-on-push triggers for Infrafile projects |
infrawrench_workflow_schedule | The cron on an existing workflow |
Alert delivery
| Resource | Manages |
|---|---|
infrawrench_alert_routing | The whole ordered alert routing table, quiet hours and escalation policies included |
infrawrench_on_call_schedule | An on-call rotation a routing rule can name as a destination |
infrawrench_slack_channel | Slack channels as destinations |
infrawrench_msteams_webhook | Teams webhooks as destinations |
infrawrench_digest_settings | When the weekly digest is sent |
infrawrench_digest_recipient | An email address the digest goes to |
infrawrench_jira_integration | The Jira connection |
infrawrench_linear_integration | The Linear connection |
Data sources
| Data source | Reads |
|---|---|
infrawrench_accounts | Connected accounts — for resolving account ids |
infrawrench_plugins | Available plugins — for resolving plugin ids |
infrawrench_cost_centres | Cost centres, including ones not managed by Terraform |
infrawrench_resources | Synced resources — for resolving a probe or schedule target |
infrawrench_permissions | The permission catalogue roles and keys grant from |
infrawrench_slack_installations | Connected Slack workspaces |
Things worth knowing
Objects deleted outside Terraform come back. Every resource does a real read on refresh. If someone deletes a budget in the app, the next plan shows it as needing to be created again rather than failing.
Live figures aren’t in state. A budget’s month-to-date spend, an alert’s last-fired time, a probe’s uptime, an export’s last run — none of it appears as a Terraform attribute. It changes constantly, and putting it in state would make every plan noisy for no benefit. Read those from the app, the CLI, or the API.
Singletons don’t really get destroyed. There is always exactly one anomaly settings row, one routing table, one tag policy — so terraform destroy can’t remove them. Each one does the sensible thing instead, and it differs on purpose:
- The cost settings and alert routing restore the shipped defaults. An organization routing nothing at all is a worse state than the default one.
- Currency settings clear the display currency, which turns conversion off. Your stated exchange rates survive, so you can turn it back on without re-entering them.
- Session recording is deliberately left running. Silently disabling an audit control because someone deleted a resource block is not a safe default.
- The drift, expiry and posture alert settings are left alone — they have no documented shipped values to restore to.
- Network flow collection is turned off, and it’s the one singleton where destroy deliberately changes something. Collection runs queries your cloud provider bills to your own account; leaving it running for a resource you deleted would keep spending your money with nothing in Terraform left to explain why.
Deletes can be refused. Saved filters and scenario models something still points at return a conflict rather than being deleted, and the error names what’s referencing them. A role members still hold, and a managed account with invoices against it, are refused the same way. Repoint or reassign first — Terraform won’t decide for you which permission set those people should get instead.
Some things are create-only. A probe’s linked resource, a schedule’s resource and account, a Slack channel’s workspace, a deploy trigger’s repo and branch — the API has no route to change them, so Terraform replaces instead. The plan says so; it’s worth reading before applying to a bastion, where replacing means a new enrollment token and an agent that has to be restarted with it.
Credentials are marked sensitive. Export keys, account credentials, API keys, SSH private keys, bastion tokens and webhook URLs never appear in plan output in plaintext. They do appear in state — see the warning above.
Billing rule amounts are in the major currency unit. Everything else in Infrawrench counts cents; a fixed billing rule’s amount is dollars. It’s the one inconsistency, and it is inherited from the API.
Custom graph source isn’t type-checked at plan time. The API has a checker, but calling it from a plan would mean your code had to compile before Terraform would tell you what it was about to change. A graph whose source doesn’t compile is stored and fails when it renders.
Related
- Config as code — move a whole organization’s configuration as one document
- Terraform export — write your cloud resources out as HCL
- The CLI — the same objects from a terminal
- HTTP API — what the provider calls underneath