diff --git a/helper/schema/provider.go b/helper/schema/provider.go index cb7e2a8981..28c39bb049 100644 --- a/helper/schema/provider.go +++ b/helper/schema/provider.go @@ -505,7 +505,7 @@ func (p *Provider) ImportStateWithIdentity( if err != nil { return nil, err // this should not happen, as we checked above } - identityData.raw = identity // is this too hacky / unexpected? + identityData.raw = identity } else if identity != nil { return nil, fmt.Errorf("resource %s doesn't support identity import", info.Type) } diff --git a/helper/schema/resource_importer.go b/helper/schema/resource_importer.go index e2c4d08b4f..4dd691d226 100644 --- a/helper/schema/resource_importer.go +++ b/helper/schema/resource_importer.go @@ -6,6 +6,7 @@ package schema import ( "context" "errors" + "fmt" ) // ResourceImporter defines how a resource is imported in Terraform. This @@ -83,3 +84,49 @@ func ImportStatePassthrough(d *ResourceData, m interface{}) ([]*ResourceData, er func ImportStatePassthroughContext(ctx context.Context, d *ResourceData, m interface{}) ([]*ResourceData, error) { return []*ResourceData{d}, nil } + +// ImportStatePassthroughWithIdentity creates a StateContextFunc that supports both +// identity-based and ID-only resource import scenarios. This function is useful +// when a resource can be imported either by its unique ID or by an identity attribute. +// +// The `idAttributePath` parameter specifies the name of the identity attribute +// to use when importing by identity. Since identity attributes are "flat", +// `idAttributePath` should be a simple attribute name (e.g., "name" or "identifier"). +// Note that the identity attribute must be a string, as this function expects +// to set the resource ID using the value of the specified attribute. +// +// If the resource is imported by ID (i.e., `d.Id()` is already set), the function +// simply returns the resource data as-is. Otherwise, it attempts to retrieve the +// identity attribute specified by `idAttributePath` and sets it as the resource ID. +// +// Parameters: +// - idAttributePath: The name of the identity attribute to use for setting the ID. +// +// Returns: +// - A StateContextFunc that handles the import logic. +func ImportStatePassthroughWithIdentity(idAttributePath string) StateContextFunc { + return func(ctx context.Context, d *ResourceData, m interface{}) ([]*ResourceData, error) { + // If we import by id, we just return the resource data as is, no need to change it + if d.Id() != "" { + return []*ResourceData{d}, nil + } + + // If we import by identity, we need to set the id based on the idAttributePath + identity, err := d.Identity() + if err != nil { + return nil, fmt.Errorf("error getting identity: %s", err) + } + id, exists := identity.GetOk(idAttributePath) + if !exists { + return nil, fmt.Errorf("expected identity to contain key %s", idAttributePath) + } + idStr, ok := id.(string) + if !ok { + return nil, fmt.Errorf("expected identity key %s to be a string, was: %T", idAttributePath, id) + } + + d.SetId(idStr) + + return []*ResourceData{d}, nil + } +} diff --git a/helper/schema/resource_importer_test.go b/helper/schema/resource_importer_test.go index 36e4256a0b..6a08634c1d 100644 --- a/helper/schema/resource_importer_test.go +++ b/helper/schema/resource_importer_test.go @@ -3,7 +3,11 @@ package schema -import "testing" +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) func TestInternalValidate(t *testing.T) { r := &ResourceImporter{ @@ -14,3 +18,186 @@ func TestInternalValidate(t *testing.T) { t.Fatal("ResourceImporter should not allow State and StateContext to be set") } } + +func TestImportStatePassthroughWithIdentity(t *testing.T) { + // shared among all tests, defined once to keep them shorter + identitySchema := map[string]*Schema{ + "email": { + Type: TypeString, + RequiredForImport: true, + }, + "region": { + Type: TypeString, + OptionalForImport: true, + }, + } + + tests := []struct { + name string + idAttributePath string + resourceData *ResourceData + expectedResourceData *ResourceData + expectedError string + }{ + { + name: "import from id just sets id", + idAttributePath: "email", + resourceData: &ResourceData{ + identitySchema: identitySchema, + state: &terraform.InstanceState{ + ID: "hello@example.internal", + }, + }, + expectedResourceData: &ResourceData{ + identitySchema: identitySchema, + state: &terraform.InstanceState{ + ID: "hello@example.internal", + }, + }, + }, + { + name: "import from identity sets id and identity", + idAttributePath: "email", + resourceData: &ResourceData{ + identitySchema: identitySchema, + state: &terraform.InstanceState{ + Identity: map[string]string{ + "email": "hello@example.internal", + }, + }, + }, + expectedResourceData: &ResourceData{ + identitySchema: identitySchema, + state: &terraform.InstanceState{ + ID: "hello@example.internal", + }, + newIdentity: &IdentityData{ + schema: identitySchema, + raw: map[string]string{ + "email": "hello@example.internal", + }, + }, + }, + }, + { + name: "import from identity sets id and identity (with region set)", + idAttributePath: "email", + resourceData: &ResourceData{ + identitySchema: identitySchema, + state: &terraform.InstanceState{ + Identity: map[string]string{ + "email": "hello@example.internal", + "region": "eu-west-1", + }, + }, + }, + expectedResourceData: &ResourceData{ + identitySchema: identitySchema, + state: &terraform.InstanceState{ + ID: "hello@example.internal", + }, + newIdentity: &IdentityData{ + schema: identitySchema, + raw: map[string]string{ + "email": "hello@example.internal", + "region": "eu-west-1", + }, + }, + }, + }, + { + name: "import from identity fails without required field", + idAttributePath: "email", + resourceData: &ResourceData{ + identitySchema: identitySchema, + state: &terraform.InstanceState{ + Identity: map[string]string{ + "region": "eu-west-1", + }, + }, + }, + expectedError: "expected identity to contain key email", + }, + { + name: "import from identity fails if attribute is not a string", + idAttributePath: "number", + resourceData: &ResourceData{ + identitySchema: map[string]*Schema{ + "number": { + Type: TypeInt, + RequiredForImport: true, + }, + }, + state: &terraform.InstanceState{ + Identity: map[string]string{ + "number": "1", + }, + }, + }, + expectedError: "expected identity key number to be a string, was: int", + }, + { + name: "import from identity fails without schema", + idAttributePath: "email", + resourceData: &ResourceData{ + state: &terraform.InstanceState{ + Identity: map[string]string{ + "email": "hello@example.internal", + }, + }, + }, + expectedError: "error getting identity: Resource does not have Identity schema. Please set one in order to use Identity(). This is always a problem in the provider code.", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + results, err := ImportStatePassthroughWithIdentity(test.idAttributePath)(nil, test.resourceData, nil) + if err != nil { + if test.expectedError == "" { + t.Fatalf("unexpected error: %s", err) + } + if err.Error() != test.expectedError { + t.Fatalf("expected error: %s, got: %s", test.expectedError, err) + } + return // we don't expect any results if there is an error + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got: %d", len(results)) + } + // compare id and identity in resource data + if results[0].Id() != test.expectedResourceData.Id() { + t.Fatalf("expected id: %s, got: %s", test.expectedResourceData.Id(), results[0].Id()) + } + // compare identity + expectedIdentity, err := test.expectedResourceData.Identity() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + resultIdentity, err := results[0].Identity() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + // check whether all result identity attributes exist as expected + for key := range expectedIdentity.schema { + expected := expectedIdentity.getRaw(key) + if expected.Exists { + result := resultIdentity.getRaw(key) + if !result.Exists { + t.Fatalf("expected identity attribute %s to exist", key) + } + if expected.Value != result.Value { + t.Fatalf("expected identity attribute %s to be %s, got: %s", key, expected.Value, result.Value) + } + } + } + // check whether there are no additional attributes in the result identity + for key := range resultIdentity.schema { + if _, ok := expectedIdentity.schema[key]; !ok { + t.Fatalf("unexpected identity attribute %s", key) + } + } + }) + } +}