From a4222965f46f52c68bf7d9c619cc111bbadb2427 Mon Sep 17 00:00:00 2001 From: James Pogran Date: Mon, 10 Nov 2025 12:33:04 -0500 Subject: [PATCH] Add component registry source resolution support to Terraform Stacks This change implements the missing component source resolution case in the stack configuration loader, enabling Terraform Stacks to properly handle component registry sources from HCP Terraform and other component registries. The implementation mirrors the existing module registry resolution workflow, where component sources are first resolved to their versioned form using the source bundle's component metadata, then converted to final source addresses that can be used to locate the actual component code. This completes the integration between the terraform-registry-address component parsing capabilities and the go-slug sourcebundle component resolution APIs. --- internal/stacks/stackconfig/config.go | 11 +++ internal/stacks/stackconfig/config_test.go | 82 +++++++++++++++++++ .../component-test/main.tfcomponent.hcl | 9 ++ .../component-test/variables.tfcomponent.hcl | 7 ++ .../testdata/basics-bundle/pet-nulls/main.tf | 15 ++++ .../basics-bundle/terraform-sources.json | 23 ++++++ 6 files changed, 147 insertions(+) create mode 100644 internal/stacks/stackconfig/testdata/basics-bundle/component-test/main.tfcomponent.hcl create mode 100644 internal/stacks/stackconfig/testdata/basics-bundle/component-test/variables.tfcomponent.hcl create mode 100644 internal/stacks/stackconfig/testdata/basics-bundle/pet-nulls/main.tf diff --git a/internal/stacks/stackconfig/config.go b/internal/stacks/stackconfig/config.go index 883b52f40e8f..f72082436396 100644 --- a/internal/stacks/stackconfig/config.go +++ b/internal/stacks/stackconfig/config.go @@ -391,6 +391,17 @@ func resolveFinalSourceAddr(base sourceaddrs.FinalSource, rel sourceaddrs.Source } finalRel := rel.Versioned(selectedVersion) return sourceaddrs.ResolveRelativeFinalSource(base, finalRel) + + case sourceaddrs.ComponentSource: + // Component registry sources work similar to module registry sources + allowedVersions := versions.MeetingConstraints(versionConstraints) + availableVersions := sources.ComponentPackageVersions(rel.Package()) + selectedVersion := availableVersions.NewestInSet(allowedVersions) + if selectedVersion == versions.Unspecified { + return nil, fmt.Errorf("no cached versions of %s match the given version constraints", rel.Package()) + } + finalRel := rel.Versioned(selectedVersion) + return sourceaddrs.ResolveRelativeFinalSource(base, finalRel) default: // Should not get here because the above cases should be exhaustive // for all implementations of sourceaddrs.Source. diff --git a/internal/stacks/stackconfig/config_test.go b/internal/stacks/stackconfig/config_test.go index 6af08c8be0fc..8875711fbf6c 100644 --- a/internal/stacks/stackconfig/config_test.go +++ b/internal/stacks/stackconfig/config_test.go @@ -284,3 +284,85 @@ func TestOmittingBuiltInProviders(t *testing.T) { }) }) } + +func TestComponentSourceResolution(t *testing.T) { + bundle, err := sourcebundle.OpenDir("testdata/basics-bundle") + if err != nil { + t.Fatal(err) + } + + rootAddr := sourceaddrs.MustParseSource("git::https://example.com/component-test.git").(sourceaddrs.RemoteSource) + config, diags := LoadConfigDir(rootAddr, bundle) + if len(diags) != 0 { + t.Fatalf("unexpected diagnostics:\n%s", diags.NonFatalErr().Error()) + } + + t.Run("component source resolution", func(t *testing.T) { + // Verify that the component was loaded + if got, want := len(config.Root.Stack.Components), 1; got != want { + t.Errorf("wrong number of components %d; want %d", got, want) + } + + t.Run("pet-nulls component", func(t *testing.T) { + cmpn, ok := config.Root.Stack.Components["pet-nulls"] + if !ok { + t.Fatal("Root stack config has no component named \"pet-nulls\".") + } + + // Verify component name + if got, want := cmpn.Name, "pet-nulls"; got != want { + t.Errorf("wrong component name\ngot: %s\nwant: %s", got, want) + } + + // Verify that the source address was parsed correctly + componentSource, ok := cmpn.SourceAddr.(sourceaddrs.ComponentSource) + if !ok { + t.Fatalf("expected ComponentSource, got %T", cmpn.SourceAddr) + } + + expectedSourceStr := "app.staging.terraform.io/component-configurations/pet-nulls" + if got := componentSource.String(); got != expectedSourceStr { + t.Errorf("wrong source address\ngot: %s\nwant: %s", got, expectedSourceStr) + } + + // Verify that version constraints were parsed + if cmpn.VersionConstraints == nil { + t.Fatal("component has no version constraints") + } + + // Verify that the final source address was resolved + if cmpn.FinalSourceAddr == nil { + t.Fatal("component FinalSourceAddr was not resolved") + } + + // The final source should be a ComponentSourceFinal + componentSourceFinal, ok := cmpn.FinalSourceAddr.(sourceaddrs.ComponentSourceFinal) + if !ok { + t.Fatalf("expected ComponentSourceFinal for FinalSourceAddr, got %T", cmpn.FinalSourceAddr) + } + + // Verify it resolved to the correct version (0.0.2) + expectedVersion := "0.0.2" + if got := componentSourceFinal.SelectedVersion().String(); got != expectedVersion { + t.Errorf("wrong selected version\ngot: %s\nwant: %s", got, expectedVersion) + } + + // Verify the unversioned component source matches + if got := componentSourceFinal.Unversioned().String(); got != expectedSourceStr { + t.Errorf("wrong unversioned source in final address\ngot: %s\nwant: %s", got, expectedSourceStr) + } + + // Verify we can get the local path from the bundle + localPath, err := bundle.LocalPathForSource(cmpn.FinalSourceAddr) + if err != nil { + t.Fatalf("failed to get local path for component source: %s", err) + } + + // The local path should point to the pet-nulls directory + if localPath == "" { + t.Error("local path is empty") + } + t.Logf("Component resolved to local path: %s", localPath) + }) + }) +} diff --git a/internal/stacks/stackconfig/testdata/basics-bundle/component-test/main.tfcomponent.hcl b/internal/stacks/stackconfig/testdata/basics-bundle/component-test/main.tfcomponent.hcl new file mode 100644 index 000000000000..e7c05608b4b0 --- /dev/null +++ b/internal/stacks/stackconfig/testdata/basics-bundle/component-test/main.tfcomponent.hcl @@ -0,0 +1,9 @@ +component "pet-nulls" { + source = "app.staging.terraform.io/component-configurations/pet-nulls" + version = "0.0.2" + + inputs = { + instances = var.instances + prefix = var.prefix + } +} diff --git a/internal/stacks/stackconfig/testdata/basics-bundle/component-test/variables.tfcomponent.hcl b/internal/stacks/stackconfig/testdata/basics-bundle/component-test/variables.tfcomponent.hcl new file mode 100644 index 000000000000..1b6fb4a69643 --- /dev/null +++ b/internal/stacks/stackconfig/testdata/basics-bundle/component-test/variables.tfcomponent.hcl @@ -0,0 +1,7 @@ +variable "instances" { + type = number +} + +variable "prefix" { + type = string +} diff --git a/internal/stacks/stackconfig/testdata/basics-bundle/pet-nulls/main.tf b/internal/stacks/stackconfig/testdata/basics-bundle/pet-nulls/main.tf new file mode 100644 index 000000000000..22a8feabf6d3 --- /dev/null +++ b/internal/stacks/stackconfig/testdata/basics-bundle/pet-nulls/main.tf @@ -0,0 +1,15 @@ +variable "instances" { + type = number +} + +variable "prefix" { + type = string +} + +resource "null_resource" "pet" { + count = var.instances +} + +output "pet_ids" { + value = null_resource.pet[*].id +} diff --git a/internal/stacks/stackconfig/testdata/basics-bundle/terraform-sources.json b/internal/stacks/stackconfig/testdata/basics-bundle/terraform-sources.json index 4d79b35dd0be..fbe5268dad24 100644 --- a/internal/stacks/stackconfig/testdata/basics-bundle/terraform-sources.json +++ b/internal/stacks/stackconfig/testdata/basics-bundle/terraform-sources.json @@ -30,6 +30,16 @@ "source": "git::https://example.com/builtin.git", "local": "builtin", "meta": {} + }, + { + "source": "git::https://example.com/component-test.git", + "local": "component-test", + "meta": {} + }, + { + "source": "git::https://example.com/pet-nulls.git?ref=v0.0.2", + "local": "pet-nulls", + "meta": {} } ], "registry": [ @@ -44,5 +54,18 @@ } } } + ], + "components": [ + { + "source": "app.staging.terraform.io/component-configurations/pet-nulls", + "versions": { + "0.0.1": { + "source": "git::https://example.com/pet-nulls.git?ref=v0.0.1" + }, + "0.0.2": { + "source": "git::https://example.com/pet-nulls.git?ref=v0.0.2" + } + } + } ] } \ No newline at end of file