Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/types"

"github.com/hashicorp/terraform-plugin-framework-validators/listvalidator"

"github.com/terraform-providers/terraform-provider-datadog/datadog/internal/utils"
"github.com/terraform-providers/terraform-provider-datadog/datadog/internal/validators"
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import (
"context"

"github.com/DataDog/datadog-api-client-go/v2/api/datadogV2"

"github.com/hashicorp/terraform-plugin-framework/diag"
frameworkPath "github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"

"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"

"github.com/terraform-providers/terraform-provider-datadog/datadog/internal/utils"
Expand All @@ -26,11 +29,23 @@ type integrationCloudflareAccountResource struct {
}

type integrationCloudflareAccountModel struct {
ID types.String `tfsdk:"id"`
ApiKey types.String `tfsdk:"api_key"`
Email types.String `tfsdk:"email"`
Name types.String `tfsdk:"name"`
Resources types.Set `tfsdk:"resources"`
ID types.String `tfsdk:"id"`
ApiKey types.String `tfsdk:"api_key"`
ApiKeyWo types.String `tfsdk:"api_key_wo"`
ApiKeyWoVersion types.String `tfsdk:"api_key_wo_version"`
Email types.String `tfsdk:"email"`
Name types.String `tfsdk:"name"`
Resources types.Set `tfsdk:"resources"`
}

// Write-only secret configuration for Cloudflare API key
var cloudflareApiKeyConfig = utils.WriteOnlySecretConfig{
OriginalAttr: "api_key",
WriteOnlyAttr: "api_key_wo",
TriggerAttr: "api_key_wo_version",
OriginalDescription: "The API key (or token) for the Cloudflare account.",
WriteOnlyDescription: "Write-only API key (or token) for the Cloudflare account.",
TriggerDescription: "Version associated with api_key_wo. Changing this triggers an update. Can be any string (e.g., '1', 'v2.1', '2024-Q1').",
}

func NewIntegrationCloudflareAccountResource() resource.Resource {
Expand All @@ -48,33 +63,39 @@ func (r *integrationCloudflareAccountResource) Metadata(_ context.Context, reque
}

func (r *integrationCloudflareAccountResource) Schema(_ context.Context, _ resource.SchemaRequest, response *resource.SchemaResponse) {
response.Schema = schema.Schema{
Description: "Provides a Datadog IntegrationCloudflareAccount resource. This can be used to create and manage Datadog integration_cloudflare_account.",
Attributes: map[string]schema.Attribute{
"api_key": schema.StringAttribute{
Required: true,
Description: "The API key (or token) for the Cloudflare account.",
Sensitive: true,
},
"email": schema.StringAttribute{
Optional: true,
Description: "The email associated with the Cloudflare account. If an API key is provided (and not a token), this field is also required.",
},
"name": schema.StringAttribute{
Required: true,
Description: "The name of the Cloudflare account.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"id": utils.ResourceIDAttribute(),
"resources": schema.SetAttribute{
ElementType: types.StringType,
Optional: true,
Computed: true,
Description: "An allowlist of resources to pull metrics for. Includes `web`, `dns`, `lb` (load balancer), and `worker`).",
// Generate write-only secret attributes using helper
writeOnlyAttrs := utils.CreateWriteOnlySecretAttributes(cloudflareApiKeyConfig)

// Combine with other resource-specific attributes
allAttributes := map[string]schema.Attribute{
"email": schema.StringAttribute{
Optional: true,
Description: "The email associated with the Cloudflare account. If an API key is provided (and not a token), this field is also required.",
},
"name": schema.StringAttribute{
Required: true,
Description: "The name of the Cloudflare account.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"id": utils.ResourceIDAttribute(),
"resources": schema.SetAttribute{
ElementType: types.StringType,
Optional: true,
Computed: true,
Description: "An allowlist of resources to pull metrics for. Includes `web`, `dns`, `lb` (load balancer), and `worker`).",
},
}

// Merge write-only attributes with resource-specific ones
for key, attr := range writeOnlyAttrs {
allAttributes[key] = attr
}

response.Schema = schema.Schema{
Description: "Provides a Datadog IntegrationCloudflareAccount resource. This can be used to create and manage Datadog integration_cloudflare_account.",
Attributes: allAttributes,
}
}

Expand Down Expand Up @@ -117,7 +138,7 @@ func (r *integrationCloudflareAccountResource) Create(ctx context.Context, reque
return
}

body, diags := r.buildIntegrationCloudflareAccountRequestBody(ctx, &state)
body, diags := r.buildIntegrationCloudflareAccountRequestBody(ctx, &state, &request.Config)
response.Diagnostics.Append(diags...)
if response.Diagnostics.HasError() {
return
Expand All @@ -139,15 +160,21 @@ func (r *integrationCloudflareAccountResource) Create(ctx context.Context, reque
}

func (r *integrationCloudflareAccountResource) Update(ctx context.Context, request resource.UpdateRequest, response *resource.UpdateResponse) {
var state integrationCloudflareAccountModel
response.Diagnostics.Append(request.Plan.Get(ctx, &state)...)
var plan integrationCloudflareAccountModel
response.Diagnostics.Append(request.Plan.Get(ctx, &plan)...)
if response.Diagnostics.HasError() {
return
}

id := state.ID.ValueString()
var prior integrationCloudflareAccountModel
response.Diagnostics.Append(request.State.Get(ctx, &prior)...)
if response.Diagnostics.HasError() {
return
}

body, diags := r.buildIntegrationCloudflareAccountUpdateRequestBody(ctx, &state)
id := plan.ID.ValueString()

body, diags := r.buildIntegrationCloudflareAccountUpdateRequestBody(ctx, &plan, &prior, &request.Config, &request)
response.Diagnostics.Append(diags...)
if response.Diagnostics.HasError() {
return
Expand All @@ -162,10 +189,10 @@ func (r *integrationCloudflareAccountResource) Update(ctx context.Context, reque
response.Diagnostics.AddError("response contains unparsedObject", err.Error())
return
}
r.updateState(ctx, &state, &resp)
r.updateState(ctx, &plan, &resp)

// Save data into Terraform state
response.Diagnostics.Append(response.State.Set(ctx, &state)...)
response.Diagnostics.Append(response.State.Set(ctx, &plan)...)
}

func (r *integrationCloudflareAccountResource) Delete(ctx context.Context, request resource.DeleteRequest, response *resource.DeleteResponse) {
Expand Down Expand Up @@ -206,11 +233,20 @@ func (r *integrationCloudflareAccountResource) updateState(ctx context.Context,
}
}

func (r *integrationCloudflareAccountResource) buildIntegrationCloudflareAccountRequestBody(ctx context.Context, state *integrationCloudflareAccountModel) (*datadogV2.CloudflareAccountCreateRequest, diag.Diagnostics) {
func (r *integrationCloudflareAccountResource) buildIntegrationCloudflareAccountRequestBody(ctx context.Context, state *integrationCloudflareAccountModel, config *tfsdk.Config) (*datadogV2.CloudflareAccountCreateRequest, diag.Diagnostics) {
diags := diag.Diagnostics{}
attributes := datadogV2.NewCloudflareAccountCreateRequestAttributesWithDefaults()

attributes.SetApiKey(state.ApiKey.ValueString())
// Use helper to get secret for creation
handler := utils.WriteOnlySecretHandler{Config: cloudflareApiKeyConfig}
secret, useWriteOnly, secretDiags := handler.GetSecretForCreate(ctx, state, config)
diags.Append(secretDiags...)

if useWriteOnly {
attributes.SetApiKey(secret)
} else if !state.ApiKey.IsNull() && !state.ApiKey.IsUnknown() {
attributes.SetApiKey(state.ApiKey.ValueString())
}
if !state.Email.IsNull() {
attributes.SetEmail(state.Email.ValueString())
}
Expand All @@ -229,18 +265,29 @@ func (r *integrationCloudflareAccountResource) buildIntegrationCloudflareAccount
return req, diags
}

func (r *integrationCloudflareAccountResource) buildIntegrationCloudflareAccountUpdateRequestBody(ctx context.Context, state *integrationCloudflareAccountModel) (*datadogV2.CloudflareAccountUpdateRequest, diag.Diagnostics) {
func (r *integrationCloudflareAccountResource) buildIntegrationCloudflareAccountUpdateRequestBody(ctx context.Context, plan *integrationCloudflareAccountModel, prior *integrationCloudflareAccountModel, config *tfsdk.Config, request *resource.UpdateRequest) (*datadogV2.CloudflareAccountUpdateRequest, diag.Diagnostics) {
diags := diag.Diagnostics{}
attributes := datadogV2.NewCloudflareAccountUpdateRequestAttributesWithDefaults()

attributes.SetApiKey(state.ApiKey.ValueString())
if !state.Email.IsNull() {
attributes.SetEmail(state.Email.ValueString())
// Use helper to determine if secret should be updated
handler := utils.WriteOnlySecretHandler{Config: cloudflareApiKeyConfig}
secret, shouldUpdate, secretDiags := handler.GetSecretForUpdate(ctx, config, request)
diags.Append(secretDiags...)

if shouldUpdate {
attributes.SetApiKey(secret)
} else if !plan.ApiKey.IsNull() && !plan.ApiKey.IsUnknown() {
// Plaintext mode: always update
attributes.SetApiKey(plan.ApiKey.ValueString())
}

if !state.Resources.IsNull() && !state.Resources.IsUnknown() {
if !plan.Email.IsNull() {
attributes.SetEmail(plan.Email.ValueString())
}

if !plan.Resources.IsNull() && !plan.Resources.IsUnknown() {
var resources []string
diags.Append(state.Resources.ElementsAs(ctx, &resources, false)...)
diags.Append(plan.Resources.ElementsAs(ctx, &resources, false)...)
attributes.SetResources(resources)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"

"github.com/terraform-providers/terraform-provider-datadog/datadog/internal/utils"
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"

"github.com/terraform-providers/terraform-provider-datadog/datadog/internal/utils"
"github.com/terraform-providers/terraform-provider-datadog/datadog/internal/validators"
)
Expand Down
133 changes: 133 additions & 0 deletions datadog/internal/utils/writeonly_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package utils

import (
"context"

"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/diag"
frameworkPath "github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
)

// WriteOnlySecretConfig represents configuration for a write-only secret attribute
type WriteOnlySecretConfig struct {
// Name of the original attribute (e.g., "api_key")
OriginalAttr string
// Name of the write-only attribute (e.g., "api_key_wo")
WriteOnlyAttr string
// Name of the version trigger attribute (e.g., "api_key_wo_version")
TriggerAttr string
// Description for the original attribute
OriginalDescription string
// Description for the write-only attribute
WriteOnlyDescription string
// Description for the trigger attribute
TriggerDescription string
}

// CreateWriteOnlySecretAttributes creates schema attributes for a write-only secret pattern
func CreateWriteOnlySecretAttributes(config WriteOnlySecretConfig) map[string]schema.Attribute {
attrs := map[string]schema.Attribute{
config.OriginalAttr: schema.StringAttribute{
Optional: true,
Description: config.OriginalDescription,
Sensitive: true,
Validators: []validator.String{
stringvalidator.ExactlyOneOf(
frameworkPath.MatchRoot(config.OriginalAttr),
frameworkPath.MatchRoot(config.WriteOnlyAttr),
),
stringvalidator.PreferWriteOnlyAttribute(
frameworkPath.MatchRoot(config.WriteOnlyAttr),
),
},
},
config.WriteOnlyAttr: schema.StringAttribute{
Optional: true,
Description: config.WriteOnlyDescription,
Sensitive: true,
WriteOnly: true,
Validators: []validator.String{
stringvalidator.ExactlyOneOf(
frameworkPath.MatchRoot(config.OriginalAttr),
frameworkPath.MatchRoot(config.WriteOnlyAttr),
),
stringvalidator.AlsoRequires(
frameworkPath.MatchRoot(config.TriggerAttr),
),
},
},
config.TriggerAttr: schema.StringAttribute{
Optional: true,
Description: config.TriggerDescription,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.AlsoRequires(frameworkPath.Expressions{
frameworkPath.MatchRoot(config.WriteOnlyAttr),
}...),
},
},
}

return attrs
}

// WriteOnlySecretHandler helps handle write-only secrets in CRUD operations
type WriteOnlySecretHandler struct {
Config WriteOnlySecretConfig
}

// GetSecretForCreate retrieves the secret value for creation, preferring write-only from config
func (h *WriteOnlySecretHandler) GetSecretForCreate(ctx context.Context, state interface{}, config *tfsdk.Config) (string, bool, diag.Diagnostics) {
diags := diag.Diagnostics{}

// Try to get write-only secret from config first
var writeOnlySecret types.String
diags.Append(config.GetAttribute(ctx, frameworkPath.Root(h.Config.WriteOnlyAttr), &writeOnlySecret)...)
if diags.HasError() {
return "", false, diags
}

// If write-only secret is provided, use it
if !writeOnlySecret.IsNull() && !writeOnlySecret.IsUnknown() {
return writeOnlySecret.ValueString(), true, diags
}

// Otherwise, we'll use the regular attribute (handled by caller)
return "", false, diags
}

// GetSecretForUpdate retrieves the secret value for updates, only if version changed
func (h *WriteOnlySecretHandler) GetSecretForUpdate(ctx context.Context, config *tfsdk.Config, req *resource.UpdateRequest) (string, bool, diag.Diagnostics) {
diags := diag.Diagnostics{}

// Check if version changed by comparing plan vs state
var planVersion, priorVersion types.String
diags.Append(req.Plan.GetAttribute(ctx, frameworkPath.Root(h.Config.TriggerAttr), &planVersion)...)
diags.Append(req.State.GetAttribute(ctx, frameworkPath.Root(h.Config.TriggerAttr), &priorVersion)...)
if diags.HasError() {
return "", false, diags
}

// Only proceed if version actually changed
if planVersion.Equal(priorVersion) {
return "", false, diags
}

// Get write-only secret from config
var writeOnlySecret types.String
diags.Append(config.GetAttribute(ctx, frameworkPath.Root(h.Config.WriteOnlyAttr), &writeOnlySecret)...)
if diags.HasError() {
return "", false, diags
}

if !writeOnlySecret.IsNull() && !writeOnlySecret.IsUnknown() {
return writeOnlySecret.ValueString(), true, diags
}

return "", false, diags
}
Loading
Loading