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
141 changes: 141 additions & 0 deletions internal/services/ruleset/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import (
"github.com/cloudflare/terraform-provider-cloudflare/internal/customfield"
"github.com/cloudflare/terraform-provider-cloudflare/internal/importpath"
"github.com/cloudflare/terraform-provider-cloudflare/internal/logging"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-go/tftypes"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
Expand Down Expand Up @@ -309,6 +311,34 @@ func (r *RulesetResource) ModifyPlan(ctx context.Context, req resource.ModifyPla
return
}

// A replacement plans its create half with no state, exactly like a plain create,
// so mark a ruleset that is here for that create to find
if !req.State.Raw.IsNull() {
resp.Diagnostics.Append(resp.Private.SetKey(ctx, "existing_ruleset", []byte("true"))...)
if resp.Diagnostics.HasError() {
return
}
}

// Validate the plan once the planned rules below have been populated
defer func() {
Comment thread
zakcutner marked this conversation as resolved.
if resp.Diagnostics.HasError() {
return
}

// A ruleset that is not changing is not written during apply, so skip dry-run
if !req.State.Raw.IsNull() && !resp.Plan.Raw.IsNull() && req.State.Raw.Equal(resp.Plan.Raw) {
return
}

// Check if enough of the plan is known to build a request for the dry-run
if !planIsKnown(resp.Plan.Raw, req.Config.Raw) {
return
}

r.validateWithDryRun(ctx, req, state, plan, &resp.Diagnostics)
}()

// Do nothing if there is no state or no plan.
if state == nil || plan == nil {
return
Expand Down Expand Up @@ -430,3 +460,114 @@ func transformQueryStringJSON(jsonBytes []byte) []byte {
}
return jsonBytes
}

func planIsKnown(plan, config tftypes.Value) bool {
known := true

_ = tftypes.Walk(plan, func(path *tftypes.AttributePath, value tftypes.Value) (bool, error) {
if value.IsKnown() {
return true, nil
}

configValue, _, err := tftypes.WalkAttributePath(config, path)
if value, ok := configValue.(tftypes.Value); ok && err == nil && !value.IsKnown() {
known = false
}

return false, nil
})

return known
}

func (r *RulesetResource) validateWithDryRun(
ctx context.Context,
req resource.ModifyPlanRequest,
state *RulesetModel,
plan *RulesetModel,
diagnostics *diag.Diagnostics,
) {
if r.client == nil {
return
}

requestOptions := []option.RequestOption{
option.WithQuery("dry_run", "true"),
option.WithMiddleware(logging.Middleware(ctx)),
}

var err error
switch {
// Terraform plans a create as a null state
case req.State.Raw.IsNull():
// skip the dry run when creating as part of a replacement
if replacesExistingRuleset(ctx, req) {
return
}

dataBytes, marshalErr := plan.MarshalJSON()
if marshalErr != nil {
diagnostics.AddError("failed to serialize http request", marshalErr.Error())
return
}

params := rulesets.RulesetNewParams{}
if !plan.AccountID.IsNull() {
params.AccountID = cloudflare.F(plan.AccountID.ValueString())
} else {
params.ZoneID = cloudflare.F(plan.ZoneID.ValueString())
}

_, err = r.client.Rulesets.New(ctx, params,
append(requestOptions, option.WithRequestBody("application/json", dataBytes))...)

// Terraform plans a destroy as a null plan
case req.Plan.Raw.IsNull():
return

// Terraform carries out a replacement as a destroy followed by a create that it
// plans in a separate call, skip dry run
case replacesRuleset(state, plan):
return

// Updating a ruleset that already exists
default:
dataBytes, marshalErr := plan.MarshalJSONForUpdate(*state)
if marshalErr != nil {
diagnostics.AddError("failed to serialize http request", marshalErr.Error())
return
}

params := rulesets.RulesetUpdateParams{}
if !plan.AccountID.IsNull() {
params.AccountID = cloudflare.F(plan.AccountID.ValueString())
} else {
params.ZoneID = cloudflare.F(plan.ZoneID.ValueString())
}

_, err = r.client.Rulesets.Update(ctx, plan.ID.ValueString(), params,
append(requestOptions, option.WithRequestBody("application/json", dataBytes))...)
}

if err != nil {
diagnostics.AddError("failed to make http request", err.Error())
}
}

// replacesExistingRuleset checks whether a planned create is the second half of a
// replacement, by looking for the mark ModifyPlan leaves on a ruleset that is there
func replacesExistingRuleset(ctx context.Context, req resource.ModifyPlanRequest) bool {
existing, _ := req.Private.GetKey(ctx, "existing_ruleset")

return existing != nil
}

// replacesRuleset reports whether changing from the state to the plan replaces the
// ruleset instead of updating it in place
func replacesRuleset(state, plan *RulesetModel) bool {
return !plan.AccountID.Equal(state.AccountID) ||
!plan.ZoneID.Equal(state.ZoneID) ||
!plan.Kind.Equal(state.Kind) ||
!plan.Name.Equal(state.Name) ||
!plan.Phase.Equal(state.Phase)
}
Loading