From c44d9a859ca0ccf2e216f842031a20c0163c3fc7 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:48:29 +0000 Subject: [PATCH 1/9] feat: add cors_behavior support to template resource This adds the ability to configure the CORS behavior for workspace apps via Terraform. The cors_behavior field accepts "simple" (default CORS middleware) or "passthru" (bypass CORS middleware). Changes: - Add cors_behavior field to TemplateResourceModel struct - Add schema attribute with validation - Handle cors_behavior in Create, Read, and Update operations - Add cors_behavior to toUpdateRequest for API calls - Add cors_behavior field to data source as well Fixes #293 --- internal/provider/template_data_source.go | 6 ++++ internal/provider/template_resource.go | 34 +++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/internal/provider/template_data_source.go b/internal/provider/template_data_source.go index 3a5f59a..4323a08 100644 --- a/internal/provider/template_data_source.go +++ b/internal/provider/template_data_source.go @@ -58,6 +58,7 @@ type TemplateDataSourceModel struct { RequireActiveVersion types.Bool `tfsdk:"require_active_version"` MaxPortShareLevel types.String `tfsdk:"max_port_share_level"` + CORSBehavior types.String `tfsdk:"cors_behavior"` CreatedByUserID UUID `tfsdk:"created_by_user_id"` CreatedAt types.Int64 `tfsdk:"created_at"` // Unix timestamp @@ -188,6 +189,10 @@ func (d *TemplateDataSource) Schema(ctx context.Context, req datasource.SchemaRe MarkdownDescription: "The maximum port share level for workspaces created from the template.", Computed: true, }, + "cors_behavior": schema.StringAttribute{ + MarkdownDescription: "The CORS behavior for workspace apps in this template.", + Computed: true, + }, "created_by_user_id": schema.StringAttribute{ MarkdownDescription: "ID of the user who created the template.", CustomType: UUIDType, @@ -330,6 +335,7 @@ func (d *TemplateDataSource) Read(ctx context.Context, req datasource.ReadReques data.TimeTilDormantAutoDeleteMillis = types.Int64Value(template.TimeTilDormantAutoDeleteMillis) data.RequireActiveVersion = types.BoolValue(template.RequireActiveVersion) data.MaxPortShareLevel = types.StringValue(string(template.MaxPortShareLevel)) + data.CORSBehavior = types.StringValue(string(template.CORSBehavior)) data.CreatedByUserID = UUIDValue(template.CreatedByID) data.CreatedAt = types.Int64Value(template.CreatedAt.Unix()) data.UpdatedAt = types.Int64Value(template.UpdatedAt.Unix()) diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index 6a1ac36..2aa487e 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -73,6 +73,7 @@ type TemplateResourceModel struct { RequireActiveVersion types.Bool `tfsdk:"require_active_version"` DeprecationMessage types.String `tfsdk:"deprecation_message"` MaxPortShareLevel types.String `tfsdk:"max_port_share_level"` + CORSBehavior types.String `tfsdk:"cors_behavior"` UseClassicParameterFlow types.Bool `tfsdk:"use_classic_parameter_flow"` // If null, we are not managing ACL via Terraform (such as for AGPL). @@ -100,6 +101,7 @@ func (m *TemplateResourceModel) EqualTemplateMetadata(other *TemplateResourceMod m.RequireActiveVersion.Equal(other.RequireActiveVersion) && m.DeprecationMessage.Equal(other.DeprecationMessage) && m.MaxPortShareLevel.Equal(other.MaxPortShareLevel) && + m.CORSBehavior.Equal(other.CORSBehavior) && m.UseClassicParameterFlow.Equal(other.UseClassicParameterFlow) } @@ -398,6 +400,17 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques Computed: true, Default: stringdefault.StaticString(""), }, + "cors_behavior": schema.StringAttribute{ + MarkdownDescription: "The CORS behavior for workspace apps in this template. Valid values are `simple` (default CORS middleware) or `passthru` (bypass CORS middleware). Defaults to `simple`.", + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.OneOfCaseInsensitive(string(codersdk.CORSBehaviorSimple), string(codersdk.CORSBehaviorPassthru)), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, "use_classic_parameter_flow": schema.BoolAttribute{ MarkdownDescription: "If true, the classic parameter flow will be used when creating workspaces from this template. Defaults to false.", Optional: true, @@ -602,6 +615,24 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques data.MaxPortShareLevel = types.StringValue(string(mpslResp.MaxPortShareLevel)) } + // Handle cors_behavior - can't be set during create, needs update + if data.CORSBehavior.IsUnknown() { + data.CORSBehavior = types.StringValue(string(templateResp.CORSBehavior)) + } else if data.CORSBehavior.ValueString() == string(templateResp.CORSBehavior) { + tflog.Info(ctx, "cors behavior set to default, not updating") + } else { + corsReq := data.toUpdateRequest(ctx, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + corsResp, err := client.UpdateTemplateMeta(ctx, data.ID.ValueUUID(), *corsReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to set cors behavior via update: %s", err)) + return + } + data.CORSBehavior = types.StringValue(string(corsResp.CORSBehavior)) + } + // TODO: Remove this update call (and the attribute) once the provider // requires a Coder version where this flag has been removed. if data.UseClassicParameterFlow.IsUnknown() { @@ -660,6 +691,7 @@ func (r *TemplateResource) Read(ctx context.Context, req resource.ReadRequest, r return } data.MaxPortShareLevel = types.StringValue(string(template.MaxPortShareLevel)) + data.CORSBehavior = types.StringValue(string(template.CORSBehavior)) data.UseClassicParameterFlow = types.BoolValue(template.UseClassicParameterFlow) if !data.ACL.IsNull() { @@ -836,6 +868,7 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques return } newState.MaxPortShareLevel = types.StringValue(string(templateResp.MaxPortShareLevel)) + newState.CORSBehavior = types.StringValue(string(templateResp.CORSBehavior)) resp.Diagnostics.Append(newState.Versions.setPrivateState(ctx, resp.Private)...) if resp.Diagnostics.HasError() { @@ -1331,6 +1364,7 @@ func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, diag *diag. RequireActiveVersion: r.RequireActiveVersion.ValueBool(), DeprecationMessage: r.DeprecationMessage.ValueStringPointer(), MaxPortShareLevel: ptr.Ref(codersdk.WorkspaceAgentPortShareLevel(r.MaxPortShareLevel.ValueString())), + CORSBehavior: ptr.Ref(codersdk.CORSBehavior(r.CORSBehavior.ValueString())), UseClassicParameterFlow: ptr.Ref(r.UseClassicParameterFlow.ValueBool()), // If we're managing ACL, we want to delete the everyone group DisableEveryoneGroupAccess: !r.ACL.IsNull(), From a07c42e33135561aed03cd2878b6ab5fbfc8a95c Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:50:54 +0000 Subject: [PATCH 2/9] chore: regenerate documentation for cors_behavior --- docs/data-sources/template.md | 1 + docs/resources/template.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/data-sources/template.md b/docs/data-sources/template.md index 28cfcad..7b249cb 100644 --- a/docs/data-sources/template.md +++ b/docs/data-sources/template.md @@ -56,6 +56,7 @@ resource "coderd_template" "debian-main" { - `allow_user_cancel_workspace_jobs` (Boolean) Whether users can cancel jobs in workspaces created from the template. - `auto_start_permitted_days_of_week` (Set of String) List of days of the week in which autostart is allowed to happen, for all workspaces created from this template. Defaults to all days. If no days are specified, autostart is not allowed. - `auto_stop_requirement` (Attributes) The auto-stop requirement for all workspaces created from this template. (see [below for nested schema](#nestedatt--auto_stop_requirement)) +- `cors_behavior` (String) The CORS behavior for workspace apps in this template. - `created_at` (Number) Unix timestamp of when the template was created. - `created_by_user_id` (String) ID of the user who created the template. - `default_ttl_ms` (Number) Default time-to-live for workspaces created from the template. diff --git a/docs/resources/template.md b/docs/resources/template.md index a7a95f8..638e4fa 100644 --- a/docs/resources/template.md +++ b/docs/resources/template.md @@ -72,6 +72,7 @@ resource "coderd_template" "ubuntu-main" { - `allow_user_cancel_workspace_jobs` (Boolean) Whether users can cancel in-progress workspace jobs using this template. Defaults to true. - `auto_start_permitted_days_of_week` (Set of String) (Enterprise) List of days of the week in which autostart is allowed to happen, for all workspaces created from this template. Defaults to all days. If no days are specified, autostart is not allowed. - `auto_stop_requirement` (Attributes) (Enterprise) The auto-stop requirement for all workspaces created from this template. (see [below for nested schema](#nestedatt--auto_stop_requirement)) +- `cors_behavior` (String) The CORS behavior for workspace apps in this template. Valid values are `simple` (default CORS middleware) or `passthru` (bypass CORS middleware). Defaults to `simple`. - `default_ttl_ms` (Number) The default time-to-live for all workspaces created from this template, in milliseconds. - `deprecation_message` (String) If set, the template will be marked as deprecated with the provided message and users will be blocked from creating new workspaces from it. Does nothing if set when the resource is created. - `description` (String) A description of the template. From 3fad3c0b20ddbe3ccbbfa403d111c3be9e13fa6f Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:58:32 +0000 Subject: [PATCH 3/9] refactor: set cors_behavior during template creation Instead of using a post-create update call, cors_behavior is now set directly via the CreateTemplateRequest which is cleaner and matches the API behavior. --- internal/provider/template_resource.go | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index 2aa487e..c8f6a29 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -615,23 +615,8 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques data.MaxPortShareLevel = types.StringValue(string(mpslResp.MaxPortShareLevel)) } - // Handle cors_behavior - can't be set during create, needs update - if data.CORSBehavior.IsUnknown() { - data.CORSBehavior = types.StringValue(string(templateResp.CORSBehavior)) - } else if data.CORSBehavior.ValueString() == string(templateResp.CORSBehavior) { - tflog.Info(ctx, "cors behavior set to default, not updating") - } else { - corsReq := data.toUpdateRequest(ctx, &resp.Diagnostics) - if resp.Diagnostics.HasError() { - return - } - corsResp, err := client.UpdateTemplateMeta(ctx, data.ID.ValueUUID(), *corsReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to set cors behavior via update: %s", err)) - return - } - data.CORSBehavior = types.StringValue(string(corsResp.CORSBehavior)) - } + // Set cors_behavior from the response (it's set during create via toCreateRequest) + data.CORSBehavior = types.StringValue(string(templateResp.CORSBehavior)) // TODO: Remove this update call (and the attribute) once the provider // requires a Coder version where this flag has been removed. @@ -1411,6 +1396,7 @@ func (r *TemplateResourceModel) toCreateRequest(ctx context.Context, resp *resou TimeTilDormantAutoDeleteMillis: r.TimeTilDormantAutoDeleteMillis.ValueInt64Pointer(), RequireActiveVersion: r.RequireActiveVersion.ValueBool(), UseClassicParameterFlow: r.UseClassicParameterFlow.ValueBoolPointer(), + CORSBehavior: ptr.Ref(codersdk.CORSBehavior(r.CORSBehavior.ValueString())), DisableEveryoneGroupAccess: !r.ACL.IsNull(), } } From e9dc99419119917ca34fa4811970825a2eee1b69 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:03:18 +0000 Subject: [PATCH 4/9] fix: handle empty cors_behavior value in create/update requests When cors_behavior is not specified (unknown/empty), we should not pass an empty string to the API. Instead, we return nil to use the server default. --- internal/provider/template_resource.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index c8f6a29..c57d523 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -1349,7 +1349,7 @@ func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, diag *diag. RequireActiveVersion: r.RequireActiveVersion.ValueBool(), DeprecationMessage: r.DeprecationMessage.ValueStringPointer(), MaxPortShareLevel: ptr.Ref(codersdk.WorkspaceAgentPortShareLevel(r.MaxPortShareLevel.ValueString())), - CORSBehavior: ptr.Ref(codersdk.CORSBehavior(r.CORSBehavior.ValueString())), + CORSBehavior: corsPtr(r.CORSBehavior.ValueString()), UseClassicParameterFlow: ptr.Ref(r.UseClassicParameterFlow.ValueBool()), // If we're managing ACL, we want to delete the everyone group DisableEveryoneGroupAccess: !r.ACL.IsNull(), @@ -1396,11 +1396,21 @@ func (r *TemplateResourceModel) toCreateRequest(ctx context.Context, resp *resou TimeTilDormantAutoDeleteMillis: r.TimeTilDormantAutoDeleteMillis.ValueInt64Pointer(), RequireActiveVersion: r.RequireActiveVersion.ValueBool(), UseClassicParameterFlow: r.UseClassicParameterFlow.ValueBoolPointer(), - CORSBehavior: ptr.Ref(codersdk.CORSBehavior(r.CORSBehavior.ValueString())), + CORSBehavior: corsPtr(r.CORSBehavior.ValueString()), DisableEveryoneGroupAccess: !r.ACL.IsNull(), } } +// corsPtr returns a pointer to a CORSBehavior if the value is not empty, +// otherwise returns nil (which will use the server default). +func corsPtr(v string) *codersdk.CORSBehavior { + if v == "" { + return nil + } + b := codersdk.CORSBehavior(v) + return &b +} + type LastVersionsByHash = map[string][]PreviousTemplateVersion var LastVersionsKey = "last_versions" From 8b0a77f4ed1720a9ad9d883dc4accaebc1e9537f Mon Sep 17 00:00:00 2001 From: ethan Date: Fri, 6 Feb 2026 12:37:54 +1100 Subject: [PATCH 5/9] fix: replace corsPtr with ptr.Ref, add null-on-read handling, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete custom corsPtr helper; use ptr.Ref (update) and (*codersdk.CORSBehavior)(ValueStringPointer()) (create) to match the UseClassicParameterFlow pattern. - On read, map empty CORSBehavior (old servers) to types.StringNull() instead of storing an invalid empty string. - Add cors_behavior to BasicUsage enterprise subtest (simple → passthru → simple). - Add cors_behavior check to data source test. --- internal/provider/template_data_source.go | 6 +++- .../provider/template_data_source_test.go | 1 + internal/provider/template_resource.go | 32 ++++++++++--------- internal/provider/template_resource_test.go | 7 ++++ 4 files changed, 30 insertions(+), 16 deletions(-) diff --git a/internal/provider/template_data_source.go b/internal/provider/template_data_source.go index 4323a08..b730508 100644 --- a/internal/provider/template_data_source.go +++ b/internal/provider/template_data_source.go @@ -335,7 +335,11 @@ func (d *TemplateDataSource) Read(ctx context.Context, req datasource.ReadReques data.TimeTilDormantAutoDeleteMillis = types.Int64Value(template.TimeTilDormantAutoDeleteMillis) data.RequireActiveVersion = types.BoolValue(template.RequireActiveVersion) data.MaxPortShareLevel = types.StringValue(string(template.MaxPortShareLevel)) - data.CORSBehavior = types.StringValue(string(template.CORSBehavior)) + if template.CORSBehavior == "" { + data.CORSBehavior = types.StringNull() + } else { + data.CORSBehavior = types.StringValue(string(template.CORSBehavior)) + } data.CreatedByUserID = UUIDValue(template.CreatedByID) data.CreatedAt = types.Int64Value(template.CreatedAt.Unix()) data.UpdatedAt = types.Int64Value(template.UpdatedAt.Unix()) diff --git a/internal/provider/template_data_source_test.go b/internal/provider/template_data_source_test.go index 6797f61..8e0e4dc 100644 --- a/internal/provider/template_data_source_test.go +++ b/internal/provider/template_data_source_test.go @@ -135,6 +135,7 @@ func TestAccTemplateDataSource(t *testing.T) { resource.TestCheckResourceAttr("data.coderd_template.test", "time_til_dormant_autodelete_ms", strconv.FormatInt(tpl.TimeTilDormantAutoDeleteMillis, 10)), resource.TestCheckResourceAttr("data.coderd_template.test", "require_active_version", strconv.FormatBool(tpl.RequireActiveVersion)), resource.TestCheckResourceAttr("data.coderd_template.test", "max_port_share_level", string(tpl.MaxPortShareLevel)), + resource.TestCheckResourceAttr("data.coderd_template.test", "cors_behavior", string(tpl.CORSBehavior)), resource.TestCheckResourceAttr("data.coderd_template.test", "created_by_user_id", firstUser.ID.String()), resource.TestCheckResourceAttr("data.coderd_template.test", "created_at", strconv.Itoa(int(tpl.CreatedAt.Unix()))), resource.TestCheckResourceAttr("data.coderd_template.test", "updated_at", strconv.Itoa(int(tpl.UpdatedAt.Unix()))), diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index c57d523..c12b2c6 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -616,7 +616,11 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques } // Set cors_behavior from the response (it's set during create via toCreateRequest) - data.CORSBehavior = types.StringValue(string(templateResp.CORSBehavior)) + if templateResp.CORSBehavior == "" { + data.CORSBehavior = types.StringNull() + } else { + data.CORSBehavior = types.StringValue(string(templateResp.CORSBehavior)) + } // TODO: Remove this update call (and the attribute) once the provider // requires a Coder version where this flag has been removed. @@ -676,7 +680,11 @@ func (r *TemplateResource) Read(ctx context.Context, req resource.ReadRequest, r return } data.MaxPortShareLevel = types.StringValue(string(template.MaxPortShareLevel)) - data.CORSBehavior = types.StringValue(string(template.CORSBehavior)) + if template.CORSBehavior == "" { + data.CORSBehavior = types.StringNull() + } else { + data.CORSBehavior = types.StringValue(string(template.CORSBehavior)) + } data.UseClassicParameterFlow = types.BoolValue(template.UseClassicParameterFlow) if !data.ACL.IsNull() { @@ -853,7 +861,11 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques return } newState.MaxPortShareLevel = types.StringValue(string(templateResp.MaxPortShareLevel)) - newState.CORSBehavior = types.StringValue(string(templateResp.CORSBehavior)) + if templateResp.CORSBehavior == "" { + newState.CORSBehavior = types.StringNull() + } else { + newState.CORSBehavior = types.StringValue(string(templateResp.CORSBehavior)) + } resp.Diagnostics.Append(newState.Versions.setPrivateState(ctx, resp.Private)...) if resp.Diagnostics.HasError() { @@ -1349,7 +1361,7 @@ func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, diag *diag. RequireActiveVersion: r.RequireActiveVersion.ValueBool(), DeprecationMessage: r.DeprecationMessage.ValueStringPointer(), MaxPortShareLevel: ptr.Ref(codersdk.WorkspaceAgentPortShareLevel(r.MaxPortShareLevel.ValueString())), - CORSBehavior: corsPtr(r.CORSBehavior.ValueString()), + CORSBehavior: ptr.Ref(codersdk.CORSBehavior(r.CORSBehavior.ValueString())), UseClassicParameterFlow: ptr.Ref(r.UseClassicParameterFlow.ValueBool()), // If we're managing ACL, we want to delete the everyone group DisableEveryoneGroupAccess: !r.ACL.IsNull(), @@ -1396,21 +1408,11 @@ func (r *TemplateResourceModel) toCreateRequest(ctx context.Context, resp *resou TimeTilDormantAutoDeleteMillis: r.TimeTilDormantAutoDeleteMillis.ValueInt64Pointer(), RequireActiveVersion: r.RequireActiveVersion.ValueBool(), UseClassicParameterFlow: r.UseClassicParameterFlow.ValueBoolPointer(), - CORSBehavior: corsPtr(r.CORSBehavior.ValueString()), + CORSBehavior: (*codersdk.CORSBehavior)(r.CORSBehavior.ValueStringPointer()), DisableEveryoneGroupAccess: !r.ACL.IsNull(), } } -// corsPtr returns a pointer to a CORSBehavior if the value is not empty, -// otherwise returns nil (which will use the server default). -func corsPtr(v string) *codersdk.CORSBehavior { - if v == "" { - return nil - } - b := codersdk.CORSBehavior(v) - return &b -} - type LastVersionsByHash = map[string][]PreviousTemplateVersion var LastVersionsKey = "last_versions" diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go index 8764e60..9c01925 100644 --- a/internal/provider/template_resource_test.go +++ b/internal/provider/template_resource_test.go @@ -693,10 +693,12 @@ func TestAccTemplateResourceEnterprise(t *testing.T) { cfg2 := cfg1 cfg2.ACL.GroupACL = slices.Clone(cfg2.ACL.GroupACL[1:]) cfg2.MaxPortShareLevel = ptr.Ref("owner") + cfg2.CORSBehavior = ptr.Ref("passthru") cfg3 := cfg2 cfg3.ACL.null = true cfg3.MaxPortShareLevel = ptr.Ref("public") + cfg3.CORSBehavior = ptr.Ref("simple") cfg4 := cfg3 cfg4.AllowUserAutostart = ptr.Ref(false) @@ -714,6 +716,7 @@ func TestAccTemplateResourceEnterprise(t *testing.T) { Config: cfg1.String(t), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("coderd_template.test", "max_port_share_level", "owner"), + resource.TestCheckResourceAttr("coderd_template.test", "cors_behavior", "simple"), resource.TestCheckResourceAttr("coderd_template.test", "acl.groups.#", "2"), resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "acl.groups.*", map[string]*regexp.Regexp{ "id": regexp.MustCompile(firstUser.OrganizationIDs[0].String()), @@ -734,6 +737,7 @@ func TestAccTemplateResourceEnterprise(t *testing.T) { Config: cfg2.String(t), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("coderd_template.test", "max_port_share_level", "owner"), + resource.TestCheckResourceAttr("coderd_template.test", "cors_behavior", "passthru"), resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "acl.users.*", map[string]*regexp.Regexp{ "id": regexp.MustCompile(firstUser.ID.String()), "role": regexp.MustCompile("^admin$"), @@ -744,6 +748,7 @@ func TestAccTemplateResourceEnterprise(t *testing.T) { Config: cfg3.String(t), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("coderd_template.test", "max_port_share_level", "public"), + resource.TestCheckResourceAttr("coderd_template.test", "cors_behavior", "simple"), resource.TestCheckNoResourceAttr("coderd_template.test", "acl"), func(s *terraform.State) error { templates, err := client.Templates(ctx, codersdk.TemplateFilter{}) @@ -992,6 +997,7 @@ type testAccTemplateResourceConfig struct { RequireActiveVersion *bool DeprecationMessage *string MaxPortShareLevel *string + CORSBehavior *string UseClassicParameterFlow *bool Versions []testAccTemplateVersionConfig @@ -1100,6 +1106,7 @@ resource "coderd_template" "test" { require_active_version = {{orNull .RequireActiveVersion}} deprecation_message = {{orNull .DeprecationMessage}} max_port_share_level = {{orNull .MaxPortShareLevel}} + cors_behavior = {{orNull .CORSBehavior}} use_classic_parameter_flow = {{orNull .UseClassicParameterFlow}} acl = ` + c.ACL.String(t) + ` From 0eee153355d22d761e942d7bc58b4cf7926a5296 Mon Sep 17 00:00:00 2001 From: ethan Date: Fri, 6 Feb 2026 12:44:31 +1100 Subject: [PATCH 6/9] test: add backward compatibility test for cors_behavior on Coder 2.25 Adds TestAccTemplateResourceBackCompat that spins up Coder v2.25.0 (which does not support cors_behavior), creates a template without setting cors_behavior, and verifies the attribute is absent in state. --- internal/provider/template_resource_test.go | 44 +++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go index 9c01925..971de6e 100644 --- a/internal/provider/template_resource_test.go +++ b/internal/provider/template_resource_test.go @@ -820,6 +820,50 @@ func TestAccTemplateResourceEnterprise(t *testing.T) { }) } +func TestAccTemplateResourceBackCompat(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + ctx := t.Context() + // Coder 2.25 does not support cors_behavior. Verify that not setting it works. + client := integration.StartCoder(ctx, t, "tmpl_back_compat_acc", integration.CoderVersion("v2.25.0")) + + exTemplateOne := t.TempDir() + err := cp.Copy("../../integration/template-test/example-template", exTemplateOne) + require.NoError(t, err) + + cfg1 := testAccTemplateResourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + Name: ptr.Ref("example-template"), + Versions: []testAccTemplateVersionConfig{ + { + Directory: &exTemplateOne, + Active: ptr.Ref(true), + }, + }, + ACL: testAccTemplateACLConfig{ + null: true, + }, + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + IsUnitTest: true, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg1.String(t), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("coderd_template.test", "id"), + resource.TestCheckNoResourceAttr("coderd_template.test", "cors_behavior"), + ), + }, + }, + }) +} + func TestAccTemplateResourceAGPL(t *testing.T) { t.Parallel() if os.Getenv("TF_ACC") == "" { From 37c5589024829a199ecba014f6258d86443dda5c Mon Sep 17 00:00:00 2001 From: ethan Date: Fri, 6 Feb 2026 12:48:17 +1100 Subject: [PATCH 7/9] refactor: extract stringValueOrNull helper to util.go Replace the repeated if/else null-on-empty-string pattern at all 4 CORSBehavior read sites with a single stringValueOrNull() call. --- internal/provider/template_data_source.go | 6 +----- internal/provider/template_resource.go | 18 +++--------------- internal/provider/util.go | 11 +++++++++++ 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/internal/provider/template_data_source.go b/internal/provider/template_data_source.go index b730508..3ee7ef3 100644 --- a/internal/provider/template_data_source.go +++ b/internal/provider/template_data_source.go @@ -335,11 +335,7 @@ func (d *TemplateDataSource) Read(ctx context.Context, req datasource.ReadReques data.TimeTilDormantAutoDeleteMillis = types.Int64Value(template.TimeTilDormantAutoDeleteMillis) data.RequireActiveVersion = types.BoolValue(template.RequireActiveVersion) data.MaxPortShareLevel = types.StringValue(string(template.MaxPortShareLevel)) - if template.CORSBehavior == "" { - data.CORSBehavior = types.StringNull() - } else { - data.CORSBehavior = types.StringValue(string(template.CORSBehavior)) - } + data.CORSBehavior = stringValueOrNull(string(template.CORSBehavior)) data.CreatedByUserID = UUIDValue(template.CreatedByID) data.CreatedAt = types.Int64Value(template.CreatedAt.Unix()) data.UpdatedAt = types.Int64Value(template.UpdatedAt.Unix()) diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index c12b2c6..bd65e94 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -616,11 +616,7 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques } // Set cors_behavior from the response (it's set during create via toCreateRequest) - if templateResp.CORSBehavior == "" { - data.CORSBehavior = types.StringNull() - } else { - data.CORSBehavior = types.StringValue(string(templateResp.CORSBehavior)) - } + data.CORSBehavior = stringValueOrNull(string(templateResp.CORSBehavior)) // TODO: Remove this update call (and the attribute) once the provider // requires a Coder version where this flag has been removed. @@ -680,11 +676,7 @@ func (r *TemplateResource) Read(ctx context.Context, req resource.ReadRequest, r return } data.MaxPortShareLevel = types.StringValue(string(template.MaxPortShareLevel)) - if template.CORSBehavior == "" { - data.CORSBehavior = types.StringNull() - } else { - data.CORSBehavior = types.StringValue(string(template.CORSBehavior)) - } + data.CORSBehavior = stringValueOrNull(string(template.CORSBehavior)) data.UseClassicParameterFlow = types.BoolValue(template.UseClassicParameterFlow) if !data.ACL.IsNull() { @@ -861,11 +853,7 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques return } newState.MaxPortShareLevel = types.StringValue(string(templateResp.MaxPortShareLevel)) - if templateResp.CORSBehavior == "" { - newState.CORSBehavior = types.StringNull() - } else { - newState.CORSBehavior = types.StringValue(string(templateResp.CORSBehavior)) - } + newState.CORSBehavior = stringValueOrNull(string(templateResp.CORSBehavior)) resp.Diagnostics.Append(newState.Versions.setPrivateState(ctx, resp.Private)...) if resp.Diagnostics.HasError() { diff --git a/internal/provider/util.go b/internal/provider/util.go index 3f35a25..3f80bf2 100644 --- a/internal/provider/util.go +++ b/internal/provider/util.go @@ -12,6 +12,8 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/google/uuid" + + "github.com/hashicorp/terraform-plugin-framework/types" ) func PrintOrNull(v any) string { @@ -123,3 +125,12 @@ func isNotFound(err error) bool { } return false } + +// stringValueOrNull returns types.StringNull() if s is empty, +// otherwise types.StringValue(s). +func stringValueOrNull(s string) types.String { + if s == "" { + return types.StringNull() + } + return types.StringValue(s) +} From 0d12339137a4d0ff919a4e19befb965b7478805e Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 01:53:37 +0000 Subject: [PATCH 8/9] fix: handle null/unknown cors_behavior to avoid empty string validation error --- internal/provider/template_resource.go | 4 ++-- internal/provider/util.go | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index bd65e94..594d7c9 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -1349,7 +1349,7 @@ func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, diag *diag. RequireActiveVersion: r.RequireActiveVersion.ValueBool(), DeprecationMessage: r.DeprecationMessage.ValueStringPointer(), MaxPortShareLevel: ptr.Ref(codersdk.WorkspaceAgentPortShareLevel(r.MaxPortShareLevel.ValueString())), - CORSBehavior: ptr.Ref(codersdk.CORSBehavior(r.CORSBehavior.ValueString())), + CORSBehavior: corsPtr(r.CORSBehavior), UseClassicParameterFlow: ptr.Ref(r.UseClassicParameterFlow.ValueBool()), // If we're managing ACL, we want to delete the everyone group DisableEveryoneGroupAccess: !r.ACL.IsNull(), @@ -1396,7 +1396,7 @@ func (r *TemplateResourceModel) toCreateRequest(ctx context.Context, resp *resou TimeTilDormantAutoDeleteMillis: r.TimeTilDormantAutoDeleteMillis.ValueInt64Pointer(), RequireActiveVersion: r.RequireActiveVersion.ValueBool(), UseClassicParameterFlow: r.UseClassicParameterFlow.ValueBoolPointer(), - CORSBehavior: (*codersdk.CORSBehavior)(r.CORSBehavior.ValueStringPointer()), + CORSBehavior: corsPtr(r.CORSBehavior), DisableEveryoneGroupAccess: !r.ACL.IsNull(), } } diff --git a/internal/provider/util.go b/internal/provider/util.go index 3f80bf2..dbc3441 100644 --- a/internal/provider/util.go +++ b/internal/provider/util.go @@ -134,3 +134,13 @@ func stringValueOrNull(s string) types.String { } return types.StringValue(s) } + +// corsPtr returns a pointer to a CORSBehavior if the value is known and not empty, +// otherwise returns nil (which will use the server default). +func corsPtr(v types.String) *codersdk.CORSBehavior { + if v.IsNull() || v.IsUnknown() || v.ValueString() == "" { + return nil + } + b := codersdk.CORSBehavior(v.ValueString()) + return &b +} From 7b7c8d6162197257c271bddfa87ed54ce4cdef0b Mon Sep 17 00:00:00 2001 From: ethan Date: Fri, 6 Feb 2026 13:04:16 +1100 Subject: [PATCH 9/9] docs: add v2.26.0 version requirement to cors_behavior attribute --- docs/data-sources/template.md | 2 +- docs/resources/template.md | 2 +- internal/provider/template_data_source.go | 2 +- internal/provider/template_resource.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/data-sources/template.md b/docs/data-sources/template.md index 7b249cb..b049b80 100644 --- a/docs/data-sources/template.md +++ b/docs/data-sources/template.md @@ -56,7 +56,7 @@ resource "coderd_template" "debian-main" { - `allow_user_cancel_workspace_jobs` (Boolean) Whether users can cancel jobs in workspaces created from the template. - `auto_start_permitted_days_of_week` (Set of String) List of days of the week in which autostart is allowed to happen, for all workspaces created from this template. Defaults to all days. If no days are specified, autostart is not allowed. - `auto_stop_requirement` (Attributes) The auto-stop requirement for all workspaces created from this template. (see [below for nested schema](#nestedatt--auto_stop_requirement)) -- `cors_behavior` (String) The CORS behavior for workspace apps in this template. +- `cors_behavior` (String) The CORS behavior for workspace apps in this template. Requires a Coder deployment running v2.26.0 or later. - `created_at` (Number) Unix timestamp of when the template was created. - `created_by_user_id` (String) ID of the user who created the template. - `default_ttl_ms` (Number) Default time-to-live for workspaces created from the template. diff --git a/docs/resources/template.md b/docs/resources/template.md index 638e4fa..6e761c2 100644 --- a/docs/resources/template.md +++ b/docs/resources/template.md @@ -72,7 +72,7 @@ resource "coderd_template" "ubuntu-main" { - `allow_user_cancel_workspace_jobs` (Boolean) Whether users can cancel in-progress workspace jobs using this template. Defaults to true. - `auto_start_permitted_days_of_week` (Set of String) (Enterprise) List of days of the week in which autostart is allowed to happen, for all workspaces created from this template. Defaults to all days. If no days are specified, autostart is not allowed. - `auto_stop_requirement` (Attributes) (Enterprise) The auto-stop requirement for all workspaces created from this template. (see [below for nested schema](#nestedatt--auto_stop_requirement)) -- `cors_behavior` (String) The CORS behavior for workspace apps in this template. Valid values are `simple` (default CORS middleware) or `passthru` (bypass CORS middleware). Defaults to `simple`. +- `cors_behavior` (String) The CORS behavior for workspace apps in this template. Valid values are `simple` (default CORS middleware) or `passthru` (bypass CORS middleware). Defaults to `simple`. Requires a Coder deployment running v2.26.0 or later. - `default_ttl_ms` (Number) The default time-to-live for all workspaces created from this template, in milliseconds. - `deprecation_message` (String) If set, the template will be marked as deprecated with the provided message and users will be blocked from creating new workspaces from it. Does nothing if set when the resource is created. - `description` (String) A description of the template. diff --git a/internal/provider/template_data_source.go b/internal/provider/template_data_source.go index 3ee7ef3..4393768 100644 --- a/internal/provider/template_data_source.go +++ b/internal/provider/template_data_source.go @@ -190,7 +190,7 @@ func (d *TemplateDataSource) Schema(ctx context.Context, req datasource.SchemaRe Computed: true, }, "cors_behavior": schema.StringAttribute{ - MarkdownDescription: "The CORS behavior for workspace apps in this template.", + MarkdownDescription: "The CORS behavior for workspace apps in this template. Requires a Coder deployment running v2.26.0 or later.", Computed: true, }, "created_by_user_id": schema.StringAttribute{ diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index 594d7c9..ace421f 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -401,7 +401,7 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques Default: stringdefault.StaticString(""), }, "cors_behavior": schema.StringAttribute{ - MarkdownDescription: "The CORS behavior for workspace apps in this template. Valid values are `simple` (default CORS middleware) or `passthru` (bypass CORS middleware). Defaults to `simple`.", + MarkdownDescription: "The CORS behavior for workspace apps in this template. Valid values are `simple` (default CORS middleware) or `passthru` (bypass CORS middleware). Defaults to `simple`. Requires a Coder deployment running v2.26.0 or later.", Optional: true, Computed: true, Validators: []validator.String{