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
2 changes: 1 addition & 1 deletion internal/services/worker_version/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ type WorkerVersionModel struct {
Containers *[]*WorkerVersionContainersModel `tfsdk:"containers" json:"containers,optional"`
Migrations *WorkerVersionMigrationsModel `tfsdk:"migrations" json:"migrations,optional"`
Modules *[]*WorkerVersionModulesModel `tfsdk:"modules" json:"modules,optional"`
Placement *WorkerVersionPlacementModel `tfsdk:"placement" json:"placement,optional"`
Placement *WorkerVersionPlacementModel `tfsdk:"placement" json:"-,optional"`
UsageModel types.String `tfsdk:"usage_model" json:"usage_model,computed_optional"`
CompatibilityFlags customfield.Set[types.String] `tfsdk:"compatibility_flags" json:"compatibility_flags,computed_optional"`
Annotations customfield.NestedObject[WorkerVersionAnnotationsModel] `tfsdk:"annotations" json:"annotations,computed_optional"`
Expand Down
12 changes: 12 additions & 0 deletions internal/services/worker_version/placement_export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package worker_version

import "github.com/cloudflare/cloudflare-go/v7/workers"

// PlacementFromSettingsForTest exports placementFromSettings for use in tests.
var PlacementFromSettingsForTest = placementFromSettings

// ScriptPlacementGetResponse is an alias for the SDK type to avoid import verbosity in tests.
type ScriptPlacementGetResponse = workers.ScriptScriptAndVersionSettingGetResponsePlacement

// ScriptPlacementModeMode is an alias for the SDK mode type.
type ScriptPlacementModeMode = workers.ScriptScriptAndVersionSettingGetResponsePlacementModeMode
114 changes: 114 additions & 0 deletions internal/services/worker_version/placement_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package worker_version_test

import (
"testing"

"github.com/cloudflare/terraform-provider-cloudflare/internal/services/worker_version"
"github.com/hashicorp/terraform-plugin-framework/types"
)

func TestPlacementFromSettings(t *testing.T) {
t.Parallel()

tests := []struct {
name string
input worker_version.ScriptPlacementGetResponse
want *worker_version.WorkerVersionPlacementModel
}{
{
name: "empty returns nil",
input: worker_version.ScriptPlacementGetResponse{},
want: nil,
},
{
name: "region is populated",
input: worker_version.ScriptPlacementGetResponse{
Region: "WEUR",
},
want: &worker_version.WorkerVersionPlacementModel{
Region: types.StringValue("WEUR"),
Mode: types.StringNull(),
Hostname: types.StringNull(),
Host: types.StringNull(),
},
},
{
name: "mode is populated",
input: worker_version.ScriptPlacementGetResponse{
Mode: worker_version.ScriptPlacementModeMode("smart"),
},
want: &worker_version.WorkerVersionPlacementModel{
Region: types.StringNull(),
Mode: types.StringValue("smart"),
Hostname: types.StringNull(),
Host: types.StringNull(),
},
},
{
name: "hostname is populated",
input: worker_version.ScriptPlacementGetResponse{
Hostname: "example.com",
},
want: &worker_version.WorkerVersionPlacementModel{
Region: types.StringNull(),
Mode: types.StringNull(),
Hostname: types.StringValue("example.com"),
Host: types.StringNull(),
},
},
{
name: "host is populated",
input: worker_version.ScriptPlacementGetResponse{
Host: "10.0.0.1:8080",
},
want: &worker_version.WorkerVersionPlacementModel{
Region: types.StringNull(),
Mode: types.StringNull(),
Hostname: types.StringNull(),
Host: types.StringValue("10.0.0.1:8080"),
},
},
{
name: "all non-empty fields in response are populated in model",
input: worker_version.ScriptPlacementGetResponse{
Region: "WEUR",
Hostname: "example.com",
},
want: &worker_version.WorkerVersionPlacementModel{
Region: types.StringValue("WEUR"),
Mode: types.StringNull(),
Hostname: types.StringValue("example.com"),
Host: types.StringNull(),
},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := worker_version.PlacementFromSettingsForTest(tc.input)

if tc.want == nil {
if got != nil {
t.Errorf("expected nil, got %+v", got)
}
return
}
if got == nil {
t.Fatalf("expected non-nil result, got nil")
}
if got.Region != tc.want.Region {
t.Errorf("Region: got %v, want %v", got.Region, tc.want.Region)
}
if got.Mode != tc.want.Mode {
t.Errorf("Mode: got %v, want %v", got.Mode, tc.want.Mode)
}
if got.Hostname != tc.want.Hostname {
t.Errorf("Hostname: got %v, want %v", got.Hostname, tc.want.Hostname)
}
if got.Host != tc.want.Host {
t.Errorf("Host: got %v, want %v", got.Host, tc.want.Host)
}
})
}
}
125 changes: 125 additions & 0 deletions internal/services/worker_version/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ func (r *WorkerVersionResource) Create(ctx context.Context, req resource.CreateR
return
}

// Save planned placement before API call; versions endpoint does not accept
// or return placement — it must be applied separately via the settings API.
planPlacement := data.Placement

var assets *WorkerVersionAssetsModel
if data.Assets != nil {
assets = &WorkerVersionAssetsModel{
Expand Down Expand Up @@ -205,6 +209,15 @@ func (r *WorkerVersionResource) Create(ctx context.Context, req resource.CreateR
)
resp.Diagnostics.Append(diags...)

// Restore planned placement and apply it via the script settings endpoint.
// The versions API does not accept or return placement; the correct endpoint
// is PATCH /accounts/{id}/workers/scripts/{name}/settings.
data.Placement = planPlacement
if settingsErr := r.applyPlacementSettings(ctx, data.AccountID.ValueString(), data.WorkerID.ValueString(), planPlacement); settingsErr != nil {
resp.Diagnostics.AddError("failed to apply placement settings", settingsErr.Error())
return
}

resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}

Expand Down Expand Up @@ -342,6 +355,22 @@ func (r *WorkerVersionResource) Read(ctx context.Context, req resource.ReadReque
)
resp.Diagnostics.Append(diags...)

// Read placement from the script settings endpoint — the versions API does
// not return placement since it is a script-level (not version-level) setting.
settings, settingsErr := r.client.Workers.Scripts.ScriptAndVersionSettings.Get(
ctx,
data.WorkerID.ValueString(),
workers.ScriptScriptAndVersionSettingGetParams{
AccountID: cloudflare.F(data.AccountID.ValueString()),
},
option.WithMiddleware(logging.Middleware(ctx)),
)
if settingsErr != nil {
resp.Diagnostics.AddError("failed to read script settings", settingsErr.Error())
return
}
data.Placement = placementFromSettings(settings.Placement)

resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}

Expand Down Expand Up @@ -396,6 +425,20 @@ func (r *WorkerVersionResource) ImportState(ctx context.Context, req resource.Im
}
data = &env.Result

settings, settingsErr := r.client.Workers.Scripts.ScriptAndVersionSettings.Get(
ctx,
path_worker_id,
workers.ScriptScriptAndVersionSettingGetParams{
AccountID: cloudflare.F(path_account_id),
},
option.WithMiddleware(logging.Middleware(ctx)),
)
if settingsErr != nil {
resp.Diagnostics.AddError("failed to read script settings", settingsErr.Error())
return
}
data.Placement = placementFromSettings(settings.Placement)

if data.Modules != nil {
for _, mod := range *data.Modules {
contentBase64 := mod.ContentBase64.ValueString()
Expand All @@ -419,3 +462,85 @@ func (r *WorkerVersionResource) ImportState(ctx context.Context, req resource.Im
func (r *WorkerVersionResource) ModifyPlan(_ context.Context, _ resource.ModifyPlanRequest, _ *resource.ModifyPlanResponse) {

}

// applyPlacementSettings sends a PATCH to the script-and-version-settings endpoint
// to set or clear the placement configuration. The versions API endpoint does not
// accept placement, so this must be called after version creation.
// Only one placement variant (region, hostname, host, or mode) is sent per call;
// the Cloudflare placement field is a union type. The target field is not yet supported.
func (r *WorkerVersionResource) applyPlacementSettings(ctx context.Context, accountID, scriptName string, placement *WorkerVersionPlacementModel) error {
settings := workers.ScriptScriptAndVersionSettingEditParamsSettings{
Placement: cloudflare.Null[workers.ScriptScriptAndVersionSettingEditParamsSettingsPlacementUnion](),
}

if placement != nil {
// TODO: add Target support once the SDK exposes a union variant for it.
switch {
case !placement.Region.IsNull() && !placement.Region.IsUnknown() && placement.Region.ValueString() != "":
settings.Placement = cloudflare.F[workers.ScriptScriptAndVersionSettingEditParamsSettingsPlacementUnion](
workers.ScriptScriptAndVersionSettingEditParamsSettingsPlacementRegion{
Region: cloudflare.F(placement.Region.ValueString()),
},
)
case !placement.Hostname.IsNull() && !placement.Hostname.IsUnknown() && placement.Hostname.ValueString() != "":
settings.Placement = cloudflare.F[workers.ScriptScriptAndVersionSettingEditParamsSettingsPlacementUnion](
workers.ScriptScriptAndVersionSettingEditParamsSettingsPlacementHostname{
Hostname: cloudflare.F(placement.Hostname.ValueString()),
},
)
case !placement.Host.IsNull() && !placement.Host.IsUnknown() && placement.Host.ValueString() != "":
settings.Placement = cloudflare.F[workers.ScriptScriptAndVersionSettingEditParamsSettingsPlacementUnion](
workers.ScriptScriptAndVersionSettingEditParamsSettingsPlacementHost{
Host: cloudflare.F(placement.Host.ValueString()),
},
)
case !placement.Mode.IsNull() && !placement.Mode.IsUnknown() && placement.Mode.ValueString() != "":
settings.Placement = cloudflare.F[workers.ScriptScriptAndVersionSettingEditParamsSettingsPlacementUnion](
workers.ScriptScriptAndVersionSettingEditParamsSettingsPlacementMode{
Mode: cloudflare.F(workers.ScriptScriptAndVersionSettingEditParamsSettingsPlacementModeMode(placement.Mode.ValueString())),
},
)
}
}

_, err := r.client.Workers.Scripts.ScriptAndVersionSettings.Edit(
ctx,
scriptName,
workers.ScriptScriptAndVersionSettingEditParams{
AccountID: cloudflare.F(accountID),
Settings: cloudflare.F(settings),
},
option.WithMiddleware(logging.Middleware(ctx)),
)
return err
}

// placementFromSettings converts the placement field from a script settings
// response into the WorkerVersionPlacementModel used in state. Returns nil
// when the API reports no active placement configuration.
// Note: the target field is not yet read back (see applyPlacementSettings TODO).
func placementFromSettings(p workers.ScriptScriptAndVersionSettingGetResponsePlacement) *WorkerVersionPlacementModel {
if p.Region == "" && string(p.Mode) == "" && p.Hostname == "" && p.Host == "" {
return nil
}
m := &WorkerVersionPlacementModel{
Region: types.StringNull(),
Mode: types.StringNull(),
Hostname: types.StringNull(),
Host: types.StringNull(),
Target: nil,
}
if p.Region != "" {
m.Region = types.StringValue(p.Region)
}
if string(p.Mode) != "" {
m.Mode = types.StringValue(string(p.Mode))
}
if p.Hostname != "" {
m.Hostname = types.StringValue(p.Hostname)
}
if p.Host != "" {
m.Host = types.StringValue(p.Host)
}
return m
}
52 changes: 52 additions & 0 deletions internal/services/worker_version/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,10 @@ func testAccCloudflareWorkerVersionConfigSensitiveBindings(rnd, accountID, conte
return acctest.LoadTestCase("sensitive_bindings.tf", rnd, accountID, contentFile)
}

func testAccCloudflareWorkerVersionConfigPlacementSmart(rnd, accountID, contentFile string) string {
return acctest.LoadTestCase("placement_smart.tf", rnd, accountID, contentFile)
}

// TestAccCloudflareWorkerVersion_SensitiveBindingsImport tests that importing
// with sensitive bindings (plain_text/secret_text) doesn't force replacement.
func TestAccCloudflareWorkerVersion_SensitiveBindingsImport(t *testing.T) {
Expand Down Expand Up @@ -758,6 +762,54 @@ func TestAccCloudflareWorkerVersion_RunWorkerFirstUpgrade(t *testing.T) {
})
}

// TestAccCloudflareWorkerVersion_Placement verifies that placement config is
// applied via the settings API, read back on refresh, and survives import
// without drift. Regression test for #7206.
func TestAccCloudflareWorkerVersion_Placement(t *testing.T) {
t.Parallel()

rnd := utils.GenerateRandomResourceName()
accountID := os.Getenv("CLOUDFLARE_ACCOUNT_ID")
resourceName := "cloudflare_worker_version." + rnd

tmpDir := t.TempDir()
contentFile := path.Join(tmpDir, "index.js")

err := os.WriteFile(contentFile, []byte(`export default {fetch() {return new Response()}}`), 0644)
if err != nil {
t.Fatalf("Error creating temp file at path %s: %s", contentFile, err.Error())
}

resource.Test(t, resource.TestCase{
PreCheck: func() { acctest.TestAccPreCheck(t) },
ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: testAccCloudflareWorkerVersionConfigPlacementSmart(rnd, accountID, contentFile),
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("placement").AtMapKey("mode"), knownvalue.StringExact("smart")),
},
},
// Re-apply same config - expect no changes
{
Config: testAccCloudflareWorkerVersionConfigPlacementSmart(rnd, accountID, contentFile),
ConfigPlanChecks: resource.ConfigPlanChecks{
PreApply: []plancheck.PlanCheck{
plancheck.ExpectEmptyPlan(),
},
},
},
{
ResourceName: resourceName,
ImportStateIdFunc: testAccCloudflareWorkerVersionImportStateIdFunc(resourceName, accountID),
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{"modules.0.content_file", "modules.0.content_base64"},
},
},
})
}

func TestAccUpgradeWorkerVersion_FromPublishedV5(t *testing.T) {
rnd := utils.GenerateRandomResourceName()
accountID := os.Getenv("CLOUDFLARE_ACCOUNT_ID")
Expand Down
21 changes: 21 additions & 0 deletions internal/services/worker_version/testdata/placement_smart.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
resource "cloudflare_worker" "%[1]s" {
account_id = "%[2]s"
name = "%[1]s"
}

resource "cloudflare_worker_version" "%[1]s" {
account_id = "%[2]s"
worker_id = cloudflare_worker.%[1]s.id
modules = [
{
name = "index.js"
content_file = "%[3]s"
content_type = "application/javascript+module"
}
]
main_module = "index.js"

placement = {
mode = "smart"
}
}