Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Your contribution is welcome! Thank you for your interest in contributing to the
- [Repository structure](#repository-structure)
- [Implementing a new resource](#implementing-a-new-resource)
- [Resource file structure](#resource-file-structure)
- [Implementing write-only attributes](#implementing-write-only-attributes)
- [Implementing a new datasource](#implementing-a-new-datasource)
- [Onboarding a new STACKIT service](#onboarding-a-new-stackit-service)
- [Implementing IAM Role Bindings](#implementing-iam-role-bindings)
Expand Down Expand Up @@ -66,6 +67,14 @@ https://github.com/stackitcloud/terraform-provider-stackit/blob/main/.github/doc

If the new resource `bar` is the first resource in the TFP using a STACKIT service `foo`, please refer to [Onboarding a new STACKIT service](./CONTRIBUTING.md/#onboarding-a-new-stackit-service).

#### Implementing write-only attributes

When implementing [write-only attributes](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments) (supported in Terraform 1.11.0 and later), keep in mind that Terraform never populates write-only values in the plan or state models. They are only available in the config model, i.e. they must be read via `req.Config.Get(...)` in the `Create`/`Update` handlers, while all other values are read from the plan model as usual.

You can find a reference implementation of write-only attributes (including the accompanying `<attribute>_wo_version` rotation counter pattern) in the VPN connection resource:

https://github.com/stackitcloud/terraform-provider-stackit/blob/main/stackit/internal/services/vpn/connection/resource.go

### Implementing a new datasource

The process to implement a new datasource is similar to [implementing a new resource](#implementing-a-new-resource). Some differences worth noting are:
Expand Down
32 changes: 26 additions & 6 deletions stackit/internal/services/iaas/volume/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,16 @@ func (r *volumeResource) Create(ctx context.Context, req resource.CreateRequest,
return
}

// The config model - this has to be used because Terraform doesn't include write-only field values in the
// plan and state models - for security measures. Write-only values should be only kept in the config model
// so that they never end up in the state (or plan).
var configModel Model
diags = req.Config.Get(ctx, &configModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

ctx = core.InitProviderContext(ctx)

projectId := model.ProjectId.ValueString()
Expand All @@ -450,7 +460,7 @@ func (r *volumeResource) Create(ctx context.Context, req resource.CreateRequest,
}

// Generate API request body from model
payload, err := toCreatePayload(ctx, &model, source)
payload, err := toCreatePayload(ctx, &model, &configModel, source)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating volume", fmt.Sprintf("Creating API payload: %v", err))
return
Expand Down Expand Up @@ -753,10 +763,13 @@ func mapFields(ctx context.Context, volumeResp *iaas.Volume, model *Model, regio
return nil
}

func toCreatePayload(ctx context.Context, model *Model, source *sourceModel) (*iaas.CreateVolumePayload, error) {
func toCreatePayload(ctx context.Context, model, configModel *Model, source *sourceModel) (*iaas.CreateVolumePayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
if configModel == nil {
return nil, fmt.Errorf("nil config model")
}

labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels)
if err != nil {
Expand All @@ -782,12 +795,19 @@ func toCreatePayload(ctx context.Context, model *Model, source *sourceModel) (*i
Source: sourcePayload,
}

if model.EncryptionParameters != nil {
if model.EncryptionParameters != nil && configModel.EncryptionParameters != nil {
// Terraform keeps the write-only field values in the config model - and they shouldn't leave this config model
// to make sure they don't end up being stored in the state. In the plan model the write-only field values are just
// empty. That's why write-only field values must be read from the config model. Everything else comes from the
// plan model.
var keyPayload *string
if !utils.IsUndefined(model.EncryptionParameters.KeyPayloadBase64WriteOnly) {
keyPayload = conversion.StringValueToPointer(model.EncryptionParameters.KeyPayloadBase64WriteOnly)
} else if !utils.IsUndefined(model.EncryptionParameters.KeyPayloadBase64) {
if !utils.IsUndefined(model.EncryptionParameters.KeyPayloadBase64) {
// handle the legacy fallback logic
keyPayload = conversion.StringValueToPointer(model.EncryptionParameters.KeyPayloadBase64)
} else if !utils.IsUndefined(configModel.EncryptionParameters.KeyPayloadBase64WriteOnly) &&
!utils.IsUndefined(model.EncryptionParameters.KeyPayloadBase64WriteOnlyVersion) {
// the user is using the write-only field
keyPayload = conversion.StringValueToPointer(configModel.EncryptionParameters.KeyPayloadBase64WriteOnly)
}

payload.EncryptionParameters = &iaas.VolumeEncryptionParameter{
Expand Down
202 changes: 202 additions & 0 deletions stackit/internal/services/iaas/volume/resource_create_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package volume

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"

"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-go/tftypes"
sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config"
iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2api"

"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
)

const (
testProjectId = "4e684f79-a12c-449d-aa89-bcd9d8aafaf2"
testRegion = "eu01"
testVolumeId = "3dee3fb9-59f0-4f97-8eeb-a4da37d05a00"
testKeyPayloadBase64 = "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIDEzIGxhenkgZG9ncy4="
)

// buildCreateRequest builds a resource.CreateRequest from a plan and a config model.
// Terraform populates write-only attribute values only in the config model - never in the plan or state model.
// That's why we need both, the plan model AND config model to build the request.
func buildCreateRequest(ctx context.Context, t *testing.T, schemaResp *resource.SchemaResponse, planModel, configModel *Model) resource.CreateRequest {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would actually prefer to have this tested via resource.UnitTest like e.g. here:

This is hard to mock then (I know), that's why I worked on #1663 . Will make your life easier in that regard 😅

t.Helper()

req := resource.CreateRequest{}
req.Plan = tfsdk.Plan{
Schema: schemaResp.Schema,
Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil),
}
if diags := req.Plan.Set(ctx, planModel); diags.HasError() {
t.Fatalf("Failed to set plan: %v", diags.Errors())
}

configScratch := tfsdk.Plan{
Schema: schemaResp.Schema,
Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil),
}
if diags := configScratch.Set(ctx, configModel); diags.HasError() {
t.Fatalf("Failed to set config: %v", diags.Errors())
}
req.Config = tfsdk.Config{
Schema: schemaResp.Schema,
Raw: configScratch.Raw,
}

return req
}

type volumeFixture struct {
server *httptest.Server
capturedKeyPayload *string
createCalled bool
}

// newVolumeFixture spins up a mock IaaS API server handling volume creation and the subsequent
// polling of the wait handler. The create handler decodes the request body and records the
// encryption key payload that the provider sent to the API.
func newVolumeFixture(t *testing.T) *volumeFixture {
t.Helper()
fixture := &volumeFixture{}

mux := http.NewServeMux()
// Create volume
mux.HandleFunc(fmt.Sprintf("POST /v2/projects/%s/regions/%s/volumes", testProjectId, testRegion), func(w http.ResponseWriter, r *http.Request) {
fixture.createCalled = true
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("Failed to read create request body: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
var payload iaas.CreateVolumePayload
if err := json.Unmarshal(body, &payload); err != nil {
t.Errorf("Failed to unmarshal create request body: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
if payload.EncryptionParameters != nil {
fixture.capturedKeyPayload = payload.EncryptionParameters.KeyPayload
}

w.Header().Set("content-type", "application/json")
volumeId := testVolumeId
_ = json.NewEncoder(w).Encode(iaas.Volume{Id: &volumeId})
})
// Get volume (used by the create wait handler and by mapFields via the response of the wait handler)
mux.HandleFunc(fmt.Sprintf("GET /v2/projects/%s/regions/%s/volumes/%s", testProjectId, testRegion, testVolumeId), func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("content-type", "application/json")
volumeId := testVolumeId
status := "AVAILABLE"
_ = json.NewEncoder(w).Encode(iaas.Volume{
Id: &volumeId,
Status: &status,
AvailabilityZone: "eu01-1",
})
})

fixture.server = httptest.NewServer(mux)
t.Cleanup(fixture.server.Close)
return fixture
}

// newTestVolumeResource builds a volumeResource with the client's URL being set to the mock URL
func newTestVolumeResource(t *testing.T, server *httptest.Server) *volumeResource {
t.Helper()
client, err := iaas.NewAPIClient(
sdkConfig.WithEndpoint(server.URL),
sdkConfig.WithoutAuthentication(),
)
if err != nil {
t.Fatalf("Failed to initialize client: %v", err)
}
return &volumeResource{
client: client,
providerData: core.ProviderData{
DefaultRegion: testRegion,
},
}
}

func encryptionParametersTestModel() *encryptionParametersModel {
return &encryptionParametersModel{
KekKeyId: types.StringValue("11111111-1111-1111-1111-111111111111"),
KekKeyVersion: types.Int64Value(1),
KekKeyringId: types.StringValue("22222222-2222-2222-2222-222222222222"),
KeyPayloadBase64: types.StringNull(),
KeyPayloadBase64WriteOnly: types.StringNull(), // will be set manually for the config model
KeyPayloadBase64WriteOnlyVersion: types.Int64Value(1),
ServiceAccount: types.StringValue("test-sa@sa.stackit.cloud"),
}
}

func baseTestModel() Model {
return Model{
ProjectId: types.StringValue(testProjectId),
Region: types.StringValue(testRegion),
AvailabilityZone: types.StringValue("eu01-1"),
Name: types.StringValue("test-volume"),
Size: types.Int64Value(16),
Labels: types.MapNull(types.StringType),
Source: types.ObjectNull(sourceTypes),
}
}

// TestCreate_WriteOnlyKeyPayload is a regression test for the bug where the write-only key payload
// was read from the plan model instead of the config model.
// The test asserts that the value configured via key_payload_base64_wo is actually
// sent to the API in the create request.
func TestCreate_WriteOnlyKeyPayload(t *testing.T) {
ctx := context.Background()

// Usually terraform will only ever write write-only fields in the config model, not the plan.
// Since we're setting the models manually here, we have to ensure this is done correctly.
// Ensuring that the write-only fields never go into the state/plan model is not part of this test's scope here
planModel := baseTestModel()
planModel.EncryptionParameters = encryptionParametersTestModel()

configModel := baseTestModel()
configEncryptionParams := encryptionParametersTestModel()
configEncryptionParams.KeyPayloadBase64WriteOnly = types.StringValue(testKeyPayloadBase64)
configModel.EncryptionParameters = configEncryptionParams

fixture := newVolumeFixture(t)
iaasRessource := newTestVolumeResource(t, fixture.server)

schemaResp := &resource.SchemaResponse{}
iaasRessource.Schema(ctx, resource.SchemaRequest{}, schemaResp)

req := buildCreateRequest(ctx, t, schemaResp, &planModel, &configModel)
// we have to set an initial empty state so it is != nil
resp := &resource.CreateResponse{}
resp.State = tfsdk.State{
Schema: schemaResp.Schema,
Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil),
}

iaasRessource.Create(ctx, req, resp)

if resp.Diagnostics.HasError() {
t.Fatalf("Create should succeed, but got errors: %v", resp.Diagnostics.Errors())
}
if !fixture.createCalled {
t.Fatalf("Expected the create endpoint to be called")
}

if fixture.capturedKeyPayload == nil {
t.Fatalf("Expected key payload %q to be sent to the API, but none was sent", testKeyPayloadBase64)
}
if *fixture.capturedKeyPayload != testKeyPayloadBase64 {
t.Fatalf("Wrong key payload sent to the API: expected %q, got %q", testKeyPayloadBase64, *fixture.capturedKeyPayload)
}
}
Loading
Loading