From 721ffea0e148c815abd916c43cb0e41f5c6c8a51 Mon Sep 17 00:00:00 2001 From: Brian Flad Date: Wed, 15 May 2024 13:59:17 -0400 Subject: [PATCH] Support moving from null_resource to terraform_data This change enables the built-in provider's `terraform_data` managed resource to work with the `moved` configuration block where the `from` address is a `null_resource` managed resource type from the official `hashicorp/null` provider. It produces no plan differences for typical configurations and specifically helps practitioners from re-running provisioners while moving resource types. In addition to the unit testing, this was manually tested with the following configurations and outputs: Initial configuration (no `triggers`): ```terraform terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.2" } } } resource "null_resource" "example" { provisioner "local-exec" { command = "echo 'Hello, World!'" } } ``` Moved configuration (no `triggers`): ```terraform resource "terraform_data" "example" { provisioner "local-exec" { command = "echo 'Hello, World!'" } } moved { from = null_resource.example to = terraform_data.example } ``` Moved output (no `triggers`): ```console $ terraform apply terraform_data.example: Refreshing state... [id=892002337455008838] Terraform will perform the following actions: # null_resource.example has moved to terraform_data.example resource "terraform_data" "example" { id = "892002337455008838" } Plan: 0 to add, 0 to change, 0 to destroy. Do you want to perform these actions? Terraform will perform the actions described above. Only 'yes' will be accepted to approve. Enter a value: yes Apply complete! Resources: 0 added, 0 changed, 0 destroyed. ``` Initial configuration (with `triggers`): ```terraform terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.2" } } } resource "null_resource" "example" { triggers = { examplekey = "examplevalue" } provisioner "local-exec" { command = "echo 'Hello, World!'" } } ``` Moved configuration (with `triggers`): ```terraform resource "terraform_data" "example" { triggers_replace = { examplekey = "examplevalue" } provisioner "local-exec" { command = "echo 'Hello, World!'" } } moved { from = null_resource.example to = terraform_data.example } ``` Moved output (with `triggers`): ```console $ terraform apply terraform_data.example: Refreshing state... [id=1651348367769440250] Terraform will perform the following actions: # null_resource.example has moved to terraform_data.example resource "terraform_data" "example" { id = "1651348367769440250" # (1 unchanged attribute hidden) } Plan: 0 to add, 0 to change, 0 to destroy. Do you want to perform these actions? Terraform will perform the actions described above. Only 'yes' will be accepted to approve. Enter a value: yes Apply complete! Resources: 0 added, 0 changed, 0 destroyed. ``` --- .../builtin/providers/terraform/provider.go | 19 +++- .../providers/terraform/provider_test.go | 56 ++++++++++ .../providers/terraform/resource_data.go | 82 ++++++++++++++ .../providers/terraform/resource_data_test.go | 105 ++++++++++++++++++ 4 files changed, 258 insertions(+), 4 deletions(-) diff --git a/internal/builtin/providers/terraform/provider.go b/internal/builtin/providers/terraform/provider.go index 545e6537ecc7..9c6af3240273 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 58a4b7d46f67..555e796b28bf 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 d5c9ce6ee3c8..34900091364e 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 5bd993511169..4b52bb524ffc 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) + } +}