Subchapter 28.12
references/phases/discover/discover-terraform.mdMarkdown18 KBView on GitHub
Self-contained Terraform discovery sub-file. Scans
.tffiles forheroku_*resource types, extracts resource configuration attributes, maps them to inventory format, and integrates Procfile/app.json parsing when repo artifacts are available. If no files with resources are found, exits cleanly with no output.
.tfheroku_*Execute ALL steps in order. Do not skip or optimize.
Recursively scan the workspace directory for Terraform files containing Heroku resources.
Glob pattern: **/*.tf
Exclude directories:
.terraform/ (provider binaries and cached modules)node_modules/.git/For each discovered .tf file, scan file contents for resource blocks matching:
resource "heroku_*" "..." {Specifically, match lines containing resource "heroku_ (with double-quote before the provider prefix).
Target resource types:
heroku_appheroku_addonheroku_formationheroku_domainheroku_config_associationheroku_pipelineheroku_spaceFor each .tf file containing heroku_* resources, parse the Terraform HCL to extract resource blocks. Process files in alphabetical order for deterministic output.
For each resource block with a heroku_* type, extract:
| Field | Source | Description |
|---|---|---|
tf_resource_type | Block type label (e.g., heroku_app) | The Terraform resource type |
tf_resource_name | Block name label (e.g., "my-app") | The Terraform resource local name |
tf_file | File path relative to workspace root | Source file for traceability |
attributes | All key-value pairs within the block body | Configuration attributes |
Extract top-level attributes from each resource block. Handle these patterns:
Simple attributes:
resource "heroku_app" "my_app" {
name = "my-web-app"
region = "us"
stack = "heroku-22"
}→ { "name": "my-web-app", "region": "us", "stack": "heroku-22" }
Nested blocks (flatten with dot notation for key fields):
resource "heroku_app" "my_app" {
name = "my-web-app"
region = "us"
organization {
name = "my-org"
}
}→ { "name": "my-web-app", "region": "us", "organization.name": "my-org" }
Dynamic references and interpolations:
resource "heroku_addon" "postgres" {
app_id = heroku_app.my_app.id
plan = "heroku-postgresql:standard-0"
}heroku_app.my_app.id) → record as "ref:heroku_app.my_app.id""${var.name}") → record as "var:name" (unresolvable, metadata only)Lists and maps:
resource "heroku_config_association" "config" {
app_id = heroku_app.my_app.id
vars = {
DATABASE_URL = "postgres://..."
REDIS_URL = "redis://..."
}
}"vars_keys": ["DATABASE_URL", "REDIS_URL"]| Attribute | Inventory Field | Required |
|---|---|---|
name | app_name | Yes |
region | region | Yes |
stack | stack (feeds Cedar/Fir detection) | No |
space | space (Private Space name) | No |
organization.name | organization | No |
buildpacks | buildpacks (array) | No |
acm | acm_enabled (boolean) | No |
| Attribute | Inventory Field | Required |
|---|---|---|
app_id | resolve to heroku_app via reference | Yes |
plan | plan (format: service:plan-tier) | Yes |
Parse the plan attribute to split into addon_service and plan_tier:
"heroku-postgresql:standard-0" → addon_service: "heroku-postgresql", plan: "standard-0""papertrail:choklad" → addon_service: "papertrail", plan: "choklad"| Attribute | Inventory Field | Required |
|---|---|---|
app_id | resolve to heroku_app via reference | Yes |
type | process_type | Yes |
quantity | quantity (integer) | Yes |
size | dyno_type | Yes |
| Attribute | Inventory Field | Required |
|---|---|---|
app_id | resolve to heroku_app via reference | Yes |
hostname | hostname | Yes |
sni_endpoint_id | sni_endpoint | No |
| Attribute | Inventory Field | Required |
|---|---|---|
app_id | resolve to heroku_app via reference | Yes |
vars | config_var_keys (keys only, values redacted) | Yes |
Security: Record variable KEYS ONLY from vars map. Redact all values.
| Attribute | Inventory Field | Required |
|---|---|---|
name | pipeline_name | Yes |
Note: Pipeline stage assignments come from heroku_pipeline_coupling resources. If couplings are present, associate apps to stages. If not found, record pipeline with empty stages.
| Attribute | Inventory Field | Required |
|---|---|---|
name | space_name | Yes |
region | region | Yes |
shield | shield (boolean) | No (default: false) |
organization | organization | No |
Terraform resources often reference each other (e.g., heroku_addon.postgres.app_id = heroku_app.my_app.id).
Resolution strategy:
heroku_app resources: tf_resource_name → app_name.app_id reference:
heroku_app resource in the same set → resolve to that app’s name attribute."unresolved:{reference}" and set heroku_app to "unassociated".app_id is a literal string (UUID or app name) → use directly.If a .tf file contains syntax that prevents extraction (malformed HCL, incomplete blocks):
{filename} at line {N}: {error}. Skipping this resource block.”parse_warnings array.Transform each extracted Terraform resource into the standard inventory resource entry format.
| Terraform Type | Inventory resource_id Format | Inventory resource_type |
|---|---|---|
heroku_app | app:{app_name} | app |
heroku_addon | addon:{app_name}:{addon_service}:{plan} | addon |
heroku_formation | formation:{app_name}:{process_type} | formation |
heroku_domain | domain:{app_name}:{hostname} | domain |
heroku_config_association | config:{app_name} | config |
heroku_pipeline | pipeline:{pipeline_name} | pipeline |
heroku_space | space:{space_name} | space |
Each resource becomes a standard inventory entry:
{
"resource_id": "<generated per 2a>",
"resource_type": "<mapped type>",
"heroku_app": "<resolved app name or 'unassociated'>",
"config": { "<extracted attributes>" },
"source": "terraform",
"tf_file": "<relative file path>",
"tf_resource_name": "<terraform local name>"
}resource "heroku_formation" "web" {
app_id = heroku_app.my_app.id
type = "web"
quantity = 2
size = "standard-2x"
}→
{
"resource_id": "formation:my-web-app:web",
"resource_type": "formation",
"heroku_app": "my-web-app",
"config": {
"process_type": "web",
"command": null,
"dyno_type": "standard-2x",
"quantity": 2
},
"source": "terraform",
"tf_file": "heroku.tf",
"tf_resource_name": "web"
}Note: Terraform heroku_formation does not include the command field — this comes from Procfile. Set to null when unavailable.
resource "heroku_addon" "postgres" {
app_id = heroku_app.my_app.id
plan = "heroku-postgresql:standard-0"
}→
{
"resource_id": "addon:my-web-app:heroku-postgresql:standard-0",
"resource_type": "addon",
"heroku_app": "my-web-app",
"config": {
"addon_service": "heroku-postgresql",
"plan": "standard-0",
"provider": "heroku"
},
"source": "terraform",
"tf_file": "heroku.tf",
"tf_resource_name": "postgres"
}resource "heroku_space" "private" {
name = "my-private-space"
organization = "my-org"
region = "virginia"
shield = false
}→
{
"resource_id": "space:my-private-space",
"resource_type": "space",
"heroku_app": "unassociated",
"config": {
"space_name": "my-private-space",
"region": "virginia",
"shield": false,
"organization": "my-org",
"peering": {
"detected": false,
"vpc_id": null,
"peer_cidr": null
}
},
"source": "terraform",
"tf_file": "spaces.tf",
"tf_resource_name": "private"
}Note: VPC peering cannot be detected from Terraform alone unless heroku_space_peering_connection_accepter or similar resources are present. Default peering.detected to false unless peering resources found.
resource "heroku_pipeline" "main" {
name = "my-pipeline"
}→
{
"resource_id": "pipeline:my-pipeline",
"resource_type": "pipeline",
"heroku_app": "unassociated",
"config": {
"pipeline_name": "my-pipeline",
"stages": [],
"review_apps_enabled": false,
"detection_status": "detect-only"
},
"source": "terraform",
"tf_file": "pipelines.tf",
"tf_resource_name": "main"
}If heroku_pipeline_coupling resources are found, populate the stages array:
resource "heroku_pipeline_coupling" "production" {
app_id = heroku_app.my_app.id
pipeline = heroku_pipeline.main.id
stage = "production"
}→ Add to pipeline stages: { "stage": "production", "app": "my-web-app" }
resource "heroku_domain" "www" {
app_id = heroku_app.my_app.id
hostname = "www.example.com"
}→
{
"resource_id": "domain:my-web-app:www.example.com",
"resource_type": "domain",
"heroku_app": "my-web-app",
"config": {
"hostname": "www.example.com",
"sni_endpoint": null
},
"source": "terraform",
"tf_file": "dns.tf",
"tf_resource_name": "www"
}resource "heroku_config_association" "config" {
app_id = heroku_app.my_app.id
vars = {
DATABASE_URL = "postgres://..."
REDIS_URL = "redis://..."
SECRET_KEY = "abc123"
}
}→
{
"resource_id": "config:my-web-app",
"resource_type": "config",
"heroku_app": "my-web-app",
"config": {
"config_var_keys": ["DATABASE_URL", "REDIS_URL", "SECRET_KEY"]
},
"source": "terraform",
"tf_file": "config.tf",
"tf_resource_name": "config"
}Security: Only key names are recorded. All values are redacted.
After Terraform extraction, scan the workspace for Procfile and app.json to supplement resource data.
Search for Procfile at workspace root or in subdirectories matching Terraform-discovered app names.
If found, parse using the Procfile format:
<process_type>: <command># are comments[a-zA-Z0-9_-]+For each parsed process type:
formation resource exists from Terraform (same app + process type) → add the command field from Procfilecommand from Procfile and quantity: 0, dyno_type: "unknown" (declared but not in Terraform)If NOT found: Log “No Procfile found — command fields will be null for formation resources.” Continue processing.
On parse error: Record procfile_parse_warning on the app entry, continue processing.
Search for app.json at workspace root or in subdirectories.
If found, parse as JSON and extract:
addons → Cross-reference with Terraform-discovered add-ons. Record any add-ons declared in app.json but not in Terraform (useful for detecting intent).formation → Formation defaults (quantity, size). Terraform values take precedence; app.json provides supplementary context.buildpacks → Record for Cedar/Fir assessment and container build strategy.env → Record variable names (keys only, no values) for downstream reference.stack → Supplements Cedar/Fir generation detection.If NOT found: Log “No app.json found.” Continue processing.
On parse error: Record app_json_parse_warning, continue processing.
For each heroku_app resource:
stack attribute is present in Terraform OR app.json:
heroku-20, heroku-22, heroku-24 → heroku_generation: "cedar"fir or cnb → heroku_generation: "fir"heroku_generation: "unknown"generation_action: "detect_only" for all apps (v1)The phase assembler (discover-assemble.md) owns the inventory’s STRUCTURE (which
sections exist and their field lists). This fragment contributes the following
terraform-specific content and rules:
resources[] as the
primary inventory data; Procfile/app.json supplements them (adds command,
buildpacks, declared add-ons). When Procfile was available, formation entries
have command populated; otherwise command is null.metadata.confidence to "full" when all Terraform files
parsed successfully, or "reduced" if any parse errors occurred or expected
resources were missing."terraform" to metadata.discovery_sources,
and "procfile" as well if Procfile/app.json were found and parsed.terraform_metadata: contribute the shape shown below.{
"terraform_metadata": {
"found": true,
"tf_files_scanned": 5,
"resource_types_extracted": ["heroku_app", "heroku_addon", "heroku_formation"],
"parse_warnings": []
}
}| Error Category | Behavior | Effect on Discovery |
|---|---|---|
| HCL parse error in one file | Log warning, skip malformed blocks, continue | Other files still processed |
| HCL parse error in all files | Log warning, exit cleanly | Billing discovery may still run |
| Unresolvable Terraform reference | Set heroku_app: "unassociated", continue | Resource included with limited context |
heroku_app resource has no name | Skip resource, log warning | Other resources still processed |
heroku_addon has unparseable plan | Record with plan: "unknown", continue | Resource included with limited plan info |
| File read permission denied | Log warning, skip file, continue | Other files still processed |
| Circular Terraform references | Resolve to best-effort, log warning | Resources included with available data |
Key principle: Terraform discovery is the primary discovery path in v1. Any parse failure results in a warning and graceful skip for that specific block — it should NOT halt the entire discovery. Partial results are always better than no results.
This sub-file covers Terraform resource extraction ONLY.
FORBIDDEN — Do NOT include ANY of:
terraform.tfstate) parsing — only .tf source filesterraform plan or terraform apply executionYour ONLY job: Extract Heroku resource declarations from .tf files, integrate Procfile/app.json data, and produce inventory entries. Nothing else.
After generating the resource entries and conflict records, the parent discover.md handles merging into the final inventory and updating phase status — do NOT update .phase-status.json from this sub-file.