Skip to content

Commit

Permalink
Support moving from null_resource to terraform_data (#35163)
Browse files Browse the repository at this point in the history
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.
```
  • Loading branch information
bflad committed May 16, 2024
1 parent d346432 commit 0cbab0f
Show file tree
Hide file tree
Showing 4 changed files with 258 additions and 4 deletions.
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)
}
}

0 comments on commit 0cbab0f

Please sign in to comment.