diff --git a/docs/resources/group.md b/docs/resources/group.md index 31044b2..ec10901 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. Creating groups 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..598f385 100644 --- a/docs/resources/template.md +++ b/docs/resources/template.md @@ -22,23 +22,23 @@ A Coder template ### Optional -- `acl` (Attributes) Access control list for the template. Requires an enterprise Coder deployment. If null, ACL policies will not be added or removed by Terraform. (see [below for nested schema](#nestedatt--acl)) +- `acl` (Attributes) (Enterprise) Access control list for the template. If null, ACL policies will not be added or removed by Terraform. (see [below for nested schema](#nestedatt--acl)) - `activity_bump_ms` (Number) The activity bump duration for all workspaces created from this template, in milliseconds. Defaults to one hour. -- `allow_user_auto_start` (Boolean) Whether users can auto-start workspaces created from this template. Defaults to true. Requires an enterprise Coder deployment. -- `allow_user_auto_stop` (Boolean) Whether users can auto-start workspaces created from this template. Defaults to true. Requires an enterprise Coder deployment. +- `allow_user_auto_start` (Boolean) (Enterprise) Whether users can auto-start workspaces created from this template. Defaults to true. +- `allow_user_auto_stop` (Boolean) (Enterprise) Whether users can auto-start workspaces created from this template. Defaults to true. - `allow_user_cancel_workspace_jobs` (Boolean) Whether users can cancel in-progress workspace jobs using this template. Defaults to true. -- `auto_start_permitted_days_of_week` (Set of String) List of days of the week in which autostart is allowed to happen, for all workspaces created from this template. Defaults to all days. If no days are specified, autostart is not allowed. Requires an enterprise Coder deployment. -- `auto_stop_requirement` (Attributes) The auto-stop requirement for all workspaces created from this template. Requires an enterprise Coder deployment. (see [below for nested schema](#nestedatt--auto_stop_requirement)) +- `auto_start_permitted_days_of_week` (Set of String) (Enterprise) List of days of the week in which autostart is allowed to happen, for all workspaces created from this template. Defaults to all days. If no days are specified, autostart is not allowed. +- `auto_stop_requirement` (Attributes) (Enterprise) The auto-stop requirement for all workspaces created from this template. (see [below for nested schema](#nestedatt--auto_stop_requirement)) - `default_ttl_ms` (Number) The default time-to-live for all workspaces created from this template, in milliseconds. - `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) (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. - `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) (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. +- `time_til_dormant_ms` (Number) Enterprise) The max lifetime before Coder locks inactive workspaces created from this template, in milliseconds. ### 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 aa83c59..ab58c68 100644 --- a/internal/provider/group_resource.go +++ b/internal/provider/group_resource.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "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" @@ -45,13 +46,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 use 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. Creating groups requires an Enterprise license.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ @@ -136,6 +145,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..0e32456 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 use 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 078f0c1..cbf81eb 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"` @@ -245,7 +286,7 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques Default: int64default.StaticInt64(3600000), }, "auto_stop_requirement": schema.SingleNestedAttribute{ - MarkdownDescription: "The auto-stop requirement for all workspaces created from this template. Requires an enterprise Coder deployment.", + MarkdownDescription: "(Enterprise) The auto-stop requirement for all workspaces created from this template.", Optional: true, Computed: true, Attributes: map[string]schema.Attribute{ @@ -270,7 +311,7 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques })), }, "auto_start_permitted_days_of_week": schema.SetAttribute{ - MarkdownDescription: "List of days of the week in which autostart is allowed to happen, for all workspaces created from this template. Defaults to all days. If no days are specified, autostart is not allowed. Requires an enterprise Coder deployment.", + MarkdownDescription: "(Enterprise) List of days of the week in which autostart is allowed to happen, for all workspaces created from this template. Defaults to all days. If no days are specified, autostart is not allowed.", Optional: true, Computed: true, ElementType: types.StringType, @@ -284,37 +325,37 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques Default: booldefault.StaticBool(true), }, "allow_user_auto_start": schema.BoolAttribute{ - MarkdownDescription: "Whether users can auto-start workspaces created from this template. Defaults to true. Requires an enterprise Coder deployment.", + MarkdownDescription: "(Enterprise) Whether users can auto-start workspaces created from this template. Defaults to true.", Optional: true, Computed: true, Default: booldefault.StaticBool(true), }, "allow_user_auto_stop": schema.BoolAttribute{ - MarkdownDescription: "Whether users can auto-start workspaces created from this template. Defaults to true. Requires an enterprise Coder deployment.", + MarkdownDescription: "(Enterprise) Whether users can auto-start workspaces created from this template. Defaults to true.", Optional: true, Computed: true, 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: "(Enterprise) The max lifetime before Coder stops all resources for failed workspaces created from this template, in milliseconds.", 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: "Enterprise) The max lifetime before Coder locks inactive workspaces created from this template, in milliseconds.", 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: "(Enterprise) The max lifetime before Coder permanently deletes dormant workspaces created from this template.", 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: "(Enterprise) Whether workspaces must be created from the active version of this template. Defaults to false.", Optional: true, Computed: true, Default: booldefault.StaticBool(false), @@ -326,7 +367,7 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques Default: stringdefault.StaticString(""), }, "acl": schema.SingleNestedAttribute{ - MarkdownDescription: "Access control list for the template. Requires an enterprise Coder deployment. If null, ACL policies will not be added or removed by Terraform.", + MarkdownDescription: "(Enterprise) Access control list for the template. If null, ACL policies will not be added or removed by Terraform.", Optional: true, Attributes: map[string]schema.Attribute{ "users": permissionAttribute, @@ -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 f264b95..a9cdaa1 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