diff --git a/docs/data-sources/template.md b/docs/data-sources/template.md index d799075..f453728 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 7bd03cd..6a31295 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. 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 1736de2..2a2fa9b 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: 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{ 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 6fe29f8..09ce4c3 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(), "v"+templateAgentsAllowedMinVersion) >= 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 f0e88c3..adbae5d 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{} } @@ -79,6 +81,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 +109,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 +471,14 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques boolplanmodifier.UseStateForUnknown(), }, }, + "agents_allowed": schema.BoolAttribute{ + 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{ + 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, @@ -690,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 @@ -728,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") @@ -905,8 +923,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.reconcileVersionedMetadata(&templateResp) resp.Diagnostics.Append(newState.Versions.setPrivateState(ctx, resp.Private)...) if resp.Diagnostics.HasError() { @@ -1416,6 +1433,16 @@ 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)) + 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) @@ -1491,7 +1518,8 @@ 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()), } @@ -1536,7 +1564,8 @@ 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 fbab11e..63efe40 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" @@ -19,6 +21,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" @@ -35,6 +38,106 @@ func mustVariablesToSet(vars []Variable) types.Set { return s } +func TestTemplateResourceReconcileVersionedMetadata(t *testing.T) { + t.Parallel() + + t.Run("overwrites all versioned fields", 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()) + }) + + 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 TestTemplateResourceBoolRequests(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + useClassicParameterFlow types.Bool + agentsAllowed types.Bool + wantClassic *bool + wantAgents *bool + }{ + { + name: "classic unknown, agents true", + useClassicParameterFlow: types.BoolUnknown(), + agentsAllowed: types.BoolValue(true), + wantAgents: ptr.Ref(true), + }, + { + name: "classic true, agents unknown", + useClassicParameterFlow: types.BoolValue(true), + agentsAllowed: types.BoolUnknown(), + wantClassic: ptr.Ref(true), + }, + { + name: "classic null, agents false", + useClassicParameterFlow: types.BoolNull(), + agentsAllowed: types.BoolValue(false), + wantAgents: ptr.Ref(false), + }, + { + name: "classic false, agents null", + useClassicParameterFlow: types.BoolValue(false), + agentsAllowed: types.BoolNull(), + wantClassic: ptr.Ref(false), + }, + } + 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.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.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.wantClassic, createReq.UseClassicParameterFlow) + require.Equal(t, tc.wantAgents, createReq.AgentsAllowed) + }) + } +} + func TestTemplateResourceACLRoleSchemaValidation(t *testing.T) { t.Parallel() @@ -751,6 +854,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(), "v"+templateAgentsAllowedMinVersion) < 0 { + t.Skipf("test requires Coder v%s or later, deployment is %s", templateAgentsAllowedMinVersion, 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) + cfgOmittedAgain := cfgTrue + cfgOmittedAgain.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"), + }, + { + // Omitting the attribute again preserves the prior state value. + Config: cfgOmittedAgain.String(t), + PlanOnly: true, + }, + }, + }) +} + // 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 +1771,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 +1923,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 031c78f..0d0748a 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 false. +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 0000000..186017e --- /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) + }) +}