Skip to content

Commit

Permalink
resource: Initial MoveResourceState RPC support (#917)
Browse files Browse the repository at this point in the history
This change adds initial support for the `MoveResourceState` RPC to the framework, including:

* Adding the framework shared server implementation for the new RPC
* Adding the protocol version 5 and 6 server implementations for the new RPC
* Adding the type conversion logic for the framework types to/from the protocol types
* Exposing a new `resource.ResourceWithMoveState` interface that providers can implement to support the new RPC
* Adding a website documentation page for the new functionality

A state move using the new RPC occurs when a Terraform 1.8 and later configuration includes a `moved` configuration block such as the following:

```terraform
moved {
    from = "examplecloud_source.XXX"
    to   = "examplecloud_target.XXX"
}
```

There are no restrictions on the source resource types, but target resource types must opt into support to prevent data loss. Target resources can support moves from multiple, differing source resources, so the framework implementation is exposed as an ordered list to developers.

The framework implementation for the new RPC is as follows:

* If no state move support is defined for the resource, the framework returns an error diagnostic.
* If state move support is defined for the resource, each provider-defined state move implementation is called until one responds with error diagnostics or state data.
* If all provider-defined state move implementations return without error diagnostics and state data, the framework returns an error diagnostic.

The protocol server unit testing shows the end-to-end handling of the new RPC, including the provider-defined resource implementations, the type conversion logic, and the framework shared server implementation. The exposed `resource.ResourceWithMoveState` implementation is intentionally similar to the existing `resource.ResourceWithUpgradeState` handling, both in internal details and the exposed API. This is to provide a smoother experience for provider developers familiar with the other functionality and maintainers of the framework.

This initial implementation exposes a lot of the request/response handling as-is, however there is likely potential for introducing native helper functionality for developers, such as implementing one to simplify "aliasing"/"renaming" an existing `resource.Resource` within the same provider codebase.
  • Loading branch information
bflad committed Feb 16, 2024
1 parent f471850 commit 86b2acb
Show file tree
Hide file tree
Showing 25 changed files with 4,024 additions and 1 deletion.
6 changes: 6 additions & 0 deletions .changes/unreleased/FEATURES-20240201-173428.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kind: FEATURES
body: 'resource: Added the `ResourceWithMoveState` interface, which enables state
moves across resource types with Terraform 1.8 and later'
time: 2024-02-01T17:34:28.190047-05:00
custom:
Issue: "917"
57 changes: 57 additions & 0 deletions internal/fromproto5/moveresourcestate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package fromproto5

import (
"context"

"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/internal/fwschema"
"github.com/hashicorp/terraform-plugin-framework/internal/fwserver"
"github.com/hashicorp/terraform-plugin-framework/internal/privatestate"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-go/tfprotov5"
"github.com/hashicorp/terraform-plugin-go/tfprotov6"
)

// MoveResourceStateRequest returns the *fwserver.MoveResourceStateRequest
// equivalent of a *tfprotov5.MoveResourceStateRequest.
func MoveResourceStateRequest(ctx context.Context, proto5 *tfprotov5.MoveResourceStateRequest, resource resource.Resource, resourceSchema fwschema.Schema) (*fwserver.MoveResourceStateRequest, diag.Diagnostics) {
if proto5 == nil {
return nil, nil
}

var diags diag.Diagnostics

// Panic prevention here to simplify the calling implementations.
// This should not happen, but just in case.
if resourceSchema == nil {
diags.AddError(
"Framework Implementation Error",
"An unexpected issue was encountered when converting the MoveResourceState RPC request information from the protocol type to the framework type. "+
"The resource schema was missing. "+
"This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.",
)

return nil, diags
}

fw := &fwserver.MoveResourceStateRequest{
SourceProviderAddress: proto5.SourceProviderAddress,
SourceRawState: (*tfprotov6.RawState)(proto5.SourceState),
SourceSchemaVersion: proto5.SourceSchemaVersion,
SourceTypeName: proto5.SourceTypeName,
TargetResource: resource,
TargetResourceSchema: resourceSchema,
TargetTypeName: proto5.TargetTypeName,
}

sourcePrivate, sourcePrivateDiags := privatestate.NewData(ctx, proto5.SourcePrivate)

diags.Append(sourcePrivateDiags...)

fw.SourcePrivate = sourcePrivate

return fw, diags
}
184 changes: 184 additions & 0 deletions internal/fromproto5/moveresourcestate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package fromproto5_test

import (
"context"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/internal/fromproto5"
"github.com/hashicorp/terraform-plugin-framework/internal/fwschema"
"github.com/hashicorp/terraform-plugin-framework/internal/fwserver"
"github.com/hashicorp/terraform-plugin-framework/internal/privatestate"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-go/tfprotov5"
)

func TestMoveResourceStateRequest(t *testing.T) {
t.Parallel()

testFwSchema := schema.Schema{
Attributes: map[string]schema.Attribute{
"test_attribute": schema.StringAttribute{
Required: true,
},
},
}

testCases := map[string]struct {
input *tfprotov5.MoveResourceStateRequest
resourceSchema fwschema.Schema
resource resource.Resource
expected *fwserver.MoveResourceStateRequest
expectedDiagnostics diag.Diagnostics
}{
"nil": {
input: nil,
expected: nil,
},
"SourcePrivate": {
input: &tfprotov5.MoveResourceStateRequest{
SourcePrivate: privatestate.MustMarshalToJson(map[string][]byte{
".frameworkKey": []byte(`{"fKeyOne": {"k0": "zero", "k1": 1}}`),
"providerKeyOne": []byte(`{"pKeyOne": {"k0": "zero", "k1": 1}}`),
}),
},
resourceSchema: testFwSchema,
expected: &fwserver.MoveResourceStateRequest{
SourcePrivate: &privatestate.Data{
Framework: map[string][]byte{
".frameworkKey": []byte(`{"fKeyOne": {"k0": "zero", "k1": 1}}`),
},
Provider: privatestate.MustProviderData(context.Background(), privatestate.MustMarshalToJson(map[string][]byte{
"providerKeyOne": []byte(`{"pKeyOne": {"k0": "zero", "k1": 1}}`),
})),
},
TargetResourceSchema: testFwSchema,
},
},
"SourcePrivate-malformed-json": {
input: &tfprotov5.MoveResourceStateRequest{
SourcePrivate: []byte(`{`),
},
resourceSchema: testFwSchema,
expected: &fwserver.MoveResourceStateRequest{
TargetResourceSchema: testFwSchema,
},
expectedDiagnostics: diag.Diagnostics{
diag.NewErrorDiagnostic(
"Error Decoding Private State",
"An error was encountered when decoding private state: unexpected end of JSON input.\n\n"+
"This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.",
),
},
},
"SourcePrivate-empty-json": {
input: &tfprotov5.MoveResourceStateRequest{
SourcePrivate: []byte("{}"),
},
resourceSchema: testFwSchema,
expected: &fwserver.MoveResourceStateRequest{
SourcePrivate: &privatestate.Data{
Framework: map[string][]byte{},
Provider: privatestate.EmptyProviderData(context.Background()),
},
TargetResourceSchema: testFwSchema,
},
},
"SourceProviderAddress": {
input: &tfprotov5.MoveResourceStateRequest{
SourceProviderAddress: "example.com/namespace/type",
},
resourceSchema: testFwSchema,
expected: &fwserver.MoveResourceStateRequest{
SourceProviderAddress: "example.com/namespace/type",
TargetResourceSchema: testFwSchema,
},
},
"SourceRawState": {
input: &tfprotov5.MoveResourceStateRequest{
SourceState: testNewTfprotov5RawState(t, map[string]interface{}{
"test_attribute": "test-value",
}),
},
resourceSchema: testFwSchema,
expected: &fwserver.MoveResourceStateRequest{
SourceRawState: testNewTfprotov6RawState(t, map[string]interface{}{
"test_attribute": "test-value",
}),
TargetResourceSchema: testFwSchema,
},
},
"SourceSchemaVersion": {
input: &tfprotov5.MoveResourceStateRequest{
SourceSchemaVersion: 123,
},
resourceSchema: testFwSchema,
expected: &fwserver.MoveResourceStateRequest{
SourceSchemaVersion: 123,
TargetResourceSchema: testFwSchema,
},
},
"SourceTypeName": {
input: &tfprotov5.MoveResourceStateRequest{
SourceTypeName: "examplecloud_thing",
},
resourceSchema: testFwSchema,
expected: &fwserver.MoveResourceStateRequest{
SourceTypeName: "examplecloud_thing",
TargetResourceSchema: testFwSchema,
},
},
"TargetResourceSchema": {
input: &tfprotov5.MoveResourceStateRequest{},
resourceSchema: testFwSchema,
expected: &fwserver.MoveResourceStateRequest{
TargetResourceSchema: testFwSchema,
},
},
"TargetResourceSchema-missing": {
input: &tfprotov5.MoveResourceStateRequest{},
expected: nil,
expectedDiagnostics: diag.Diagnostics{
diag.NewErrorDiagnostic(
"Framework Implementation Error",
"An unexpected issue was encountered when converting the MoveResourceState RPC request information from the protocol type to the framework type. "+
"The resource schema was missing. "+
"This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.",
),
},
},
"TargetTypeName": {
input: &tfprotov5.MoveResourceStateRequest{
TargetTypeName: "examplecloud_thing",
},
resourceSchema: testFwSchema,
expected: &fwserver.MoveResourceStateRequest{
TargetResourceSchema: testFwSchema,
TargetTypeName: "examplecloud_thing",
},
},
}

for name, testCase := range testCases {
name, testCase := name, testCase

t.Run(name, func(t *testing.T) {
t.Parallel()

got, diags := fromproto5.MoveResourceStateRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema)

if diff := cmp.Diff(got, testCase.expected); diff != "" {
t.Errorf("unexpected difference: %s", diff)
}

if diff := cmp.Diff(diags, testCase.expectedDiagnostics); diff != "" {
t.Errorf("unexpected diagnostics difference: %s", diff)
}
})
}
}
56 changes: 56 additions & 0 deletions internal/fromproto6/moveresourcestate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package fromproto6

import (
"context"

"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/internal/fwschema"
"github.com/hashicorp/terraform-plugin-framework/internal/fwserver"
"github.com/hashicorp/terraform-plugin-framework/internal/privatestate"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-go/tfprotov6"
)

// MoveResourceStateRequest returns the *fwserver.MoveResourceStateRequest
// equivalent of a *tfprotov6.MoveResourceStateRequest.
func MoveResourceStateRequest(ctx context.Context, proto6 *tfprotov6.MoveResourceStateRequest, resource resource.Resource, resourceSchema fwschema.Schema) (*fwserver.MoveResourceStateRequest, diag.Diagnostics) {
if proto6 == nil {
return nil, nil
}

var diags diag.Diagnostics

// Panic prevention here to simplify the calling implementations.
// This should not happen, but just in case.
if resourceSchema == nil {
diags.AddError(
"Framework Implementation Error",
"An unexpected issue was encountered when converting the MoveResourceState RPC request information from the protocol type to the framework type. "+
"The resource schema was missing. "+
"This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.",
)

return nil, diags
}

fw := &fwserver.MoveResourceStateRequest{
SourceProviderAddress: proto6.SourceProviderAddress,
SourceRawState: proto6.SourceState,
SourceSchemaVersion: proto6.SourceSchemaVersion,
SourceTypeName: proto6.SourceTypeName,
TargetResource: resource,
TargetResourceSchema: resourceSchema,
TargetTypeName: proto6.TargetTypeName,
}

sourcePrivate, sourcePrivateDiags := privatestate.NewData(ctx, proto6.SourcePrivate)

diags.Append(sourcePrivateDiags...)

fw.SourcePrivate = sourcePrivate

return fw, diags
}
Loading

0 comments on commit 86b2acb

Please sign in to comment.