Skip to content

Commit dcf7db0

Browse files
committed
feat: add template max port sharing level attribute
1 parent 5a8d83a commit dcf7db0

File tree

3 files changed

+78
-8
lines changed

3 files changed

+78
-8
lines changed

Diff for: docs/resources/template.md

+1
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ resource "coderd_template" "ubuntu-main" {
8181
- `display_name` (String) The display name of the template. Defaults to the template name.
8282
- `failure_ttl_ms` (Number) (Enterprise) The max lifetime before Coder stops all resources for failed workspaces created from this template, in milliseconds.
8383
- `icon` (String) Relative path or external URL that specifes an icon to be displayed in the dashboard.
84+
- `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.
8485
- `organization_id` (String) The ID of the organization. Defaults to the provider's default organization
8586
- `require_active_version` (Boolean) (Enterprise) Whether workspaces must be created from the active version of this template. Defaults to false.
8687
- `time_til_dormant_autodelete_ms` (Number) (Enterprise) The max lifetime before Coder permanently deletes dormant workspaces created from this template.

Diff for: internal/provider/template_resource.go

+61-8
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ type TemplateResourceModel struct {
6969
TimeTilDormantAutoDeleteMillis types.Int64 `tfsdk:"time_til_dormant_autodelete_ms"`
7070
RequireActiveVersion types.Bool `tfsdk:"require_active_version"`
7171
DeprecationMessage types.String `tfsdk:"deprecation_message"`
72+
MaxPortShareLevel types.String `tfsdk:"max_port_share_level"`
7273

7374
// If null, we are not managing ACL via Terraform (such as for AGPL).
7475
ACL types.Object `tfsdk:"acl"`
@@ -92,7 +93,9 @@ func (m *TemplateResourceModel) EqualTemplateMetadata(other *TemplateResourceMod
9293
m.FailureTTLMillis.Equal(other.FailureTTLMillis) &&
9394
m.TimeTilDormantMillis.Equal(other.TimeTilDormantMillis) &&
9495
m.TimeTilDormantAutoDeleteMillis.Equal(other.TimeTilDormantAutoDeleteMillis) &&
95-
m.RequireActiveVersion.Equal(other.RequireActiveVersion)
96+
m.RequireActiveVersion.Equal(other.RequireActiveVersion) &&
97+
m.DeprecationMessage.Equal(other.DeprecationMessage) &&
98+
m.MaxPortShareLevel.Equal(other.MaxPortShareLevel)
9699
}
97100

98101
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
110113
len(m.AutostartPermittedDaysOfWeek.Elements()) != 7
111114
requiresActiveVersion := m.RequireActiveVersion.ValueBool()
112115
requiresACL := !m.ACL.IsNull()
113-
if requiresScheduling || requiresActiveVersion || requiresACL {
116+
requiresSharedPortsControl := m.MaxPortShareLevel.ValueString() != "" && m.MaxPortShareLevel.ValueString() != string(codersdk.WorkspaceAgentPortShareLevelPublic)
117+
if requiresScheduling || requiresActiveVersion || requiresACL || requiresSharedPortsControl {
114118
if requiresScheduling && !features[codersdk.FeatureAdvancedTemplateScheduling].Enabled {
115119
diags.AddError(
116120
"Feature not enabled",
@@ -132,6 +136,13 @@ func (m *TemplateResourceModel) CheckEntitlements(ctx context.Context, features
132136
)
133137
return
134138
}
139+
if requiresSharedPortsControl && !features[codersdk.FeatureControlSharedPorts].Enabled {
140+
diags.AddError(
141+
"Feature not enabled",
142+
"Your license is not entitled to use port sharing control, so you cannot set max_port_share_level.",
143+
)
144+
return
145+
}
135146
}
136147
return
137148
}
@@ -369,6 +380,14 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques
369380
Computed: true,
370381
Default: booldefault.StaticBool(false),
371382
},
383+
"max_port_share_level": schema.StringAttribute{
384+
MarkdownDescription: "(Enterprise) The maximum port share level for workspaces created from this template. Defaults to `owner` on an Enterprise deployment, or `public` otherwise.",
385+
Optional: true,
386+
Computed: true,
387+
Validators: []validator.String{
388+
stringvalidator.OneOfCaseInsensitive(string(codersdk.WorkspaceAgentPortShareLevelAuthenticated), string(codersdk.WorkspaceAgentPortShareLevelOwner), string(codersdk.WorkspaceAgentPortShareLevelPublic)),
389+
},
390+
},
372391
"deprecation_message": schema.StringAttribute{
373392
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.",
374393
Optional: true,
@@ -553,6 +572,23 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques
553572
data.ID = UUIDValue(templateResp.ID)
554573
data.DisplayName = types.StringValue(templateResp.DisplayName)
555574

575+
// TODO: Remove this update call once this provider requires a Coder
576+
// deployment running `v2.15.0` or later.
577+
if data.MaxPortShareLevel.IsUnknown() {
578+
data.MaxPortShareLevel = types.StringValue(string(templateResp.MaxPortShareLevel))
579+
} else {
580+
mpslReq := data.toUpdateRequest(ctx, &resp.Diagnostics)
581+
if resp.Diagnostics.HasError() {
582+
return
583+
}
584+
mpslResp, err := client.UpdateTemplateMeta(ctx, data.ID.ValueUUID(), *mpslReq)
585+
if err != nil {
586+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to set max port share level via update: %s", err))
587+
return
588+
}
589+
data.MaxPortShareLevel = types.StringValue(string(mpslResp.MaxPortShareLevel))
590+
}
591+
556592
resp.Diagnostics.Append(data.Versions.setPrivateState(ctx, resp.Private)...)
557593
if resp.Diagnostics.HasError() {
558594
return
@@ -591,6 +627,7 @@ func (r *TemplateResource) Read(ctx context.Context, req resource.ReadRequest, r
591627
resp.Diagnostics.Append(diag...)
592628
return
593629
}
630+
data.MaxPortShareLevel = types.StringValue(string(template.MaxPortShareLevel))
594631

595632
if !data.ACL.IsNull() {
596633
tflog.Info(ctx, "reading template ACL")
@@ -665,11 +702,16 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques
665702

666703
client := r.data.Client
667704

705+
// TODO(ethanndickson): Remove this once the provider requires a Coder
706+
// deployment running `v2.15.0` or later.
707+
if newState.MaxPortShareLevel.IsUnknown() {
708+
newState.MaxPortShareLevel = curState.MaxPortShareLevel
709+
}
668710
templateMetadataChanged := !newState.EqualTemplateMetadata(&curState)
669711
// This is required, as the API will reject no-diff updates.
670712
if templateMetadataChanged {
671713
tflog.Info(ctx, "change in template metadata detected, updating.")
672-
updateReq := newState.toUpdateRequest(ctx, resp)
714+
updateReq := newState.toUpdateRequest(ctx, &resp.Diagnostics)
673715
if resp.Diagnostics.HasError() {
674716
return
675717
}
@@ -758,6 +800,14 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques
758800
}
759801
}
760802
}
803+
// TODO(ethanndickson): Remove this once the provider requires a Coder
804+
// deployment running `v2.15.0` or later.
805+
templateResp, err := client.Template(ctx, templateID)
806+
if err != nil {
807+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template: %s", err))
808+
return
809+
}
810+
newState.MaxPortShareLevel = types.StringValue(string(templateResp.MaxPortShareLevel))
761811

762812
resp.Diagnostics.Append(newState.Versions.setPrivateState(ctx, resp.Private)...)
763813
if resp.Diagnostics.HasError() {
@@ -1147,25 +1197,27 @@ func (r *TemplateResourceModel) readResponse(ctx context.Context, template *code
11471197
r.TimeTilDormantAutoDeleteMillis = types.Int64Value(template.TimeTilDormantAutoDeleteMillis)
11481198
r.RequireActiveVersion = types.BoolValue(template.RequireActiveVersion)
11491199
r.DeprecationMessage = types.StringValue(template.DeprecationMessage)
1200+
// TODO(ethanndickson): MaxPortShareLevel deliberately omitted, as it can't
1201+
// be set during a create request, and we call this during `Create`.
11501202
return nil
11511203
}
11521204

1153-
func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, resp *resource.UpdateResponse) *codersdk.UpdateTemplateMeta {
1205+
func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, diag *diag.Diagnostics) *codersdk.UpdateTemplateMeta {
11541206
var days []string
1155-
resp.Diagnostics.Append(
1207+
diag.Append(
11561208
r.AutostartPermittedDaysOfWeek.ElementsAs(ctx, &days, false)...,
11571209
)
1158-
if resp.Diagnostics.HasError() {
1210+
if diag.HasError() {
11591211
return nil
11601212
}
11611213
autoStart := &codersdk.TemplateAutostartRequirement{
11621214
DaysOfWeek: days,
11631215
}
11641216
var reqs AutostopRequirement
1165-
resp.Diagnostics.Append(
1217+
diag.Append(
11661218
r.AutostopRequirement.As(ctx, &reqs, basetypes.ObjectAsOptions{})...,
11671219
)
1168-
if resp.Diagnostics.HasError() {
1220+
if diag.HasError() {
11691221
return nil
11701222
}
11711223
autoStop := &codersdk.TemplateAutostopRequirement{
@@ -1189,6 +1241,7 @@ func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, resp *resou
11891241
TimeTilDormantAutoDeleteMillis: r.TimeTilDormantAutoDeleteMillis.ValueInt64(),
11901242
RequireActiveVersion: r.RequireActiveVersion.ValueBool(),
11911243
DeprecationMessage: r.DeprecationMessage.ValueStringPointer(),
1244+
MaxPortShareLevel: PtrTo(codersdk.WorkspaceAgentPortShareLevel(r.MaxPortShareLevel.ValueString())),
11921245
// If we're managing ACL, we want to delete the everyone group
11931246
DisableEveryoneGroupAccess: !r.ACL.IsNull(),
11941247
}

Diff for: internal/provider/template_resource_test.go

+16
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ func TestAccTemplateResource(t *testing.T) {
113113
resource.TestCheckResourceAttr("coderd_template.test", "time_til_dormant_ms", "0"),
114114
resource.TestCheckResourceAttr("coderd_template.test", "time_til_dormant_autodelete_ms", "0"),
115115
resource.TestCheckResourceAttr("coderd_template.test", "require_active_version", "false"),
116+
resource.TestCheckResourceAttr("coderd_template.test", "max_port_share_level", "public"),
116117
resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{
117118
"name": regexp.MustCompile(".+"),
118119
"id": regexp.MustCompile(".+"),
@@ -465,9 +466,11 @@ func TestAccTemplateResourceEnterprise(t *testing.T) {
465466

466467
cfg2 := cfg1
467468
cfg2.ACL.GroupACL = slices.Clone(cfg2.ACL.GroupACL[1:])
469+
cfg2.MaxPortShareLevel = PtrTo("owner")
468470

469471
cfg3 := cfg2
470472
cfg3.ACL.null = true
473+
cfg3.MaxPortShareLevel = PtrTo("public")
471474

472475
cfg4 := cfg3
473476
cfg4.AllowUserAutostart = PtrTo(false)
@@ -484,6 +487,7 @@ func TestAccTemplateResourceEnterprise(t *testing.T) {
484487
{
485488
Config: cfg1.String(t),
486489
Check: resource.ComposeAggregateTestCheckFunc(
490+
resource.TestCheckResourceAttr("coderd_template.test", "max_port_share_level", "owner"),
487491
resource.TestCheckResourceAttr("coderd_template.test", "acl.groups.#", "2"),
488492
resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "acl.groups.*", map[string]*regexp.Regexp{
489493
"id": regexp.MustCompile(firstUser.OrganizationIDs[0].String()),
@@ -503,6 +507,7 @@ func TestAccTemplateResourceEnterprise(t *testing.T) {
503507
{
504508
Config: cfg2.String(t),
505509
Check: resource.ComposeAggregateTestCheckFunc(
510+
resource.TestCheckResourceAttr("coderd_template.test", "max_port_share_level", "owner"),
506511
resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "acl.users.*", map[string]*regexp.Regexp{
507512
"id": regexp.MustCompile(firstUser.ID.String()),
508513
"role": regexp.MustCompile("^admin$"),
@@ -512,6 +517,7 @@ func TestAccTemplateResourceEnterprise(t *testing.T) {
512517
{
513518
Config: cfg3.String(t),
514519
Check: resource.ComposeAggregateTestCheckFunc(
520+
resource.TestCheckResourceAttr("coderd_template.test", "max_port_share_level", "public"),
515521
resource.TestCheckNoResourceAttr("coderd_template.test", "acl"),
516522
func(s *terraform.State) error {
517523
templates, err := client.Templates(ctx, codersdk.TemplateFilter{})
@@ -603,6 +609,10 @@ func TestAccTemplateResourceAGPL(t *testing.T) {
603609
},
604610
}
605611

612+
cfg7 := cfg6
613+
cfg7.ACL.null = true
614+
cfg7.MaxPortShareLevel = PtrTo("owner")
615+
606616
for _, cfg := range []testAccTemplateResourceConfig{cfg1, cfg2, cfg3, cfg4} {
607617
resource.Test(t, resource.TestCase{
608618
PreCheck: func() { testAccPreCheck(t) },
@@ -630,6 +640,10 @@ func TestAccTemplateResourceAGPL(t *testing.T) {
630640
Config: cfg6.String(t),
631641
ExpectError: regexp.MustCompile("Your license is not entitled to use template access control"),
632642
},
643+
{
644+
Config: cfg7.String(t),
645+
ExpectError: regexp.MustCompile("Your license is not entitled to use port sharing control"),
646+
},
633647
},
634648
})
635649
}
@@ -655,6 +669,7 @@ type testAccTemplateResourceConfig struct {
655669
TimeTilDormantAutodelete *int64
656670
RequireActiveVersion *bool
657671
DeprecationMessage *string
672+
MaxPortShareLevel *string
658673

659674
Versions []testAccTemplateVersionConfig
660675
ACL testAccTemplateACLConfig
@@ -761,6 +776,7 @@ resource "coderd_template" "test" {
761776
time_til_dormant_autodelete_ms = {{orNull .TimeTilDormantAutodelete}}
762777
require_active_version = {{orNull .RequireActiveVersion}}
763778
deprecation_message = {{orNull .DeprecationMessage}}
779+
max_port_share_level = {{orNull .MaxPortShareLevel}}
764780
765781
acl = ` + c.ACL.String(t) + `
766782

0 commit comments

Comments
 (0)