diff --git a/docs/resources/template.md b/docs/resources/template.md index d070206..cacd1b7 100644 --- a/docs/resources/template.md +++ b/docs/resources/template.md @@ -81,6 +81,7 @@ resource "coderd_template" "ubuntu-main" { - `display_name` (String) The display name of the template. Defaults to the template name. - `failure_ttl_ms` (Number) (Enterprise) The max lifetime before Coder stops all resources for failed workspaces created from this template, in milliseconds. - `icon` (String) Relative path or external URL that specifes an icon to be displayed in the dashboard. +- `max_port_share_level` (String) (Enterprise) The maximum port share level for workspaces created from this template. Defaults to `owner` on an Enterprise deployment, or `public` otherwise. - `organization_id` (String) The ID of the organization. Defaults to the provider's default organization - `require_active_version` (Boolean) (Enterprise) Whether workspaces must be created from the active version of this template. Defaults to false. - `time_til_dormant_autodelete_ms` (Number) (Enterprise) The max lifetime before Coder permanently deletes dormant workspaces created from this template. diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index cd18ba0..a23f408 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -69,6 +69,7 @@ type TemplateResourceModel struct { TimeTilDormantAutoDeleteMillis types.Int64 `tfsdk:"time_til_dormant_autodelete_ms"` RequireActiveVersion types.Bool `tfsdk:"require_active_version"` DeprecationMessage types.String `tfsdk:"deprecation_message"` + MaxPortShareLevel types.String `tfsdk:"max_port_share_level"` // If null, we are not managing ACL via Terraform (such as for AGPL). ACL types.Object `tfsdk:"acl"` @@ -92,7 +93,9 @@ func (m *TemplateResourceModel) EqualTemplateMetadata(other *TemplateResourceMod m.FailureTTLMillis.Equal(other.FailureTTLMillis) && m.TimeTilDormantMillis.Equal(other.TimeTilDormantMillis) && m.TimeTilDormantAutoDeleteMillis.Equal(other.TimeTilDormantAutoDeleteMillis) && - m.RequireActiveVersion.Equal(other.RequireActiveVersion) + m.RequireActiveVersion.Equal(other.RequireActiveVersion) && + m.DeprecationMessage.Equal(other.DeprecationMessage) && + m.MaxPortShareLevel.Equal(other.MaxPortShareLevel) } func (m *TemplateResourceModel) CheckEntitlements(ctx context.Context, features map[codersdk.FeatureName]codersdk.Feature) (diags diag.Diagnostics) { @@ -110,7 +113,8 @@ func (m *TemplateResourceModel) CheckEntitlements(ctx context.Context, features len(m.AutostartPermittedDaysOfWeek.Elements()) != 7 requiresActiveVersion := m.RequireActiveVersion.ValueBool() requiresACL := !m.ACL.IsNull() - if requiresScheduling || requiresActiveVersion || requiresACL { + requiresSharedPortsControl := m.MaxPortShareLevel.ValueString() != "" && m.MaxPortShareLevel.ValueString() != string(codersdk.WorkspaceAgentPortShareLevelPublic) + if requiresScheduling || requiresActiveVersion || requiresACL || requiresSharedPortsControl { if requiresScheduling && !features[codersdk.FeatureAdvancedTemplateScheduling].Enabled { diags.AddError( "Feature not enabled", @@ -132,6 +136,13 @@ func (m *TemplateResourceModel) CheckEntitlements(ctx context.Context, features ) return } + if requiresSharedPortsControl && !features[codersdk.FeatureControlSharedPorts].Enabled { + diags.AddError( + "Feature not enabled", + "Your license is not entitled to use port sharing control, so you cannot set max_port_share_level.", + ) + return + } } return } @@ -369,6 +380,14 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques Computed: true, Default: booldefault.StaticBool(false), }, + "max_port_share_level": schema.StringAttribute{ + MarkdownDescription: "(Enterprise) The maximum port share level for workspaces created from this template. Defaults to `owner` on an Enterprise deployment, or `public` otherwise.", + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.OneOfCaseInsensitive(string(codersdk.WorkspaceAgentPortShareLevelAuthenticated), string(codersdk.WorkspaceAgentPortShareLevelOwner), string(codersdk.WorkspaceAgentPortShareLevelPublic)), + }, + }, "deprecation_message": schema.StringAttribute{ MarkdownDescription: "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.", Optional: true, @@ -553,6 +572,23 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques data.ID = UUIDValue(templateResp.ID) data.DisplayName = types.StringValue(templateResp.DisplayName) + // TODO: Remove this update call once this provider requires a Coder + // deployment running `v2.15.0` or later. + if data.MaxPortShareLevel.IsUnknown() { + data.MaxPortShareLevel = types.StringValue(string(templateResp.MaxPortShareLevel)) + } else { + mpslReq := data.toUpdateRequest(ctx, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + mpslResp, err := client.UpdateTemplateMeta(ctx, data.ID.ValueUUID(), *mpslReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to set max port share level via update: %s", err)) + return + } + data.MaxPortShareLevel = types.StringValue(string(mpslResp.MaxPortShareLevel)) + } + resp.Diagnostics.Append(data.Versions.setPrivateState(ctx, resp.Private)...) if resp.Diagnostics.HasError() { return @@ -591,6 +627,7 @@ func (r *TemplateResource) Read(ctx context.Context, req resource.ReadRequest, r resp.Diagnostics.Append(diag...) return } + data.MaxPortShareLevel = types.StringValue(string(template.MaxPortShareLevel)) if !data.ACL.IsNull() { tflog.Info(ctx, "reading template ACL") @@ -665,11 +702,16 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques client := r.data.Client + // TODO(ethanndickson): Remove this once the provider requires a Coder + // deployment running `v2.15.0` or later. + if newState.MaxPortShareLevel.IsUnknown() { + newState.MaxPortShareLevel = curState.MaxPortShareLevel + } templateMetadataChanged := !newState.EqualTemplateMetadata(&curState) // This is required, as the API will reject no-diff updates. if templateMetadataChanged { tflog.Info(ctx, "change in template metadata detected, updating.") - updateReq := newState.toUpdateRequest(ctx, resp) + updateReq := newState.toUpdateRequest(ctx, &resp.Diagnostics) if resp.Diagnostics.HasError() { return } @@ -758,6 +800,14 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques } } } + // TODO(ethanndickson): Remove this once the provider requires a Coder + // deployment running `v2.15.0` or later. + templateResp, err := client.Template(ctx, templateID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template: %s", err)) + return + } + newState.MaxPortShareLevel = types.StringValue(string(templateResp.MaxPortShareLevel)) resp.Diagnostics.Append(newState.Versions.setPrivateState(ctx, resp.Private)...) if resp.Diagnostics.HasError() { @@ -1147,25 +1197,27 @@ func (r *TemplateResourceModel) readResponse(ctx context.Context, template *code r.TimeTilDormantAutoDeleteMillis = types.Int64Value(template.TimeTilDormantAutoDeleteMillis) r.RequireActiveVersion = types.BoolValue(template.RequireActiveVersion) 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`. return nil } -func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, resp *resource.UpdateResponse) *codersdk.UpdateTemplateMeta { +func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, diag *diag.Diagnostics) *codersdk.UpdateTemplateMeta { var days []string - resp.Diagnostics.Append( + diag.Append( r.AutostartPermittedDaysOfWeek.ElementsAs(ctx, &days, false)..., ) - if resp.Diagnostics.HasError() { + if diag.HasError() { return nil } autoStart := &codersdk.TemplateAutostartRequirement{ DaysOfWeek: days, } var reqs AutostopRequirement - resp.Diagnostics.Append( + diag.Append( r.AutostopRequirement.As(ctx, &reqs, basetypes.ObjectAsOptions{})..., ) - if resp.Diagnostics.HasError() { + if diag.HasError() { return nil } autoStop := &codersdk.TemplateAutostopRequirement{ @@ -1189,6 +1241,7 @@ func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, resp *resou TimeTilDormantAutoDeleteMillis: r.TimeTilDormantAutoDeleteMillis.ValueInt64(), RequireActiveVersion: r.RequireActiveVersion.ValueBool(), DeprecationMessage: r.DeprecationMessage.ValueStringPointer(), + MaxPortShareLevel: PtrTo(codersdk.WorkspaceAgentPortShareLevel(r.MaxPortShareLevel.ValueString())), // If we're managing ACL, we want to delete the everyone group DisableEveryoneGroupAccess: !r.ACL.IsNull(), } diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go index 9c54edd..82f19ac 100644 --- a/internal/provider/template_resource_test.go +++ b/internal/provider/template_resource_test.go @@ -113,6 +113,7 @@ func TestAccTemplateResource(t *testing.T) { resource.TestCheckResourceAttr("coderd_template.test", "time_til_dormant_ms", "0"), resource.TestCheckResourceAttr("coderd_template.test", "time_til_dormant_autodelete_ms", "0"), resource.TestCheckResourceAttr("coderd_template.test", "require_active_version", "false"), + resource.TestCheckResourceAttr("coderd_template.test", "max_port_share_level", "public"), resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ "name": regexp.MustCompile(".+"), "id": regexp.MustCompile(".+"), @@ -465,9 +466,11 @@ func TestAccTemplateResourceEnterprise(t *testing.T) { cfg2 := cfg1 cfg2.ACL.GroupACL = slices.Clone(cfg2.ACL.GroupACL[1:]) + cfg2.MaxPortShareLevel = PtrTo("owner") cfg3 := cfg2 cfg3.ACL.null = true + cfg3.MaxPortShareLevel = PtrTo("public") cfg4 := cfg3 cfg4.AllowUserAutostart = PtrTo(false) @@ -484,6 +487,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", "acl.groups.#", "2"), resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "acl.groups.*", map[string]*regexp.Regexp{ "id": regexp.MustCompile(firstUser.OrganizationIDs[0].String()), @@ -503,6 +507,7 @@ func TestAccTemplateResourceEnterprise(t *testing.T) { { Config: cfg2.String(t), Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_template.test", "max_port_share_level", "owner"), resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "acl.users.*", map[string]*regexp.Regexp{ "id": regexp.MustCompile(firstUser.ID.String()), "role": regexp.MustCompile("^admin$"), @@ -512,6 +517,7 @@ func TestAccTemplateResourceEnterprise(t *testing.T) { { Config: cfg3.String(t), Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_template.test", "max_port_share_level", "public"), resource.TestCheckNoResourceAttr("coderd_template.test", "acl"), func(s *terraform.State) error { templates, err := client.Templates(ctx, codersdk.TemplateFilter{}) @@ -603,6 +609,10 @@ func TestAccTemplateResourceAGPL(t *testing.T) { }, } + cfg7 := cfg6 + cfg7.ACL.null = true + cfg7.MaxPortShareLevel = PtrTo("owner") + for _, cfg := range []testAccTemplateResourceConfig{cfg1, cfg2, cfg3, cfg4} { resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -630,6 +640,10 @@ func TestAccTemplateResourceAGPL(t *testing.T) { Config: cfg6.String(t), ExpectError: regexp.MustCompile("Your license is not entitled to use template access control"), }, + { + Config: cfg7.String(t), + ExpectError: regexp.MustCompile("Your license is not entitled to use port sharing control"), + }, }, }) } @@ -655,6 +669,7 @@ type testAccTemplateResourceConfig struct { TimeTilDormantAutodelete *int64 RequireActiveVersion *bool DeprecationMessage *string + MaxPortShareLevel *string Versions []testAccTemplateVersionConfig ACL testAccTemplateACLConfig @@ -761,6 +776,7 @@ resource "coderd_template" "test" { time_til_dormant_autodelete_ms = {{orNull .TimeTilDormantAutodelete}} require_active_version = {{orNull .RequireActiveVersion}} deprecation_message = {{orNull .DeprecationMessage}} + max_port_share_level = {{orNull .MaxPortShareLevel}} acl = ` + c.ACL.String(t) + `