diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index 01e5a35adf..b732c809a2 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -5463,6 +5463,106 @@ func TestPlan_RemovedBlocks(t *testing.T) { } } +func TestPlanWithResourceIdentities(t *testing.T) { + ctx := context.Background() + cfg := loadMainBundleConfigForTest(t, "resource-identity") + + fakePlanTimestamp, err := time.Parse(time.RFC3339, "1991-08-25T20:57:08Z") + if err != nil { + t.Fatal(err) + } + + lock := depsfile.NewLocks() + lock.SetProvider( + addrs.NewDefaultProvider("testing"), + providerreqs.MustParseVersion("0.0.0"), + providerreqs.MustParseVersionConstraints("=0.0.0"), + providerreqs.PreferredHashes([]providerreqs.Hash{}), + ) + + changesCh := make(chan stackplan.PlannedChange, 8) + diagsCh := make(chan tfdiags.Diagnostic, 2) + req := PlanRequest{ + Config: cfg, + ForcePlanTimestamp: &fakePlanTimestamp, + ProviderFactories: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) { + return stacks_testing_provider.NewProvider(t), nil + }, + }, + DependencyLocks: *lock, + } + resp := PlanResponse{ + PlannedChanges: changesCh, + Diagnostics: diagsCh, + } + + go Plan(ctx, &req, &resp) + gotChanges, diags := collectPlanOutput(changesCh, diagsCh) + + if len(diags) != 0 { + t.Errorf("unexpected diagnostics\n%s", diags.ErrWithWarnings().Error()) + } + + wantChanges := []stackplan.PlannedChange{ + &stackplan.PlannedChangeApplyable{ + Applyable: true, + }, + &stackplan.PlannedChangeComponentInstance{ + Addr: stackaddrs.Absolute( + stackaddrs.RootStackInstance, + stackaddrs.ComponentInstance{ + Component: stackaddrs.Component{Name: "self"}, + }, + ), + Action: plans.Create, + PlanApplyable: true, + PlanComplete: true, + PlannedCheckResults: &states.CheckResults{}, + PlannedInputValues: map[string]plans.DynamicValue{ + "name": mustPlanDynamicValueDynamicType(cty.StringVal("example")), + }, + PlannedInputValueMarks: map[string][]cty.PathValueMarks{"name": nil}, + PlannedOutputValues: map[string]cty.Value{}, + PlanTimestamp: fakePlanTimestamp, + }, + &stackplan.PlannedChangeResourceInstancePlanned{ + ResourceInstanceObjectAddr: mustAbsResourceInstanceObject("component.self.testing_resource_with_identity.hello"), + ChangeSrc: &plans.ResourceInstanceChangeSrc{ + Addr: mustAbsResourceInstance("testing_resource_with_identity.hello"), + PrevRunAddr: mustAbsResourceInstance("testing_resource_with_identity.hello"), + ProviderAddr: mustDefaultRootProvider("testing"), + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + Before: mustPlanDynamicValue(cty.NullVal(cty.DynamicPseudoType)), + After: mustPlanDynamicValue(cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("example"), + "value": cty.NullVal(cty.String), + })), + AfterIdentity: mustPlanDynamicValue(cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("id:example"), + })), + }, + }, + ProviderConfigAddr: mustDefaultRootProvider("testing"), + Schema: stacks_testing_provider.TestingResourceWithIdentitySchema, + }, + &stackplan.PlannedChangeHeader{ + TerraformVersion: version.SemVer, + }, + &stackplan.PlannedChangePlannedTimestamp{ + PlannedTimestamp: fakePlanTimestamp, + }, + } + sort.SliceStable(gotChanges, func(i, j int) bool { + return plannedChangeSortKey(gotChanges[i]) < plannedChangeSortKey(gotChanges[j]) + }) + + if diff := cmp.Diff(wantChanges, gotChanges, changesCmpOpts); diff != "" { + t.Errorf("wrong changes\n%s", diff) + } +} + // collectPlanOutput consumes the two output channels emitting results from // a call to [Plan], and collects all of the data written to them before // returning once changesCh has been closed by the sender to indicate that diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/resource-identity/resource-identity.tf b/internal/stacks/stackruntime/testdata/mainbundle/test/resource-identity/resource-identity.tf new file mode 100644 index 0000000000..3966256bd6 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/resource-identity/resource-identity.tf @@ -0,0 +1,7 @@ +variable "name" { + type = string +} + +resource "testing_resource_with_identity" "hello" { + id = var.name +} \ No newline at end of file diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/resource-identity/resource-identity.tfstack.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/resource-identity/resource-identity.tfstack.hcl new file mode 100644 index 0000000000..11a8b41e73 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/resource-identity/resource-identity.tfstack.hcl @@ -0,0 +1,19 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +provider "testing" "main" {} + +component "self" { + source = "./" + inputs = { + name = "example" + } + + providers = { + testing = provider.testing.main + } +} diff --git a/internal/stacks/stackruntime/testing/provider.go b/internal/stacks/stackruntime/testing/provider.go index fc008bbc36..e70db5134f 100644 --- a/internal/stacks/stackruntime/testing/provider.go +++ b/internal/stacks/stackruntime/testing/provider.go @@ -67,6 +67,21 @@ var ( }, }, } + + TestingResourceWithIdentitySchema = providers.Schema{ + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Optional: true, Computed: true}, + "value": {Type: cty.String, Optional: true}, + }, + }, + Identity: &configschema.Object{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Required: true}, + }, + Nesting: configschema.NestingSingle, + }, + } ) // MockProvider wraps the standard MockProvider with a simple in-memory @@ -148,6 +163,10 @@ func NewProviderWithData(t *testing.T, store *ResourceStore) *MockProvider { "testing_blocked_resource": { Body: BlockedResourceSchema.Body, }, + "testing_resource_with_identity": { + Body: TestingResourceSchema.Body, + Identity: TestingResourceWithIdentitySchema.Identity, + }, }, DataSources: map[string]providers.Schema{ "testing_data_source": { diff --git a/internal/stacks/stackruntime/testing/resource.go b/internal/stacks/stackruntime/testing/resource.go index 617d88a420..2524191502 100644 --- a/internal/stacks/stackruntime/testing/resource.go +++ b/internal/stacks/stackruntime/testing/resource.go @@ -35,6 +35,8 @@ func getResource(name string) resource { return &failedResource{} case "testing_blocked_resource": return &blockedResource{} + case "testing_resource_with_identity": + return &testingResourceWithIdentity{} default: panic("unknown resource: " + name) } @@ -45,6 +47,7 @@ var ( _ resource = (*deferredResource)(nil) _ resource = (*failedResource)(nil) _ resource = (*blockedResource)(nil) + _ resource = (*testingResourceWithIdentity)(nil) ) // testingResource is a simple resource that can be managed by the mock provider @@ -316,6 +319,66 @@ func (b *blockedResource) Apply(request providers.ApplyResourceChangeRequest, st return } +// testingResourceWithIdentity is the same as testingResource but it returns an identity. +type testingResourceWithIdentity struct{} + +func (t *testingResourceWithIdentity) Read(request providers.ReadResourceRequest, store *ResourceStore) (response providers.ReadResourceResponse) { + id := request.PriorState.GetAttr("id").AsString() + var exists bool + response.NewState, exists = store.Get(id) + if !exists { + response.NewState = cty.NullVal(TestingResourceSchema.Body.ImpliedType()) + response.Identity = cty.UnknownVal(TestingResourceWithIdentitySchema.Identity.ImpliedType()) + } else { + response.Identity = cty.StringVal("id:" + id) + } + return +} + +func (t *testingResourceWithIdentity) Plan(request providers.PlanResourceChangeRequest, store *ResourceStore) (response providers.PlanResourceChangeResponse) { + if request.ProposedNewState.IsNull() { + response.PlannedState = request.ProposedNewState + return + } + + response.PlannedState = planEnsureId(request.ProposedNewState) + response.PlannedIdentity = cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("id:" + response.PlannedState.GetAttr("id").AsString()), + }) + replace, err := validateId(response.PlannedState, request.PriorState, store) + if err != nil { + response.Diagnostics = append(response.Diagnostics, tfdiags.Sourceless(tfdiags.Error, "testingResourceWithIdentity error", err.Error())) + return + } + if replace { + response.RequiresReplace = []cty.Path{cty.GetAttrPath("id")} + } + return +} + +func (t *testingResourceWithIdentity) Apply(request providers.ApplyResourceChangeRequest, store *ResourceStore) (response providers.ApplyResourceChangeResponse) { + if request.PlannedState.IsNull() { + store.Delete(request.PriorState.GetAttr("id").AsString()) + response.NewState = request.PlannedState + return + } + + value := applyEnsureId(request.PlannedState) + replace, err := validateId(value, request.PriorState, store) + if err != nil { + response.Diagnostics = append(response.Diagnostics, tfdiags.Sourceless(tfdiags.Error, "testingResourceWithIdentity error", err.Error())) + return + } + response.NewState = value + response.NewIdentity = request.PlannedIdentity + + if replace { + store.Delete(request.PriorState.GetAttr("id").AsString()) + } + store.Set(response.NewState.GetAttr("id").AsString(), response.NewState) + return +} + func validateId(target cty.Value, prior cty.Value, store *ResourceStore) (bool, error) { if prior.IsNull() { // Then we're creating a resource, we want to make sure we're not