diff --git a/internal/builtin/providers/terraform/provider.go b/internal/builtin/providers/terraform/provider.go index 545e6537ec..9c6af32402 100644 --- a/internal/builtin/providers/terraform/provider.go +++ b/internal/builtin/providers/terraform/provider.go @@ -23,6 +23,9 @@ func NewProvider() providers.Interface { // GetSchema returns the complete schema for the provider. func (p *Provider) GetProviderSchema() providers.GetProviderSchemaResponse { return providers.GetProviderSchemaResponse{ + ServerCapabilities: providers.ServerCapabilities{ + MoveResourceState: true, + }, DataSources: map[string]providers.Schema{ "terraform_remote_state": dataSourceRemoteStateGetSchema(), }, @@ -169,10 +172,18 @@ func (p *Provider) ImportResourceState(req providers.ImportResourceStateRequest) panic("unimplemented - terraform_remote_state has no resources") } -func (p *Provider) MoveResourceState(providers.MoveResourceStateRequest) providers.MoveResourceStateResponse { - // We don't expose the move_resource_state capability, so this should never - // be called. - panic("unimplemented - terraform.io/builtin/terraform does not support cross-resource moves") +// MoveResourceState requests that the given resource be moved. +func (p *Provider) MoveResourceState(req providers.MoveResourceStateRequest) providers.MoveResourceStateResponse { + switch req.TargetTypeName { + case "terraform_data": + return moveDataStoreResourceState(req) + default: + var resp providers.MoveResourceStateResponse + + resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("Error: unsupported resource %s", req.TargetTypeName)) + + return resp + } } // ValidateResourceConfig is used to to validate the resource configuration values. diff --git a/internal/builtin/providers/terraform/provider_test.go b/internal/builtin/providers/terraform/provider_test.go index 58a4b7d46f..555e796b28 100644 --- a/internal/builtin/providers/terraform/provider_test.go +++ b/internal/builtin/providers/terraform/provider_test.go @@ -4,10 +4,66 @@ package terraform import ( + "testing" + backendInit "github.com/hashicorp/terraform/internal/backend/init" + "github.com/hashicorp/terraform/internal/providers" + "github.com/zclconf/go-cty/cty" + ctyjson "github.com/zclconf/go-cty/cty/json" ) func init() { // Initialize the backends backendInit.Init(nil) } + +func TestMoveResourceState_DataStore(t *testing.T) { + t.Parallel() + + nullResourceStateValue := cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test"), + }) + nullResourceStateJSON, err := ctyjson.Marshal(nullResourceStateValue, nullResourceStateValue.Type()) + + if err != nil { + t.Fatalf("failed to marshal null resource state: %s", err) + } + + provider := &Provider{} + req := providers.MoveResourceStateRequest{ + SourceProviderAddress: "registry.terraform.io/hashicorp/null", + SourceStateJSON: nullResourceStateJSON, + SourceTypeName: "null_resource", + TargetTypeName: "terraform_data", + } + resp := provider.MoveResourceState(req) + + if resp.Diagnostics.HasErrors() { + t.Errorf("unexpected diagnostics: %s", resp.Diagnostics.Err()) + } + + expectedTargetState := cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test"), + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.NullVal(cty.DynamicPseudoType), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + }) + + if !resp.TargetState.RawEquals(expectedTargetState) { + t.Errorf("expected state was:\n%#v\ngot state is:\n%#v\n", expectedTargetState, resp.TargetState) + } +} + +func TestMoveResourceState_NonExistentResource(t *testing.T) { + t.Parallel() + + provider := &Provider{} + req := providers.MoveResourceStateRequest{ + TargetTypeName: "nonexistent_resource", + } + resp := provider.MoveResourceState(req) + + if !resp.Diagnostics.HasErrors() { + t.Fatal("expected diagnostics") + } +} diff --git a/internal/builtin/providers/terraform/resource_data.go b/internal/builtin/providers/terraform/resource_data.go index d5c9ce6ee3..3490009136 100644 --- a/internal/builtin/providers/terraform/resource_data.go +++ b/internal/builtin/providers/terraform/resource_data.go @@ -5,6 +5,7 @@ package terraform import ( "fmt" + "strings" "github.com/hashicorp/go-uuid" "github.com/hashicorp/terraform/internal/configs/configschema" @@ -170,3 +171,84 @@ func importDataStore(req providers.ImportResourceStateRequest) (resp providers.I } return resp } + +// moveDataStoreResourceState enables moving from the official null_resource +// managed resource to the terraform_data managed resource. +func moveDataStoreResourceState(req providers.MoveResourceStateRequest) (resp providers.MoveResourceStateResponse) { + // Verify that the source provider is an official hashicorp/null provider, + // but ignore the hostname for mirrors. + if !strings.HasSuffix(req.SourceProviderAddress, "hashicorp/null") { + diag := tfdiags.Sourceless( + tfdiags.Error, + "Unsupported source provider for move operation", + "Only moving from the official hashicorp/null provider to terraform_data is supported.", + ) + resp.Diagnostics = resp.Diagnostics.Append(diag) + + return resp + } + + // Verify that the source resource type name is null_resource. + if req.SourceTypeName != "null_resource" { + diag := tfdiags.Sourceless( + tfdiags.Error, + "Unsupported source resource type for move operation", + "Only moving from the null_resource managed resource to terraform_data is supported.", + ) + resp.Diagnostics = resp.Diagnostics.Append(diag) + + return resp + } + + nullResourceSchemaType := nullResourceSchema().Block.ImpliedType() + nullResourceValue, err := ctyjson.Unmarshal(req.SourceStateJSON, nullResourceSchemaType) + + if err != nil { + resp.Diagnostics = resp.Diagnostics.Append(err) + + return resp + } + + triggersReplace := nullResourceValue.GetAttr("triggers") + + // PlanResourceChange uses RawEquals comparison, which will show a + // difference between cty.NullVal(cty.Map(cty.String)) and + // cty.NullVal(cty.DynamicPseudoType). + if triggersReplace.IsNull() { + triggersReplace = cty.NullVal(cty.DynamicPseudoType) + } else { + // PlanResourceChange uses RawEquals comparison, which will show a + // difference between cty.MapVal(...) and cty.ObjectVal(...). Given that + // triggers is typically configured using direct configuration syntax of + // {...}, which is a cty.ObjectVal, over a map typed variable or + // explicitly type converted map, this pragmatically chooses to convert + // the triggers value to cty.ObjectVal to prevent an immediate plan + // difference for the more typical case. + triggersReplace = cty.ObjectVal(triggersReplace.AsValueMap()) + } + + schema := dataStoreResourceSchema() + v := cty.ObjectVal(map[string]cty.Value{ + "id": nullResourceValue.GetAttr("id"), + "triggers_replace": triggersReplace, + }) + + state, err := schema.Block.CoerceValue(v) + + // null_resource did not use private state, so it is unnecessary to move. + resp.Diagnostics = resp.Diagnostics.Append(err) + resp.TargetState = state + + return resp +} + +func nullResourceSchema() providers.Schema { + return providers.Schema{ + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Computed: true}, + "triggers": {Type: cty.Map(cty.String), Optional: true}, + }, + }, + } +} diff --git a/internal/builtin/providers/terraform/resource_data_test.go b/internal/builtin/providers/terraform/resource_data_test.go index 5bd9935111..4b52bb524f 100644 --- a/internal/builtin/providers/terraform/resource_data_test.go +++ b/internal/builtin/providers/terraform/resource_data_test.go @@ -383,3 +383,108 @@ func TestManagedDataApply(t *testing.T) { }) } } + +func TestMoveDataStoreResourceState_Id(t *testing.T) { + t.Parallel() + + nullResourceStateValue := cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test"), + "triggers": cty.NullVal(cty.Map(cty.String)), + }) + nullResourceStateJSON, err := ctyjson.Marshal(nullResourceStateValue, nullResourceStateValue.Type()) + + if err != nil { + t.Fatalf("failed to marshal null resource state: %s", err) + } + + req := providers.MoveResourceStateRequest{ + SourceProviderAddress: "registry.terraform.io/hashicorp/null", + SourceStateJSON: nullResourceStateJSON, + SourceTypeName: "null_resource", + TargetTypeName: "terraform_data", + } + resp := moveDataStoreResourceState(req) + + if resp.Diagnostics.HasErrors() { + t.Errorf("unexpected diagnostics: %s", resp.Diagnostics.Err()) + } + + expectedTargetState := cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test"), + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.NullVal(cty.DynamicPseudoType), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + }) + + if !resp.TargetState.RawEquals(expectedTargetState) { + t.Errorf("expected state was:\n%#v\ngot state is:\n%#v\n", expectedTargetState, resp.TargetState) + } +} + +func TestMoveResourceState_SourceProviderAddress(t *testing.T) { + t.Parallel() + + req := providers.MoveResourceStateRequest{ + SourceProviderAddress: "registry.terraform.io/examplecorp/null", + } + resp := moveDataStoreResourceState(req) + + if !resp.Diagnostics.HasErrors() { + t.Fatal("expected diagnostics") + } +} + +func TestMoveResourceState_SourceTypeName(t *testing.T) { + t.Parallel() + + req := providers.MoveResourceStateRequest{ + SourceProviderAddress: "registry.terraform.io/hashicorp/null", + SourceTypeName: "null_data_source", + } + resp := moveDataStoreResourceState(req) + + if !resp.Diagnostics.HasErrors() { + t.Fatal("expected diagnostics") + } +} + +func TestMoveDataStoreResourceState_Triggers(t *testing.T) { + t.Parallel() + + nullResourceStateValue := cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test"), + "triggers": cty.MapVal(map[string]cty.Value{ + "testkey": cty.StringVal("testvalue"), + }), + }) + nullResourceStateJSON, err := ctyjson.Marshal(nullResourceStateValue, nullResourceStateValue.Type()) + + if err != nil { + t.Fatalf("failed to marshal null resource state: %s", err) + } + + req := providers.MoveResourceStateRequest{ + SourceProviderAddress: "registry.terraform.io/hashicorp/null", + SourceStateJSON: nullResourceStateJSON, + SourceTypeName: "null_resource", + TargetTypeName: "terraform_data", + } + resp := moveDataStoreResourceState(req) + + if resp.Diagnostics.HasErrors() { + t.Errorf("unexpected diagnostics: %s", resp.Diagnostics.Err()) + } + + expectedTargetState := cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test"), + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.NullVal(cty.DynamicPseudoType), + "triggers_replace": cty.ObjectVal(map[string]cty.Value{ + "testkey": cty.StringVal("testvalue"), + }), + }) + + if !resp.TargetState.RawEquals(expectedTargetState) { + t.Errorf("expected state was:\n%#v\ngot state is:\n%#v\n", expectedTargetState, resp.TargetState) + } +}