Skip to content

Commit 92e865b

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

File tree

3 files changed

+79
-8
lines changed

3 files changed

+79
-8
lines changed

docs/resources/template.md

Lines changed: 1 addition & 0 deletions
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.

internal/provider/template_resource.go

Lines changed: 62 additions & 8 deletions
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,24 @@ 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+
581+
mpslReq := data.toUpdateRequest(ctx, &resp.Diagnostics)
582+
if resp.Diagnostics.HasError() {
583+
return
584+
}
585+
mpslResp, err := client.UpdateTemplateMeta(ctx, data.ID.ValueUUID(), *mpslReq)
586+
if err != nil {
587+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to set max port share level via update: %s", err))
588+
return
589+
}
590+
data.MaxPortShareLevel = types.StringValue(string(mpslResp.MaxPortShareLevel))
591+
}
592+
556593
resp.Diagnostics.Append(data.Versions.setPrivateState(ctx, resp.Private)...)
557594
if resp.Diagnostics.HasError() {
558595
return
@@ -591,6 +628,7 @@ func (r *TemplateResource) Read(ctx context.Context, req resource.ReadRequest, r
591628
resp.Diagnostics.Append(diag...)
592629
return
593630
}
631+
data.MaxPortShareLevel = types.StringValue(string(template.MaxPortShareLevel))
594632

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

666704
client := r.data.Client
667705

706+
// TODO(ethanndickson): Remove this once the provider requires a Coder
707+
// deployment running `v2.15.0` or later.
708+
if newState.MaxPortShareLevel.IsUnknown() {
709+
newState.MaxPortShareLevel = curState.MaxPortShareLevel
710+
}
668711
templateMetadataChanged := !newState.EqualTemplateMetadata(&curState)
669712
// This is required, as the API will reject no-diff updates.
670713
if templateMetadataChanged {
671714
tflog.Info(ctx, "change in template metadata detected, updating.")
672-
updateReq := newState.toUpdateRequest(ctx, resp)
715+
updateReq := newState.toUpdateRequest(ctx, &resp.Diagnostics)
673716
if resp.Diagnostics.HasError() {
674717
return
675718
}
@@ -758,6 +801,14 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques
758801
}
759802
}
760803
}
804+
// TODO(ethanndickson): Remove this once the provider requires a Coder
805+
// deployment running `v2.15.0` or later.
806+
templateResp, err := client.Template(ctx, templateID)
807+
if err != nil {
808+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template: %s", err))
809+
return
810+
}
811+
newState.MaxPortShareLevel = types.StringValue(string(templateResp.MaxPortShareLevel))
761812

762813
resp.Diagnostics.Append(newState.Versions.setPrivateState(ctx, resp.Private)...)
763814
if resp.Diagnostics.HasError() {
@@ -1147,25 +1198,27 @@ func (r *TemplateResourceModel) readResponse(ctx context.Context, template *code
11471198
r.TimeTilDormantAutoDeleteMillis = types.Int64Value(template.TimeTilDormantAutoDeleteMillis)
11481199
r.RequireActiveVersion = types.BoolValue(template.RequireActiveVersion)
11491200
r.DeprecationMessage = types.StringValue(template.DeprecationMessage)
1201+
// TODO(ethanndickson): MaxPortShareLevel deliberately omitted, as it can't
1202+
// be set during a create request, and we call this during `Create`.
11501203
return nil
11511204
}
11521205

1153-
func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, resp *resource.UpdateResponse) *codersdk.UpdateTemplateMeta {
1206+
func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, diag *diag.Diagnostics) *codersdk.UpdateTemplateMeta {
11541207
var days []string
1155-
resp.Diagnostics.Append(
1208+
diag.Append(
11561209
r.AutostartPermittedDaysOfWeek.ElementsAs(ctx, &days, false)...,
11571210
)
1158-
if resp.Diagnostics.HasError() {
1211+
if diag.HasError() {
11591212
return nil
11601213
}
11611214
autoStart := &codersdk.TemplateAutostartRequirement{
11621215
DaysOfWeek: days,
11631216
}
11641217
var reqs AutostopRequirement
1165-
resp.Diagnostics.Append(
1218+
diag.Append(
11661219
r.AutostopRequirement.As(ctx, &reqs, basetypes.ObjectAsOptions{})...,
11671220
)
1168-
if resp.Diagnostics.HasError() {
1221+
if diag.HasError() {
11691222
return nil
11701223
}
11711224
autoStop := &codersdk.TemplateAutostopRequirement{
@@ -1189,6 +1242,7 @@ func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, resp *resou
11891242
TimeTilDormantAutoDeleteMillis: r.TimeTilDormantAutoDeleteMillis.ValueInt64(),
11901243
RequireActiveVersion: r.RequireActiveVersion.ValueBool(),
11911244
DeprecationMessage: r.DeprecationMessage.ValueStringPointer(),
1245+
MaxPortShareLevel: PtrTo(codersdk.WorkspaceAgentPortShareLevel(r.MaxPortShareLevel.ValueString())),
11921246
// If we're managing ACL, we want to delete the everyone group
11931247
DisableEveryoneGroupAccess: !r.ACL.IsNull(),
11941248
}

internal/provider/template_resource_test.go

Lines changed: 16 additions & 0 deletions
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)