feat(terraform): remote-state bridge, provider aliases, moved/import/check refs (#1174)

Follow-ups noted in #1173:

- cloudposse/atmos remote-state: module.M.outputs.X emits a scoped
  module.M:remote-output.X candidate; the resolver bridges it to the
  target COMPONENT's own output when every gate holds — the module
  source is the stack-config remote-state module, the component name is
  static (a literal, or component = var.X whose variable declares a
  literal default in the same directory), and exactly one directory in
  the repo matches the component name and declares that output. Dynamic
  (each.value) or ambiguous wiring stays unlinked. On
  cloudposse/terraform-aws-components: 254 remote-state bridge edges,
  every one re-derived from a matching source declaration (789/789
  cross-directory output edges explained: 528 local-module + 254
  remote-state + 7 checker-artifact false alarms under deprecated/);
  coverage 66.4% -> 69.1%.

- provider aliases: provider "aws" { alias = "east" } is addressed as
  provider.aws.east so aliased and default configurations stop
  colliding; provider = aws.east on a resource/data block (and the
  values of a module's providers map) reference the selected
  configuration, resolved same-directory first then up the module tree
  — the one construct Terraform genuinely inherits from parents. The
  selection is no longer misread as a resource reference (aws.east).

- moved/import/removed blocks reference the resource addresses they
  name (anchored to the file node — no phantom symbols), so a
  refactor's paper trail joins the graph; check-assert conditions
  contribute their references while check-scoped data blocks keep
  indexing as before. Scoped module candidates are suppressed there:
  module.a.aws_x.b names a resource inside a module instance, not an
  output. +91 edges on cloud-foundation-fabric's moved-heavy stages.

Also fixes a latent test bug from #1173: cg.getNodeById is not public
API (cg.getNode is) — it only passed because the asserted edge list was
empty.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-03 19:38:03 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 6c24f4bddf
commit f8cdbe3c67
6 changed files with 526 additions and 33 deletions
+123 -1
View File
@@ -1077,7 +1077,7 @@ describe('Terraform end-to-end module-boundary resolution', () => {
.filter((n) => n.qualifiedName === 'aws_eip.e')
.flatMap((n) => cg.getOutgoingEdges(n.id))
.filter((e) => e.kind === 'references');
const orphanTargets = orphanEdges.map((e) => cg.getNodeById(e.target)?.qualifiedName);
const orphanTargets = orphanEdges.map((e) => cg.getNode(e.target)?.qualifiedName);
expect(orphanTargets).not.toContain('var.undeclared_here_elsewhere_yes');
// Registry-sourced module: inputs stay unresolved (no guessed edges).
@@ -1092,3 +1092,125 @@ describe('Terraform end-to-end module-boundary resolution', () => {
}
});
});
describe('Terraform follow-ups: remote-state bridge, provider alias, moved blocks', () => {
let tmpDir: string | undefined;
afterEach(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
it('bridges atmos remote-state to the target component, resolves provider aliases up the tree, links moved blocks', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-terraform-fu-'));
// Component producing state.
fs.mkdirSync(path.join(tmpDir, 'components/terraform/vpc'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, 'components/terraform/vpc/outputs.tf'),
'output "vpc_id" {\n value = "vpc-123"\n}\n'
);
// Component consuming it via the cloudposse remote-state module.
fs.mkdirSync(path.join(tmpDir, 'components/terraform/eks/cluster'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, 'components/terraform/eks/cluster/remote-state.tf'),
'module "vpc" {\n' +
' source = "cloudposse/stack-config/yaml//modules/remote-state"\n' +
' component = var.vpc_component_name\n' +
'}\n' +
'variable "vpc_component_name" {\n' +
' type = string\n' +
' default = "vpc"\n' +
'}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'components/terraform/eks/cluster/main.tf'),
'resource "aws_eks_cluster" "this" {\n vpc_id = module.vpc.outputs.vpc_id\n}\n'
);
// Ambiguous component name — two directories called "dns" with the same
// output; the bridge must refuse to pick one.
fs.mkdirSync(path.join(tmpDir, 'components/terraform/dns'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, 'legacy/dns'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'components/terraform/dns/outputs.tf'), 'output "zone_id" {\n value = "z1"\n}\n');
fs.writeFileSync(path.join(tmpDir, 'legacy/dns/outputs.tf'), 'output "zone_id" {\n value = "z2"\n}\n');
fs.writeFileSync(
path.join(tmpDir, 'components/terraform/eks/cluster/dns.tf'),
'module "dns" {\n' +
' source = "cloudposse/stack-config/yaml//modules/remote-state"\n' +
' component = "dns"\n' +
'}\n' +
'output "zone" {\n value = module.dns.outputs.zone_id\n}\n'
);
// Provider alias declared at the root, selected inside a module dir.
fs.writeFileSync(
path.join(tmpDir, 'providers.tf'),
'provider "aws" {\n region = "us-east-1"\n}\n' +
'provider "aws" {\n alias = "east"\n region = "us-east-2"\n}\n'
);
fs.mkdirSync(path.join(tmpDir, 'modules/app'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, 'modules/app/main.tf'),
'resource "aws_s3_bucket" "b" {\n provider = aws.east\n bucket = "x"\n}\n'
);
// Moved block referencing a live resource.
fs.writeFileSync(
path.join(tmpDir, 'main.tf'),
'resource "aws_instance" "renamed" {}\n' +
'moved {\n from = aws_instance.old\n to = aws_instance.renamed\n}\n'
);
const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
try {
const byQname = (q: string, file?: string) =>
cg
.getNodesByName(q.split('.').pop()!)
.filter((n) => n.qualifiedName === q && (!file || n.filePath === file));
// 1. remote-state bridge: consumer resource → producer component's output.
const consumer = byQname('aws_eks_cluster.this')[0] ??
cg.getNodesInFile('components/terraform/eks/cluster/main.tf').find((n) => n.qualifiedName === 'aws_eks_cluster.this');
expect(consumer, 'consumer resource').toBeDefined();
const producerOut = byQname('output.vpc_id', 'components/terraform/vpc/outputs.tf')[0];
expect(producerOut, "producer component's output").toBeDefined();
expect(
cg.getOutgoingEdges(consumer!.id).find((e) => e.target === producerOut!.id),
'remote-state bridge edge eks/cluster → vpc output'
).toBeDefined();
// 2. Ambiguous component name → no bridge edge to either candidate.
const zoneOut = byQname('output.zone', 'components/terraform/eks/cluster/dns.tf')[0];
expect(zoneOut).toBeDefined();
const zoneTargets = cg
.getOutgoingEdges(zoneOut!.id)
.map((e) => cg.getNode(e.target))
.filter((n) => n?.qualifiedName === 'output.zone_id');
expect(zoneTargets, 'ambiguous component must not be guessed').toHaveLength(0);
// 3. Provider alias: nodes are distinct, and the selection inside the
// module resolves up the tree to the aliased configuration.
const provNodes = cg.getNodesInFile('providers.tf');
const aliased = provNodes.find((n) => n.qualifiedName === 'provider.aws.east');
const defaultProv = provNodes.find((n) => n.qualifiedName === 'provider.aws');
expect(aliased, 'aliased provider node').toBeDefined();
expect(defaultProv, 'default provider node').toBeDefined();
const bucket = cg.getNodesInFile('modules/app/main.tf').find((n) => n.qualifiedName === 'aws_s3_bucket.b');
expect(bucket).toBeDefined();
const bucketEdges = cg.getOutgoingEdges(bucket!.id);
expect(
bucketEdges.find((e) => e.target === aliased!.id),
'provider = aws.east → aliased provider (ancestor walk)'
).toBeDefined();
expect(bucketEdges.find((e) => e.target === defaultProv!.id), 'must not link the default provider').toBeUndefined();
// 4. moved block: the file references the live resource.
const renamed = cg.getNodesInFile('main.tf').find((n) => n.qualifiedName === 'aws_instance.renamed');
expect(renamed).toBeDefined();
const rootFile = cg.getNodesInFile('main.tf').find((n) => n.kind === 'file');
expect(
cg.getOutgoingEdges(rootFile!.id).find((e) => e.target === renamed!.id),
'moved block → live resource edge'
).toBeDefined();
} finally {
cg.close();
}
});
});