From 44f7cf8d8cc7e20689883637bc10c9caa3e5fd55 Mon Sep 17 00:00:00 2001 From: GreenOps E2E Date: Thu, 26 Mar 2026 23:04:50 +0000 Subject: [PATCH] fix(extractor): resolve region from provider config for real AWS plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real terraform plans set region on the provider block, not individual resources. The extractor was exhausting all resource-level lookups and falling back to known_after_apply, causing all resources to be skipped. Changes: - Add extractProviderRegion() — reads configuration.provider_config - Add providerRegion as 5th fallback in resolveRegion() - Add plannedValuesMap fallback for instance_type resolution - Add TerraformPlan interface for proper typing Also adds: - fixtures/tfplan.e2e.json — real AWS plan fixture (credentials stripped) - .github/workflows/greenops-e2e.yml — live Action e2e test on PRs --- .github/workflows/greenops-e2e.yml | 27 +++++ dist/index.cjs | 45 +++++++- extractor.ts | 104 +++++++++++++++--- fixtures/tfplan.e2e.json | 164 +++++++++++++++++++++++++++++ 4 files changed, 322 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/greenops-e2e.yml create mode 100644 fixtures/tfplan.e2e.json diff --git a/.github/workflows/greenops-e2e.yml b/.github/workflows/greenops-e2e.yml new file mode 100644 index 0000000..55ca30e --- /dev/null +++ b/.github/workflows/greenops-e2e.yml @@ -0,0 +1,27 @@ +name: GreenOps E2E Test + +on: + pull_request: + branches: [main] + paths: + - 'extractor.ts' + - 'engine.ts' + - 'cli.ts' + - 'factors.json' + - 'dist/index.cjs' + - '.github/workflows/greenops-e2e.yml' + +jobs: + greenops-e2e: + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - uses: actions/checkout@v4 + + - name: Run GreenOps against fixture plan + uses: ./ + with: + plan-file: fixtures/tfplan.e2e.json + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/dist/index.cjs b/dist/index.cjs index 284d5a0..8ad8424 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -235,7 +235,30 @@ function isKnownAfterApply(change, fieldPath) { return true; return false; } -function resolveRegion(change) { +function extractProviderRegion(plan) { + const providerConfig = plan.configuration?.provider_config; + if (!providerConfig) + return null; + for (const [key, provider] of Object.entries(providerConfig)) { + if (key === "aws" || key.startsWith("aws.")) { + const alias = provider.expressions?.alias?.constant_value; + if (alias && key !== "aws") + continue; + const region = provider.expressions?.region?.constant_value; + if (region && typeof region === "string") + return region; + } + } + for (const [key, provider] of Object.entries(providerConfig)) { + if (key === "aws" || key.startsWith("aws.")) { + const region = provider.expressions?.region?.constant_value; + if (region && typeof region === "string") + return region; + } + } + return null; +} +function resolveRegion(change, providerRegion) { if (change?.after?.arn && typeof change.after.arn === "string") { const parts = change.after.arn.split(":"); if (parts.length >= 4 && parts[3]) @@ -252,6 +275,8 @@ function resolveRegion(change) { if (change?.before?.region && typeof change.before.region === "string") { return change.before.region; } + if (providerRegion) + return providerRegion; return null; } function extractResourceInputs(planFilePath) { @@ -275,6 +300,12 @@ function extractResourceInputs(planFilePath) { return result2; } const typedPlan = plan; + const providerRegion = extractProviderRegion(typedPlan); + const plannedValuesMap = /* @__PURE__ */ new Map(); + for (const r of typedPlan.planned_values?.root_module?.resources ?? []) { + if (r.address && r.values) + plannedValuesMap.set(r.address, r.values); + } for (const rawRes of typedPlan.resource_changes) { const res = rawRes; const actions = res.change?.actions; @@ -292,8 +323,14 @@ function extractResourceInputs(planFilePath) { const isDb = res.type === "aws_db_instance"; const typeField = isDb ? "instance_class" : "instance_type"; if (isKnownAfterApply(res.change, typeField)) { - result2.skipped.push({ resourceId: res.address, reason: "known_after_apply" }); - continue; + const plannedType = plannedValuesMap.get(res.address)?.[typeField]; + if (typeof plannedType !== "string") { + result2.skipped.push({ resourceId: res.address, reason: "known_after_apply" }); + continue; + } + if (!res.change.after) + res.change.after = {}; + res.change.after[typeField] = plannedType; } let instanceType = res.change.after[typeField]; if (typeof res.change.after[typeField] !== "string") { @@ -307,7 +344,7 @@ function extractResourceInputs(planFilePath) { continue; } } - const region = resolveRegion(res.change); + const region = resolveRegion(res.change, providerRegion); if (!region) { result2.skipped.push({ resourceId: res.address, reason: "known_after_apply" }); continue; diff --git a/extractor.ts b/extractor.ts index 0e16347..0425d19 100644 --- a/extractor.ts +++ b/extractor.ts @@ -16,6 +16,26 @@ interface TerraformResourceChange { }; } +interface TerraformPlan { + resource_changes: unknown[]; + configuration?: { + provider_config?: Record; + }; + planned_values?: { + root_module?: { + resources?: Array<{ + address: string; + values?: Record; + }>; + }; + }; +} + export interface ExtractorResult { resources: ResourceInput[]; skipped: PlanAnalysisResult['skipped']; @@ -37,24 +57,56 @@ function isKnownAfterApply(change: TerraformResourceChange['change'], fieldPath: return false; } +/** + * Extracts the default AWS region from the plan's provider configuration block. + * Handles multi-provider configs by preferring the un-aliased "aws" provider. + * Returns null if not statically resolvable (e.g. region set via variable). + */ +function extractProviderRegion(plan: TerraformPlan): string | null { + const providerConfig = plan.configuration?.provider_config; + if (!providerConfig) return null; + + // First pass: prefer the default (un-aliased) aws provider + for (const [key, provider] of Object.entries(providerConfig)) { + if (key === 'aws' || key.startsWith('aws.')) { + const alias = provider.expressions?.alias?.constant_value; + if (alias && key !== 'aws') continue; + const region = provider.expressions?.region?.constant_value; + if (region && typeof region === 'string') return region; + } + } + + // Second pass: accept any aws provider if no un-aliased one found + for (const [key, provider] of Object.entries(providerConfig)) { + if (key === 'aws' || key.startsWith('aws.')) { + const region = provider.expressions?.region?.constant_value; + if (region && typeof region === 'string') return region; + } + } + + return null; +} + /** * Attempts to resolve the AWS region for a resource in the plan. - * Lookup chain is: + * Lookup chain: * 1. `change.after.arn` (e.g. arn:aws:ec2:us-east-1:...) - * 2. `change.after.availability_zone` (fallback, stripping last char) - * 3. `change.after.region` (if provided explicitly on the resource) - * - * If all fail, we will emit known_after_apply to skip. + * 2. `change.after.availability_zone` (strips trailing AZ letter) + * 3. `change.after.region` (explicit resource-level region attribute) + * 4. `change.before.region` (for update actions where region is unchanged) + * 5. `providerRegion` (from configuration.provider_config — handles real-world plans + * where region is set on the provider block, not on individual resources) + * + * If all fail, returns null → resource will be skipped as known_after_apply. */ -function resolveRegion(change: TerraformResourceChange['change']): string | null { +function resolveRegion(change: TerraformResourceChange['change'], providerRegion: string | null): string | null { if (change?.after?.arn && typeof change.after.arn === 'string') { const parts = change.after.arn.split(':'); if (parts.length >= 4 && parts[3]) return parts[3]; } if (change?.after?.availability_zone && typeof change.after.availability_zone === 'string') { - // Extract region from AZ using regex to handle Local Zones (e.g. us-east-1-bos-1a) - // and standard AZs (e.g. us-east-1a) correctly. + // Handles Local Zones (e.g. us-east-1-bos-1a) and standard AZs (e.g. us-east-1a) const azMatch = (change.after.availability_zone as string).match(/^([a-z]{2}-[a-z]+-\d+)/); if (azMatch) return azMatch[1]; } @@ -63,10 +115,14 @@ function resolveRegion(change: TerraformResourceChange['change']): string | null return change.after.region as string; } - // Fallback: check 'before' state for update actions where region persists unchanged + // For update actions where region is stable and lives in before state if (change?.before?.region && typeof change.before.region === 'string') { return change.before.region as string; } + + // Real-world AWS plans: region lives on the provider block, not the resource. + // This is the most common case in practice. + if (providerRegion) return providerRegion; return null; } @@ -95,7 +151,19 @@ export function extractResourceInputs(planFilePath: string): ExtractorResult { return result; } - const typedPlan = plan as { resource_changes: unknown[] }; + const typedPlan = plan as TerraformPlan; + + // Extract provider-level region once — used as final fallback in resolveRegion() + const providerRegion = extractProviderRegion(typedPlan); + + // Build a lookup map from planned_values for instance type resolution. + // planned_values resolves attributes that are statically known but may not yet + // be reflected in change.after (e.g. when the provider populates defaults at plan time). + const plannedValuesMap = new Map>(); + for (const r of typedPlan.planned_values?.root_module?.resources ?? []) { + if (r.address && r.values) plannedValuesMap.set(r.address, r.values); + } + for (const rawRes of typedPlan.resource_changes) { const res = rawRes as TerraformResourceChange; const actions = res.change?.actions; @@ -122,10 +190,18 @@ export function extractResourceInputs(planFilePath: string): ExtractorResult { const isDb = res.type === 'aws_db_instance'; const typeField = isDb ? 'instance_class' : 'instance_type'; - // Verify type isn't unknown_after_apply + // Verify type isn't unknown_after_apply. + // If change.after doesn't have it, check planned_values before giving up — + // some providers populate planned_values even when change.after is incomplete. if (isKnownAfterApply(res.change, typeField)) { - result.skipped.push({ resourceId: res.address, reason: 'known_after_apply' }); - continue; + const plannedType = plannedValuesMap.get(res.address)?.[typeField]; + if (typeof plannedType !== 'string') { + result.skipped.push({ resourceId: res.address, reason: 'known_after_apply' }); + continue; + } + // Inject into change.after so downstream logic stays consistent + if (!res.change!.after) res.change!.after = {}; + res.change!.after[typeField] = plannedType; } let instanceType: string = res.change!.after![typeField] as string; @@ -146,7 +222,7 @@ export function extractResourceInputs(planFilePath: string): ExtractorResult { } } - const region = resolveRegion(res.change); + const region = resolveRegion(res.change, providerRegion); if (!region) { // If we completely exhausted our lookup heuristics and failed to find a region, // it means we either need it applied dynamically, or the TF configuration leverages entirely external provider abstractions diff --git a/fixtures/tfplan.e2e.json b/fixtures/tfplan.e2e.json new file mode 100644 index 0000000..a0e5977 --- /dev/null +++ b/fixtures/tfplan.e2e.json @@ -0,0 +1,164 @@ +{ + "format_version": "1.2", + "terraform_version": "1.7.5", + "planned_values": { + "root_module": { + "resources": [ + { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 1, + "values": { + "ami": "ami-0c02fb55956c7d316", + "credit_specification": [], + "get_password_data": false, + "hibernation": null, + "instance_type": "m5.large", + "launch_template": [], + "source_dest_check": true, + "tags": { "Name": "greenops-e2e-test" }, + "tags_all": { "Name": "greenops-e2e-test" }, + "timeouts": null, + "user_data_replace_on_change": false, + "volume_tags": null + } + }, + { + "address": "aws_instance.worker", + "mode": "managed", + "type": "aws_instance", + "name": "worker", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 1, + "values": { + "ami": "ami-0c02fb55956c7d316", + "credit_specification": [], + "get_password_data": false, + "hibernation": null, + "instance_type": "m6g.large", + "launch_template": [], + "source_dest_check": true, + "tags": { "Name": "greenops-e2e-worker" }, + "tags_all": { "Name": "greenops-e2e-worker" }, + "timeouts": null, + "user_data_replace_on_change": false, + "volume_tags": null + } + } + ] + } + }, + "resource_changes": [ + { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["create"], + "before": null, + "after": { + "ami": "ami-0c02fb55956c7d316", + "credit_specification": [], + "get_password_data": false, + "hibernation": null, + "instance_type": "m5.large", + "launch_template": [], + "source_dest_check": true, + "tags": { "Name": "greenops-e2e-test" }, + "tags_all": { "Name": "greenops-e2e-test" }, + "timeouts": null, + "user_data_replace_on_change": false, + "volume_tags": null + }, + "after_unknown": { + "arn": true, "associate_public_ip_address": true, + "availability_zone": true, "id": true, + "instance_state": true, "private_ip": true, + "public_ip": true, "subnet_id": true, + "vpc_security_group_ids": true + }, + "before_sensitive": false, + "after_sensitive": {} + } + }, + { + "address": "aws_instance.worker", + "mode": "managed", + "type": "aws_instance", + "name": "worker", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["create"], + "before": null, + "after": { + "ami": "ami-0c02fb55956c7d316", + "credit_specification": [], + "get_password_data": false, + "hibernation": null, + "instance_type": "m6g.large", + "launch_template": [], + "source_dest_check": true, + "tags": { "Name": "greenops-e2e-worker" }, + "tags_all": { "Name": "greenops-e2e-worker" }, + "timeouts": null, + "user_data_replace_on_change": false, + "volume_tags": null + }, + "after_unknown": { + "arn": true, "associate_public_ip_address": true, + "availability_zone": true, "id": true, + "instance_state": true, "private_ip": true, + "public_ip": true, "subnet_id": true, + "vpc_security_group_ids": true + }, + "before_sensitive": false, + "after_sensitive": {} + } + } + ], + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "version_constraint": "~> 5.0", + "expressions": { + "region": { "constant_value": "us-east-1" } + } + } + }, + "root_module": { + "resources": [ + { + "address": "aws_instance.web", + "mode": "managed", "type": "aws_instance", "name": "web", + "provider_config_key": "aws", + "expressions": { + "ami": { "constant_value": "ami-0c02fb55956c7d316" }, + "instance_type": { "constant_value": "m5.large" }, + "tags": { "constant_value": { "Name": "greenops-e2e-test" } } + }, + "schema_version": 1 + }, + { + "address": "aws_instance.worker", + "mode": "managed", "type": "aws_instance", "name": "worker", + "provider_config_key": "aws", + "expressions": { + "ami": { "constant_value": "ami-0c02fb55956c7d316" }, + "instance_type": { "constant_value": "m6g.large" }, + "tags": { "constant_value": { "Name": "greenops-e2e-worker" } } + }, + "schema_version": 1 + } + ] + } + }, + "timestamp": "2026-03-26T22:49:12Z", + "errored": false +}