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
19 changes: 15 additions & 4 deletions internal/builtin/providers/terraform/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
Expand Down Expand Up @@ -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.
Expand Down
56 changes: 56 additions & 0 deletions internal/builtin/providers/terraform/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
82 changes: 82 additions & 0 deletions internal/builtin/providers/terraform/resource_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package terraform

import (
"fmt"
"strings"

"github.com/hashicorp/go-uuid"
"github.com/hashicorp/terraform/internal/configs/configschema"
Expand Down Expand Up @@ -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},
},
},
}
}
105 changes: 105 additions & 0 deletions internal/builtin/providers/terraform/resource_data_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}