diff --git a/CHANGELOG.md b/CHANGELOG.md index a821dd317..fe3a550be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,13 @@ DEPRECATIONS: * `r/tfe_opa_version`: The `url` and `sha` attributes are deprecated and will be removed in a future version. Use the `archs` attribute to specify architecture-specific binaries going forward, by @kelsi-hoyle [1762](https://github.com/hashicorp/terraform-provider-tfe/pull/1762) * `r/tfe_sentinel_version`: The `url` and `sha` attributes are deprecated and will be removed in a future version. Use the `archs` attribute to specify architecture-specific binaries going forward, by @kelsi-hoyle [1762](https://github.com/hashicorp/terraform-provider-tfe/pull/1762) * `r/tfe_oauth_client`: The `oauth_token` attribute no longer triggers resource replacement unless combined with other replacement-triggering attributes. Use `terraform apply -replace` to force replacement. By @lilincmu [#1825](https://github.com/hashicorp/terraform-provider-tfe/pull/1825) +* `d/tfe_agent_pool`: Adds the `allowed_project_ids` and `excluded_workspace_ids` attributes, by @tylerworlf [#1822](https://github.com/hashicorp/terraform-provider-tfe/pull/1822) +* `r/tfe_agent_pool_allowed_projects`: Adds support for scoping agent pools to projects, by @tylerworlf [#1822](https://github.com/hashicorp/terraform-provider-tfe/pull/1822) +* `r/tfe_agent_pool_excluded_workspaces`: Adds support for excluding workspaces from the scope of agent pools, by @tylerworlf [#1822](https://github.com/hashicorp/terraform-provider-tfe/pull/1822) +* `r/tfe_project_settings`: Adds support for managing project settings. This initially supports setting a `default_execution_mode` and `default_agent_pool_id` which override the organization defaults. When not specified in the configuration, the organization defaults will be used and can be read from the resource. by @JarrettSpiker [#1822](Thttps://github.com/hashicorp/terraform-provider-tfe/pull/1822) * `r/tfe_test_variable`: Add missing argument reference and attributes documentation ([#1625](https://github.com/hashicorp/terraform-provider-tfe/issues/1625)) ## v0.68.3 - BUG FIXES: * `r/tfe_notification_configuration`: update url attribute to be sensitive, by @jillirami [#1799](https://github.com/hashicorp/terraform-provider-tfe/pull/1799) diff --git a/internal/provider/data_source_agent_pool.go b/internal/provider/data_source_agent_pool.go index 0c811c3b5..8e5a8e3cf 100644 --- a/internal/provider/data_source_agent_pool.go +++ b/internal/provider/data_source_agent_pool.go @@ -37,6 +37,18 @@ func dataSourceTFEAgentPool() *schema.Resource { Computed: true, Elem: &schema.Schema{Type: schema.TypeString}, }, + + "allowed_project_ids": { + Type: schema.TypeSet, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + + "excluded_workspace_ids": { + Type: schema.TypeSet, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, }, } } @@ -59,11 +71,23 @@ func dataSourceTFEAgentPoolRead(d *schema.ResourceData, meta interface{}) error d.SetId(pool.ID) d.Set("organization_scoped", pool.OrganizationScoped) + var allowedProjectIDs []string + for _, allowedProjectID := range pool.AllowedProjects { + allowedProjectIDs = append(allowedProjectIDs, allowedProjectID.ID) + } + d.Set("allowed_project_ids", allowedProjectIDs) + var allowedWorkspaceIDs []string for _, allowedWorkspaceID := range pool.AllowedWorkspaces { allowedWorkspaceIDs = append(allowedWorkspaceIDs, allowedWorkspaceID.ID) } d.Set("allowed_workspace_ids", allowedWorkspaceIDs) + var excludedWorkspaceIDs []string + for _, excludedWorkspaceID := range pool.ExcludedWorkspaces { + excludedWorkspaceIDs = append(excludedWorkspaceIDs, excludedWorkspaceID.ID) + } + d.Set("excluded_workspace_ids", excludedWorkspaceIDs) + return nil } diff --git a/internal/provider/data_source_agent_pool_test.go b/internal/provider/data_source_agent_pool_test.go index a19985af2..28d35b50b 100644 --- a/internal/provider/data_source_agent_pool_test.go +++ b/internal/provider/data_source_agent_pool_test.go @@ -89,6 +89,90 @@ func TestAccTFEAgentPoolDataSource_allowed_workspaces(t *testing.T) { }) } +func TestAccTFEAgentPoolDataSource_allowed_projects(t *testing.T) { + skipIfEnterprise(t) + + tfeClient, err := getClientUsingEnv() + if err != nil { + t.Fatal(err) + } + + org, orgCleanup := createBusinessOrganization(t, tfeClient) + t.Cleanup(orgCleanup) + + rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() + + ws, err := tfeClient.Projects.Create(ctx, org.Name, tfe.ProjectCreateOptions{ + Name: fmt.Sprintf("tst-proj-test-%d", rInt), + }) + if err != nil { + t.Fatal(err) + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccMuxedProviders, + Steps: []resource.TestStep{ + { + Config: testAccTFEAgentPoolDataSourceAllowedProjectsConfig(org.Name, rInt, ws.ID), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.tfe_agent_pool.foobar", "id"), + resource.TestCheckResourceAttr( + "data.tfe_agent_pool.foobar", "name", fmt.Sprintf("agent-pool-test-%d", rInt)), + resource.TestCheckResourceAttr( + "data.tfe_agent_pool.foobar", "organization", org.Name), + resource.TestCheckResourceAttr( + "data.tfe_agent_pool.foobar", "organization_scoped", "false"), + resource.TestCheckResourceAttr( + "data.tfe_agent_pool.foobar", "allowed_project_ids.0", ws.ID), + ), + }, + }, + }) +} + +func TestAccTFEAgentPoolDataSource_excluded_workspaces(t *testing.T) { + skipIfEnterprise(t) + + tfeClient, err := getClientUsingEnv() + if err != nil { + t.Fatal(err) + } + + org, orgCleanup := createBusinessOrganization(t, tfeClient) + t.Cleanup(orgCleanup) + + rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() + + ws, err := tfeClient.Workspaces.Create(ctx, org.Name, tfe.WorkspaceCreateOptions{ + Name: tfe.String(fmt.Sprintf("tst-workspace-test-%d", rInt)), + }) + if err != nil { + t.Fatal(err) + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccMuxedProviders, + Steps: []resource.TestStep{ + { + Config: testAccTFEAgentPoolDataSourceExcludedWorkspacesConfig(org.Name, rInt, ws.ID), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.tfe_agent_pool.foobar", "id"), + resource.TestCheckResourceAttr( + "data.tfe_agent_pool.foobar", "name", fmt.Sprintf("agent-pool-test-%d", rInt)), + resource.TestCheckResourceAttr( + "data.tfe_agent_pool.foobar", "organization", org.Name), + resource.TestCheckResourceAttr( + "data.tfe_agent_pool.foobar", "organization_scoped", "false"), + resource.TestCheckResourceAttr( + "data.tfe_agent_pool.foobar", "excluded_workspace_ids.0", ws.ID), + ), + }, + }, + }) +} + func testAccTFEAgentPoolDataSourceConfig(organization string, rInt int) string { return fmt.Sprintf(` resource "tfe_agent_pool" "foobar" { @@ -121,3 +205,43 @@ data "tfe_agent_pool" "foobar" { depends_on = [ tfe_agent_pool_allowed_workspaces.foobar ] }`, rInt, organization, workspaceID, organization) } + +func testAccTFEAgentPoolDataSourceAllowedProjectsConfig(organization string, rInt int, projectID string) string { + return fmt.Sprintf(` +resource "tfe_agent_pool" "foobar" { + name = "agent-pool-test-%d" + organization = "%s" + organization_scoped = false +} + +resource "tfe_agent_pool_allowed_projects" "foobar" { + agent_pool_id = tfe_agent_pool.foobar.id + allowed_project_ids = ["%s"] +} + +data "tfe_agent_pool" "foobar" { + name = tfe_agent_pool.foobar.name + organization = "%s" + depends_on = [ tfe_agent_pool_allowed_projects.foobar ] +}`, rInt, organization, projectID, organization) +} + +func testAccTFEAgentPoolDataSourceExcludedWorkspacesConfig(organization string, rInt int, workspaceID string) string { + return fmt.Sprintf(` +resource "tfe_agent_pool" "foobar" { + name = "agent-pool-test-%d" + organization = "%s" + organization_scoped = false +} + +resource "tfe_agent_pool_excluded_workspaces" "foobar" { + agent_pool_id = tfe_agent_pool.foobar.id + excluded_workspace_ids = ["%s"] +} + +data "tfe_agent_pool" "foobar" { + name = tfe_agent_pool.foobar.name + organization = "%s" + depends_on = [ tfe_agent_pool_excluded_workspaces.foobar ] +}`, rInt, organization, workspaceID, organization) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index dd96c4af9..6637a0abd 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -107,7 +107,9 @@ func Provider() *schema.Provider { ResourcesMap: map[string]*schema.Resource{ "tfe_admin_organization_settings": resourceTFEAdminOrganizationSettings(), "tfe_agent_pool": resourceTFEAgentPool(), + "tfe_agent_pool_allowed_projects": resourceTFEAgentPoolAllowedProjects(), "tfe_agent_pool_allowed_workspaces": resourceTFEAgentPoolAllowedWorkspaces(), + "tfe_agent_pool_excluded_workspaces": resourceTFEAgentPoolExcludedWorkspaces(), "tfe_agent_token": resourceTFEAgentToken(), "tfe_oauth_client": resourceTFEOAuthClient(), "tfe_organization": resourceTFEOrganization(), diff --git a/internal/provider/provider_next.go b/internal/provider/provider_next.go index 3eecf8c71..caa20b4dc 100644 --- a/internal/provider/provider_next.go +++ b/internal/provider/provider_next.go @@ -168,6 +168,7 @@ func (p *frameworkProvider) Resources(ctx context.Context) []func() resource.Res NewWorkspaceRunTaskResource, NewNotificationConfigurationResource, NewTeamTokenResource, + NewProjectSettingsResource, NewTerraformVersionResource, NewOPAVersionResource, NewsentinelVersionResource, diff --git a/internal/provider/resource_tfe_agent_pool_allowed_projects.go b/internal/provider/resource_tfe_agent_pool_allowed_projects.go new file mode 100644 index 000000000..c81f29036 --- /dev/null +++ b/internal/provider/resource_tfe_agent_pool_allowed_projects.go @@ -0,0 +1,144 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// NOTE: This is a legacy resource and should be migrated to the Plugin +// Framework if substantial modifications are planned. See +// docs/new-resources.md if planning to use this code as boilerplate for +// a new resource. + +package provider + +import ( + "errors" + "fmt" + "log" + + "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceTFEAgentPoolAllowedProjects() *schema.Resource { + return &schema.Resource{ + Create: resourceTFEAgentPoolAllowedProjectsCreate, + Read: resourceTFEAgentPoolAllowedProjectsRead, + Update: resourceTFEAgentPoolAllowedProjectsUpdate, + Delete: resourceTFEAgentPoolAllowedProjectsDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "agent_pool_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + "allowed_project_ids": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + }, + } +} + +func resourceTFEAgentPoolAllowedProjectsCreate(d *schema.ResourceData, meta interface{}) error { + config := meta.(ConfiguredClient) + + apID := d.Get("agent_pool_id").(string) + + // Create a new options struct. + options := tfe.AgentPoolAllowedProjectsUpdateOptions{} + + if allowedProjectIDs, allowedProjectSet := d.GetOk("allowed_project_ids"); allowedProjectSet { + options.AllowedProjects = []*tfe.Project{} + for _, projectID := range allowedProjectIDs.(*schema.Set).List() { + if val, ok := projectID.(string); ok { + options.AllowedProjects = append(options.AllowedProjects, &tfe.Project{ID: val}) + } + } + } + + log.Printf("[DEBUG] Update agent pool: %s", apID) + _, err := config.Client.AgentPools.UpdateAllowedProjects(ctx, apID, options) + if err != nil { + return fmt.Errorf("Error updating agent pool %s: %w", apID, err) + } + + d.SetId(apID) + + return nil +} + +func resourceTFEAgentPoolAllowedProjectsRead(d *schema.ResourceData, meta interface{}) error { + config := meta.(ConfiguredClient) + + agentPool, err := config.Client.AgentPools.Read(ctx, d.Id()) + if err != nil { + if errors.Is(err, tfe.ErrResourceNotFound) { + log.Printf("[DEBUG] agent pool %s no longer exists", d.Id()) + d.SetId("") + return nil + } + return fmt.Errorf("Error reading configuration of agent pool %s: %w", d.Id(), err) + } + + var allowedProjectIDs []string + for _, project := range agentPool.AllowedProjects { + allowedProjectIDs = append(allowedProjectIDs, project.ID) + } + d.Set("allowed_project_ids", allowedProjectIDs) + d.Set("agent_pool_id", agentPool.ID) + + return nil +} + +func resourceTFEAgentPoolAllowedProjectsUpdate(d *schema.ResourceData, meta interface{}) error { + config := meta.(ConfiguredClient) + + apID := d.Get("agent_pool_id").(string) + + // Create a new options struct. + options := tfe.AgentPoolAllowedProjectsUpdateOptions{ + AllowedProjects: []*tfe.Project{}, + } + + if allowedProjectIDs, allowedProjectSet := d.GetOk("allowed_project_ids"); allowedProjectSet { + options.AllowedProjects = []*tfe.Project{} + for _, projectID := range allowedProjectIDs.(*schema.Set).List() { + if val, ok := projectID.(string); ok { + options.AllowedProjects = append(options.AllowedProjects, &tfe.Project{ID: val}) + } + } + } + + log.Printf("[DEBUG] Update agent pool: %s", apID) + _, err := config.Client.AgentPools.UpdateAllowedProjects(ctx, apID, options) + if err != nil { + return fmt.Errorf("Error updating agent pool %s: %w", apID, err) + } + + d.SetId(apID) + + return nil +} + +func resourceTFEAgentPoolAllowedProjectsDelete(d *schema.ResourceData, meta interface{}) error { + config := meta.(ConfiguredClient) + + apID := d.Get("agent_pool_id").(string) + + // Create a new options struct. + options := tfe.AgentPoolAllowedProjectsUpdateOptions{ + AllowedProjects: []*tfe.Project{}, + } + + log.Printf("[DEBUG] Update agent pool: %s", apID) + _, err := config.Client.AgentPools.UpdateAllowedProjects(ctx, apID, options) + if err != nil { + return fmt.Errorf("Error updating agent pool %s: %w", apID, err) + } + + return nil +} diff --git a/internal/provider/resource_tfe_agent_pool_allowed_projects_test.go b/internal/provider/resource_tfe_agent_pool_allowed_projects_test.go new file mode 100644 index 000000000..4a3ad183f --- /dev/null +++ b/internal/provider/resource_tfe_agent_pool_allowed_projects_test.go @@ -0,0 +1,215 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "errors" + "fmt" + "testing" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAccTFEAgentPoolAllowedProjects_create_update(t *testing.T) { + tfeClient, err := getClientUsingEnv() + if err != nil { + t.Fatal(err) + } + + org, orgCleanup := createBusinessOrganization(t, tfeClient) + t.Cleanup(orgCleanup) + + allowedProjectsIDs := &[]string{} + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccMuxedProviders, + Steps: []resource.TestStep{ + { + Config: testAccTFEAgentPoolAllowedProjects_basic(org.Name), + Check: resource.ComposeTestCheckFunc( + testAccCheckTFEAgentPoolAllowedProjectExists("tfe_agent_pool.foobar", allowedProjectsIDs), + testAccCheckTFEAgentPoolAllowedProjectsCount(2, allowedProjectsIDs), + ), + }, + { + Config: testAccTFEAgentPoolAllowedProjects_update(org.Name), + Check: resource.ComposeTestCheckFunc( + testAccCheckTFEAgentPoolAllowedProjectExists("tfe_agent_pool.foobar", allowedProjectsIDs), + testAccCheckTFEAgentPoolAllowedProjectsCount(1, allowedProjectsIDs), + ), + }, + { + Config: testAccTFEAgentPoolAllowedProjects_destroy(org.Name), + Check: resource.ComposeTestCheckFunc( + testAccCheckTFEAgentPoolAllowedProjectsNotExists("tfe_agent_pool.foobar"), + ), + }, + }, + }) +} + +func testAccCheckTFEAgentPoolAllowedProjectExists(resourceName string, allowedProjects *[]string) resource.TestCheckFunc { + return func(s *terraform.State) error { + *allowedProjects = []string{} + + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + + // Resource ID equals the Agent Pool ID + if rs.Primary.ID == "" { + return fmt.Errorf("No instance ID is set") + } + + agentPool, err := testAccConfiguredClient.Client.AgentPools.Read(ctx, rs.Primary.ID) + if err != nil && !errors.Is(err, tfe.ErrResourceNotFound) { + return fmt.Errorf("error while fetching agent pool: %w", err) + } + + if len(agentPool.AllowedProjects) == 0 { + return fmt.Errorf("Allowed Projects for agent pool %s do not exist", rs.Primary.ID) + } + + for _, project := range agentPool.AllowedProjects { + *allowedProjects = append(*allowedProjects, project.ID) + } + + return nil + } +} + +func testAccCheckTFEAgentPoolAllowedProjectsNotExists(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + + // Resource ID equals the Agent Pool ID + if rs.Primary.ID == "" { + return fmt.Errorf("No instance ID is set") + } + + agentPool, err := testAccConfiguredClient.Client.AgentPools.Read(ctx, rs.Primary.ID) + if err != nil && !errors.Is(err, tfe.ErrResourceNotFound) { + return fmt.Errorf("error while fetching agent pool: %w", err) + } + + if len(agentPool.AllowedProjects) > 0 { + return fmt.Errorf("Allowed Projects for agent pool %s exists", rs.Primary.ID) + } + + return nil + } +} + +func testAccCheckTFEAgentPoolAllowedProjectsCount(expected int, allowedProjects *[]string) resource.TestCheckFunc { + return func(s *terraform.State) error { + if len(*allowedProjects) != expected { + return fmt.Errorf("expected %d allowed projects, got %d", expected, len(*allowedProjects)) + } + return nil + } +} + +func TestAccTFEAgentPoolAllowedProjects_import(t *testing.T) { + skipIfEnterprise(t) + + tfeClient, err := getClientUsingEnv() + if err != nil { + t.Fatal(err) + } + + org, orgCleanup := createBusinessOrganization(t, tfeClient) + t.Cleanup(orgCleanup) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccMuxedProviders, + Steps: []resource.TestStep{ + { + Config: testAccTFEAgentPoolAllowedProjects_basic(org.Name), + }, + { + ResourceName: "tfe_agent_pool_allowed_projects.foobar", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccTFEAgentPoolAllowedProjects_destroy(organization string) string { + return fmt.Sprintf(` +resource "tfe_project" "foobar" { + name = "foobar" + organization = "%s" +} + +resource "tfe_project" "test-project" { + name = "test-project" + organization = "%s" +} + +resource "tfe_agent_pool" "foobar" { + name = "agent-pool-updated" + organization = "%s" + organization_scoped = false +}`, organization, organization, organization) +} + +func testAccTFEAgentPoolAllowedProjects_update(organization string) string { + return fmt.Sprintf(` +resource "tfe_project" "foobar" { + name = "foobar" + organization = "%s" +} + +resource "tfe_project" "test-project" { + name = "test-project" + organization = "%s" +} + +resource "tfe_agent_pool" "foobar" { + name = "agent-pool-updated" + organization = "%s" + organization_scoped = false +} + +resource "tfe_agent_pool_allowed_projects" "foobar"{ + agent_pool_id = tfe_agent_pool.foobar.id + allowed_project_ids = [tfe_project.foobar.id] +}`, organization, organization, organization) +} + +func testAccTFEAgentPoolAllowedProjects_basic(organization string) string { + return fmt.Sprintf(` +resource "tfe_project" "foobar" { + name = "foobar" + organization = "%s" +} + +resource "tfe_project" "test-project" { + name = "test-project" + organization = "%s" +} + +resource "tfe_agent_pool" "foobar" { + name = "agent-pool-updated" + organization = "%s" + organization_scoped = false +} + +resource "tfe_agent_pool_allowed_projects" "foobar"{ + agent_pool_id = tfe_agent_pool.foobar.id + allowed_project_ids = [ + tfe_project.foobar.id, + tfe_project.test-project.id + ] +}`, organization, organization, organization) +} diff --git a/internal/provider/resource_tfe_agent_pool_excluded_workspaces.go b/internal/provider/resource_tfe_agent_pool_excluded_workspaces.go new file mode 100644 index 000000000..b9944cca3 --- /dev/null +++ b/internal/provider/resource_tfe_agent_pool_excluded_workspaces.go @@ -0,0 +1,144 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// NOTE: This is a legacy resource and should be migrated to the Plugin +// Framework if substantial modifications are planned. See +// docs/new-resources.md if planning to use this code as boilerplate for +// a new resource. + +package provider + +import ( + "errors" + "fmt" + "log" + + "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceTFEAgentPoolExcludedWorkspaces() *schema.Resource { + return &schema.Resource{ + Create: resourceTFEAgentPoolExcludedWorkspacesCreate, + Read: resourceTFEAgentPoolExcludedWorkspacesRead, + Update: resourceTFEAgentPoolExcludedWorkspacesUpdate, + Delete: resourceTFEAgentPoolExcludedWorkspacesDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "agent_pool_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + "excluded_workspace_ids": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + }, + } +} + +func resourceTFEAgentPoolExcludedWorkspacesCreate(d *schema.ResourceData, meta interface{}) error { + config := meta.(ConfiguredClient) + + apID := d.Get("agent_pool_id").(string) + + // Create a new options struct. + options := tfe.AgentPoolExcludedWorkspacesUpdateOptions{} + + if excludedWorkspaceIDs, excludedWorkspaceSet := d.GetOk("excluded_workspace_ids"); excludedWorkspaceSet { + options.ExcludedWorkspaces = []*tfe.Workspace{} + for _, workspaceID := range excludedWorkspaceIDs.(*schema.Set).List() { + if val, ok := workspaceID.(string); ok { + options.ExcludedWorkspaces = append(options.ExcludedWorkspaces, &tfe.Workspace{ID: val}) + } + } + } + + log.Printf("[DEBUG] Update agent pool: %s", apID) + _, err := config.Client.AgentPools.UpdateExcludedWorkspaces(ctx, apID, options) + if err != nil { + return fmt.Errorf("Error updating agent pool %s: %w", apID, err) + } + + d.SetId(apID) + + return nil +} + +func resourceTFEAgentPoolExcludedWorkspacesRead(d *schema.ResourceData, meta interface{}) error { + config := meta.(ConfiguredClient) + + agentPool, err := config.Client.AgentPools.Read(ctx, d.Id()) + if err != nil { + if errors.Is(err, tfe.ErrResourceNotFound) { + log.Printf("[DEBUG] agent pool %s no longer exists", d.Id()) + d.SetId("") + return nil + } + return fmt.Errorf("Error reading configuration of agent pool %s: %w", d.Id(), err) + } + + var excludedWorkspaceIDs []string + for _, workspace := range agentPool.ExcludedWorkspaces { + excludedWorkspaceIDs = append(excludedWorkspaceIDs, workspace.ID) + } + d.Set("excluded_workspace_ids", excludedWorkspaceIDs) + d.Set("agent_pool_id", agentPool.ID) + + return nil +} + +func resourceTFEAgentPoolExcludedWorkspacesUpdate(d *schema.ResourceData, meta interface{}) error { + config := meta.(ConfiguredClient) + + apID := d.Get("agent_pool_id").(string) + + // Create a new options struct. + options := tfe.AgentPoolExcludedWorkspacesUpdateOptions{ + ExcludedWorkspaces: []*tfe.Workspace{}, + } + + if excludedWorkspaceIDs, excludedWorkspaceSet := d.GetOk("excluded_workspace_ids"); excludedWorkspaceSet { + options.ExcludedWorkspaces = []*tfe.Workspace{} + for _, workspaceID := range excludedWorkspaceIDs.(*schema.Set).List() { + if val, ok := workspaceID.(string); ok { + options.ExcludedWorkspaces = append(options.ExcludedWorkspaces, &tfe.Workspace{ID: val}) + } + } + } + + log.Printf("[DEBUG] Update agent pool: %s", apID) + _, err := config.Client.AgentPools.UpdateExcludedWorkspaces(ctx, apID, options) + if err != nil { + return fmt.Errorf("Error updating agent pool %s: %w", apID, err) + } + + d.SetId(apID) + + return nil +} + +func resourceTFEAgentPoolExcludedWorkspacesDelete(d *schema.ResourceData, meta interface{}) error { + config := meta.(ConfiguredClient) + + apID := d.Get("agent_pool_id").(string) + + // Create a new options struct. + options := tfe.AgentPoolExcludedWorkspacesUpdateOptions{ + ExcludedWorkspaces: []*tfe.Workspace{}, + } + + log.Printf("[DEBUG] Update agent pool: %s", apID) + _, err := config.Client.AgentPools.UpdateExcludedWorkspaces(ctx, apID, options) + if err != nil { + return fmt.Errorf("Error updating agent pool %s: %w", apID, err) + } + + return nil +} diff --git a/internal/provider/resource_tfe_agent_pool_excluded_workspaces_test.go b/internal/provider/resource_tfe_agent_pool_excluded_workspaces_test.go new file mode 100644 index 000000000..d7cb953ac --- /dev/null +++ b/internal/provider/resource_tfe_agent_pool_excluded_workspaces_test.go @@ -0,0 +1,215 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "errors" + "fmt" + "testing" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAccTFEAgentPoolExcludedWorkspaces_create_update(t *testing.T) { + tfeClient, err := getClientUsingEnv() + if err != nil { + t.Fatal(err) + } + + org, orgCleanup := createBusinessOrganization(t, tfeClient) + t.Cleanup(orgCleanup) + + excludedWorkspaceIDs := &[]string{} + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccMuxedProviders, + Steps: []resource.TestStep{ + { + Config: testAccTFEAgentPoolExcludedWorkspaces_basic(org.Name), + Check: resource.ComposeTestCheckFunc( + testAccCheckTFEAgentPoolExcludedWorkspacesExists("tfe_agent_pool.foobar", excludedWorkspaceIDs), + testAccCheckTFEAgentPoolExcludedWorkspacesCount(2, excludedWorkspaceIDs), + ), + }, + { + Config: testAccTFEAgentPoolExcludedWorkspaces_update(org.Name), + Check: resource.ComposeTestCheckFunc( + testAccCheckTFEAgentPoolExcludedWorkspacesExists("tfe_agent_pool.foobar", excludedWorkspaceIDs), + testAccCheckTFEAgentPoolExcludedWorkspacesCount(1, excludedWorkspaceIDs), + ), + }, + { + Config: testAccTFEAgentPoolExcludedWorkspaces_destroy(org.Name), + Check: resource.ComposeTestCheckFunc( + testAccCheckTFEAgentPoolExcludedWorkspacesNotExists("tfe_agent_pool.foobar"), + ), + }, + }, + }) +} + +func testAccCheckTFEAgentPoolExcludedWorkspacesExists(resourceName string, excludedWorkspaces *[]string) resource.TestCheckFunc { + return func(s *terraform.State) error { + *excludedWorkspaces = []string{} + + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + + // Resource ID equals the Agent Pool ID + if rs.Primary.ID == "" { + return fmt.Errorf("No instance ID is set") + } + + agentPool, err := testAccConfiguredClient.Client.AgentPools.Read(ctx, rs.Primary.ID) + if err != nil && !errors.Is(err, tfe.ErrResourceNotFound) { + return fmt.Errorf("error while fetching agent pool: %w", err) + } + + if len(agentPool.ExcludedWorkspaces) == 0 { + return fmt.Errorf("Excluded Workspaces for agent pool %s do not exist", rs.Primary.ID) + } + + for _, workspace := range agentPool.ExcludedWorkspaces { + *excludedWorkspaces = append(*excludedWorkspaces, workspace.ID) + } + + return nil + } +} + +func testAccCheckTFEAgentPoolExcludedWorkspacesNotExists(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + + // Resource ID equals the Agent Pool ID + if rs.Primary.ID == "" { + return fmt.Errorf("No instance ID is set") + } + + agentPool, err := testAccConfiguredClient.Client.AgentPools.Read(ctx, rs.Primary.ID) + if err != nil && !errors.Is(err, tfe.ErrResourceNotFound) { + return fmt.Errorf("error while fetching agent pool: %w", err) + } + + if len(agentPool.ExcludedWorkspaces) > 0 { + return fmt.Errorf("Excluded Workspaces for agent pool %s exists", rs.Primary.ID) + } + + return nil + } +} + +func testAccCheckTFEAgentPoolExcludedWorkspacesCount(expected int, excludedWorkspaces *[]string) resource.TestCheckFunc { + return func(s *terraform.State) error { + if len(*excludedWorkspaces) != expected { + return fmt.Errorf("expected %d excluded workspaces, got %d", expected, len(*excludedWorkspaces)) + } + return nil + } +} + +func TestAccTFEAgentPoolExcludedWorkspaces_import(t *testing.T) { + skipIfEnterprise(t) + + tfeClient, err := getClientUsingEnv() + if err != nil { + t.Fatal(err) + } + + org, orgCleanup := createBusinessOrganization(t, tfeClient) + t.Cleanup(orgCleanup) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccMuxedProviders, + Steps: []resource.TestStep{ + { + Config: testAccTFEAgentPoolExcludedWorkspaces_basic(org.Name), + }, + { + ResourceName: "tfe_agent_pool_excluded_workspaces.foobar", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccTFEAgentPoolExcludedWorkspaces_destroy(organization string) string { + return fmt.Sprintf(` +resource "tfe_workspace" "foobar" { + name = "foobar" + organization = "%s" +} + +resource "tfe_workspace" "test-workspace" { + name = "test-workspace" + organization = "%s" +} + +resource "tfe_agent_pool" "foobar" { + name = "agent-pool-updated" + organization = "%s" + organization_scoped = false +}`, organization, organization, organization) +} + +func testAccTFEAgentPoolExcludedWorkspaces_update(organization string) string { + return fmt.Sprintf(` +resource "tfe_workspace" "foobar" { + name = "foobar" + organization = "%s" +} + +resource "tfe_workspace" "test-workspace" { + name = "test-workspace" + organization = "%s" +} + +resource "tfe_agent_pool" "foobar" { + name = "agent-pool-updated" + organization = "%s" + organization_scoped = false +} + +resource "tfe_agent_pool_excluded_workspaces" "foobar"{ + agent_pool_id = tfe_agent_pool.foobar.id + excluded_workspace_ids = [tfe_workspace.foobar.id] +}`, organization, organization, organization) +} + +func testAccTFEAgentPoolExcludedWorkspaces_basic(organization string) string { + return fmt.Sprintf(` +resource "tfe_workspace" "foobar" { + name = "foobar" + organization = "%s" +} + +resource "tfe_workspace" "test-workspace" { + name = "test-workspace" + organization = "%s" +} + +resource "tfe_agent_pool" "foobar" { + name = "agent-pool-updated" + organization = "%s" + organization_scoped = false +} + +resource "tfe_agent_pool_excluded_workspaces" "foobar"{ + agent_pool_id = tfe_agent_pool.foobar.id + excluded_workspace_ids = [ + tfe_workspace.foobar.id, + tfe_workspace.test-workspace.id + ] +}`, organization, organization, organization) +} diff --git a/internal/provider/resource_tfe_project_settings.go b/internal/provider/resource_tfe_project_settings.go new file mode 100644 index 000000000..922eade82 --- /dev/null +++ b/internal/provider/resource_tfe_project_settings.go @@ -0,0 +1,443 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "errors" + "fmt" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// tfe_project_settings resource +var _ resource.Resource = &projectSettings{} + +// projectOverwritesElementType is the object type definition for the +// overwrites field schema. +var projectOverwritesElementType = map[string]attr.Type{ + "default_execution_mode": types.BoolType, + "default_agent_pool_id": types.BoolType, +} + +type projectSettings struct { + config ConfiguredClient +} + +type modelProjectSettings struct { + ID types.String `tfsdk:"id"` + ProjectID types.String `tfsdk:"project_id"` + DefaultExecutionMode types.String `tfsdk:"default_execution_mode"` + DefaultAgentPoolID types.String `tfsdk:"default_agent_pool_id"` + Overwrites types.Object `tfsdk:"overwrites"` +} + +type projectOverwrites struct { + DefaultExecutionMode types.Bool `tfsdk:"default_execution_mode"` + DefaultAgentPoolID types.Bool `tfsdk:"default_agent_pool_id"` +} + +// errProjectNoLongerExists is returned when reading the project settings but +// the project no longer exists. +var errProjectNoLongerExists = errors.New("project no longer exists") + +// validateProjectDefaultAgentExecutionMode is a PlanModifier that validates that the combination +// of "default_execution_mode" and "default_agent_pool_id" is compatible. +type validateProjectDefaultAgentExecutionMode struct{} + +// revertOverwritesIfDefaultExecutionModeUnset is a PlanModifier for "overwrites" that +// sets the values to false if default_execution_mode is unset. This tells the server to +// compute default_execution_mode and default_agent_pool_id if defaults are set. This +// modifier must be used in conjunction with unknownIfDefaultExecutionModeUnset plan +// modifier on the default_execution_mode and default_agent_pool_id fields. +type revertOverwritesIfDefaultExecutionModeUnset struct{} + +// unknownIfDefaultExecutionModeUnset sets the planned value to (known after apply) if +// default_execution_mode is unset, avoiding an inconsistent state after the apply. This +// allows the server to compute the new value based on the default. It should be +// applied to both default_execution_mode and default_agent_pool_id in conjunction with +// revertOverwritesIfDefaultExecutionModeUnset. +type unknownIfDefaultExecutionModeUnset struct{} + +// overwriteExecutionModeIfSpecified is a PlanModifier that forces the value of +// default_execution_mode to be set if it is explicitly specified in the configuration, +// even if it matches the organization default. This ensures that a value is always +// sent to the API when the user has specified one. +type overwriteExecutionModeIfSpecified struct{} + +var _ planmodifier.String = (*validateProjectDefaultAgentExecutionMode)(nil) +var _ planmodifier.Object = (*revertOverwritesIfDefaultExecutionModeUnset)(nil) +var _ planmodifier.String = (*unknownIfDefaultExecutionModeUnset)(nil) +var _ planmodifier.Object = (*overwriteExecutionModeIfSpecified)(nil) + +func (m validateProjectDefaultAgentExecutionMode) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + configured := modelProjectSettings{} + resp.Diagnostics.Append(req.Config.Get(ctx, &configured)...) + + if configured.DefaultExecutionMode.ValueString() == "agent" && configured.DefaultAgentPoolID.IsNull() { + resp.Diagnostics.AddError("Invalid default_agent_pool_id", "If default execution mode is \"agent\", \"default_agent_pool_id\" is required") + } + + if configured.DefaultExecutionMode.ValueString() != "agent" && !configured.DefaultAgentPoolID.IsNull() { + resp.Diagnostics.AddError("Invalid default_agent_pool_id", "If default execution mode is not \"agent\", \"default_agent_pool_id\" must not be set") + } +} + +func (m validateProjectDefaultAgentExecutionMode) Description(_ context.Context) string { + return "Validates that configuration values for \"default_agent_pool_id\" and \"default_execution_mode\" are compatible" +} + +func (m validateProjectDefaultAgentExecutionMode) MarkdownDescription(_ context.Context) string { + return "Validates that configuration values for \"default_agent_pool_id\" and \"default_execution_mode\" are compatible" +} + +func (m revertOverwritesIfDefaultExecutionModeUnset) PlanModifyObject(ctx context.Context, req planmodifier.ObjectRequest, resp *planmodifier.ObjectResponse) { + // Check if the resource is being created. + if req.State.Raw.IsNull() { + return + } + + // Determine if configured default_execution_mode is being unset + state := modelProjectSettings{} + configured := modelProjectSettings{} + + resp.Diagnostics.Append(req.Config.Get(ctx, &configured)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + // Check if overwrites are supported by the platform + if state.Overwrites.IsNull() { + return + } + + overwritesState := projectOverwrites{} + + state.Overwrites.As(ctx, &overwritesState, basetypes.ObjectAsOptions{}) + + // if there is a default execution mode set in state, but not one configured, then set the overwrites to false + if configured.DefaultExecutionMode.IsNull() && overwritesState.DefaultExecutionMode.ValueBool() { + overwritesState.DefaultAgentPoolID = types.BoolValue(false) + overwritesState.DefaultExecutionMode = types.BoolValue(false) + + newProjOverwrites, diags := types.ObjectValueFrom(ctx, projectOverwritesElementType, overwritesState) + resp.Diagnostics.Append(diags...) + + resp.PlanValue = newProjOverwrites + } +} + +func (m revertOverwritesIfDefaultExecutionModeUnset) Description(_ context.Context) string { + return "Reverts to computed defaults if settings are unset" +} + +func (m revertOverwritesIfDefaultExecutionModeUnset) MarkdownDescription(_ context.Context) string { + return "Reverts to computed defaults if settings are unset" +} + +func (m unknownIfDefaultExecutionModeUnset) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + // Check if the resource is being created. + if req.State.Raw.IsNull() { + return + } + + // Determine if configured default_execution_mode is being unset + state := modelProjectSettings{} + configured := modelProjectSettings{} + + resp.Diagnostics.Append(req.Config.Get(ctx, &configured)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if !state.Overwrites.IsNull() { + overwritesState := projectOverwrites{} + state.Overwrites.As(ctx, &overwritesState, basetypes.ObjectAsOptions{}) + + // if there is a default execution mode set in state, but not one configured, then set the planned value for the default execution mode and agent pool to unknown + if configured.DefaultExecutionMode.IsNull() && overwritesState.DefaultExecutionMode.ValueBool() { + resp.PlanValue = types.StringUnknown() + } + } +} + +func (m unknownIfDefaultExecutionModeUnset) Description(_ context.Context) string { + return "Resets default_execution_mode to an unknown value if it is unset" +} + +func (m unknownIfDefaultExecutionModeUnset) MarkdownDescription(_ context.Context) string { + return "Resets default_execution_mode to an unknown value if it is unset" +} + +func (m overwriteExecutionModeIfSpecified) PlanModifyObject(ctx context.Context, req planmodifier.ObjectRequest, resp *planmodifier.ObjectResponse) { + // Check if the resource is being created. + if req.State.Raw.IsNull() { + return + } + + state := modelProjectSettings{} + configured := modelProjectSettings{} + + resp.Diagnostics.Append(req.Config.Get(ctx, &configured)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + overwritesState := projectOverwrites{} + state.Overwrites.As(ctx, &overwritesState, basetypes.ObjectAsOptions{}) + + if !state.Overwrites.IsNull() { + // if an execution mode is configured, ensure that the overwrites are set to true + if !configured.DefaultExecutionMode.IsNull() { + overwritesState.DefaultAgentPoolID = types.BoolValue(true) + overwritesState.DefaultExecutionMode = types.BoolValue(true) + + newList, diags := types.ObjectValueFrom(ctx, projectOverwritesElementType, overwritesState) + resp.Diagnostics.Append(diags...) + + resp.PlanValue = newList + } + } +} + +func (m overwriteExecutionModeIfSpecified) Description(_ context.Context) string { + return "Overwrites default_execution_mode if it is explicitly specified in the configuration, even if it matches the organization default" +} + +func (m overwriteExecutionModeIfSpecified) MarkdownDescription(_ context.Context) string { + return "Overwrites default_execution_mode if it is explicitly specified in the configuration, even if it matches the organization default" +} + +func (r *projectSettings) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Additional Project settings, which may override organization defaults", + DeprecationMessage: "", + Version: 1, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: "Service-generated identifier for the resource", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + + "project_id": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + + "default_execution_mode": schema.StringAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + unknownIfDefaultExecutionModeUnset{}, + }, + Validators: []validator.String{ + stringvalidator.OneOf("agent", "local", "remote"), + }, + }, + + "default_agent_pool_id": schema.StringAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + unknownIfDefaultExecutionModeUnset{}, + validateProjectDefaultAgentExecutionMode{}, + }, + }, + "overwrites": schema.SingleNestedAttribute{ + Computed: true, + Description: "Describes which settings are being overwritten from the organization defaults", + Attributes: map[string]schema.Attribute{ + "default_execution_mode": schema.BoolAttribute{ + Computed: true, + Description: "Whether the default_execution_mode is being overwritten from the organization default", + }, + "default_agent_pool_id": schema.BoolAttribute{ + Computed: true, + Description: "Whether the default_agent_pool_id is being overwritten from the organization default", + }, + }, + PlanModifiers: []planmodifier.Object{ + revertOverwritesIfDefaultExecutionModeUnset{}, + overwriteExecutionModeIfSpecified{}, + }, + }, + }, + } +} + +// projectSettingsModelFromTFEProject builds a resource model from the TFE model +func (r *projectSettings) projectSettingsModelFromTFEProject(proj *tfe.Project) *modelProjectSettings { + result := modelProjectSettings{ + ID: types.StringValue(proj.ID), + ProjectID: types.StringValue(proj.ID), + DefaultExecutionMode: types.StringValue(proj.DefaultExecutionMode), + } + + if proj.DefaultAgentPool != nil && proj.DefaultExecutionMode == "agent" { + result.DefaultAgentPoolID = types.StringValue(proj.DefaultAgentPool.ID) + } + + result.Overwrites = types.ObjectNull(projectOverwritesElementType) + if proj.SettingOverwrites != nil { + settingsModel := projectOverwrites{ + DefaultExecutionMode: types.BoolValue(*proj.SettingOverwrites.ExecutionMode), + DefaultAgentPoolID: types.BoolValue(*proj.SettingOverwrites.AgentPool), + } + + objectOverwrites, diags := types.ObjectValueFrom(ctx, projectOverwritesElementType, settingsModel) + if diags.HasError() { + panic("Could not build object value from model. This should not be possible unless the model breaks reflection rules.") + } + + result.Overwrites = objectOverwrites + } + + return &result +} + +func (r *projectSettings) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data modelProjectSettings + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + model, err := r.readSettings(ctx, data.ProjectID.ValueString()) + if errors.Is(err, errProjectNoLongerExists) { + resp.State.RemoveResource(ctx) + return + } else if err != nil { + resp.Diagnostics.AddError("Error reading project", err.Error()) + } + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) +} + +func (r *projectSettings) readSettings(ctx context.Context, projectID string) (*modelProjectSettings, error) { + proj, err := r.config.Client.Projects.Read(ctx, projectID) + if errors.Is(err, tfe.ErrResourceNotFound) { + return nil, errProjectNoLongerExists + } + + if err != nil { + return nil, fmt.Errorf("error reading configuration of project %s: %w", projectID, err) + } + + return r.projectSettingsModelFromTFEProject(proj), nil +} + +func (r *projectSettings) updateSettings(ctx context.Context, data *modelProjectSettings, state *tfsdk.State) error { + projectID := data.ProjectID.ValueString() + + updateOptions := tfe.ProjectUpdateOptions{ + SettingOverwrites: &tfe.ProjectSettingOverwrites{ + ExecutionMode: tfe.Bool(false), + AgentPool: tfe.Bool(false), + }, + } + + defaultExecutionMode := data.DefaultExecutionMode.ValueString() + if defaultExecutionMode != "" { + updateOptions.DefaultExecutionMode = tfe.String(defaultExecutionMode) + updateOptions.SettingOverwrites.ExecutionMode = tfe.Bool(true) + updateOptions.SettingOverwrites.AgentPool = tfe.Bool(true) + + defaultAgentPoolID := data.DefaultAgentPoolID.ValueString() // may be empty + updateOptions.DefaultAgentPoolID = tfe.String(defaultAgentPoolID) + } + + proj, err := r.config.Client.Projects.Update(ctx, projectID, updateOptions) + if err != nil { + return fmt.Errorf("couldn't update project %s: %w", projectID, err) + } + + model, err := r.readSettings(ctx, proj.ID) + if errors.Is(err, errProjectNoLongerExists) { + state.RemoveResource(ctx) + } + + if err == nil { + state.Set(ctx, model) + } + + return err +} + +func (r *projectSettings) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var projectID string + resp.Diagnostics.Append(req.Plan.GetAttribute(ctx, path.Root("project_id"), &projectID)...) + if resp.Diagnostics.HasError() { + return + } + + planned := modelProjectSettings{} + resp.Diagnostics.Append(req.Config.Get(ctx, &planned)...) + + if resp.Diagnostics.HasError() { + return + } + + if err := r.updateSettings(ctx, &planned, &resp.State); err != nil { + resp.Diagnostics.AddError("Error updating project", err.Error()) + } +} + +func (r *projectSettings) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data modelProjectSettings + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if err := r.updateSettings(ctx, &data, &resp.State); err != nil { + resp.Diagnostics.AddError("Error updating project", err.Error()) + } +} + +func (r *projectSettings) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data modelProjectSettings + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + noneModel := modelProjectSettings{ + ID: data.ID, + ProjectID: data.ID, + } + + if err := r.updateSettings(ctx, &noneModel, &resp.State); err == nil { + resp.State.RemoveResource(ctx) + } +} + +func (r *projectSettings) Metadata(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "tfe_project_settings" +} + +func (r *projectSettings) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Early exit if provider is un-configured (i.e. we're only validating config or something) + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(ConfiguredClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected resource Configure type", + fmt.Sprintf("Expected tfe.ConfiguredClient, got %T. This is a bug in the tfe provider, so please report it on GitHub.", req.ProviderData), + ) + } + r.config = client +} + +func (r *projectSettings) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resp.State.SetAttribute(ctx, path.Root("project_id"), req.ID) + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func NewProjectSettingsResource() resource.Resource { + return &projectSettings{} +} diff --git a/internal/provider/resource_tfe_project_settings_test.go b/internal/provider/resource_tfe_project_settings_test.go new file mode 100644 index 000000000..2de0f16f6 --- /dev/null +++ b/internal/provider/resource_tfe_project_settings_test.go @@ -0,0 +1,345 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "fmt" + "math/rand" + "regexp" + "strings" + "testing" + "time" + + "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +// test that agent pool needs execution mode and vice versa + +func TestAccTFEProjectSettings_DefaultExecutionMode(t *testing.T) { + project := &tfe.Project{} + rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccMuxedProviders, + CheckDestroy: testAccCheckTFEProjectDestroy, + Steps: []resource.TestStep{ + { + Config: testAccTFEProjectSettings_empty(rInt), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("tfe_project_settings.foobar_settings", tfjsonpath.New("default_execution_mode")), + plancheck.ExpectUnknownValue("tfe_project_settings.foobar_settings", tfjsonpath.New("default_agent_pool_id")), + plancheck.ExpectUnknownValue("tfe_project_settings.foobar_settings", tfjsonpath.New("overwrites")), + }, + }, + Check: resource.ComposeTestCheckFunc( + testAccCheckTFEProjectExists( + "tfe_project.foobar", project), + + func(s *terraform.State) error { + if project.Name != "project_settings_test" { + return fmt.Errorf("Bad name: %s", project.Name) + } + return nil + }, + + resource.TestCheckResourceAttr( + "tfe_project.foobar", "name", "project_settings_test"), + // the default execution mode should be the org default which is remote + resource.TestCheckResourceAttr( + "tfe_project_settings.foobar_settings", "default_execution_mode", "remote"), + resource.TestCheckNoResourceAttr( + "tfe_project_settings.foobar_settings", "default_agent_pool_id"), + resource.TestCheckResourceAttr( + "tfe_project_settings.foobar_settings", "overwrites.default_execution_mode", "false"), + resource.TestCheckResourceAttr( + "tfe_project_settings.foobar_settings", "overwrites.default_agent_pool_id", "false"), + ), + }, + { + Config: testAccTFEProjectSettings_executionMode(rInt, "remote"), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectKnownValue("tfe_project_settings.foobar_settings", + tfjsonpath.New("default_execution_mode"), + knownvalue.StringExact("remote")), + plancheck.ExpectKnownValue("tfe_project_settings.foobar_settings", + tfjsonpath.New("default_agent_pool_id"), + knownvalue.Null()), + plancheck.ExpectKnownValue("tfe_project_settings.foobar_settings", + tfjsonpath.New("overwrites").AtMapKey("default_execution_mode"), + knownvalue.Bool(true)), + plancheck.ExpectKnownValue("tfe_project_settings.foobar_settings", + tfjsonpath.New("overwrites").AtMapKey("default_agent_pool_id"), + knownvalue.Bool(true)), + }, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "tfe_project_settings.foobar_settings", "default_execution_mode", "remote"), + resource.TestCheckNoResourceAttr( + "tfe_project_settings.foobar_settings", "default_agent_pool_id"), + resource.TestCheckResourceAttr( + "tfe_project_settings.foobar_settings", "overwrites.default_execution_mode", "true"), + resource.TestCheckResourceAttr( + "tfe_project_settings.foobar_settings", "overwrites.default_agent_pool_id", "true"), + ), + }, + { + Config: testAccTFEProjectSettings_executionMode(rInt, "local"), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectKnownValue("tfe_project_settings.foobar_settings", + tfjsonpath.New("default_execution_mode"), + knownvalue.StringExact("local")), + plancheck.ExpectKnownValue("tfe_project_settings.foobar_settings", + tfjsonpath.New("overwrites").AtMapKey("default_execution_mode"), + knownvalue.Bool(true)), + plancheck.ExpectKnownValue("tfe_project_settings.foobar_settings", + tfjsonpath.New("overwrites").AtMapKey("default_agent_pool_id"), + knownvalue.Bool(true)), + }, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "tfe_project_settings.foobar_settings", "default_execution_mode", "local"), + resource.TestCheckNoResourceAttr( + "tfe_project_settings.foobar_settings", "default_agent_pool_id"), + resource.TestCheckResourceAttr( + "tfe_project_settings.foobar_settings", "overwrites.default_execution_mode", "true"), + resource.TestCheckResourceAttr( + "tfe_project_settings.foobar_settings", "overwrites.default_agent_pool_id", "true"), + ), + }, + { + Config: testAccTFEProjectSettings_executionMode(rInt, "agent"), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectKnownValue("tfe_project_settings.foobar_settings", + tfjsonpath.New("default_execution_mode"), + knownvalue.StringExact("agent")), + plancheck.ExpectKnownValue("tfe_project_settings.foobar_settings", + tfjsonpath.New("default_agent_pool_id"), + knownvalue.StringFunc(func(v string) error { + if strings.HasPrefix(v, "apool-") { + return nil + } + return fmt.Errorf("expected an agent pool id, got %s", v) + })), + plancheck.ExpectKnownValue("tfe_project_settings.foobar_settings", + tfjsonpath.New("overwrites").AtMapKey("default_execution_mode"), + knownvalue.Bool(true)), + plancheck.ExpectKnownValue("tfe_project_settings.foobar_settings", + tfjsonpath.New("overwrites").AtMapKey("default_agent_pool_id"), + knownvalue.Bool(true)), + }, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "tfe_project_settings.foobar_settings", "default_execution_mode", "agent"), + resource.TestCheckResourceAttrSet( + "tfe_project_settings.foobar_settings", "default_agent_pool_id"), + resource.TestCheckResourceAttr( + "tfe_project_settings.foobar_settings", "overwrites.default_execution_mode", "true"), + resource.TestCheckResourceAttr( + "tfe_project_settings.foobar_settings", "overwrites.default_agent_pool_id", "true"), + ), + }, + { + Config: testAccTFEProjectSettings_empty(rInt), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("tfe_project_settings.foobar_settings", tfjsonpath.New("default_execution_mode")), + plancheck.ExpectUnknownValue("tfe_project_settings.foobar_settings", tfjsonpath.New("default_agent_pool_id")), + plancheck.ExpectKnownValue("tfe_project_settings.foobar_settings", + tfjsonpath.New("overwrites").AtMapKey("default_execution_mode"), + knownvalue.Bool(false)), + plancheck.ExpectKnownValue("tfe_project_settings.foobar_settings", + tfjsonpath.New("overwrites").AtMapKey("default_agent_pool_id"), + knownvalue.Bool(false)), + }, + }, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "tfe_project_settings.foobar_settings", "default_execution_mode", "remote"), + resource.TestCheckNoResourceAttr( + "tfe_project_settings.foobar_settings", "default_agent_pool_id"), + resource.TestCheckResourceAttr( + "tfe_project_settings.foobar_settings", "overwrites.default_execution_mode", "false"), + resource.TestCheckResourceAttr( + "tfe_project_settings.foobar_settings", "overwrites.default_agent_pool_id", "false"), + ), + }, + }, + }) +} + +func TestAccTFEProjectSettingsImport(t *testing.T) { + rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() + project := &tfe.Project{} + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccMuxedProviders, + CheckDestroy: testAccCheckTFEProjectDestroy, + Steps: []resource.TestStep{ + { + Config: testAccTFEProjectSettings_empty(rInt), + Check: testAccCheckTFEProjectExists( + "tfe_project.foobar", project), + }, + + { + ResourceName: "tfe_project_settings.foobar_settings", + ImportState: true, + ImportStateVerify: true, + }, + { + ResourceName: "tfe_project_settings.foobar_settings", + ImportState: true, + ImportStateId: project.ID, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccTFEProjectSettings_executionModeAgentPoolMismatch(t *testing.T) { + project := &tfe.Project{} + rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() + + // Verify that setting execution mode to agent requires and agent pool ID, and vice versa + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccMuxedProviders, + CheckDestroy: testAccCheckTFEProjectDestroy, + Steps: []resource.TestStep{ + { + Config: testAccTFEProjectSettings_empty(rInt), + Check: resource.ComposeTestCheckFunc( + testAccCheckTFEProjectExists( + "tfe_project.foobar", project), + + func(s *terraform.State) error { + if project.Name != "project_settings_test" { + return fmt.Errorf("Bad name: %s", project.Name) + } + return nil + }, + ), + }, + { + Config: testAccTFEProjectSettings_executionModeWithoutAgentPool(rInt), + ExpectError: regexp.MustCompile(`If default execution mode is \"agent\", \"default_agent_pool_id\" is required`), + }, + { + Config: testAccTFEProjectSettings_agentPoolWithoutExecutionMode(rInt), + ExpectError: regexp.MustCompile(`If default execution mode is not \"agent\", \"default_agent_pool_id\" must not be`), + }, + }, + }) +} + +func testAccTFEProjectSettings_empty(rInt int) string { + return fmt.Sprintf(` +resource "tfe_organization" "foobar" { + name = "tst-terraform-%d" + email = "admin@company.com" +} + +resource "tfe_agent_pool" "project_pool" { + name = "project-agent-pool" + organization = tfe_organization.foobar.name +} + +resource "tfe_project" "foobar" { + organization = tfe_organization.foobar.name + name = "project_settings_test" + description = "project description" +} + +resource "tfe_project_settings" "foobar_settings" { + project_id = tfe_project.foobar.id +}`, rInt) +} + +func testAccTFEProjectSettings_executionMode(rInt int, executionMode string) string { + agentPool := "" + if executionMode == "agent" { + agentPool = ` default_agent_pool_id = tfe_agent_pool.project_pool.id ` + } + return fmt.Sprintf(` +resource "tfe_organization" "foobar" { + name = "tst-terraform-%d" + email = "admin@company.com" +} + +resource "tfe_agent_pool" "project_pool" { + name = "project-agent-pool" + organization = tfe_organization.foobar.name +} + +resource "tfe_project" "foobar" { + organization = tfe_organization.foobar.name + name = "projecttest" + description = "project description" +} + +resource "tfe_project_settings" "foobar_settings" { + project_id = tfe_project.foobar.id + default_execution_mode="%s" + %s +}`, rInt, executionMode, agentPool) +} + +func testAccTFEProjectSettings_executionModeWithoutAgentPool(rInt int) string { + return fmt.Sprintf(`resource "tfe_organization" "foobar" { + name = "tst-terraform-%d" + email = "admin@company.com" +} + +resource "tfe_agent_pool" "project_pool" { + name = "project-agent-pool" + organization = tfe_organization.foobar.name +} + +resource "tfe_project" "foobar" { + organization = tfe_organization.foobar.name + name = "project_settings_test" + description = "project description" +} + +resource "tfe_project_settings" "foobar_settings" { + project_id = tfe_project.foobar.id + default_execution_mode="agent" +}`, rInt) +} + +func testAccTFEProjectSettings_agentPoolWithoutExecutionMode(rInt int) string { + return fmt.Sprintf(`resource "tfe_organization" "foobar" { + name = "tst-terraform-%d" + email = "admin@company.com" +} + +resource "tfe_agent_pool" "project_pool" { + name = "project-agent-pool" + organization = tfe_organization.foobar.name +} + +resource "tfe_project" "foobar" { + organization = tfe_organization.foobar.name + name = "project_settings_test" + description = "project description" +} + +resource "tfe_project_settings" "foobar_settings" { + project_id = tfe_project.foobar.id + default_agent_pool_id = tfe_agent_pool.project_pool.id +}`, rInt) +} diff --git a/website/docs/d/agent_pool.html.markdown b/website/docs/d/agent_pool.html.markdown index bf0320c12..b8ac99a47 100644 --- a/website/docs/d/agent_pool.html.markdown +++ b/website/docs/d/agent_pool.html.markdown @@ -30,4 +30,7 @@ The following arguments are supported: In addition to all arguments above, the following attributes are exported: * `id` - The agent pool ID. +* `allowed_project_ids` - The set of project IDs that have permission to use the agent pool. +* `allowed_workspace_ids` - The set of workspace IDs that have permission to use the agent pool. +* `excluded_workspace_ids` - The set of workspace IDs that are excluded from the scope of the agent pool. * `organization_scoped` - Whether or not the agent pool can be used by all workspaces in the organization. diff --git a/website/docs/r/agent_pool_allowed_projects.html.markdown b/website/docs/r/agent_pool_allowed_projects.html.markdown new file mode 100644 index 000000000..31f4ebdfe --- /dev/null +++ b/website/docs/r/agent_pool_allowed_projects.html.markdown @@ -0,0 +1,57 @@ +--- +layout: "tfe" +page_title: "Terraform Enterprise: tfe_agent_pool_allowed_projects" +description: |- + Manages allowed projects on agent pools +--- + +# tfe_agent_pool_allowed_projects + +Adds and removes allowed projects on an agent pool. + +~> **NOTE:** This resource requires using the provider with HCP Terraform and a HCP Terraform +for Business account. +[Learn more about HCP Terraform pricing here](https://www.hashicorp.com/products/terraform/pricing). + +## Example Usage + +```hcl +resource "tfe_organization" "test-organization" { + name = "my-org-name" + email = "admin@company.com" +} + +// Ensure project and agent pool are create first +resource "tfe_project" "test-project" { + name = "my-project-name" + organization = tfe_organization.test-organization.name +} + +resource "tfe_agent_pool" "test-agent-pool" { + name = "my-agent-pool-name" + organization = tfe_organization.test-organization.name + organization_scoped = false +} + +// Ensure permissions are assigned second +resource "tfe_agent_pool_allowed_projects" "allowed" { + agent_pool_id = tfe_agent_pool.test-agent-pool.id + allowed_project_ids = [tfe_project.test-project.id] +} +``` + +## Argument Reference + +The following arguments are supported: + +* `agent_pool_id` - (Required) The ID of the agent pool. +* `allowed_project_ids` - (Required) IDs of projects to be added as allowed projects on the agent pool. + + +## Import + +A resource can be imported; use `` as the import ID. For example: + +```shell +terraform import tfe_agent_pool_allowed_projects.foobar apool-rW0KoLSlnuNb5adB +``` diff --git a/website/docs/r/agent_pool_excluded_workspaces.html.markdown b/website/docs/r/agent_pool_excluded_workspaces.html.markdown new file mode 100644 index 000000000..d7b7052bc --- /dev/null +++ b/website/docs/r/agent_pool_excluded_workspaces.html.markdown @@ -0,0 +1,57 @@ +--- +layout: "tfe" +page_title: "Terraform Enterprise: tfe_agent_pool_excluded_workspaces" +description: |- + Manages excluded workspaces on agent pools +--- + +# tfe_agent_pool_excluded_workspaces + +Adds and removes excluded workspaces on an agent pool. + +~> **NOTE:** This resource requires using the provider with HCP Terraform and a HCP Terraform +for Business account. +[Learn more about HCP Terraform pricing here](https://www.hashicorp.com/products/terraform/pricing). + +## Example Usage + +```hcl +resource "tfe_organization" "test-organization" { + name = "my-org-name" + email = "admin@company.com" +} + +// Ensure workspace and agent pool are create first +resource "tfe_workspace" "test-workspace" { + name = "my-workspace-name" + organization = tfe_organization.test-organization.name +} + +resource "tfe_agent_pool" "test-agent-pool" { + name = "my-agent-pool-name" + organization = tfe_organization.test-organization.name + organization_scoped = false +} + +// Ensure permissions are assigned second +resource "tfe_agent_pool_excluded_workspaces" "excluded" { + agent_pool_id = tfe_agent_pool.test-agent-pool.id + excluded_workspace_ids = [tfe_workspace.test-workspace.id] +} +``` + +## Argument Reference + +The following arguments are supported: + +* `agent_pool_id` - (Required) The ID of the agent pool. +* `excluded_workspace_ids` - (Required) IDs of workspaces to be added as excluded workspaces on the agent pool. + + +## Import + +A resource can be imported; use `` as the import ID. For example: + +```shell +terraform import tfe_agent_pool_excluded_workspaces.foobar apool-rW0KoLSlnuNb5adB +``` diff --git a/website/docs/r/project_settings.markdown b/website/docs/r/project_settings.markdown new file mode 100644 index 000000000..f2116b748 --- /dev/null +++ b/website/docs/r/project_settings.markdown @@ -0,0 +1,70 @@ +--- +layout: "tfe" +page_title: "Terraform Enterprise: tfe_project_settings" +description: |- + Manage Project Settings. +--- + +# tfe_project_settings + +**Requires Terraform CLI version 1.0 and later** + +Use this resource to manage Project Settings. + +Primarily, this resource allows setting default execution mode and agent pool for all workspaces within a project. When not specified, the organization defaults will be used. + +## Example Usage + +Basic usage: + +```hcl +resource "tfe_organization" "test" { + name = "my-org-name" + email = "admin@company.com" + + # this will end up being overwritten at the project level + default_execution_mode = "remote" +} + +resource "tfe_agent_pool" "my_agents" { + name = "my-agent-pool" + organization = tfe_organization.test.name +} + +resource "tfe_project" "my_project" { + name = "my-project" + organization = tfe_organization.test.name +} + +resource "tfe_project_settings" "my_project_settings" { + project_id = tfe_project.my_project.id + + # workspaces in this project will use agent execution mode by default, + # and will use the specified agent pool. + default_execution_mode = "agent" + default_agent_pool_id = tfe_agent_pool.my_agents.id +} +``` + +## Argument Reference + +The following arguments are supported: +* `project_id` - (Required) The ID of the project to manage settings for. +* `default_execution_mode` - (Optional) Which [execution mode](https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings#execution-mode) + to use as the default for all workspaces in the project. Valid values are `remote`, `local` or `agent`. +* `default_agent_pool_id` - (Optional) The ID of an agent pool to assign to the workspace. Requires `default_execution_mode` to be set to `agent`. This value _must not_ be provided if `default_execution_mode` is set to any other value. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: +* `overwrites` - Can be used to check whether a setting is currently inheriting its value from the organization. + - `default_execution_mode` - Set to `true` if the default execution mode of the project is being determined by the setting on the project itself. It will be `false` if the execution mode is inherited from another resource (e.g. the organization's default execution mode) + - `default_agent_pool_id` - Set to `true` if the default agent pool of the project is being determined by the setting on the project itself. It will be `false` if the agent pool is inherited from another resource (e.g. the organization's default agent pool)parent project. + +## Import + +Project settings can be imported; use the `` as the import ID. For example: + +```shell +terraform import tfe_project_settings.my_project_settings +```