From 04f9b5346059a3741633c466f34c35ef42bbd70f Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 6 Aug 2024 11:30:53 +0000 Subject: [PATCH] fix: validate resources against available features before creating --- docs/resources/group.md | 4 +- docs/resources/template.md | 8 +- internal/provider/group_data_source.go | 5 ++ internal/provider/group_resource.go | 16 +++- internal/provider/group_resource_test.go | 34 ++++++++ .../provider/organization_data_source_test.go | 2 +- internal/provider/provider.go | 7 ++ internal/provider/template_resource.go | 63 +++++++++++++-- internal/provider/template_resource_test.go | 81 +++++++++++++++++++ internal/provider/workspace_proxy_resource.go | 5 ++ .../provider/workspace_proxy_resource_test.go | 30 +++++++ 11 files changed, 241 insertions(+), 14 deletions(-) diff --git a/docs/resources/group.md b/docs/resources/group.md index 31044b2..4df0807 100644 --- a/docs/resources/group.md +++ b/docs/resources/group.md @@ -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. diff --git a/docs/resources/template.md b/docs/resources/template.md index 3da89fc..15db119 100644 --- a/docs/resources/template.md +++ b/docs/resources/template.md @@ -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 diff --git a/internal/provider/group_data_source.go b/internal/provider/group_data_source.go index 7d5551c..5af8998 100644 --- a/internal/provider/group_data_source.go +++ b/internal/provider/group_data_source.go @@ -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() { diff --git a/internal/provider/group_resource.go b/internal/provider/group_resource.go index 417fc8b..bb18e6e 100644 --- a/internal/provider/group_resource.go +++ b/internal/provider/group_resource.go @@ -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" @@ -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{ @@ -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() { diff --git a/internal/provider/group_resource_test.go b/internal/provider/group_resource_test.go index b6808f2..a0935f1 100644 --- a/internal/provider/group_resource_test.go +++ b/internal/provider/group_resource_test.go @@ -3,6 +3,7 @@ package provider import ( "context" "os" + "regexp" "strings" "testing" "text/template" @@ -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 diff --git a/internal/provider/organization_data_source_test.go b/internal/provider/organization_data_source_test.go index 35cb2f5..fe744db 100644 --- a/internal/provider/organization_data_source_test.go +++ b/internal/provider/organization_data_source_test.go @@ -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) diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 8b5db9d..456adb3 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -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. @@ -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 diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index 0eed781..b47737f 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -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) && @@ -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"` @@ -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), @@ -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 @@ -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.") diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go index 7273092..58bc06d 100644 --- a/internal/provider/template_resource_test.go +++ b/internal/provider/template_resource_test.go @@ -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 diff --git a/internal/provider/workspace_proxy_resource.go b/internal/provider/workspace_proxy_resource.go index 461a7fc..a95dc68 100644 --- a/internal/provider/workspace_proxy_resource.go +++ b/internal/provider/workspace_proxy_resource.go @@ -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(), diff --git a/internal/provider/workspace_proxy_resource_test.go b/internal/provider/workspace_proxy_resource_test.go index a5a5eba..a2447ea 100644 --- a/internal/provider/workspace_proxy_resource_test.go +++ b/internal/provider/workspace_proxy_resource_test.go @@ -3,6 +3,7 @@ package provider import ( "context" "os" + "regexp" "strings" "testing" "text/template" @@ -53,6 +54,35 @@ func TestAccWorkspaceProxyResource(t *testing.T) { }) } +func TestAccWorkspaceProxyResourceAGPL(t *testing.T) { + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + ctx := context.Background() + client := integration.StartCoder(ctx, t, "ws_proxy_acc", false) + + cfg1 := testAccWorkspaceProxyResourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + Name: PtrTo("example"), + DisplayName: PtrTo("Example WS Proxy"), + Icon: PtrTo("/emojis/1f407.png"), + } + + 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 workspace proxies."), + }, + }, + }) + +} + type testAccWorkspaceProxyResourceConfig struct { URL string Token string