Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion helper/schema/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
47 changes: 47 additions & 0 deletions helper/schema/resource_importer.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package schema
import (
"context"
"errors"
"fmt"
)

// ResourceImporter defines how a resource is imported in Terraform. This
Expand Down Expand Up @@ -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
}
}
189 changes: 188 additions & 1 deletion helper/schema/resource_importer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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)
}
}
})
}
}
Loading