Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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
37 changes: 37 additions & 0 deletions linode/volume/encryption_plan_modifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package volume

import (
"context"

"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)

// defaultEnabledOnCreate is a String plan modifier that sets the planned value to
// "enabled" during create when the attribute is omitted (null/unknown).
// It does nothing on updates (when a prior state value exists).
//
// This keeps the plan consistent with Create() behavior without affecting updates,
// where UseStateForUnknown preserves the existing state when the field is omitted.
type defaultEnabledOnCreate struct{}

func DefaultEnabledOnCreate() planmodifier.String { return defaultEnabledOnCreate{} }

func (m defaultEnabledOnCreate) Description(ctx context.Context) string {
return "Defaults to \"enabled\" on create when encryption is omitted"
}

func (m defaultEnabledOnCreate) MarkdownDescription(ctx context.Context) string {
return m.Description(ctx)
}

func (m defaultEnabledOnCreate) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) {
// If there is a prior state value, this is an update; do nothing.
if !req.StateValue.IsNull() && !req.StateValue.IsUnknown() {
return
}
// Create path: if the user omitted encryption (null/unknown), set planned value to "enabled".
if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
resp.PlanValue = types.StringValue("enabled")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func TestAccDataSourceVolume_basic(t *testing.T) {
resource.TestCheckResourceAttr(resourceName, "linode_id", "0"),
resource.TestCheckResourceAttrSet(resourceName, "created"),
resource.TestCheckResourceAttrSet(resourceName, "updated"),
resource.TestCheckResourceAttr(resourceName, "encryption", "disabled"),
resource.TestCheckResourceAttr(resourceName, "encryption", "enabled"),
),
},
},
Expand Down
15 changes: 11 additions & 4 deletions linode/volume/framework_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,17 @@ func (r *Resource) CreateVolume(
size := helper.FrameworkSafeInt64ToInt(data.Size.ValueInt64(), diags)

createOpts := linodego.VolumeCreateOptions{
Label: data.Label.ValueString(),
Region: data.Region.ValueString(),
Size: size,
Encryption: data.Encryption.ValueString(),
Label: data.Label.ValueString(),
Region: data.Region.ValueString(),
Size: size,
}

// Respect explicit user setting first
if !data.Encryption.IsNull() && !data.Encryption.IsUnknown() && data.Encryption.ValueString() != "" {
createOpts.Encryption = data.Encryption.ValueString()
} else {
// Default to enabled on create when encryption is omitted, without region capability checks.
createOpts.Encryption = "enabled"
}

diags.Append(data.Tags.ElementsAs(ctx, &createOpts.Tags, false)...)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/terraform"
"github.com/linode/linodego"
"github.com/linode/terraform-provider-linode/v3/linode/acceptance"
"github.com/linode/terraform-provider-linode/v3/linode/volume/tmpl"
Expand Down Expand Up @@ -88,6 +89,7 @@ func TestAccResourceVolume_basic_smoke(t *testing.T) {
resource.TestCheckResourceAttrSet(resName, "size"),
resource.TestCheckResourceAttr(resName, "label", volumeName),
resource.TestCheckResourceAttr(resName, "region", testRegion),
resource.TestCheckResourceAttr(resName, "encryption", "enabled"),
resource.TestCheckResourceAttr(resName, "linode_id", "0"),
resource.TestCheckResourceAttr(resName, "tags.#", "1"),
resource.TestCheckResourceAttr(resName, "tags.0", "tf_test"),
Expand Down Expand Up @@ -380,6 +382,109 @@ func TestAccResourceVolume_cloned(t *testing.T) {
})
}

// Explicit encryption enabled (resource test)
func TestAccResourceVolume_encryptionExplicitEnabled(t *testing.T) {
t.Parallel()

volumeName := acctest.RandomWithPrefix("tf_test")
resName := "linode_volume.foobar"

targetRegion, err := acceptance.GetRandomRegionWithCaps(nil, "core")
if err != nil {
t.Fatal(err)
}

volume := linodego.Volume{}
resource.Test(t, resource.TestCase{
PreCheck: func() { acceptance.PreCheck(t) },
ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories,
CheckDestroy: acceptance.CheckVolumeDestroy,
Steps: []resource.TestStep{
{
Config: tmpl.DataWithBlockStorageEncryption(t, volumeName, targetRegion),
Check: resource.ComposeTestCheckFunc(
acceptance.CheckVolumeExists(resName, &volume),
resource.TestCheckResourceAttr(resName, "encryption", "enabled"),
),
},
},
})
}

// Explicit encryption disabled (resource test)
func TestAccResourceVolume_encryptionExplicitDisabled(t *testing.T) {
t.Parallel()

volumeName := acctest.RandomWithPrefix("tf_test")
resName := "linode_volume.foobar"

targetRegion, err := acceptance.GetRandomRegionWithCaps(nil, "core")
if err != nil {
t.Fatal(err)
}

volume := linodego.Volume{}
resource.Test(t, resource.TestCase{
PreCheck: func() { acceptance.PreCheck(t) },
ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories,
CheckDestroy: acceptance.CheckVolumeDestroy,
Steps: []resource.TestStep{
{
Config: tmpl.DataWithBlockStorageEncryptionDisabled(t, volumeName, targetRegion),
Check: resource.ComposeTestCheckFunc(
acceptance.CheckVolumeExists(resName, &volume),
resource.TestCheckResourceAttr(resName, "encryption", "disabled"),
),
},
},
})
}

// Changing encryption forces replacement (verify ID changes)
func TestAccResourceVolume_encryptionChangeForcesReplace(t *testing.T) {
t.Parallel()

volumeName := acctest.RandomWithPrefix("tf_test")
resName := "linode_volume.foobar"

targetRegion, err := acceptance.GetRandomRegionWithCaps(nil, "core")
if err != nil {
t.Fatal(err)
}

var v linodego.Volume
var firstID int

resource.Test(t, resource.TestCase{
PreCheck: func() { acceptance.PreCheck(t) },
ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories,
CheckDestroy: acceptance.CheckVolumeDestroy,
Steps: []resource.TestStep{
{
Config: tmpl.DataWithBlockStorageEncryptionDisabled(t, volumeName, targetRegion),
Check: resource.ComposeTestCheckFunc(
acceptance.CheckVolumeExists(resName, &v),
resource.TestCheckResourceAttr(resName, "encryption", "disabled"),
func(_ *terraform.State) error { firstID = v.ID; return nil },
),
},
{
Config: tmpl.DataWithBlockStorageEncryption(t, volumeName, targetRegion),
Check: resource.ComposeTestCheckFunc(
acceptance.CheckVolumeExists(resName, &v),
resource.TestCheckResourceAttr(resName, "encryption", "enabled"),
func(_ *terraform.State) error {
if v.ID == firstID {
return fmt.Errorf("expected replacement, ID unchanged: %d", v.ID)
}
return nil
},
),
},
},
})
}

const scriptFormatDrive = `
until [ -e "%s" ]; do sleep .1; done && \
mkfs.ext4 "%s" && \
Expand Down
4 changes: 2 additions & 2 deletions linode/volume/framework_schema_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
Expand Down Expand Up @@ -116,11 +115,12 @@ var frameworkResourceSchema = schema.Schema{
Description: "Whether Block Storage Disk Encryption is enabled or disabled on this Volume. ",
Optional: true,
Computed: true,
Default: stringdefault.StaticString("disabled"),
Validators: []validator.String{
stringvalidator.OneOf("enabled", "disabled"),
},
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
DefaultEnabledOnCreate(),
stringplanmodifier.RequiresReplace(),
},
},
Expand Down
64 changes: 64 additions & 0 deletions linode/volume/framework_schema_resource_unit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//go:build unit

package volume

import (
"reflect"
"testing"

"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/stretchr/testify/require"
)

// test that guards the schema for the encryption attribute.
func TestEncryptionAttribute_HasDefaultAndRequiresReplace(t *testing.T) {
t.Helper()

attrRaw, ok := frameworkResourceSchema.Attributes["encryption"]
require.True(t, ok, "encryption attribute must exist in schema")

attr, ok := attrRaw.(schema.StringAttribute)
require.True(t, ok, "encryption must be a StringAttribute")

// Should be Optional + Computed with no schema default; provider preserves state when omitted.
require.True(t, attr.Optional, "encryption should be Optional")
require.True(t, attr.Computed, "encryption should be Computed")
require.Nil(t, attr.Default, "encryption should not have a schema default")

// Must preserve state when config omits the field.
expectedUseStateType := reflect.TypeOf(stringplanmodifier.UseStateForUnknown())
foundUseState := false
for _, pm := range attr.PlanModifiers {
if reflect.TypeOf(pm) == expectedUseStateType {
foundUseState = true
break
}
}
require.True(t, foundUseState, "encryption should have UseStateForUnknown plan modifier")

// Must require replacement when changed.
expectedReplaceType := reflect.TypeOf(stringplanmodifier.RequiresReplace())
foundReplace := false
for _, pm := range attr.PlanModifiers {
if reflect.TypeOf(pm) == expectedReplaceType {
foundReplace = true
break
}
}
require.True(t, foundReplace, "encryption should have a RequiresReplace plan modifier")

// Should show default "enabled" on create when omitted.
expectedDefaultOnCreateType := reflect.TypeOf(DefaultEnabledOnCreate())
foundDefaultOnCreate := false
for _, pm := range attr.PlanModifiers {
if reflect.TypeOf(pm) == expectedDefaultOnCreateType {
foundDefaultOnCreate = true
break
}
}
require.True(t, foundDefaultOnCreate, "encryption should have DefaultEnabledOnCreate plan modifier")

// Should have validators (e.g., OneOf("enabled","disabled")). We don't assert exact type, just presence.
require.NotEmpty(t, attr.Validators, "encryption should have validators (e.g., OneOf)")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{{ define "volume_data_with_block_storage_encryption_disabled" }}

resource "linode_volume" "foobar" {
label = "{{.Label}}"
region = "{{ .Region }}"
tags = ["tf_test"]
encryption = "disabled"
}

data "linode_volume" "foobar" {
id = "${linode_volume.foobar.id}"
}

{{ end }}
5 changes: 5 additions & 0 deletions linode/volume/tmpl/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,8 @@ func DataWithBlockStorageEncryption(t *testing.T, volume, region string) string
return acceptance.ExecuteTemplate(t,
"volume_data_with_block_storage_encryption", TemplateData{Label: volume, Region: region})
}

func DataWithBlockStorageEncryptionDisabled(t *testing.T, volume, region string) string {
return acceptance.ExecuteTemplate(t,
"volume_data_with_block_storage_encryption_disabled", TemplateData{Label: volume, Region: region})
}