From 5418ed7084b46f0d2bfd1db8bc8522004b5ba07c Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Thu, 3 Apr 2025 12:02:00 +0000 Subject: [PATCH 1/6] feat: allow presets to define prebuilds --- README.md | 2 +- provider/workspace.go | 22 +++++++++++++++++++ provider/workspace_preset.go | 35 ++++++++++++++++++++++++++++-- provider/workspace_preset_test.go | 36 +++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b8ee884..f055961 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ to setup your local Terraform to use your local version rather than the registry } ``` 2. Run `terraform init` and observe a warning like `Warning: Provider development overrides are in effect` -4. Run `go build -o terraform-provider-coder` to build the provider binary, which Terraform will try locate and execute +4. Run `make build` to build the provider binary, which Terraform will try locate and execute 5. All local Terraform runs will now use your local provider! 6. _**NOTE**: we vendor in this provider into `github.com/coder/coder`, so if you're testing with a local clone then you should also run `go mod edit -replace github.com/coder/terraform-provider-coder=/path/to/terraform-provider-coder` in your clone._ diff --git a/provider/workspace.go b/provider/workspace.go index fde742b..19da0d0 100644 --- a/provider/workspace.go +++ b/provider/workspace.go @@ -27,6 +27,14 @@ func workspaceDataSource() *schema.Resource { } _ = rd.Set("start_count", count) + prebuild := helpers.OptionalEnv(IsPrebuildEnvironmentVariable()) + prebuildCount := 0 + if prebuild == "true" { + prebuildCount = 1 + _ = rd.Set("is_prebuild", true) + } + _ = rd.Set("prebuild_count", prebuildCount) + name := helpers.OptionalEnvOrDefault("CODER_WORKSPACE_NAME", "default") rd.Set("name", name) @@ -88,6 +96,16 @@ func workspaceDataSource() *schema.Resource { Computed: true, Description: "A computed count based on `transition` state. If `start`, count will equal 1.", }, + "prebuild_count": { + Type: schema.TypeInt, + Computed: true, + Description: "TODO", + }, + "is_prebuild": { + Type: schema.TypeBool, + Computed: true, + Description: "TODO", + }, "transition": { Type: schema.TypeString, Computed: true, @@ -121,3 +139,7 @@ func workspaceDataSource() *schema.Resource { }, } } + +func IsPrebuildEnvironmentVariable() string { + return "CODER_WORKSPACE_IS_PREBUILD" +} diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index cd56c98..eafc39e 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -10,8 +10,13 @@ import ( ) type WorkspacePreset struct { - Name string `mapstructure:"name"` - Parameters map[string]string `mapstructure:"parameters"` + Name string `mapstructure:"name"` + Parameters map[string]string `mapstructure:"parameters"` + Prebuild []WorkspacePrebuild `mapstructure:"prebuilds"` +} + +type WorkspacePrebuild struct { + Instances int `mapstructure:"instances"` } func workspacePresetDataSource() *schema.Resource { @@ -24,9 +29,19 @@ func workspacePresetDataSource() *schema.Resource { err := mapstructure.Decode(struct { Name interface{} Parameters interface{} + Prebuilds []struct { + Instances interface{} + } }{ Name: rd.Get("name"), Parameters: rd.Get("parameters"), + Prebuilds: []struct { + Instances interface{} + }{ + { + Instances: rd.Get("prebuilds.0.instances"), + }, + }, }, &preset) if err != nil { return diag.Errorf("decode workspace preset: %s", err) @@ -65,6 +80,22 @@ func workspacePresetDataSource() *schema.Resource { ValidateFunc: validation.StringIsNotEmpty, }, }, + "prebuilds": { + Type: schema.TypeSet, + Description: "Prebuilds of the workspace preset.", + Optional: true, + MaxItems: 1, // TODO: is this always true? More than 1 prebuilds config per preset? + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "instances": { + Type: schema.TypeInt, + Required: true, + ForceNew: true, + ValidateFunc: validation.IntAtLeast(0), + }, + }, + }, + }, }, } } diff --git a/provider/workspace_preset_test.go b/provider/workspace_preset_test.go index 876e204..8f0d31e 100644 --- a/provider/workspace_preset_test.go +++ b/provider/workspace_preset_test.go @@ -108,6 +108,42 @@ func TestWorkspacePreset(t *testing.T) { // So we test it here to make sure we don't regress. ExpectError: regexp.MustCompile("Inappropriate value for attribute \"parameters\": map of string required"), }, + { + Name: "Prebuilds is set, but not its required fields", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = { + "region" = "us-east1-a" + } + prebuilds {} + }`, + ExpectError: regexp.MustCompile("The argument \"instances\" is required, but no definition was found."), + }, + { + Name: "Prebuilds is set, and so are its required fields", + Config: ` + data "coder_workspace_preset" "preset_1" { + name = "preset_1" + parameters = { + "region" = "us-east1-a" + } + prebuilds { + instances = 1 + } + }`, + ExpectError: nil, + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 1) + resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"] + require.NotNil(t, resource) + attrs := resource.Primary.Attributes + require.Equal(t, attrs["name"], "preset_1") + require.Equal(t, attrs["prebuilds.0.instances"], "1") + return nil + }, + }, } for _, testcase := range testcases { From af250375660c21ab9c2ecfa4001834b44e6db46f Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Thu, 3 Apr 2025 12:06:34 +0000 Subject: [PATCH 2/6] document prebuild parameters --- provider/workspace.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/provider/workspace.go b/provider/workspace.go index 19da0d0..30e7ad8 100644 --- a/provider/workspace.go +++ b/provider/workspace.go @@ -91,20 +91,15 @@ func workspaceDataSource() *schema.Resource { Computed: true, Description: "The access port of the Coder deployment provisioning this workspace.", }, - "start_count": { - Type: schema.TypeInt, - Computed: true, - Description: "A computed count based on `transition` state. If `start`, count will equal 1.", - }, "prebuild_count": { Type: schema.TypeInt, Computed: true, - Description: "TODO", + Description: "A computed count, equal to 1 if the workspace was prebuilt.", }, - "is_prebuild": { - Type: schema.TypeBool, + "start_count": { + Type: schema.TypeInt, Computed: true, - Description: "TODO", + Description: "A computed count based on `transition` state. If `start`, count will equal 1.", }, "transition": { Type: schema.TypeString, @@ -116,6 +111,11 @@ func workspaceDataSource() *schema.Resource { Computed: true, Description: "UUID of the workspace.", }, + "is_prebuild": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether the workspace is a prebuild.", + }, "name": { Type: schema.TypeString, Computed: true, From 56d1ab72a05ac67eaf2409d8826868234ffa1109 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Thu, 3 Apr 2025 12:07:59 +0000 Subject: [PATCH 3/6] remove todo --- provider/workspace_preset.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index eafc39e..b29ecba 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -84,7 +84,7 @@ func workspacePresetDataSource() *schema.Resource { Type: schema.TypeSet, Description: "Prebuilds of the workspace preset.", Optional: true, - MaxItems: 1, // TODO: is this always true? More than 1 prebuilds config per preset? + MaxItems: 1, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "instances": { From c8c510180877b66ca4098f5eeaeaf751343de3e0 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Thu, 3 Apr 2025 12:09:44 +0000 Subject: [PATCH 4/6] make gen --- docs/data-sources/workspace.md | 2 ++ docs/data-sources/workspace_preset.md | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/docs/data-sources/workspace.md b/docs/data-sources/workspace.md index 26396ba..30deee2 100644 --- a/docs/data-sources/workspace.md +++ b/docs/data-sources/workspace.md @@ -69,7 +69,9 @@ resource "docker_container" "workspace" { - `access_port` (Number) The access port of the Coder deployment provisioning this workspace. - `access_url` (String) The access URL of the Coder deployment provisioning this workspace. - `id` (String) UUID of the workspace. +- `is_prebuild` (Boolean) Whether the workspace is a prebuild. - `name` (String) Name of the workspace. +- `prebuild_count` (Number) A computed count, equal to 1 if the workspace was prebuilt. - `start_count` (Number) A computed count based on `transition` state. If `start`, count will equal 1. - `template_id` (String) ID of the workspace's template. - `template_name` (String) Name of the workspace's template. diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md index 28f90fa..9c393fa 100644 --- a/docs/data-sources/workspace_preset.md +++ b/docs/data-sources/workspace_preset.md @@ -37,6 +37,17 @@ data "coder_workspace_preset" "example" { - `name` (String) Name of the workspace preset. - `parameters` (Map of String) Parameters of the workspace preset. +### Optional + +- `prebuilds` (Block Set, Max: 1) Prebuilds of the workspace preset. (see [below for nested schema](#nestedblock--prebuilds)) + ### Read-Only - `id` (String) ID of the workspace preset. + + +### Nested Schema for `prebuilds` + +Required: + +- `instances` (Number) From 0a50b31f2add793ceb9a0b4870f063d7d9e030c0 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Thu, 3 Apr 2025 12:17:10 +0000 Subject: [PATCH 5/6] feat: reuse agent tokens when a prebuilt agent reinitializes --- provider/agent.go | 55 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/provider/agent.go b/provider/agent.go index 3ddae23..9232db7 100644 --- a/provider/agent.go +++ b/provider/agent.go @@ -3,10 +3,13 @@ package provider import ( "context" "fmt" + "os" "path/filepath" "reflect" "strings" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/google/uuid" "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -22,10 +25,54 @@ func agentResource() *schema.Resource { SchemaVersion: 1, Description: "Use this resource to associate an agent.", - CreateContext: func(_ context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics { + CreateContext: func(ctx context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics { // This should be a real authentication token! resourceData.SetId(uuid.NewString()) - err := resourceData.Set("token", uuid.NewString()) + + // CODER_RUNNING_WORKSPACE_AGENT_TOKEN is *only* used for prebuilds. We pass it down when we want to rebuild a prebuilt workspace + // but not generate a new agent token. The provisionerdserver will retrieve this token and push it down to + // here where it will be reused. + // Context: the agent token is often used in immutable attributes of workspace resource (e.g. VM/container) + // to initialize the agent, so if that value changes it will necessitate a replacement of that resource, thus + // obviating the whole point of the prebuild. + // + // The default path is for a new token to be generated on each new resource creation. + // TODO: add logging when the running token is actually used. + var token string + + isPrebuild := helpers.OptionalEnv(IsPrebuildEnvironmentVariable()) == "true" + if !isPrebuild { + token = os.Getenv(RunningAgentTokenEnvironmentVariable()) + } + + allEnv := make(map[string]interface{}) + for _, v := range os.Environ() { + split := strings.Split(v, "=") + var key, val string + if len(split) > 0 { + key = split[0] + } + if len(split) > 1 { + val = split[1] + } + + allEnv[key] = val + } + + allEnv["is_prebuild"] = fmt.Sprintf("%v", isPrebuild) + + if token == "" { + token = uuid.NewString() + if !isPrebuild { + tflog.Warn(ctx, "NOT USING EXISTING AGENT TOKEN", allEnv) + } + } else { + if !isPrebuild { + tflog.Info(ctx, "IS USING EXISTING AGENT TOKEN", allEnv) + } + } + + err := resourceData.Set("token", token) if err != nil { return diag.FromErr(err) } @@ -469,3 +516,7 @@ func updateInitScript(resourceData *schema.ResourceData, i interface{}) diag.Dia } return nil } + +func RunningAgentTokenEnvironmentVariable() string { + return "CODER_RUNNING_WORKSPACE_AGENT_TOKEN" +} From e46f69a01efe9047ae66d9c46757068811833e18 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Thu, 17 Apr 2025 11:20:58 +0000 Subject: [PATCH 6/6] WIP: get agent.go ready to be merged with support for prebuilds --- provider/agent.go | 81 ++++++++++++++++++++----------------------- provider/workspace.go | 6 ++++ 2 files changed, 43 insertions(+), 44 deletions(-) diff --git a/provider/agent.go b/provider/agent.go index 9232db7..4df312e 100644 --- a/provider/agent.go +++ b/provider/agent.go @@ -2,16 +2,16 @@ package provider import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" - "os" "path/filepath" "reflect" "strings" - "github.com/hashicorp/terraform-plugin-log/tflog" - "github.com/google/uuid" "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" @@ -26,50 +26,34 @@ func agentResource() *schema.Resource { Description: "Use this resource to associate an agent.", CreateContext: func(ctx context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics { - // This should be a real authentication token! - resourceData.SetId(uuid.NewString()) + agentID := uuid.NewString() + resourceData.SetId(agentID) - // CODER_RUNNING_WORKSPACE_AGENT_TOKEN is *only* used for prebuilds. We pass it down when we want to rebuild a prebuilt workspace - // but not generate a new agent token. The provisionerdserver will retrieve this token and push it down to - // here where it will be reused. - // Context: the agent token is often used in immutable attributes of workspace resource (e.g. VM/container) - // to initialize the agent, so if that value changes it will necessitate a replacement of that resource, thus - // obviating the whole point of the prebuild. - // - // The default path is for a new token to be generated on each new resource creation. - // TODO: add logging when the running token is actually used. - var token string + // Most of the time, we will generate a new token for the agent. + // In the case of a prebuilt workspace being claimed, we will override with + // an existing token provided below. + token := uuid.NewString() + // If isPrebuild is true, then this workspace was built by the prebuilds system. + // This does not determine whether the workspace has been claimed by a user. + // At this point, it may or may not have been claimed. isPrebuild := helpers.OptionalEnv(IsPrebuildEnvironmentVariable()) == "true" - if !isPrebuild { - token = os.Getenv(RunningAgentTokenEnvironmentVariable()) - } - - allEnv := make(map[string]interface{}) - for _, v := range os.Environ() { - split := strings.Split(v, "=") - var key, val string - if len(split) > 0 { - key = split[0] - } - if len(split) > 1 { - val = split[1] - } - - allEnv[key] = val + // existingToken should only have been set if isPrebuild is true, because we only + // reuse the token when a prebuilt workspace is being claimed. + existingToken := helpers.OptionalEnv(RunningAgentTokenEnvironmentVariable(agentID)) + logFields := map[string]interface{}{ + "agent_id": agentID, + "is_prebuild": isPrebuild, + "token_provided": existingToken != "", } - - allEnv["is_prebuild"] = fmt.Sprintf("%v", isPrebuild) - - if token == "" { - token = uuid.NewString() - if !isPrebuild { - tflog.Warn(ctx, "NOT USING EXISTING AGENT TOKEN", allEnv) - } + if isPrebuild && existingToken != "" { + // check if a token was already generated for this agent. + // If so, this workspace is in the process of being claimed + // and we should reuse the token. If not, we use a new token as usual. + tflog.Info(ctx, "using provided agent token for prebuild", logFields) + token = existingToken } else { - if !isPrebuild { - tflog.Info(ctx, "IS USING EXISTING AGENT TOKEN", allEnv) - } + tflog.Info(ctx, "using a new agent token", logFields) } err := resourceData.Set("token", token) @@ -517,6 +501,15 @@ func updateInitScript(resourceData *schema.ResourceData, i interface{}) diag.Dia return nil } -func RunningAgentTokenEnvironmentVariable() string { - return "CODER_RUNNING_WORKSPACE_AGENT_TOKEN" +// RunningAgentTokenEnvironmentVariable returns the name of the environment variable +// that contains the token for the running agent. This is used for prebuilds, where +// we want to reuse the same token for the next iteration of a workspace agent before +// and after the workspace was claimed by a user. +// +// agentID is unused for now, but will be used as soon as we support multiple agents. +func RunningAgentTokenEnvironmentVariable(agentID string) string { + agentID = "" // remove this once we need to support multiple agents per prebuilt workspace. + + sum := sha256.Sum256([]byte(agentID)) + return "CODER_RUNNING_WORKSPACE_AGENT_TOKEN_" + hex.EncodeToString(sum[:]) } diff --git a/provider/workspace.go b/provider/workspace.go index 5ddd3ee..ee318ba 100644 --- a/provider/workspace.go +++ b/provider/workspace.go @@ -140,6 +140,12 @@ func workspaceDataSource() *schema.Resource { } } +// IsPrebuildEnvironmentVariable returns the name of the environment +// variable that indicates whether the workspace was prebuilt. The value of +// this environment variable should be set to "true" if the workspace is prebuilt. +// Any other values, including "false" and "" will be interpreted to mean that the +// workspace is not prebuilt. If the workspace is prebuilt, it may or may not yet +// have been claimed by a user. func IsPrebuildEnvironmentVariable() string { return "CODER_WORKSPACE_IS_PREBUILD" }