From dcf7db0765d2d16caca5b7102e950eb911a15ace Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Mon, 30 Sep 2024 04:39:47 +0000
Subject: [PATCH] feat: add template max port sharing level attribute

---
 docs/resources/template.md                  |  1 +
 internal/provider/template_resource.go      | 69 ++++++++++++++++++---
 internal/provider/template_resource_test.go | 16 +++++
 3 files changed, 78 insertions(+), 8 deletions(-)

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) + `