Skip to content

feat: add template max port sharing level attribute #110

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/resources/template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
69 changes: 61 additions & 8 deletions internal/provider/template_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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) {
Expand All @@ -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",
Expand All @@ -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
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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{
Expand All @@ -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(),
}
Expand Down
16 changes: 16 additions & 0 deletions internal/provider/template_resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(".+"),
Expand Down Expand Up @@ -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)
Expand All @@ -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()),
Expand All @@ -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$"),
Expand All @@ -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{})
Expand Down Expand Up @@ -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) },
Expand Down Expand Up @@ -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"),
},
},
})
}
Expand All @@ -655,6 +669,7 @@ type testAccTemplateResourceConfig struct {
TimeTilDormantAutodelete *int64
RequireActiveVersion *bool
DeprecationMessage *string
MaxPortShareLevel *string

Versions []testAccTemplateVersionConfig
ACL testAccTemplateACLConfig
Expand Down Expand Up @@ -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) + `

Expand Down
Loading