mirror of
https://github.com/hashicorp/terraform.git
synced 2026-05-28 04:03:27 -04:00
Support moving from null_resource to terraform_data (#35163)
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.
```
This commit is contained in:
parent
d346432dd0
commit
0cbab0f06a
4 changed files with 258 additions and 4 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue