Skip to content

Commit

Permalink
fix: validate resources against available features before creating
Browse files Browse the repository at this point in the history
  • Loading branch information
ethanndickson committed Aug 7, 2024
1 parent eaab432 commit 04f9b53
Show file tree
Hide file tree
Showing 11 changed files with 241 additions and 14 deletions.
4 changes: 2 additions & 2 deletions docs/resources/group.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
page_title: "coderd_group Resource - terraform-provider-coderd"
subcategory: ""
description: |-
A group on the Coder deployment. If you want to have a group resource with unmanaged members, but still want to read the members in Terraform, use the data.coderd_group data source.
A group on the Coder deployment. If you want to have a group resource with unmanaged members, but still want to read the members in Terraform, use the data.coderd_group data source. Requires an Enterprise license.
---

# coderd_group (Resource)

A group on the Coder deployment. If you want to have a group resource with unmanaged members, but still want to read the members in Terraform, use the `data.coderd_group` data source.
A group on the Coder deployment. If you want to have a group resource with unmanaged members, but still want to read the members in Terraform, use the `data.coderd_group` data source. Creating groups requires an Enterprise license.



Expand Down
8 changes: 4 additions & 4 deletions docs/resources/template.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@ A Coder template
- `deprecation_message` (String) If set, the template will be marked as deprecated and users will be blocked from creating new workspaces from it.
- `description` (String) A description of the template.
- `display_name` (String) The display name of the template. Defaults to the template name.
- `failure_ttl_ms` (Number) The max lifetime before Coder stops all resources for failed workspaces created from this template, in milliseconds.
- `failure_ttl_ms` (Number) The max lifetime before Coder stops all resources for failed workspaces created from this template, in milliseconds. Requires an enterprise Coder deployment.
- `icon` (String) Relative path or external URL that specifes an icon to be displayed in the dashboard.
- `organization_id` (String) The ID of the organization. Defaults to the provider's default organization
- `require_active_version` (Boolean) Whether workspaces must be created from the active version of this template. Defaults to false.
- `time_til_dormant_autodelete_ms` (Number) The max lifetime before Coder permanently deletes dormant workspaces created from this template.
- `time_til_dormant_ms` (Number) The max lifetime before Coder locks inactive workspaces created from this template, in milliseconds.
- `require_active_version` (Boolean) Whether workspaces must be created from the active version of this template. Defaults to false. Requires an enterprise Coder deployment.
- `time_til_dormant_autodelete_ms` (Number) The max lifetime before Coder permanently deletes dormant workspaces created from this template. Requires an enterprise Coder deployment.
- `time_til_dormant_ms` (Number) The max lifetime before Coder locks inactive workspaces created from this template, in milliseconds. Requires an enterprise Coder deployment.

### Read-Only

Expand Down
5 changes: 5 additions & 0 deletions internal/provider/group_data_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ func (d *GroupDataSource) Read(ctx context.Context, req datasource.ReadRequest,
return
}

resp.Diagnostics.Append(CheckGroupEntitlements(ctx, d.data.Features)...)
if resp.Diagnostics.HasError() {
return
}

client := d.data.Client

if data.OrganizationID.IsNull() {
Expand Down
16 changes: 15 additions & 1 deletion internal/provider/group_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/coder/coder/v2/codersdk"
"github.com/google/uuid"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
Expand Down Expand Up @@ -43,13 +44,21 @@ type GroupResourceModel struct {
Members types.Set `tfsdk:"members"`
}

func CheckGroupEntitlements(ctx context.Context, features map[codersdk.FeatureName]codersdk.Feature) (diags diag.Diagnostics) {
if !features[codersdk.FeatureTemplateRBAC].Enabled {
diags.AddError("Feature not enabled", "Your license is not entitled to create groups.")
return
}
return nil
}

func (r *GroupResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_group"
}

func (r *GroupResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "A group on the Coder deployment. If you want to have a group resource with unmanaged members, but still want to read the members in Terraform, use the `data.coderd_group` data source.",
MarkdownDescription: "A group on the Coder deployment. If you want to have a group resource with unmanaged members, but still want to read the members in Terraform, use the `data.coderd_group` data source. Requires an Enterprise license.",

Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Expand Down Expand Up @@ -131,6 +140,11 @@ func (r *GroupResource) Create(ctx context.Context, req resource.CreateRequest,
return
}

resp.Diagnostics.Append(CheckGroupEntitlements(ctx, r.data.Features)...)
if resp.Diagnostics.HasError() {
return
}

client := r.data.Client

if data.OrganizationID.IsUnknown() {
Expand Down
34 changes: 34 additions & 0 deletions internal/provider/group_resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package provider
import (
"context"
"os"
"regexp"
"strings"
"testing"
"text/template"
Expand Down Expand Up @@ -124,6 +125,39 @@ func TestAccGroupResource(t *testing.T) {
})
}

func TestAccGroupResourceAGPL(t *testing.T) {
if os.Getenv("TF_ACC") == "" {
t.Skip("Acceptance tests are disabled.")
}
ctx := context.Background()
client := integration.StartCoder(ctx, t, "group_acc_agpl", false)
firstUser, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)

cfg1 := testAccGroupResourceconfig{
URL: client.URL.String(),
Token: client.SessionToken(),
Name: PtrTo("example-group"),
DisplayName: PtrTo("Example Group"),
AvatarUrl: PtrTo("https://google.com"),
QuotaAllowance: PtrTo(int32(100)),
Members: PtrTo([]string{firstUser.ID.String()}),
}

resource.Test(t, resource.TestCase{
IsUnitTest: true,
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: cfg1.String(t),
ExpectError: regexp.MustCompile("Your license is not entitled to create groups."),
},
},
})

}

type testAccGroupResourceconfig struct {
URL string
Token string
Expand Down
2 changes: 1 addition & 1 deletion internal/provider/organization_data_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func TestAccOrganizationDataSource(t *testing.T) {
t.Skip("Acceptance tests are disabled.")
}
ctx := context.Background()
client := integration.StartCoder(ctx, t, "org_data_acc", true)
client := integration.StartCoder(ctx, t, "org_data_acc", false)
firstUser, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)

Expand Down
7 changes: 7 additions & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type CoderdProvider struct {
type CoderdProviderData struct {
Client *codersdk.Client
DefaultOrganizationID uuid.UUID
Features map[codersdk.FeatureName]codersdk.Feature
}

// CoderdProviderModel describes the provider data model.
Expand Down Expand Up @@ -111,9 +112,15 @@ func (p *CoderdProvider) Configure(ctx context.Context, req provider.ConfigureRe
}
data.DefaultOrganizationID = UUIDValue(user.OrganizationIDs[0])
}
entitlements, err := client.Entitlements(ctx)
if err != nil {
resp.Diagnostics.AddError("Client Error", "failed to get deployment entitlements: "+err.Error())
}

providerData := &CoderdProviderData{
Client: client,
DefaultOrganizationID: data.DefaultOrganizationID.ValueUUID(),
Features: entitlements.Features,
}
resp.DataSourceData = providerData
resp.ResourceData = providerData
Expand Down
63 changes: 57 additions & 6 deletions internal/provider/template_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ type TemplateResourceModel struct {
}

// EqualTemplateMetadata returns true if two templates have identical metadata (excluding ACL).
func (m TemplateResourceModel) EqualTemplateMetadata(other TemplateResourceModel) bool {
func (m *TemplateResourceModel) EqualTemplateMetadata(other *TemplateResourceModel) bool {
return m.Name.Equal(other.Name) &&
m.DisplayName.Equal(other.DisplayName) &&
m.Description.Equal(other.Description) &&
Expand All @@ -93,6 +93,47 @@ func (m TemplateResourceModel) EqualTemplateMetadata(other TemplateResourceModel
m.RequireActiveVersion.Equal(other.RequireActiveVersion)
}

func (m *TemplateResourceModel) CheckEntitlements(ctx context.Context, features map[codersdk.FeatureName]codersdk.Feature) (diags diag.Diagnostics) {
var autoStop AutostopRequirement
diags.Append(m.AutostopRequirement.As(ctx, &autoStop, basetypes.ObjectAsOptions{})...)
if diags.HasError() {
return diags
}
requiresScheduling := len(autoStop.DaysOfWeek) > 0 ||
!m.AllowUserAutostart.ValueBool() ||
!m.AllowUserAutostop.ValueBool() ||
m.FailureTTLMillis.ValueInt64() != 0 ||
m.TimeTilDormantAutoDeleteMillis.ValueInt64() != 0 ||
m.TimeTilDormantMillis.ValueInt64() != 0 ||
len(m.AutostartPermittedDaysOfWeek.Elements()) != 7
requiresActiveVersion := m.RequireActiveVersion.ValueBool()
requiresACL := !m.ACL.IsNull()
if requiresScheduling || requiresActiveVersion || requiresACL {
if requiresScheduling && !features[codersdk.FeatureAdvancedTemplateScheduling].Enabled {
diags.AddError(
"Feature not enabled",
"Your license is not entitled to use advanced template scheduling, so you cannot modify any of the following fields from their defaults: auto_stop_requirement, auto_start_permitted_days_of_week, allow_user_auto_start, allow_user_auto_stop, failure_ttl_ms, time_til_dormant_ms, time_til_dormant_autodelete_ms.",
)
return
}
if requiresActiveVersion && !features[codersdk.FeatureAccessControl].Enabled {
diags.AddError(
"Feature not enabled",
"Your license is not entitled to use access control, so you cannot set require_active_version.",
)
return
}
if requiresACL && !features[codersdk.FeatureTemplateRBAC].Enabled {
diags.AddError(
"Feature not enabled",
"Your license is not entitled to use template access control, so you cannot set acl.",
)
return
}
}
return
}

type TemplateVersion struct {
ID UUID `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Expand Down Expand Up @@ -296,25 +337,25 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques
Default: booldefault.StaticBool(true),
},
"failure_ttl_ms": schema.Int64Attribute{
MarkdownDescription: "The max lifetime before Coder stops all resources for failed workspaces created from this template, in milliseconds.",
MarkdownDescription: "The max lifetime before Coder stops all resources for failed workspaces created from this template, in milliseconds. Requires an enterprise Coder deployment.",
Optional: true,
Computed: true,
Default: int64default.StaticInt64(0),
},
"time_til_dormant_ms": schema.Int64Attribute{
MarkdownDescription: "The max lifetime before Coder locks inactive workspaces created from this template, in milliseconds.",
MarkdownDescription: "The max lifetime before Coder locks inactive workspaces created from this template, in milliseconds. Requires an enterprise Coder deployment.",
Optional: true,
Computed: true,
Default: int64default.StaticInt64(0),
},
"time_til_dormant_autodelete_ms": schema.Int64Attribute{
MarkdownDescription: "The max lifetime before Coder permanently deletes dormant workspaces created from this template.",
MarkdownDescription: "The max lifetime before Coder permanently deletes dormant workspaces created from this template. Requires an enterprise Coder deployment.",
Optional: true,
Computed: true,
Default: int64default.StaticInt64(0),
},
"require_active_version": schema.BoolAttribute{
MarkdownDescription: "Whether workspaces must be created from the active version of this template. Defaults to false.",
MarkdownDescription: "Whether workspaces must be created from the active version of this template. Defaults to false. Requires an enterprise Coder deployment.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
Expand Down Expand Up @@ -429,6 +470,11 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques
data.DisplayName = data.Name
}

resp.Diagnostics.Append(data.CheckEntitlements(ctx, r.data.Features)...)
if resp.Diagnostics.HasError() {
return
}

client := r.data.Client
orgID := data.OrganizationID.ValueUUID()
var templateResp codersdk.Template
Expand Down Expand Up @@ -593,13 +639,18 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques
newState.DisplayName = newState.Name
}

resp.Diagnostics.Append(newState.CheckEntitlements(ctx, r.data.Features)...)
if resp.Diagnostics.HasError() {
return
}

orgID := newState.OrganizationID.ValueUUID()

templateID := newState.ID.ValueUUID()

client := r.data.Client

templateMetadataChanged := !newState.EqualTemplateMetadata(curState)
templateMetadataChanged := !newState.EqualTemplateMetadata(&curState)
// This is required, as the API will reject no-diff updates.
if templateMetadataChanged {
tflog.Trace(ctx, "change in template metadata detected, updating.")
Expand Down
81 changes: 81 additions & 0 deletions internal/provider/template_resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,87 @@ func TestAccTemplateResource(t *testing.T) {
})
}

func TestAccTemplateResourceAGPL(t *testing.T) {
if os.Getenv("TF_ACC") == "" {
t.Skip("Acceptance tests are disabled.")
}
ctx := context.Background()
client := integration.StartCoder(ctx, t, "template_acc", false)
firstUser, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)

cfg1 := testAccTemplateResourceConfig{
URL: client.URL.String(),
Token: client.SessionToken(),
Name: PtrTo("example-template"),
Versions: []testAccTemplateVersionConfig{
{
// Auto-generated version name
Directory: PtrTo("../../integration/template-test/example-template/"),
Active: PtrTo(true),
},
},
AllowUserAutostart: PtrTo(false),
}

cfg2 := cfg1
cfg2.AllowUserAutostart = nil
cfg2.AutostopRequirement.DaysOfWeek = PtrTo([]string{"monday", "tuesday"})

cfg3 := cfg2
cfg3.AutostopRequirement.null = true
cfg3.AutostartRequirement = PtrTo([]string{})

cfg4 := cfg3
cfg4.FailureTTL = PtrTo(int64(1))

cfg5 := cfg4
cfg5.FailureTTL = nil
cfg5.AutostartRequirement = nil
cfg5.RequireActiveVersion = PtrTo(true)

cfg6 := cfg5
cfg6.RequireActiveVersion = nil
cfg6.ACL = testAccTemplateACLConfig{
GroupACL: []testAccTemplateKeyValueConfig{
{
Key: PtrTo(firstUser.OrganizationIDs[0].String()),
Value: PtrTo("use"),
},
},
}

for _, cfg := range []testAccTemplateResourceConfig{cfg1, cfg2, cfg3, cfg4} {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
IsUnitTest: true,
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: cfg.String(t),
ExpectError: regexp.MustCompile("Your license is not entitled to use advanced template scheduling"),
},
},
})
}

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
IsUnitTest: true,
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: cfg5.String(t),
ExpectError: regexp.MustCompile("Your license is not entitled to use access control"),
},
{
Config: cfg6.String(t),
ExpectError: regexp.MustCompile("Your license is not entitled to use template access control"),
},
},
})
}

type testAccTemplateResourceConfig struct {
URL string
Token string
Expand Down
5 changes: 5 additions & 0 deletions internal/provider/workspace_proxy_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ func (r *WorkspaceProxyResource) Create(ctx context.Context, req resource.Create
return
}

if !r.data.Features[codersdk.FeatureWorkspaceProxy].Enabled {
resp.Diagnostics.AddError("Feature not enabled", "Your license is not entitled to create workspace proxies.")
return
}

client := r.data.Client
wsp, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
Name: data.Name.ValueString(),
Expand Down
Loading

0 comments on commit 04f9b53

Please sign in to comment.