Skip to content

Commit bc9fa48

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

File tree

3 files changed

+78
-8
lines changed

3 files changed

+78
-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: 15 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,6 +466,7 @@ func TestAccTemplateResourceEnterprise(t *testing.T) {
465466

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

469471
cfg3 := cfg2
470472
cfg3.ACL.null = true
@@ -484,6 +486,7 @@ func TestAccTemplateResourceEnterprise(t *testing.T) {
484486
{
485487
Config: cfg1.String(t),
486488
Check: resource.ComposeAggregateTestCheckFunc(
489+
resource.TestCheckResourceAttr("coderd_template.test", "max_port_share_level", "owner"),
487490
resource.TestCheckResourceAttr("coderd_template.test", "acl.groups.#", "2"),
488491
resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "acl.groups.*", map[string]*regexp.Regexp{
489492
"id": regexp.MustCompile(firstUser.OrganizationIDs[0].String()),
@@ -503,6 +506,7 @@ func TestAccTemplateResourceEnterprise(t *testing.T) {
503506
{
504507
Config: cfg2.String(t),
505508
Check: resource.ComposeAggregateTestCheckFunc(
509+
resource.TestCheckResourceAttr("coderd_template.test", "max_port_share_level", "public"),
506510
resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "acl.users.*", map[string]*regexp.Regexp{
507511
"id": regexp.MustCompile(firstUser.ID.String()),
508512
"role": regexp.MustCompile("^admin$"),
@@ -512,6 +516,7 @@ func TestAccTemplateResourceEnterprise(t *testing.T) {
512516
{
513517
Config: cfg3.String(t),
514518
Check: resource.ComposeAggregateTestCheckFunc(
519+
resource.TestCheckResourceAttr("coderd_template.test", "max_port_share_level", "public"),
515520
resource.TestCheckNoResourceAttr("coderd_template.test", "acl"),
516521
func(s *terraform.State) error {
517522
templates, err := client.Templates(ctx, codersdk.TemplateFilter{})
@@ -603,6 +608,10 @@ func TestAccTemplateResourceAGPL(t *testing.T) {
603608
},
604609
}
605610

611+
cfg7 := cfg6
612+
cfg7.ACL.null = true
613+
cfg7.MaxPortShareLevel = PtrTo("owner")
614+
606615
for _, cfg := range []testAccTemplateResourceConfig{cfg1, cfg2, cfg3, cfg4} {
607616
resource.Test(t, resource.TestCase{
608617
PreCheck: func() { testAccPreCheck(t) },
@@ -630,6 +639,10 @@ func TestAccTemplateResourceAGPL(t *testing.T) {
630639
Config: cfg6.String(t),
631640
ExpectError: regexp.MustCompile("Your license is not entitled to use template access control"),
632641
},
642+
{
643+
Config: cfg7.String(t),
644+
ExpectError: regexp.MustCompile("Your license is not entitled to use port sharing control"),
645+
},
633646
},
634647
})
635648
}
@@ -655,6 +668,7 @@ type testAccTemplateResourceConfig struct {
655668
TimeTilDormantAutodelete *int64
656669
RequireActiveVersion *bool
657670
DeprecationMessage *string
671+
MaxPortShareLevel *string
658672

659673
Versions []testAccTemplateVersionConfig
660674
ACL testAccTemplateACLConfig
@@ -761,6 +775,7 @@ resource "coderd_template" "test" {
761775
time_til_dormant_autodelete_ms = {{orNull .TimeTilDormantAutodelete}}
762776
require_active_version = {{orNull .RequireActiveVersion}}
763777
deprecation_message = {{orNull .DeprecationMessage}}
778+
max_port_share_level = {{orNull .MaxPortShareLevel}}
764779
765780
acl = ` + c.ACL.String(t) + `
766781

0 commit comments

Comments
 (0)