diff --git a/docs/data-sources/template.md b/docs/data-sources/template.md index 28cfcad..b049b80 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. 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 a7a95f8..6e761c2 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`. 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 3a5f59a..4393768 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. Requires a Coder deployment running v2.26.0 or later.", + 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 = 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_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 6a1ac36..ace421f 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`. Requires a Coder deployment running v2.26.0 or later.", + 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,9 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques data.MaxPortShareLevel = types.StringValue(string(mpslResp.MaxPortShareLevel)) } + // Set cors_behavior from the response (it's set during create via toCreateRequest) + 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. if data.UseClassicParameterFlow.IsUnknown() { @@ -660,6 +676,7 @@ func (r *TemplateResource) Read(ctx context.Context, req resource.ReadRequest, r return } data.MaxPortShareLevel = types.StringValue(string(template.MaxPortShareLevel)) + data.CORSBehavior = stringValueOrNull(string(template.CORSBehavior)) data.UseClassicParameterFlow = types.BoolValue(template.UseClassicParameterFlow) if !data.ACL.IsNull() { @@ -836,6 +853,7 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques return } newState.MaxPortShareLevel = types.StringValue(string(templateResp.MaxPortShareLevel)) + newState.CORSBehavior = stringValueOrNull(string(templateResp.CORSBehavior)) resp.Diagnostics.Append(newState.Versions.setPrivateState(ctx, resp.Private)...) if resp.Diagnostics.HasError() { @@ -1331,6 +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: 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(), @@ -1377,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: corsPtr(r.CORSBehavior), DisableEveryoneGroupAccess: !r.ACL.IsNull(), } } diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go index 8764e60..971de6e 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{}) @@ -815,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") == "" { @@ -992,6 +1041,7 @@ type testAccTemplateResourceConfig struct { RequireActiveVersion *bool DeprecationMessage *string MaxPortShareLevel *string + CORSBehavior *string UseClassicParameterFlow *bool Versions []testAccTemplateVersionConfig @@ -1100,6 +1150,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) + ` diff --git a/internal/provider/util.go b/internal/provider/util.go index 3f35a25..dbc3441 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,22 @@ 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) +} + +// 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 +}