From 26322299fa97e9365fd02d79e1abbedc7f12308a Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 18 Aug 2026 06:46:48 +0000 Subject: [PATCH 1/6] feat(coderd_template): expose agents_allowed --- docs/data-sources/template.md | 1 + docs/resources/template.md | 1 + internal/provider/template_data_source.go | 6 ++ .../provider/template_data_source_test.go | 14 +++- internal/provider/template_resource.go | 15 +++- internal/provider/template_resource_test.go | 69 ++++++++++++++++++- internal/provider/util.go | 9 +++ internal/provider/util_test.go | 36 ++++++++++ 8 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 internal/provider/util_test.go diff --git a/docs/data-sources/template.md b/docs/data-sources/template.md index d7990756..f4537281 100644 --- a/docs/data-sources/template.md +++ b/docs/data-sources/template.md @@ -51,6 +51,7 @@ resource "coderd_template" "debian-main" { - `active_user_count` (Number) Number of active users using the template. - `active_version_id` (String) ID of the active version of the template. - `activity_bump_ms` (Number) Duration to bump the deadline of a workspace when it receives activity. +- `agents_allowed` (Boolean) Whether Coder Agents can create workspaces from the template. Requires a Coder deployment running v2.37.0 or later. - `allow_user_autostart` (Boolean) Whether users can autostart workspaces created from the template. - `allow_user_autostop` (Boolean) Whether users can customize autostop behavior for workspaces created from the template. - `allow_user_cancel_workspace_jobs` (Boolean) Whether users can cancel jobs in workspaces created from the template. diff --git a/docs/resources/template.md b/docs/resources/template.md index 7bd03cd2..dce77950 100644 --- a/docs/resources/template.md +++ b/docs/resources/template.md @@ -66,6 +66,7 @@ resource "coderd_template" "ubuntu-main" { - `acl` (Attributes) (Enterprise) Access control list for the template. If null, ACL policies will not be added, removed, or read by Terraform. (see [below for nested schema](#nestedatt--acl)) - `activity_bump_ms` (Number) The activity bump duration for all workspaces created from this template, in milliseconds. Defaults to one hour. +- `agents_allowed` (Boolean) Whether Coder Agents can create workspaces from this template. Defaults to true. Requires a Coder deployment running v2.37.0 or later. - `allow_user_auto_start` (Boolean) (Enterprise) Whether users can auto-start workspaces created from this template. Defaults to true. - `allow_user_auto_stop` (Boolean) (Enterprise) Whether users can auto-stop workspaces created from this template. Defaults to true. - `allow_user_cancel_workspace_jobs` (Boolean) Whether users can cancel in-progress workspace jobs using this template. Defaults to true. diff --git a/internal/provider/template_data_source.go b/internal/provider/template_data_source.go index 1736de2d..f0b564f2 100644 --- a/internal/provider/template_data_source.go +++ b/internal/provider/template_data_source.go @@ -57,6 +57,7 @@ type TemplateDataSourceModel struct { TimeTilDormantAutoDeleteMillis types.Int64 `tfsdk:"time_til_dormant_autodelete_ms"` RequireActiveVersion types.Bool `tfsdk:"require_active_version"` + AgentsAllowed types.Bool `tfsdk:"agents_allowed"` MaxPortShareLevel types.String `tfsdk:"max_port_share_level"` CORSBehavior types.String `tfsdk:"cors_behavior"` @@ -185,6 +186,10 @@ func (d *TemplateDataSource) Schema(ctx context.Context, req datasource.SchemaRe MarkdownDescription: "Whether workspaces created from the template must be up-to-date on the latest active version.", Computed: true, }, + "agents_allowed": schema.BoolAttribute{ + MarkdownDescription: "Whether Coder Agents can create workspaces from the template. Requires a Coder deployment running v2.37.0 or later.", + Computed: true, + }, "max_port_share_level": schema.StringAttribute{ MarkdownDescription: "The maximum port share level for workspaces created from the template.", Computed: true, @@ -334,6 +339,7 @@ func (d *TemplateDataSource) Read(ctx context.Context, req datasource.ReadReques data.TimeTilDormantMillis = types.Int64Value(template.TimeTilDormantMillis) data.TimeTilDormantAutoDeleteMillis = types.Int64Value(template.TimeTilDormantAutoDeleteMillis) data.RequireActiveVersion = types.BoolValue(template.RequireActiveVersion) + data.AgentsAllowed = types.BoolValue(template.AgentsAllowed) data.MaxPortShareLevel = types.StringValue(string(template.MaxPortShareLevel)) data.CORSBehavior = stringValueOrNull(string(template.CORSBehavior)) data.CreatedByUserID = UUIDValue(template.CreatedByID) diff --git a/internal/provider/template_data_source_test.go b/internal/provider/template_data_source_test.go index 6fe29f8b..e4a10de5 100644 --- a/internal/provider/template_data_source_test.go +++ b/internal/provider/template_data_source_test.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/stretchr/testify/require" + "golang.org/x/mod/semver" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" @@ -29,6 +30,10 @@ func TestAccTemplateDataSource(t *testing.T) { require.NoError(t, err) orgID := firstUser.OrganizationIDs[0] + buildInfo, err := client.BuildInfo(ctx) + require.NoError(t, err, "fetch buildinfo") + supportsAgentsAllowed := semver.Compare(buildInfo.CanonicalVersion(), "v2.37.0") >= 0 + version, _, err := newVersion(ctx, client, newVersionRequest{ OrganizationID: orgID, Version: &TemplateVersion{ @@ -67,6 +72,7 @@ func TestAccTemplateDataSource(t *testing.T) { TimeTilDormantAutoDeleteMillis: ptr.Ref((30 * 24 * time.Hour).Milliseconds()), DisableEveryoneGroupAccess: true, RequireActiveVersion: true, + AgentsAllowed: ptr.Ref(false), }) require.NoError(t, err) @@ -110,7 +116,7 @@ func TestAccTemplateDataSource(t *testing.T) { }) require.NoError(t, err) - checkFn := resource.ComposeAggregateTestCheckFunc( + checks := []resource.TestCheckFunc{ resource.TestCheckResourceAttr("data.coderd_template.test", "organization_id", tpl.OrganizationID.String()), resource.TestCheckResourceAttr("data.coderd_template.test", "id", tpl.ID.String()), resource.TestCheckResourceAttr("data.coderd_template.test", "name", tpl.Name), @@ -149,7 +155,11 @@ func TestAccTemplateDataSource(t *testing.T) { "id": regexp.MustCompile(firstUser.ID.String()), "role": regexp.MustCompile("^admin$"), }), - ) + } + if supportsAgentsAllowed { + checks = append(checks, resource.TestCheckResourceAttr("data.coderd_template.test", "agents_allowed", strconv.FormatBool(tpl.AgentsAllowed))) + } + checkFn := resource.ComposeAggregateTestCheckFunc(checks...) t.Run("TemplateByOrgAndNameOK", func(t *testing.T) { cfg := testAccTemplateDataSourceConfig{ diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index f0e88c38..a6fb5a11 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -79,6 +79,7 @@ type TemplateResourceModel struct { MaxPortShareLevel types.String `tfsdk:"max_port_share_level"` CORSBehavior types.String `tfsdk:"cors_behavior"` UseClassicParameterFlow types.Bool `tfsdk:"use_classic_parameter_flow"` + AgentsAllowed types.Bool `tfsdk:"agents_allowed"` // If null, we are not managing ACL via Terraform (such as for AGPL). ACL types.Object `tfsdk:"acl"` @@ -106,7 +107,8 @@ func (m *TemplateResourceModel) EqualTemplateMetadata(other *TemplateResourceMod m.DeprecationMessage.Equal(other.DeprecationMessage) && m.MaxPortShareLevel.Equal(other.MaxPortShareLevel) && m.CORSBehavior.Equal(other.CORSBehavior) && - m.UseClassicParameterFlow.Equal(other.UseClassicParameterFlow) + m.UseClassicParameterFlow.Equal(other.UseClassicParameterFlow) && + m.AgentsAllowed.Equal(other.AgentsAllowed) } func (m *TemplateResourceModel) CheckEntitlements(ctx context.Context, features map[codersdk.FeatureName]codersdk.Feature) (diags diag.Diagnostics) { @@ -467,6 +469,14 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques boolplanmodifier.UseStateForUnknown(), }, }, + "agents_allowed": schema.BoolAttribute{ + MarkdownDescription: "Whether Coder Agents can create workspaces from this template. Defaults to true. Requires a Coder deployment running v2.37.0 or later.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, "acl": schema.SingleNestedAttribute{ MarkdownDescription: "(Enterprise) Access control list for the template. If null, ACL policies will not be added, removed, or read by Terraform.", Optional: true, @@ -1444,6 +1454,7 @@ func (r *TemplateResourceModel) readResponse(ctx context.Context, template *code r.TimeTilDormantMillis = types.Int64Value(template.TimeTilDormantMillis) r.TimeTilDormantAutoDeleteMillis = types.Int64Value(template.TimeTilDormantAutoDeleteMillis) r.RequireActiveVersion = types.BoolValue(template.RequireActiveVersion) + r.AgentsAllowed = types.BoolValue(template.AgentsAllowed) r.DeprecationMessage = types.StringValue(template.DeprecationMessage) // TODO(ethanndickson): MaxPortShareLevel deliberately omitted, as it can't // be set during a create request, and we call this during `Create`. @@ -1492,6 +1503,7 @@ func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, diag *diag. MaxPortShareLevel: ptr.Ref(codersdk.WorkspaceAgentPortShareLevel(r.MaxPortShareLevel.ValueString())), CORSBehavior: corsPtr(r.CORSBehavior), UseClassicParameterFlow: r.UseClassicParameterFlow.ValueBoolPointer(), + AgentsAllowed: boolPtrOrNil(r.AgentsAllowed), // If we're managing ACL, we want to delete the everyone group. DisableEveryoneGroupAccess: ptr.Ref(!r.ACL.IsNull()), } @@ -1537,6 +1549,7 @@ func (r *TemplateResourceModel) toCreateRequest(ctx context.Context, resp *resou TimeTilDormantAutoDeleteMillis: r.TimeTilDormantAutoDeleteMillis.ValueInt64Pointer(), RequireActiveVersion: r.RequireActiveVersion.ValueBool(), UseClassicParameterFlow: r.UseClassicParameterFlow.ValueBoolPointer(), + AgentsAllowed: boolPtrOrNil(r.AgentsAllowed), 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 fbab11e6..3c244b6c 100644 --- a/internal/provider/template_resource_test.go +++ b/internal/provider/template_resource_test.go @@ -19,6 +19,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/terraform" cp "github.com/otiai10/copy" "github.com/stretchr/testify/require" + "golang.org/x/mod/semver" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" @@ -260,7 +261,7 @@ func TestAccTemplateResource(t *testing.T) { testAccCheckNumTemplateVersions(ctx, client, 4), ), }, - // Append version. Creates a fifth version + // Append version. Creates a fifth version. { Config: cfg3.String(t), Check: resource.ComposeAggregateTestCheckFunc( @@ -274,7 +275,7 @@ func TestAccTemplateResource(t *testing.T) { testAccCheckNumTemplateVersions(ctx, client, 5), ), }, - // Change active version + // Change the active version and omit agents_allowed. { Config: cfg4.String(t), Check: resource.ComposeAggregateTestCheckFunc( @@ -751,6 +752,68 @@ func TestAccTemplateResource(t *testing.T) { }) } +func TestAccTemplateResourceAgentsAllowed(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + ctx := t.Context() + client := integration.StartCoder(ctx, t, "template_agents_allowed_acc") + buildInfo, err := client.BuildInfo(ctx) + require.NoError(t, err, "fetch buildinfo") + if semver.Compare(buildInfo.CanonicalVersion(), "v2.37.0") < 0 { + t.Skipf("test requires Coder v2.37.0 or later, deployment is %s", buildInfo.CanonicalVersion()) + } + + directory := t.TempDir() + err = cp.Copy("../../integration/template-test/example-template", directory) + require.NoError(t, err) + + cfgOmitted := testAccTemplateResourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + Name: ptr.Ref("agents-allowed-template"), + Versions: ptr.Ref([]testAccTemplateVersionConfig{ + { + Directory: &directory, + Active: ptr.Ref(true), + }, + }), + ACL: testAccTemplateACLConfig{null: true}, + } + cfgFalse := cfgOmitted + cfgFalse.AgentsAllowed = ptr.Ref(false) + cfgTrue := cfgFalse + cfgTrue.AgentsAllowed = ptr.Ref(true) + cfgUnmanaged := cfgTrue + cfgUnmanaged.AgentsAllowed = nil + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + IsUnitTest: true, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfgOmitted.String(t), + Check: resource.TestCheckResourceAttr("coderd_template.test", "agents_allowed", "true"), + }, + { + Config: cfgFalse.String(t), + Check: resource.TestCheckResourceAttr("coderd_template.test", "agents_allowed", "false"), + }, + { + Config: cfgTrue.String(t), + Check: resource.TestCheckResourceAttr("coderd_template.test", "agents_allowed", "true"), + }, + { + Config: cfgUnmanaged.String(t), + PlanOnly: true, + ExpectNonEmptyPlan: false, + }, + }, + }) +} + // TestAccTemplateResourceOptionalVersions covers PLAT-288: `versions` is // optional, so `coderd_template` can manage a template's settings without // owning its version lifecycle. A template still can't be *created* without @@ -1606,6 +1669,7 @@ type testAccTemplateResourceConfig struct { MaxPortShareLevel *string CORSBehavior *string UseClassicParameterFlow *bool + AgentsAllowed *bool // Versions is a pointer so that a nil value renders `versions = null` // (matching AutostartRequirement above), letting tests exercise @@ -1757,6 +1821,7 @@ resource "coderd_template" "test" { max_port_share_level = {{orNull .MaxPortShareLevel}} cors_behavior = {{orNull .CORSBehavior}} use_classic_parameter_flow = {{orNull .UseClassicParameterFlow}} + agents_allowed = {{orNull .AgentsAllowed}} acl = ` + c.ACL.String(t) + ` diff --git a/internal/provider/util.go b/internal/provider/util.go index 031c78fd..02915cba 100644 --- a/internal/provider/util.go +++ b/internal/provider/util.go @@ -144,6 +144,15 @@ func stringPtrOrNil(v types.String) *string { return v.ValueStringPointer() } +// boolPtrOrNil returns nil for null or unknown booleans. +// ValueBoolPointer returns false for unknown, which can accidentally send a value. +func boolPtrOrNil(v types.Bool) *bool { + if v.IsNull() || v.IsUnknown() { + return nil + } + return v.ValueBoolPointer() +} + // 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 { diff --git a/internal/provider/util_test.go b/internal/provider/util_test.go new file mode 100644 index 00000000..186017e7 --- /dev/null +++ b/internal/provider/util_test.go @@ -0,0 +1,36 @@ +package provider + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stretchr/testify/require" +) + +func TestBoolPtrOrNil(t *testing.T) { + t.Parallel() + + t.Run("null", func(t *testing.T) { + t.Parallel() + require.Nil(t, boolPtrOrNil(types.BoolNull())) + }) + + t.Run("unknown", func(t *testing.T) { + t.Parallel() + require.Nil(t, boolPtrOrNil(types.BoolUnknown())) + }) + + t.Run("false", func(t *testing.T) { + t.Parallel() + value := boolPtrOrNil(types.BoolValue(false)) + require.NotNil(t, value) + require.False(t, *value) + }) + + t.Run("true", func(t *testing.T) { + t.Parallel() + value := boolPtrOrNil(types.BoolValue(true)) + require.NotNil(t, value) + require.True(t, *value) + }) +} From 4c98f50d545bae18dd121cf20486dcbc0d14549c Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 18 Aug 2026 07:28:26 +0000 Subject: [PATCH 2/6] fix(coderd_template): reconcile metadata after update --- internal/provider/template_resource.go | 10 ++++++++-- internal/provider/template_resource_test.go | 22 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index a6fb5a11..8dc0094e 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -915,8 +915,7 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template: %s", err)) return } - newState.MaxPortShareLevel = types.StringValue(string(templateResp.MaxPortShareLevel)) - newState.CORSBehavior = stringValueOrNull(string(templateResp.CORSBehavior)) + newState.reconcileUpdateResponse(templateResp) resp.Diagnostics.Append(newState.Versions.setPrivateState(ctx, resp.Private)...) if resp.Diagnostics.HasError() { @@ -1426,6 +1425,13 @@ func convertResponseToACL(acl codersdk.TemplateACL) ACL { } } +func (r *TemplateResourceModel) reconcileUpdateResponse(template codersdk.Template) { + r.MaxPortShareLevel = types.StringValue(string(template.MaxPortShareLevel)) + r.CORSBehavior = stringValueOrNull(string(template.CORSBehavior)) + r.UseClassicParameterFlow = types.BoolValue(template.UseClassicParameterFlow) + r.AgentsAllowed = types.BoolValue(template.AgentsAllowed) +} + func (r *TemplateResourceModel) readResponse(ctx context.Context, template *codersdk.Template) diag.Diagnostics { r.Name = types.StringValue(template.Name) r.DisplayName = types.StringValue(template.DisplayName) diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go index 3c244b6c..ba9fc43c 100644 --- a/internal/provider/template_resource_test.go +++ b/internal/provider/template_resource_test.go @@ -36,6 +36,28 @@ func mustVariablesToSet(vars []Variable) types.Set { return s } +func TestTemplateResourceReconcileUpdateResponse(t *testing.T) { + t.Parallel() + + state := TemplateResourceModel{ + MaxPortShareLevel: types.StringValue("owner"), + CORSBehavior: types.StringValue("simple"), + UseClassicParameterFlow: types.BoolValue(true), + AgentsAllowed: types.BoolValue(true), + } + state.reconcileUpdateResponse(codersdk.Template{ + MaxPortShareLevel: codersdk.WorkspaceAgentPortShareLevelPublic, + CORSBehavior: codersdk.CORSBehaviorPassthru, + UseClassicParameterFlow: false, + AgentsAllowed: false, + }) + + require.Equal(t, "public", state.MaxPortShareLevel.ValueString()) + require.Equal(t, "passthru", state.CORSBehavior.ValueString()) + require.False(t, state.UseClassicParameterFlow.ValueBool()) + require.False(t, state.AgentsAllowed.ValueBool()) +} + func TestTemplateResourceACLRoleSchemaValidation(t *testing.T) { t.Parallel() From 008c890d8455145bd0960b4e7abf856087de0b00 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 18 Aug 2026 09:05:01 +0000 Subject: [PATCH 3/6] fix(coderd_template): address agents_allowed review --- docs/resources/template.md | 2 +- internal/provider/template_data_source.go | 2 +- .../provider/template_data_source_test.go | 2 +- internal/provider/template_resource.go | 12 +- internal/provider/template_resource_test.go | 103 ++++++++++++++---- 5 files changed, 89 insertions(+), 32 deletions(-) diff --git a/docs/resources/template.md b/docs/resources/template.md index dce77950..6a312954 100644 --- a/docs/resources/template.md +++ b/docs/resources/template.md @@ -66,7 +66,7 @@ resource "coderd_template" "ubuntu-main" { - `acl` (Attributes) (Enterprise) Access control list for the template. If null, ACL policies will not be added, removed, or read by Terraform. (see [below for nested schema](#nestedatt--acl)) - `activity_bump_ms` (Number) The activity bump duration for all workspaces created from this template, in milliseconds. Defaults to one hour. -- `agents_allowed` (Boolean) Whether Coder Agents can create workspaces from this template. Defaults to true. Requires a Coder deployment running v2.37.0 or later. +- `agents_allowed` (Boolean) Whether Coder Agents can create workspaces from this template. Coder defaults this setting to true. Requires a Coder deployment running v2.37.0 or later. - `allow_user_auto_start` (Boolean) (Enterprise) Whether users can auto-start workspaces created from this template. Defaults to true. - `allow_user_auto_stop` (Boolean) (Enterprise) Whether users can auto-stop workspaces created from this template. Defaults to true. - `allow_user_cancel_workspace_jobs` (Boolean) Whether users can cancel in-progress workspace jobs using this template. Defaults to true. diff --git a/internal/provider/template_data_source.go b/internal/provider/template_data_source.go index f0b564f2..2a2fa9b2 100644 --- a/internal/provider/template_data_source.go +++ b/internal/provider/template_data_source.go @@ -187,7 +187,7 @@ func (d *TemplateDataSource) Schema(ctx context.Context, req datasource.SchemaRe Computed: true, }, "agents_allowed": schema.BoolAttribute{ - MarkdownDescription: "Whether Coder Agents can create workspaces from the template. Requires a Coder deployment running v2.37.0 or later.", + MarkdownDescription: fmt.Sprintf("Whether Coder Agents can create workspaces from the template. Requires a Coder deployment running v%s or later.", templateAgentsAllowedMinVersion), Computed: true, }, "max_port_share_level": schema.StringAttribute{ diff --git a/internal/provider/template_data_source_test.go b/internal/provider/template_data_source_test.go index e4a10de5..09ce4c37 100644 --- a/internal/provider/template_data_source_test.go +++ b/internal/provider/template_data_source_test.go @@ -32,7 +32,7 @@ func TestAccTemplateDataSource(t *testing.T) { buildInfo, err := client.BuildInfo(ctx) require.NoError(t, err, "fetch buildinfo") - supportsAgentsAllowed := semver.Compare(buildInfo.CanonicalVersion(), "v2.37.0") >= 0 + supportsAgentsAllowed := semver.Compare(buildInfo.CanonicalVersion(), "v"+templateAgentsAllowedMinVersion) >= 0 version, _, err := newVersion(ctx, client, newVersionRequest{ OrganizationID: orgID, diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index 8dc0094e..777c65b2 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -46,6 +46,8 @@ var ( _ resource.ResourceWithConfigValidators = &TemplateResource{} ) +const templateAgentsAllowedMinVersion = "2.37.0" + func NewTemplateResource() resource.Resource { return &TemplateResource{} } @@ -470,7 +472,7 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques }, }, "agents_allowed": schema.BoolAttribute{ - MarkdownDescription: "Whether Coder Agents can create workspaces from this template. Defaults to true. Requires a Coder deployment running v2.37.0 or later.", + MarkdownDescription: fmt.Sprintf("Whether Coder Agents can create workspaces from this template. Coder defaults this setting to true. Requires a Coder deployment running v%s or later.", templateAgentsAllowedMinVersion), Optional: true, Computed: true, PlanModifiers: []planmodifier.Bool{ @@ -915,7 +917,7 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template: %s", err)) return } - newState.reconcileUpdateResponse(templateResp) + newState.reconcileVersionedMetadata(&templateResp) resp.Diagnostics.Append(newState.Versions.setPrivateState(ctx, resp.Private)...) if resp.Diagnostics.HasError() { @@ -1425,7 +1427,7 @@ func convertResponseToACL(acl codersdk.TemplateACL) ACL { } } -func (r *TemplateResourceModel) reconcileUpdateResponse(template codersdk.Template) { +func (r *TemplateResourceModel) reconcileVersionedMetadata(template *codersdk.Template) { r.MaxPortShareLevel = types.StringValue(string(template.MaxPortShareLevel)) r.CORSBehavior = stringValueOrNull(string(template.CORSBehavior)) r.UseClassicParameterFlow = types.BoolValue(template.UseClassicParameterFlow) @@ -1508,7 +1510,7 @@ func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, diag *diag. DeprecationMessage: r.DeprecationMessage.ValueStringPointer(), MaxPortShareLevel: ptr.Ref(codersdk.WorkspaceAgentPortShareLevel(r.MaxPortShareLevel.ValueString())), CORSBehavior: corsPtr(r.CORSBehavior), - UseClassicParameterFlow: r.UseClassicParameterFlow.ValueBoolPointer(), + UseClassicParameterFlow: boolPtrOrNil(r.UseClassicParameterFlow), AgentsAllowed: boolPtrOrNil(r.AgentsAllowed), // If we're managing ACL, we want to delete the everyone group. DisableEveryoneGroupAccess: ptr.Ref(!r.ACL.IsNull()), @@ -1554,7 +1556,7 @@ func (r *TemplateResourceModel) toCreateRequest(ctx context.Context, resp *resou TimeTilDormantMillis: r.TimeTilDormantMillis.ValueInt64Pointer(), TimeTilDormantAutoDeleteMillis: r.TimeTilDormantAutoDeleteMillis.ValueInt64Pointer(), RequireActiveVersion: r.RequireActiveVersion.ValueBool(), - UseClassicParameterFlow: r.UseClassicParameterFlow.ValueBoolPointer(), + UseClassicParameterFlow: boolPtrOrNil(r.UseClassicParameterFlow), AgentsAllowed: boolPtrOrNil(r.AgentsAllowed), 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 ba9fc43c..9ab68a3f 100644 --- a/internal/provider/template_resource_test.go +++ b/internal/provider/template_resource_test.go @@ -12,6 +12,8 @@ import ( "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + frameworkresource "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-testing/config" @@ -36,26 +38,79 @@ func mustVariablesToSet(vars []Variable) types.Set { return s } -func TestTemplateResourceReconcileUpdateResponse(t *testing.T) { +func TestTemplateResourceReconcileVersionedMetadata(t *testing.T) { t.Parallel() - state := TemplateResourceModel{ - MaxPortShareLevel: types.StringValue("owner"), - CORSBehavior: types.StringValue("simple"), - UseClassicParameterFlow: types.BoolValue(true), - AgentsAllowed: types.BoolValue(true), - } - state.reconcileUpdateResponse(codersdk.Template{ - MaxPortShareLevel: codersdk.WorkspaceAgentPortShareLevelPublic, - CORSBehavior: codersdk.CORSBehaviorPassthru, - UseClassicParameterFlow: false, - AgentsAllowed: false, + t.Run("values", func(t *testing.T) { + t.Parallel() + + state := TemplateResourceModel{ + MaxPortShareLevel: types.StringValue("owner"), + CORSBehavior: types.StringValue("simple"), + UseClassicParameterFlow: types.BoolValue(true), + AgentsAllowed: types.BoolValue(false), + } + state.reconcileVersionedMetadata(&codersdk.Template{ + MaxPortShareLevel: codersdk.WorkspaceAgentPortShareLevelPublic, + CORSBehavior: codersdk.CORSBehaviorPassthru, + UseClassicParameterFlow: false, + AgentsAllowed: true, + }) + + require.Equal(t, "public", state.MaxPortShareLevel.ValueString()) + require.Equal(t, "passthru", state.CORSBehavior.ValueString()) + require.False(t, state.UseClassicParameterFlow.ValueBool()) + require.True(t, state.AgentsAllowed.ValueBool()) }) - require.Equal(t, "public", state.MaxPortShareLevel.ValueString()) - require.Equal(t, "passthru", state.CORSBehavior.ValueString()) - require.False(t, state.UseClassicParameterFlow.ValueBool()) - require.False(t, state.AgentsAllowed.ValueBool()) + t.Run("empty CORS behavior", func(t *testing.T) { + t.Parallel() + + state := TemplateResourceModel{CORSBehavior: types.StringValue("simple")} + state.reconcileVersionedMetadata(&codersdk.Template{}) + + require.True(t, state.CORSBehavior.IsNull()) + }) +} + +func TestTemplateResourceUseClassicParameterFlowRequests(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + value types.Bool + want *bool + }{ + {name: "null", value: types.BoolNull()}, + {name: "unknown", value: types.BoolUnknown()}, + {name: "false", value: types.BoolValue(false), want: ptr.Ref(false)}, + {name: "true", value: types.BoolValue(true), want: ptr.Ref(true)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + model := TemplateResourceModel{ + AutostopRequirement: types.ObjectValueMust(autostopRequirementTypeAttr, map[string]attr.Value{ + "days_of_week": types.SetValueMust(types.StringType, []attr.Value{}), + "weeks": types.Int64Value(1), + }), + AutostartPermittedDaysOfWeek: types.SetValueMust(types.StringType, []attr.Value{}), + UseClassicParameterFlow: tc.value, + ACL: types.ObjectNull(aclTypeAttr), + } + + var updateDiags diag.Diagnostics + updateReq := model.toUpdateRequest(t.Context(), &updateDiags) + require.False(t, updateDiags.HasError(), updateDiags.Errors()) + require.Equal(t, tc.want, updateReq.UseClassicParameterFlow) + + createResp := frameworkresource.CreateResponse{} + createReq := model.toCreateRequest(t.Context(), &createResp, uuid.New()) + require.False(t, createResp.Diagnostics.HasError(), createResp.Diagnostics.Errors()) + require.Equal(t, tc.want, createReq.UseClassicParameterFlow) + }) + } } func TestTemplateResourceACLRoleSchemaValidation(t *testing.T) { @@ -297,7 +352,7 @@ func TestAccTemplateResource(t *testing.T) { testAccCheckNumTemplateVersions(ctx, client, 5), ), }, - // Change the active version and omit agents_allowed. + // Change the active version. { Config: cfg4.String(t), Check: resource.ComposeAggregateTestCheckFunc( @@ -783,8 +838,8 @@ func TestAccTemplateResourceAgentsAllowed(t *testing.T) { client := integration.StartCoder(ctx, t, "template_agents_allowed_acc") buildInfo, err := client.BuildInfo(ctx) require.NoError(t, err, "fetch buildinfo") - if semver.Compare(buildInfo.CanonicalVersion(), "v2.37.0") < 0 { - t.Skipf("test requires Coder v2.37.0 or later, deployment is %s", buildInfo.CanonicalVersion()) + if semver.Compare(buildInfo.CanonicalVersion(), "v"+templateAgentsAllowedMinVersion) < 0 { + t.Skipf("test requires Coder v%s or later, deployment is %s", templateAgentsAllowedMinVersion, buildInfo.CanonicalVersion()) } directory := t.TempDir() @@ -807,8 +862,8 @@ func TestAccTemplateResourceAgentsAllowed(t *testing.T) { cfgFalse.AgentsAllowed = ptr.Ref(false) cfgTrue := cfgFalse cfgTrue.AgentsAllowed = ptr.Ref(true) - cfgUnmanaged := cfgTrue - cfgUnmanaged.AgentsAllowed = nil + cfgOmittedAgain := cfgTrue + cfgOmittedAgain.AgentsAllowed = nil resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -828,9 +883,9 @@ func TestAccTemplateResourceAgentsAllowed(t *testing.T) { Check: resource.TestCheckResourceAttr("coderd_template.test", "agents_allowed", "true"), }, { - Config: cfgUnmanaged.String(t), - PlanOnly: true, - ExpectNonEmptyPlan: false, + // Omitting the attribute again preserves the prior state value. + Config: cfgOmittedAgain.String(t), + PlanOnly: true, }, }, }) From 8d300276b7c46c92db8e4729f28ed6d7fbe73300 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 18 Aug 2026 13:13:14 +0000 Subject: [PATCH 4/6] test(coderd_template): cover agents_allowed requests --- internal/provider/template_resource_test.go | 5 ++++- internal/provider/util.go | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go index 9ab68a3f..e2a35c00 100644 --- a/internal/provider/template_resource_test.go +++ b/internal/provider/template_resource_test.go @@ -73,7 +73,7 @@ func TestTemplateResourceReconcileVersionedMetadata(t *testing.T) { }) } -func TestTemplateResourceUseClassicParameterFlowRequests(t *testing.T) { +func TestTemplateResourceBoolRequests(t *testing.T) { t.Parallel() cases := []struct { @@ -97,6 +97,7 @@ func TestTemplateResourceUseClassicParameterFlowRequests(t *testing.T) { }), AutostartPermittedDaysOfWeek: types.SetValueMust(types.StringType, []attr.Value{}), UseClassicParameterFlow: tc.value, + AgentsAllowed: tc.value, ACL: types.ObjectNull(aclTypeAttr), } @@ -104,11 +105,13 @@ func TestTemplateResourceUseClassicParameterFlowRequests(t *testing.T) { updateReq := model.toUpdateRequest(t.Context(), &updateDiags) require.False(t, updateDiags.HasError(), updateDiags.Errors()) require.Equal(t, tc.want, updateReq.UseClassicParameterFlow) + require.Equal(t, tc.want, updateReq.AgentsAllowed) createResp := frameworkresource.CreateResponse{} createReq := model.toCreateRequest(t.Context(), &createResp, uuid.New()) require.False(t, createResp.Diagnostics.HasError(), createResp.Diagnostics.Errors()) require.Equal(t, tc.want, createReq.UseClassicParameterFlow) + require.Equal(t, tc.want, createReq.AgentsAllowed) }) } } diff --git a/internal/provider/util.go b/internal/provider/util.go index 02915cba..ab846f81 100644 --- a/internal/provider/util.go +++ b/internal/provider/util.go @@ -145,7 +145,7 @@ func stringPtrOrNil(v types.String) *string { } // boolPtrOrNil returns nil for null or unknown booleans. -// ValueBoolPointer returns false for unknown, which can accidentally send a value. +// ValueBoolPointer returns a pointer to false for unknown, which sends a deferred value in the request. func boolPtrOrNil(v types.Bool) *bool { if v.IsNull() || v.IsUnknown() { return nil From a9272de53b1ab2588203968e3ec79060acbaee85 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 19 Aug 2026 07:55:17 +0000 Subject: [PATCH 5/6] test(coderd_template): distinguish boolean request fields --- internal/provider/template_resource_test.go | 54 +++++++++++++++------ internal/provider/util.go | 2 +- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go index e2a35c00..f9451e23 100644 --- a/internal/provider/template_resource_test.go +++ b/internal/provider/template_resource_test.go @@ -41,7 +41,7 @@ func mustVariablesToSet(vars []Variable) types.Set { func TestTemplateResourceReconcileVersionedMetadata(t *testing.T) { t.Parallel() - t.Run("values", func(t *testing.T) { + t.Run("overwrites all versioned fields", func(t *testing.T) { t.Parallel() state := TemplateResourceModel{ @@ -77,14 +77,36 @@ func TestTemplateResourceBoolRequests(t *testing.T) { t.Parallel() cases := []struct { - name string - value types.Bool - want *bool + name string + useClassicParameterFlow types.Bool + agentsAllowed types.Bool + wantClassic *bool + wantAgents *bool }{ - {name: "null", value: types.BoolNull()}, - {name: "unknown", value: types.BoolUnknown()}, - {name: "false", value: types.BoolValue(false), want: ptr.Ref(false)}, - {name: "true", value: types.BoolValue(true), want: ptr.Ref(true)}, + { + name: "unknown and true", + useClassicParameterFlow: types.BoolUnknown(), + agentsAllowed: types.BoolValue(true), + wantAgents: ptr.Ref(true), + }, + { + name: "true and unknown", + useClassicParameterFlow: types.BoolValue(true), + agentsAllowed: types.BoolUnknown(), + wantClassic: ptr.Ref(true), + }, + { + name: "null and false", + useClassicParameterFlow: types.BoolNull(), + agentsAllowed: types.BoolValue(false), + wantAgents: ptr.Ref(false), + }, + { + name: "false and null", + useClassicParameterFlow: types.BoolValue(false), + agentsAllowed: types.BoolNull(), + wantClassic: ptr.Ref(false), + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -96,22 +118,22 @@ func TestTemplateResourceBoolRequests(t *testing.T) { "weeks": types.Int64Value(1), }), AutostartPermittedDaysOfWeek: types.SetValueMust(types.StringType, []attr.Value{}), - UseClassicParameterFlow: tc.value, - AgentsAllowed: tc.value, + UseClassicParameterFlow: tc.useClassicParameterFlow, + AgentsAllowed: tc.agentsAllowed, ACL: types.ObjectNull(aclTypeAttr), } var updateDiags diag.Diagnostics updateReq := model.toUpdateRequest(t.Context(), &updateDiags) require.False(t, updateDiags.HasError(), updateDiags.Errors()) - require.Equal(t, tc.want, updateReq.UseClassicParameterFlow) - require.Equal(t, tc.want, updateReq.AgentsAllowed) + require.Equal(t, tc.wantClassic, updateReq.UseClassicParameterFlow) + require.Equal(t, tc.wantAgents, updateReq.AgentsAllowed) createResp := frameworkresource.CreateResponse{} createReq := model.toCreateRequest(t.Context(), &createResp, uuid.New()) require.False(t, createResp.Diagnostics.HasError(), createResp.Diagnostics.Errors()) - require.Equal(t, tc.want, createReq.UseClassicParameterFlow) - require.Equal(t, tc.want, createReq.AgentsAllowed) + require.Equal(t, tc.wantClassic, createReq.UseClassicParameterFlow) + require.Equal(t, tc.wantAgents, createReq.AgentsAllowed) }) } } @@ -341,7 +363,7 @@ func TestAccTemplateResource(t *testing.T) { testAccCheckNumTemplateVersions(ctx, client, 4), ), }, - // Append version. Creates a fifth version. + // Append version. Creates a fifth version { Config: cfg3.String(t), Check: resource.ComposeAggregateTestCheckFunc( @@ -355,7 +377,7 @@ func TestAccTemplateResource(t *testing.T) { testAccCheckNumTemplateVersions(ctx, client, 5), ), }, - // Change the active version. + // Change active version { Config: cfg4.String(t), Check: resource.ComposeAggregateTestCheckFunc( diff --git a/internal/provider/util.go b/internal/provider/util.go index ab846f81..0d0748a2 100644 --- a/internal/provider/util.go +++ b/internal/provider/util.go @@ -145,7 +145,7 @@ func stringPtrOrNil(v types.String) *string { } // boolPtrOrNil returns nil for null or unknown booleans. -// ValueBoolPointer returns a pointer to false for unknown, which sends a deferred value in the request. +// ValueBoolPointer returns &false for unknown, which can accidentally send false. func boolPtrOrNil(v types.Bool) *bool { if v.IsNull() || v.IsUnknown() { return nil From ec39e20677fb794865dc28fc70157b7a8bc915f9 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 19 Aug 2026 09:25:02 +0000 Subject: [PATCH 6/6] refactor(coderd_template): centralize metadata reconciliation --- internal/provider/template_resource.go | 16 ++++++++++++---- internal/provider/template_resource_test.go | 8 ++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index 777c65b2..adbae5d7 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -702,6 +702,14 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques data.UseClassicParameterFlow = types.BoolValue(ucpfResp.UseClassicParameterFlow) } + // Fetch the authoritative values after the compatibility updates. + authoritativeTemplate, err := client.Template(ctx, data.ID.ValueUUID()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template: %s", err)) + return + } + data.reconcileVersionedMetadata(&authoritativeTemplate) + resp.Diagnostics.Append(data.Versions.setPrivateState(ctx, resp.Private)...) if resp.Diagnostics.HasError() { return @@ -740,9 +748,7 @@ func (r *TemplateResource) Read(ctx context.Context, req resource.ReadRequest, r resp.Diagnostics.Append(diag...) return } - data.MaxPortShareLevel = types.StringValue(string(template.MaxPortShareLevel)) - data.CORSBehavior = stringValueOrNull(string(template.CORSBehavior)) - data.UseClassicParameterFlow = types.BoolValue(template.UseClassicParameterFlow) + data.reconcileVersionedMetadata(&template) if !data.ACL.IsNull() { tflog.Info(ctx, "reading template ACL") @@ -1427,6 +1433,9 @@ func convertResponseToACL(acl codersdk.TemplateACL) ACL { } } +// reconcileVersionedMetadata overwrites metadata fields that older Coder +// servers can omit from mutation responses. Call it with an authoritative +// template response before writing state. func (r *TemplateResourceModel) reconcileVersionedMetadata(template *codersdk.Template) { r.MaxPortShareLevel = types.StringValue(string(template.MaxPortShareLevel)) r.CORSBehavior = stringValueOrNull(string(template.CORSBehavior)) @@ -1462,7 +1471,6 @@ func (r *TemplateResourceModel) readResponse(ctx context.Context, template *code r.TimeTilDormantMillis = types.Int64Value(template.TimeTilDormantMillis) r.TimeTilDormantAutoDeleteMillis = types.Int64Value(template.TimeTilDormantAutoDeleteMillis) r.RequireActiveVersion = types.BoolValue(template.RequireActiveVersion) - r.AgentsAllowed = types.BoolValue(template.AgentsAllowed) r.DeprecationMessage = types.StringValue(template.DeprecationMessage) // TODO(ethanndickson): MaxPortShareLevel deliberately omitted, as it can't // be set during a create request, and we call this during `Create`. diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go index f9451e23..63efe40f 100644 --- a/internal/provider/template_resource_test.go +++ b/internal/provider/template_resource_test.go @@ -84,25 +84,25 @@ func TestTemplateResourceBoolRequests(t *testing.T) { wantAgents *bool }{ { - name: "unknown and true", + name: "classic unknown, agents true", useClassicParameterFlow: types.BoolUnknown(), agentsAllowed: types.BoolValue(true), wantAgents: ptr.Ref(true), }, { - name: "true and unknown", + name: "classic true, agents unknown", useClassicParameterFlow: types.BoolValue(true), agentsAllowed: types.BoolUnknown(), wantClassic: ptr.Ref(true), }, { - name: "null and false", + name: "classic null, agents false", useClassicParameterFlow: types.BoolNull(), agentsAllowed: types.BoolValue(false), wantAgents: ptr.Ref(false), }, { - name: "false and null", + name: "classic false, agents null", useClassicParameterFlow: types.BoolValue(false), agentsAllowed: types.BoolNull(), wantClassic: ptr.Ref(false),