Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
64 changes: 64 additions & 0 deletions linode/volume/datasource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,39 @@ func TestAccDataSourceVolume_basic(t *testing.T) {
})
}

// Default encryption (Basic template) should be enabled when omitted, in an encryption-capable region
func TestAccDataSourceVolume_defaultEncryptionEnabled(t *testing.T) {
t.Parallel()

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

// Pick a region supporting Block Storage Encryption so the default is valid
targetRegion, err := acceptance.GetRandomRegionWithCaps(
[]string{"Linodes", "Block Storage Encryption"},
"core",
)
if err != nil {
t.Fatal(err)
}

resource.Test(t, resource.TestCase{
PreCheck: func() { acceptance.PreCheck(t) },
ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
// Use the basic template which omits the encryption field
Config: tmpl.DataBasic(t, volumeName, targetRegion),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(resourceName, "region", targetRegion),
resource.TestCheckResourceAttr(resourceName, "encryption", "enabled"),
),
},
},
})
}

// Explicit encryption enabled (DataWithBlockStorageEncryption template) in an encryption-capable region
func TestAccDataSourceVolume_withBlockStorageEncryption(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -67,3 +100,34 @@ func TestAccDataSourceVolume_withBlockStorageEncryption(t *testing.T) {
},
})
}

// Explicit encryption disabled (DataWithBlockStorageEncryptionDisabled template) in an encryption-capable region
func TestAccDataSourceVolume_withBlockStorageEncryptionDisabled(t *testing.T) {
t.Parallel()

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

// Resolve a region with support for Block Storage Encryption
targetRegion, err := acceptance.GetRandomRegionWithCaps(
[]string{"Linodes", "Block Storage Encryption"},
"core",
)
if err != nil {
t.Fatal(err)
}

resource.Test(t, resource.TestCase{
PreCheck: func() { acceptance.PreCheck(t) },
ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: tmpl.DataWithBlockStorageEncryptionDisabled(t, volumeName, targetRegion),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(resourceName, "region", targetRegion),
resource.TestCheckResourceAttr(resourceName, "encryption", "disabled"),
),
},
},
})
}
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")
}
}
152 changes: 152 additions & 0 deletions linode/volume/encryption_resource_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
//go:build integration || volume

package volume_test

import (
"fmt"
"testing"

"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"
)

// Default encryption (omitted) should be enabled (provider derives default at create-time)
func TestAccResourceVolume_defaultEncryptionEnabled_Derived(t *testing.T) {
t.Parallel()

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

// Choose a random core region without checking capabilities
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{
{
// Basic template omits encryption
Config: tmpl.Basic(t, volumeName, targetRegion),
Check: resource.ComposeTestCheckFunc(
acceptance.CheckVolumeExists(resName, &volume),
resource.TestCheckResourceAttr(resName, "region", targetRegion),
resource.TestCheckResourceAttr(resName, "encryption", "enabled"),
),
},
},
})
}

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

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

// Choose a random core region without checking capabilities
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"

// Choose a random core region without checking capabilities
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
},
),
},
},
})
}
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
7 changes: 5 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,15 @@ 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{
// Preserve existing state when config omits the field (updates)
stringplanmodifier.UseStateForUnknown(),
// On create and when omitted, make the plan show encryption = "enabled"
DefaultEnabledOnCreate(),
// Changing encryption requires replacement
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 }}
Loading