Repository: cloudflare/cloudflare-go
Pull request #1279 (2023-05-09 01:47:18)

Add Placement to Pages & Workers
## Description

We're adding [Smart Placement](https://developers.cloudflare.com/workers/platform/smart-placement/#smart-placement) to Pages & Workers. This adds support for setting Smart Placement.

## Has your change been tested?

### Pages

Code:
```go
package main

import (
	"context"
	"fmt"
	"log"
	"os"

	"github.com/cloudflare/cloudflare-go"
)

func main() {
	accountId := os.Getenv("ACCOUNT_ID")
	apiToken := os.Getenv("API_TOKEN")
	projectName := "terraform-test"

	if accountId == "" || apiToken == "" {
		log.Fatal("account ID or API token not set!")
	}

	// api, err := cloudflare.NewWithAPIToken(apiToken, cloudflare.Debug(true))
	api, err := cloudflare.NewWithAPIToken(apiToken)
	if err != nil {
		log.Fatal(err)
	}

	ctx := context.Background()

	test, err := api.PagesProject(ctx, accountId, projectName)
	if err == nil && test.ID != "" {
		fmt.Println("Cleaning up from old run...")
		err = api.DeletePagesProject(ctx, accountId, projectName)
		if err != nil {
			log.Fatal(err)
		}
	}

	fmt.Println("Creating project...")
	proj, err := api.CreatePagesProject(ctx, accountId, cloudflare.PagesProject{
		Name:             projectName,
		ProductionBranch: "main",
	})
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("Project created! Id:", proj.ID)
	fmt.Println("Placement:", proj.DeploymentConfigs.Production.Placement)

	// Set placement to smart
	fmt.Println("Enabling smart placement...")
	proj, err = api.UpdatePagesProject(ctx, accountId, projectName, cloudflare.PagesProject{
		DeploymentConfigs: cloudflare.PagesProjectDeploymentConfigs{
			Production: cloudflare.PagesProjectDeploymentConfigEnvironment{
				Placement: &cloudflare.Placement{
					Mode: cloudflare.PlacementModeSmart,
				},
			},
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	// Validate
	fmt.Println("Placement:", proj.DeploymentConfigs.Production.Placement)
	if proj.DeploymentConfigs.Production.Placement.Mode != "smart" {
		log.Fatal("PLACEMENT MODE WASN'T SMART!")
	}
	fmt.Println("Validated smart placement!")

	fmt.Println("Doing unrelated update...")
	// Do an unrelated update and validate again
	proj, err = api.UpdatePagesProject(ctx, accountId, projectName, cloudflare.PagesProject{
		DeploymentConfigs: cloudflare.PagesProjectDeploymentConfigs{
			Production: cloudflare.PagesProjectDeploymentConfigEnvironment{
				KvNamespaces: cloudflare.NamespaceBindingMap{
					"KV": &cloudflare.NamespaceBindingValue{
						Value: "23659b4cab594caf9df915ff59b8b30a",
					},
				},
			},
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	// Validate
	fmt.Println("Placement:", proj.DeploymentConfigs.Production.Placement)
	if proj.DeploymentConfigs.Production.Placement.Mode != "smart" {
		log.Fatal("PLACEMENT MODE WASN'T SMART!")
	}
	fmt.Println("Validated smart placement again :)")

	fmt.Println("Disabling smart placement...")
	// Disable placement
	proj, err = api.UpdatePagesProject(ctx, accountId, projectName, cloudflare.PagesProject{
		DeploymentConfigs: cloudflare.PagesProjectDeploymentConfigs{
			Production: cloudflare.PagesProjectDeploymentConfigEnvironment{
				Placement: &cloudflare.Placement{
					Mode: cloudflare.PlacementModeOff,
				},
			},
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("Placement:", proj.DeploymentConfigs.Production.Placement)
	if proj.DeploymentConfigs.Production.Placement != nil && proj.DeploymentConfigs.Production.Placement.Mode != cloudflare.PlacementModeOff {
		log.Fatal("PLACEMENT MODE WAS SMART!")
	}
	fmt.Println("Validated placement is disabled!")

	// Cleanup
	fmt.Println("Cleaning up...")
	err = api.DeletePagesProject(ctx, accountId, projectName)
	if err != nil {
		log.Fatal(err)
	}
}
```
Result:
```
Creating project...
Project created! Id: 307bafc6-822d-4884-97e4-97aa92422381
Placement: <nil>
Enabling smart placement...
Placement: &{smart}
Validated smart placement!
Doing unrelated update...
Placement: &{smart}
Validated smart placement again :)
Disabling smart placement...
Placement: &{}
Validated placement is disabled!
Cleaning up...
```

### Workers

Code:
```go
package main

import (
	"context"
	"fmt"
	"log"
	"os"

	"github.com/cloudflare/cloudflare-go"
)

func main() {
	accountId := os.Getenv("ACCOUNT_ID")
	apiToken := os.Getenv("API_TOKEN")
	scriptName := "terraform-test"
	script := `export default {
		fetch(req) {
			return new Response('Hello, World!');
		}
	}`

	if accountId == "" || apiToken == "" {
		log.Fatal("account ID or API token not set!")
	}

	// api, err := cloudflare.NewWithAPIToken(apiToken, cloudflare.Debug(true))
	api, err := cloudflare.NewWithAPIToken(apiToken)
	if err != nil {
		log.Fatal(err)
	}

	ctx := context.Background()

	test, err := api.GetWorker(ctx, cloudflare.AccountIdentifier(accountId), scriptName)
	if err == nil && test.ID != "" {
		fmt.Println("Cleaning up from old run...")
		err = api.DeleteWorker(ctx, cloudflare.AccountIdentifier(accountId), cloudflare.DeleteWorkerParams{
			ScriptName: scriptName,
		})
		if err != nil {
			log.Fatal(err)
		}
	}

	fmt.Println("Creating worker...")
	worker, err := api.UploadWorker(ctx, cloudflare.AccountIdentifier(accountId), cloudflare.CreateWorkerParams{
		ScriptName: scriptName,
		Script:     script,
		Module:     true,
	})
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("Worker created! Deployment ID:", worker.DeploymentId)
	fmt.Println("Placement:", worker.PlacementMode)

	// Set placement to smart
	fmt.Println("Enabling smart placement...")
	worker, err = api.UploadWorker(ctx, cloudflare.AccountIdentifier(accountId), cloudflare.CreateWorkerParams{
		ScriptName: scriptName,
		Script:     script,
		Module:     true,
		Placement: &cloudflare.Placement{
			Mode: cloudflare.PlacementModeSmart,
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	// Validate
	fmt.Println("Placement:", *worker.PlacementMode)
	if *worker.PlacementMode != cloudflare.PlacementModeSmart {
		log.Fatal("PLACEMENT MODE WASN'T SMART!")
	}
	fmt.Println("Validated smart placement!")

	fmt.Println("Disabling smart placement...")
	// Disable placement
	worker, err = api.UploadWorker(ctx, cloudflare.AccountIdentifier(accountId), cloudflare.CreateWorkerParams{
		ScriptName: scriptName,
		Script:     script,
		Module:     true,
		Placement: &cloudflare.Placement{
			Mode: cloudflare.PlacementModeOff,
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("Placement:", worker.PlacementMode)
	if worker.PlacementMode != nil && *worker.PlacementMode != cloudflare.PlacementModeOff {
		log.Fatal("PLACEMENT MODE WAS SMART!")
	}
	fmt.Println("Validated placement is disabled!")

	// Cleanup
	fmt.Println("Cleaning up...")
	err = api.DeleteWorker(ctx, cloudflare.AccountIdentifier(accountId), cloudflare.DeleteWorkerParams{ScriptName: scriptName})
	if err != nil {
		log.Fatal(err)
	}
}

```

Result:
```
Creating worker...
Worker created! Deployment ID: 0x140002b80d0
Placement: <nil>
Enabling smart placement...
Placement: smart
Validated smart placement!
Disabling smart placement...
Placement: <nil>
Validated placement is disabled!
Cleaning up...
```

## Types of changes

What sort of change does your code introduce/modify?

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:

- [x] My code follows the code style of this project.
- [ ] My change requires a change to the documentation.
- [ ] I have updated the documentation accordingly.
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.
- [x] This change is using publicly documented (api.cloudflare.com or developers.cloudflare.com) and stable APIs.



Patch:
diff --git a/.changelog/1279.txt b/.changelog/1279.txt
new file mode 100644
index 000000000..08cd4cab0
--- /dev/null
+++ b/.changelog/1279.txt
@@ -0,0 +1,7 @@
+```release-note:enhancement
+pages: add support for Smart Placement. Added `Placement` in `PagesProjectDeploymentConfigEnvironment`.
+```
+
+```release-note:enhancement
+workers: add support for Smart Placement. Added `Placement` in `CreateWorkerParams`.
+```
diff --git a/pages_project.go b/pages_project.go
index 3177a8e0f..4cbbb4649 100644
--- a/pages_project.go
+++ b/pages_project.go
@@ -78,6 +78,7 @@ type PagesProjectDeploymentConfigEnvironment struct {
 	FailOpen                         bool                   `json:"fail_open"`
 	AlwaysUseLatestCompatibilityDate bool                   `json:"always_use_latest_compatibility_date"`
 	UsageModel                       UsageModel             `json:"usage_model,omitempty"`
+	Placement                        *Placement             `json:"placement,omitempty"`
 }
 
 // PagesProjectDeployment represents a deployment to a Pages project.
@@ -98,6 +99,7 @@ type PagesProjectDeployment struct {
 	D1Databases        D1BindingMap                  `json:"d1_databases,omitempty"`
 	R2Bindings         R2BindingMap                  `json:"r2_buckets,omitempty"`
 	ServiceBindings    ServiceBindingMap             `json:"services,omitempty"`
+	Placement          *Placement                    `json:"placement,omitempty"`
 	DeploymentTrigger  PagesProjectDeploymentTrigger `json:"deployment_trigger"`
 	Stages             []PagesProjectDeploymentStage `json:"stages"`
 	BuildConfig        PagesProjectBuildConfig       `json:"build_config"`
diff --git a/workers.go b/workers.go
index 0a983bdc3..b934218e6 100644
--- a/workers.go
+++ b/workers.go
@@ -45,6 +45,8 @@ type CreateWorkerParams struct {
 	// usually used together with CompatibilityDate.
 	//  https://developers.cloudflare.com/workers/platform/compatibility-dates/#compatibility-flags
 	CompatibilityFlags []string
+
+	Placement *Placement
 }
 
 // WorkerScriptParams provides a worker script and the associated bindings.
@@ -90,11 +92,15 @@ type WorkerScript struct {
 
 // WorkerMetaData contains worker script information such as size, creation & modification dates.
 type WorkerMetaData struct {
-	ID         string    `json:"id,omitempty"`
-	ETAG       string    `json:"etag,omitempty"`
-	Size       int       `json:"size,omitempty"`
-	CreatedOn  time.Time `json:"created_on,omitempty"`
-	ModifiedOn time.Time `json:"modified_on,omitempty"`
+	ID               string         `json:"id,omitempty"`
+	ETAG             string         `json:"etag,omitempty"`
+	Size             int            `json:"size,omitempty"`
+	CreatedOn        time.Time      `json:"created_on,omitempty"`
+	ModifiedOn       time.Time      `json:"modified_on,omitempty"`
+	Logpush          *bool          `json:"logpush,omitempty"`
+	LastDeployedFrom *string        `json:"last_deployed_from,omitempty"`
+	DeploymentId     *string        `json:"deployment_id,omitempty"`
+	PlacementMode    *PlacementMode `json:"placement_mode,omitempty"`
 }
 
 // WorkerListResponse wrapper struct for API response to worker script list API call.
@@ -117,6 +123,17 @@ type DeleteWorkerParams struct {
 	ScriptName string
 }
 
+type PlacementMode string
+
+const (
+	PlacementModeOff   PlacementMode = ""
+	PlacementModeSmart PlacementMode = "smart"
+)
+
+type Placement struct {
+	Mode PlacementMode `json:"mode"`
+}
+
 // DeleteWorker deletes a single Worker.
 //
 // API reference: https://api.cloudflare.com/#worker-script-delete-worker
@@ -234,7 +251,7 @@ func (api *API) UploadWorker(ctx context.Context, rc *ResourceContainer, params
 		err         error
 	)
 
-	if params.Module || params.Logpush != nil || len(params.Bindings) > 0 || params.CompatibilityDate != "" || len(params.CompatibilityFlags) > 0 {
+	if params.Module || params.Logpush != nil || params.Placement != nil || len(params.Bindings) > 0 || params.CompatibilityDate != "" || len(params.CompatibilityFlags) > 0 {
 		contentType, body, err = formatMultipartBody(params)
 		if err != nil {
 			return WorkerScriptResponse{}, err
@@ -274,11 +291,13 @@ func formatMultipartBody(params CreateWorkerParams) (string, []byte, error) {
 		Logpush            *bool               `json:"logpush,omitempty"`
 		CompatibilityDate  string              `json:"compatibility_date,omitempty"`
 		CompatibilityFlags []string            `json:"compatibility_flags,omitempty"`
+		Placement          *Placement          `json:"placement,omitempty"`
 	}{
 		Bindings:           make([]workerBindingMeta, 0, len(params.Bindings)),
 		Logpush:            params.Logpush,
 		CompatibilityDate:  params.CompatibilityDate,
 		CompatibilityFlags: params.CompatibilityFlags,
+		Placement:          params.Placement,
 	}
 
 	if params.Module {


Test patch:
diff --git a/pages_project_test.go b/pages_project_test.go
index 91671a5d5..32d09ab57 100644
--- a/pages_project_test.go
+++ b/pages_project_test.go
@@ -110,7 +110,10 @@ const (
 			"compatibility_flags": ["production_flag"],
 			"fail_open": false,
 			"always_use_latest_compatibility_date": false,
-			"usage_model": "bundled"
+			"usage_model": "bundled",
+			"placement": {
+				"mode": "smart"
+			}
           }
         },
         "latest_deployment": {
@@ -144,6 +147,9 @@ const (
 			  "type": "secret_text"
 			}
           },
+		  "placement": {
+			"mode": "smart"
+		  },
 		  "compatibility_date": "2022-08-15",
 		  "compatibility_flags": ["deployment_flag"],
 		  "fail_open": false,
@@ -231,6 +237,9 @@ const (
 			  "type": "secret_text"
 			}
           },
+		  "placement": {
+			"mode": "smart"
+		  },
 		  "compatibility_date": "2022-08-15",
 		  "compatibility_flags": ["deployment_flag"],
 		  "fail_open": false,
@@ -339,6 +348,9 @@ var (
 				Type:  SecretText,
 			},
 		},
+		Placement: &Placement{
+			Mode: PlacementModeSmart,
+		},
 		CompatibilityFlags: []string{"deployment_flag"},
 		CompatibilityDate:  "2022-08-15",
 		UsageModel:         Bundled,
@@ -452,6 +464,9 @@ var (
 		FailOpen:                         false,
 		AlwaysUseLatestCompatibilityDate: false,
 		UsageModel:                       Bundled,
+		Placement: &Placement{
+			Mode: PlacementModeSmart,
+		},
 	}
 
 	expectedPagesProjectSource = &PagesProjectSource{
diff --git a/workers_test.go b/workers_test.go
index 348abed24..8492cdee0 100644
--- a/workers_test.go
+++ b/workers_test.go
@@ -211,6 +211,18 @@ export default {
 }
 --workermodulescriptdownload--
 `
+	uploadModuleWorkerSmartPlacement = `{
+    "result": {
+        "script": "export default {\n  async fetch(request, env, event) {\n    event.passThroughOnException()\n    return fetch(request)\n  }\n}",
+        "etag": "279cf40d86d70b82f6cd3ba90a646b3ad995912da446836d7371c21c6a43977a",
+        "size": 191,
+        "modified_on": "2018-06-09T15:17:01.989141Z",
+		"placement_mode": "smart"
+    },
+    "success": true,
+    "errors": [],
+    "messages": []
+}`
 )
 
 var (
@@ -280,6 +292,7 @@ type multipartUpload struct {
 	Logpush            *bool
 	CompatibilityDate  string
 	CompatibilityFlags []string
+	Placement          *Placement
 }
 
 func parseMultipartUpload(r *http.Request) (multipartUpload, error) {
@@ -296,6 +309,7 @@ func parseMultipartUpload(r *http.Request) (multipartUpload, error) {
 		Logpush            *bool               `json:"logpush,omitempty"`
 		CompatibilityDate  string              `json:"compatibility_date,omitempty"`
 		CompatibilityFlags []string            `json:"compatibility_flags,omitempty"`
+		Placement          *Placement          `json:"placement,omitempty"`
 	}
 	err = json.Unmarshal(mdBytes, &metadata)
 	if err != nil {
@@ -327,6 +341,7 @@ func parseMultipartUpload(r *http.Request) (multipartUpload, error) {
 		Logpush:            metadata.Logpush,
 		CompatibilityDate:  metadata.CompatibilityDate,
 		CompatibilityFlags: metadata.CompatibilityFlags,
+		Placement:          metadata.Placement,
 	}, nil
 }
 
@@ -933,3 +948,51 @@ func TestUploadWorker_WithQueueBinding(t *testing.T) {
 		}})
 	assert.NoError(t, err)
 }
+
+func TestUploadWorker_WithSmartPlacementEnabled(t *testing.T) {
+	setup()
+	defer teardown()
+
+	placementMode := PlacementModeSmart
+	response := uploadModuleWorkerSmartPlacement
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method)
+
+		mpUpload, err := parseMultipartUpload(r)
+		assert.NoError(t, err)
+
+		assert.Equal(t, workerScript, mpUpload.Script)
+
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprint(w, response)
+	}
+	mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/bar", handler)
+
+	t.Run("Test enabling Smart Placement", func(t *testing.T) {
+		worker, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{
+			ScriptName: "bar",
+			Script:     workerScript,
+			Placement: &Placement{
+				Mode: placementMode,
+			},
+		})
+		assert.NoError(t, err)
+		assert.Equal(t, placementMode, *worker.PlacementMode)
+	})
+
+	t.Run("Test disabling placement", func(t *testing.T) {
+		placementMode = PlacementModeOff
+		response = uploadWorkerModuleResponseData
+
+		worker, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{
+			ScriptName: "bar",
+			Script:     workerScript,
+			Placement: &Placement{
+				Mode: placementMode,
+			},
+		})
+		assert.NoError(t, err)
+		assert.Nil(t, worker.PlacementMode)
+	})
+}