diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 678d6bca9..0ff5bf358 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @hashicorp/tf-practitioner +* @hashicorp/tf-cli diff --git a/CHANGELOG.md b/CHANGELOG.md index ac43dc2e2..d0e179bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,15 @@ -## 0.33.0 (Unreleased) +## Unreleased + +ENHANCEMENTS: +* d/agent_pool: Improve efficiency of reading agent pool data when the target organization has more than 20 agent pools ([#508](https://github.com/hashicorp/terraform-provider-tfe/pull/508)) +* Added warning logs for 404 error responses ([#538](https://github.com/hashicorp/terraform-provider-tfe/pull/538)) + +## 0.33.0 (July 8th, 2022) FEATURES: * **New Resource**: `tfe_workspace_variable_set` ([#537](https://github.com/hashicorp/terraform-provider-tfe/pull/537)) adds the ability to assign a variable set to a workspace in a single, flexible resource. * r/tfe_registry_module: Add ability to create both public and private `registry_modules` without VCS. ([#546](https://github.com/hashicorp/terraform-provider-tfe/pull/546)) +* r/tfe_workspace, d/tfe_workspace: `trigger-patterns` ([#502](https://github.com/hashicorp/terraform-provider-tfe/pull/502)) attribute is introduced to support specifying a set of [glob patterns](https://www.terraform.io/cloud-docs/workspaces/settings/vcs#glob-patterns-for-automatic-run-triggering) for automatic VCS run triggering. DEPRECATION NOTICE: * The `workspace_ids` argument on `tfe_variable_set` has been labelled as deprecated and should not be used in conjunction with `tfe_workspace_variable_set`. @@ -39,6 +46,7 @@ FEATURES: * **New Data Source**: d/tfe_organization_run_task ([#488](https://github.com/hashicorp/terraform-provider-tfe/pull/488)) * **New Data Source**: d/tfe_workspace_run_task ([#488](https://github.com/hashicorp/terraform-provider-tfe/pull/488)) * r/tfe_notification_configuration: Add Microsoft Teams notification type ([#484](https://github.com/hashicorp/terraform-provider-tfe/pull/484)) +* d/workspace_ids: Add `exclude_tags` to `tfe_workspace_ids` attributes ([#523](https://github.com/hashicorp/terraform-provider-tfe/pull/523)) ## 0.31.0 (April 21, 2022) diff --git a/README.md b/README.md index bff59827f..0068df8af 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Declare the provider in your configuration and `terraform init` will automatical terraform { required_providers { tfe = { - version = "~> 0.30.2" + version = "~> 0.33.0" } } } @@ -46,7 +46,7 @@ The above snippet using `required_providers` is for Terraform 0.13+; if you are ```hcl provider "tfe" { - version = "~> 0.30.2" + version = "~> 0.33.0" ... } ``` diff --git a/go.mod b/go.mod index 7ae10523b..e12524951 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-retryablehttp v0.7.1 // indirect github.com/hashicorp/go-slug v0.9.1 - github.com/hashicorp/go-tfe v1.4.0 + github.com/hashicorp/go-tfe v1.5.0 github.com/hashicorp/go-version v1.6.0 github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce github.com/hashicorp/hcl/v2 v2.10.0 // indirect diff --git a/go.sum b/go.sum index c35817b93..859cef9b4 100644 --- a/go.sum +++ b/go.sum @@ -218,6 +218,8 @@ github.com/hashicorp/go-slug v0.9.1 h1:gYNVJ3t0jAWx8AT2eYZci3Xd7NBHyjayW9AR1DU4k github.com/hashicorp/go-slug v0.9.1/go.mod h1:Ib+IWBYfEfJGI1ZyXMGNbu2BU+aa3Dzu41RKLH301v4= github.com/hashicorp/go-tfe v1.4.0 h1:rMoQ2r1QppaglYsBdmdgFphipNTCkjTaO1oOLVj04Ec= github.com/hashicorp/go-tfe v1.4.0/go.mod h1:E8a90lC4kjU5Lc2c0D+SnWhUuyuoCIVm4Ewzv3jCD3A= +github.com/hashicorp/go-tfe v1.5.0 h1:MtABkqH2s6lRFl8HaGt0qESLGAyrmMAFfecsEm+13K8= +github.com/hashicorp/go-tfe v1.5.0/go.mod h1:E8a90lC4kjU5Lc2c0D+SnWhUuyuoCIVm4Ewzv3jCD3A= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= diff --git a/tfe/data_source_agent_pool.go b/tfe/data_source_agent_pool.go index 7e9764912..2fdeb58d5 100644 --- a/tfe/data_source_agent_pool.go +++ b/tfe/data_source_agent_pool.go @@ -33,7 +33,11 @@ func dataSourceTFEAgentPoolRead(d *schema.ResourceData, meta interface{}) error organization := d.Get("organization").(string) // Create an options struct. - options := tfe.AgentPoolListOptions{} + // to reduce the number of pages returned, search based on the name. TFE instances which + // do not support agent pool search will just ignore the query parameter + options := tfe.AgentPoolListOptions{ + Query: name, + } for { l, err := tfeClient.AgentPools.List(ctx, organization, &options) diff --git a/tfe/data_source_workspace.go b/tfe/data_source_workspace.go index bf4e89bf7..80f5fc7a9 100644 --- a/tfe/data_source_workspace.go +++ b/tfe/data_source_workspace.go @@ -117,6 +117,12 @@ func dataSourceTFEWorkspace() *schema.Resource { Elem: &schema.Schema{Type: schema.TypeString}, }, + "trigger_patterns": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "working_directory": { Type: schema.TypeString, Computed: true, @@ -183,6 +189,7 @@ func dataSourceTFEWorkspaceRead(d *schema.ResourceData, meta interface{}) error d.Set("structured_run_output_enabled", workspace.StructuredRunOutputEnabled) d.Set("terraform_version", workspace.TerraformVersion) d.Set("trigger_prefixes", workspace.TriggerPrefixes) + d.Set("trigger_patterns", workspace.TriggerPatterns) d.Set("working_directory", workspace.WorkingDirectory) // Set remote_state_consumer_ids if global_remote_state is false diff --git a/tfe/data_source_workspace_ids.go b/tfe/data_source_workspace_ids.go index c77357251..06683a765 100644 --- a/tfe/data_source_workspace_ids.go +++ b/tfe/data_source_workspace_ids.go @@ -26,6 +26,12 @@ func dataSourceTFEWorkspaceIDs() *schema.Resource { Optional: true, }, + "exclude_tags": { + Type: schema.TypeSet, + Elem: &schema.Schema{Type: schema.TypeString}, + Optional: true, + }, + "organization": { Type: schema.TypeString, Required: true, @@ -70,11 +76,27 @@ func dataSourceTFEWorkspaceIDsRead(d *schema.ResourceData, meta interface{}) err options := &tfe.WorkspaceListOptions{} + excludeTagLookupMap := make(map[string]bool) + var excludeTagBuf strings.Builder + for _, excludedTag := range d.Get("exclude_tags").(*schema.Set).List() { + if exTag, ok := excludedTag.(string); ok && len(strings.TrimSpace(exTag)) != 0 { + excludeTagLookupMap[exTag] = true + + if excludeTagBuf.Len() > 0 { + excludeTagBuf.WriteByte(',') + } + excludeTagBuf.WriteString(exTag) + } + } + + if excludeTagBuf.Len() > 0 { + options.ExcludeTags = excludeTagBuf.String() + } + // Create a search string with all the tag names we are looking for. var tagSearchParts []string for _, tagName := range d.Get("tag_names").([]interface{}) { - name := tagName.(string) - if len(strings.TrimSpace(name)) != 0 { + if name, ok := tagName.(string); ok && len(strings.TrimSpace(name)) != 0 { id += name // add to the state id tagSearchParts = append(tagSearchParts, name) } @@ -94,7 +116,15 @@ func dataSourceTFEWorkspaceIDsRead(d *schema.ResourceData, meta interface{}) err for _, w := range wl.Items { nameIncluded := isWildcard || names[w.Name] - if hasOnlyTags || nameIncluded { + // fallback for tfe instances that don't yet support exclude-tags + hasExcludedTag := false + for _, tag := range w.TagNames { + if _, ok := excludeTagLookupMap[tag]; ok { + hasExcludedTag = true + break + } + } + if (hasOnlyTags || nameIncluded) && !hasExcludedTag { fullNames[w.Name] = organization + "/" + w.Name ids[w.Name] = w.ID } diff --git a/tfe/data_source_workspace_ids_test.go b/tfe/data_source_workspace_ids_test.go index 54a21637e..e27cc6f46 100644 --- a/tfe/data_source_workspace_ids_test.go +++ b/tfe/data_source_workspace_ids_test.go @@ -263,6 +263,72 @@ func TestAccTFEWorkspaceIDsDataSource_namesEmpty(t *testing.T) { }) } +func TestAccTFEWorkspaceIDsDataSource_excludeTags(t *testing.T) { + rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() + orgName := fmt.Sprintf("tst-terraform-%d", rInt) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckTFEWorkspaceDestroy, + Steps: []resource.TestStep{ + { + Config: testAccTFEWorkspaceIDsDataSourceConfig_excludeTags(rInt), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr( + "data.tfe_workspace_ids.good", "organization", orgName), + + // full_names attribute + resource.TestCheckResourceAttr( + "data.tfe_workspace_ids.good", "full_names.%", "1"), + resource.TestCheckResourceAttr( + "data.tfe_workspace_ids.good", + fmt.Sprintf("full_names.workspace-bar-%d", rInt), + fmt.Sprintf("tst-terraform-%d/workspace-bar-%d", rInt, rInt), + ), + + // ids attribute + resource.TestCheckResourceAttr( + "data.tfe_workspace_ids.good", "ids.%", "1"), + resource.TestCheckResourceAttrSet( + "data.tfe_workspace_ids.good", fmt.Sprintf("ids.workspace-bar-%d", rInt)), + + // id attribute + resource.TestCheckResourceAttrSet("data.tfe_workspace_ids.good", "id"), + ), + }, + }, + }) +} + +func TestAccTFEWorkspaceIDsDataSource_sameTagInTagNamesAndExcludeTags(t *testing.T) { + rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() + orgName := fmt.Sprintf("tst-terraform-%d", rInt) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckTFEWorkspaceDestroy, + Steps: []resource.TestStep{ + { + Config: testAccTFEWorkspaceIDsDataSourceConfig_sameTagInTagNamesAndExcludeTags(rInt), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr( + "data.tfe_workspace_ids.good", "organization", orgName), + + // full_names attribute should be empty + resource.TestCheckResourceAttr( + "data.tfe_workspace_ids.good", "full_names.%", "0"), + + // ids attribute should be empty + resource.TestCheckResourceAttr( + "data.tfe_workspace_ids.good", "ids.%", "0"), + ), + }, + }, + }) +} + func testAccTFEWorkspaceIDsDataSourceConfig_basic(rInt int) string { return fmt.Sprintf(` resource "tfe_organization" "foobar" { @@ -427,3 +493,76 @@ data "tfe_workspace_ids" "good" { organization = tfe_workspace.foo.organization }`, rInt, rInt, rInt, rInt) } + +func testAccTFEWorkspaceIDsDataSourceConfig_excludeTags(rInt int) string { + return fmt.Sprintf(` +resource "tfe_organization" "foobar" { + name = "tst-terraform-%d" + email = "admin@company.com" +} + +resource "tfe_workspace" "foo" { + name = "workspace-foo-%d" + organization = tfe_organization.foobar.id + tag_names = ["good", "happy"] +} + +resource "tfe_workspace" "bar" { + name = "workspace-bar-%d" + organization = tfe_organization.foobar.id + tag_names = ["good"] +} + +resource "tfe_workspace" "dummy" { + name = "workspace-dummy-%d" + organization = tfe_organization.foobar.id +} + +data "tfe_workspace_ids" "good" { + tag_names = ["good"] + exclude_tags = ["happy"] + organization = tfe_workspace.foo.organization + depends_on = [ + tfe_workspace.foo, + tfe_workspace.bar, + tfe_workspace.dummy + ] +}`, rInt, rInt, rInt, rInt) +} + +func testAccTFEWorkspaceIDsDataSourceConfig_sameTagInTagNamesAndExcludeTags(rInt int) string { + return fmt.Sprintf(` +resource "tfe_organization" "foobar" { + name = "tst-terraform-%d" + email = "admin@company.com" +} + +resource "tfe_workspace" "foo" { + name = "workspace-foo-%d" + organization = tfe_organization.foobar.id + tag_names = ["good", "happy"] +} + +resource "tfe_workspace" "bar" { + name = "workspace-bar-%d" + organization = tfe_organization.foobar.id + tag_names = ["happy", "play"] +} + +resource "tfe_workspace" "dummy" { + name = "workspace-dummy-%d" + organization = tfe_organization.foobar.id + tag_names = ["good", "play", "happy"] +} + +data "tfe_workspace_ids" "good" { + tag_names = ["good", "happy"] + exclude_tags = ["happy"] + organization = tfe_workspace.foo.organization + depends_on = [ + tfe_workspace.foo, + tfe_workspace.bar, + tfe_workspace.dummy + ] +}`, rInt, rInt, rInt, rInt) +} diff --git a/tfe/data_source_workspace_test.go b/tfe/data_source_workspace_test.go index 6ee2422fa..f816fdeff 100644 --- a/tfe/data_source_workspace_test.go +++ b/tfe/data_source_workspace_test.go @@ -2,6 +2,7 @@ package tfe import ( "fmt" + "github.com/hashicorp/go-tfe" "math/rand" "strconv" "testing" @@ -112,6 +113,51 @@ func TestAccTFEWorkspaceDataSource_basic(t *testing.T) { }) } +func TestAccTFEWorkspaceDataSourceWithTriggerPatterns(t *testing.T) { + tfeClient, err := getClientUsingEnv() + if err != nil { + t.Fatalf("error getting client %v", err) + } + rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() + organization, orgCleanup := givenOrganization(t, tfeClient, fmt.Sprintf("tst-terraform-%d-ff-on", rInt)) + defer orgCleanup() + + workspaceName := fmt.Sprintf("workspace-%d", rInt) + _, err = tfeClient.Workspaces.Create(ctx, organization.Name, tfe.WorkspaceCreateOptions{ + Name: &workspaceName, + FileTriggersEnabled: tfe.Bool(true), + TriggerPatterns: []string{"/modules/**/*", "/**/networking/*"}, + }) + if err != nil { + t.Fatal(err) + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccTFEWorkspaceDataSourceConfigWithTriggerPatterns(workspaceName, organization.Name), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.tfe_workspace.foobar", "id"), + resource.TestCheckResourceAttr( + "data.tfe_workspace.foobar", "name", workspaceName), + resource.TestCheckResourceAttr( + "data.tfe_workspace.foobar", "organization", organization.Name), + resource.TestCheckResourceAttr( + "data.tfe_workspace.foobar", "file_triggers_enabled", "true"), + resource.TestCheckResourceAttr( + "data.tfe_workspace.foobar", "trigger_patterns.#", "2"), + resource.TestCheckResourceAttr( + "data.tfe_workspace.foobar", "trigger_patterns.0", "/modules/**/*"), + resource.TestCheckResourceAttr( + "data.tfe_workspace.foobar", "trigger_patterns.1", "/**/networking/*"), + ), + }, + }, + }) +} + func testAccTFEWorkspaceDataSourceConfig(rInt int) string { return fmt.Sprintf(` resource "tfe_organization" "foobar" { @@ -132,16 +178,24 @@ resource "tfe_workspace" "foobar" { terraform_version = "0.11.1" trigger_prefixes = ["/modules", "/shared"] working_directory = "terraform/test" - global_remote_state = true + global_remote_state = true } data "tfe_workspace" "foobar" { name = tfe_workspace.foobar.name organization = tfe_workspace.foobar.organization - depends_on = [tfe_workspace.foobar] + depends_on = [tfe_workspace.foobar] }`, rInt, rInt) } +func testAccTFEWorkspaceDataSourceConfigWithTriggerPatterns(workspaceName string, organizationName string) string { + return fmt.Sprintf(` +data "tfe_workspace" "foobar" { + name = "%s" + organization = "%s" +}`, workspaceName, organizationName) +} + func testAccTFEWorkspaceDataSourceConfig_remoteStateConsumers(rInt1, rInt2 int) string { return fmt.Sprintf(` resource "tfe_organization" "foobar" { @@ -167,3 +221,25 @@ data "tfe_workspace" "foobar" { depends_on = [tfe_workspace.foobar] }`, rInt1, rInt2, rInt1) } + +func givenOrganization(t *testing.T, tfeClient *tfe.Client, organizationName string) (*tfe.Organization, func()) { + var orgCleanup func() + + dummyEmail := "test@test.test" + org, err := tfeClient.Organizations.Create(ctx, tfe.OrganizationCreateOptions{ + Name: tfe.String(organizationName), + Email: &dummyEmail, + }) + if err != nil { + t.Fatal(err) + } + orgCleanup = func() { + if err := tfeClient.Organizations.Delete(ctx, org.Name); err != nil { + t.Errorf("Error destroying organization! WARNING: Dangling resources\n"+ + "may exist! The full error is shown below.\n\n"+ + "Organization: %s\nError: %s", org.Name, err) + } + } + + return org, orgCleanup +} diff --git a/tfe/logging.go b/tfe/logging.go index 057af8835..f437f8739 100644 --- a/tfe/logging.go +++ b/tfe/logging.go @@ -26,10 +26,16 @@ const ( // header values should be redacted from logs var redactedHeaders = []string{"authorization:", "proxy-authorization:"} -// IsDebugOrHigher returns whether or not the current log level is debug or trace -func IsDebugOrHigher() bool { +// logLevelSet reads the TF_LOG level and ensures it is valid +func logLevelSet() bool { level := strings.ToUpper(os.Getenv(EnvLog)) - return level == "DEBUG" || level == "TRACE" + // Ensure its set to a valid level otherwise will default logging to TRACE + switch level { + case "DEBUG", "TRACE", "INFO", "WARN", "ERROR": + return true + default: + return false + } } // RoundTrip is a transport method that logs the request and response if the TF_LOG level is @@ -37,7 +43,9 @@ func IsDebugOrHigher() bool { func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) { includeBody := !hasSensitiveValues(req) - if IsDebugOrHigher() { + // We don't need any logic to handle each specific level as + // Terraform will log accordingly based on the prefix. + if logLevelSet() { reqData, err := httputil.DumpRequestOut(req, includeBody) if err == nil { log.Printf("[DEBUG] "+logReqMsg, t.name, filterAndPrettyPrintLines(reqData, includeBody)) @@ -51,9 +59,12 @@ func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) return resp, err } - if IsDebugOrHigher() { + if logLevelSet() { respData, err := httputil.DumpResponse(resp, includeBody) if err == nil { + if strings.Contains(string(respData), "404 Not Found") { + log.Printf("[WARN] The requested resource at %s %s could not be found. Please ensure no drift occurred by attempting to import the desired resource. It may also be that your token is invalid.", req.Method, req.URL.RequestURI()) + } log.Printf("[DEBUG] "+logRespMsg, t.name, filterAndPrettyPrintLines(respData, includeBody)) } else { log.Printf("[ERROR] %s API Response error: %#v", t.name, err) diff --git a/tfe/resource_tfe_workspace.go b/tfe/resource_tfe_workspace.go index 15da56dce..fdc956168 100644 --- a/tfe/resource_tfe_workspace.go +++ b/tfe/resource_tfe_workspace.go @@ -44,6 +44,8 @@ func resourceTFEWorkspace() *schema.Resource { return err } + validateVcsTriggers(d) + return nil }, @@ -161,10 +163,19 @@ func resourceTFEWorkspace() *schema.Resource { }, "trigger_prefixes": { - Type: schema.TypeList, - Optional: true, - Computed: true, - Elem: &schema.Schema{Type: schema.TypeString}, + Type: schema.TypeList, + Optional: true, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + ConflictsWith: []string{"trigger_patterns"}, + }, + + "trigger_patterns": { + Type: schema.TypeList, + Optional: true, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + ConflictsWith: []string{"trigger_prefixes"}, }, "working_directory": { @@ -252,10 +263,18 @@ func resourceTFEWorkspaceCreate(d *schema.ResourceData, meta interface{}) error if tps, ok := d.GetOk("trigger_prefixes"); ok { for _, tp := range tps.([]interface{}) { - if t, ok := tp.(string); ok { - options.TriggerPrefixes = append(options.TriggerPrefixes, t) - } + options.TriggerPrefixes = append(options.TriggerPrefixes, tp.(string)) + } + } else { + options.TriggerPrefixes = []string{} + } + + if tps, ok := d.GetOk("trigger_patterns"); ok { + for _, tp := range tps.([]interface{}) { + options.TriggerPatterns = append(options.TriggerPatterns, tp.(string)) } + } else { + options.TriggerPatterns = []string{} } // Get and assert the VCS repo configuration block. @@ -345,6 +364,7 @@ func resourceTFEWorkspaceRead(d *schema.ResourceData, meta interface{}) error { d.Set("structured_run_output_enabled", workspace.StructuredRunOutputEnabled) d.Set("terraform_version", workspace.TerraformVersion) d.Set("trigger_prefixes", workspace.TriggerPrefixes) + d.Set("trigger_patterns", workspace.TriggerPatterns) d.Set("working_directory", workspace.WorkingDirectory) d.Set("organization", workspace.Organization.Name) @@ -400,8 +420,9 @@ func resourceTFEWorkspaceUpdate(d *schema.ResourceData, meta interface{}) error id := d.Id() if d.HasChange("name") || d.HasChange("auto_apply") || d.HasChange("queue_all_runs") || - d.HasChange("terraform_version") || d.HasChange("working_directory") || d.HasChange("vcs_repo") || - d.HasChange("file_triggers_enabled") || d.HasChange("trigger_prefixes") || + d.HasChange("terraform_version") || d.HasChange("working_directory") || + d.HasChange("vcs_repo") || d.HasChange("file_triggers_enabled") || + d.HasChange("trigger_prefixes") || d.HasChange("trigger_patterns") || d.HasChange("allow_destroy_plan") || d.HasChange("speculative_enabled") || d.HasChange("operations") || d.HasChange("execution_mode") || d.HasChange("description") || d.HasChange("agent_pool_id") || @@ -450,10 +471,17 @@ func resourceTFEWorkspaceUpdate(d *schema.ResourceData, meta interface{}) error } } } else { - // Reset trigger prefixes when none are present in the config. options.TriggerPrefixes = []string{} } + if tps, ok := d.GetOk("trigger_patterns"); ok { + for _, tp := range tps.([]interface{}) { + options.TriggerPatterns = append(options.TriggerPatterns, tp.(string)) + } + } else { + options.TriggerPatterns = []string{} + } + if workingDir, ok := d.GetOk("working_directory"); ok { options.WorkingDirectory = tfe.String(workingDir.(string)) } @@ -655,6 +683,14 @@ func validateRemoteState(_ context.Context, d *schema.ResourceDiff) error { return nil } +func validateVcsTriggers(d *schema.ResourceDiff) { + if d.HasChange("trigger_patterns") { + d.SetNewComputed("trigger_prefixes") + } else if d.HasChange("trigger_prefixes") { + d.SetNewComputed("trigger_patterns") + } +} + func resourceTFEWorkspaceImporter(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { tfeClient := meta.(*tfe.Client) diff --git a/tfe/resource_tfe_workspace_test.go b/tfe/resource_tfe_workspace_test.go index 34378840f..826fb73b2 100644 --- a/tfe/resource_tfe_workspace_test.go +++ b/tfe/resource_tfe_workspace_test.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "math/rand" + "regexp" "strconv" "testing" "time" @@ -366,6 +367,137 @@ func TestAccTFEWorkspace_updateTriggerPrefixes(t *testing.T) { }) } +func TestAccTFEWorkspace_overwriteTriggerPatternsWithPrefixes(t *testing.T) { + workspace := &tfe.Workspace{} + rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckTFEWorkspaceDestroy, + Steps: []resource.TestStep{ + { + Config: testAccTFEWorkspace_triggerPatterns(rInt), + Check: resource.ComposeTestCheckFunc( + testAccCheckTFEWorkspaceExists( + "tfe_workspace.foobar", workspace), + resource.TestCheckResourceAttr( + "tfe_workspace.foobar", "trigger_patterns.#", "2"), + resource.TestCheckResourceAttr( + "tfe_workspace.foobar", "trigger_prefixes.#", "0"), + ), + }, + { + Config: testAccTFEWorkspace_triggerPrefixes(rInt), + Check: resource.ComposeTestCheckFunc( + testAccCheckTFEWorkspaceExists( + "tfe_workspace.foobar", workspace), + resource.TestCheckResourceAttr( + "tfe_workspace.foobar", "trigger_prefixes.#", "2"), + resource.TestCheckResourceAttr( + "tfe_workspace.foobar", "trigger_prefixes.0", "/modules"), + resource.TestCheckResourceAttr( + "tfe_workspace.foobar", "trigger_prefixes.1", "/shared"), + resource.TestCheckResourceAttr( + "tfe_workspace.foobar", "trigger_patterns.#", "0"), + ), + }, + { + Config: testAccTFEWorkspace_updateEmptyTriggerPrefixes(rInt), + Check: resource.ComposeTestCheckFunc( + testAccCheckTFEWorkspaceExists( + "tfe_workspace.foobar", workspace), + testAccCheckTFEWorkspaceAttributes(workspace), + resource.TestCheckResourceAttr( + "tfe_workspace.foobar", "trigger_prefixes.#", "0"), + resource.TestCheckResourceAttr( + "tfe_workspace.foobar", "trigger_patterns.#", "0"), + ), + }, + }, + }) +} + +func TestAccTFEWorkspace_updateTriggerPatterns(t *testing.T) { + workspace := &tfe.Workspace{} + rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckTFEWorkspaceDestroy, + Steps: []resource.TestStep{ + // Create trigger prefixes first so we can verify they are being removed if we introduce trigger patterns + { + Config: testAccTFEWorkspace_triggerPrefixes(rInt), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "tfe_workspace.foobar", "trigger_prefixes.#", "2"), + ), + }, + // Overwrite prefixes with patterns + { + Config: testAccTFEWorkspace_triggerPatterns(rInt), + Check: resource.ComposeTestCheckFunc( + testAccCheckTFEWorkspaceExists( + "tfe_workspace.foobar", workspace), + resource.TestCheckResourceAttr( + "tfe_workspace.foobar", "trigger_patterns.#", "2"), + resource.TestCheckResourceAttr( + "tfe_workspace.foobar", "trigger_patterns.0", "/modules/**/*"), + resource.TestCheckResourceAttr( + "tfe_workspace.foobar", "trigger_patterns.1", "/**/networking/*"), + resource.TestCheckResourceAttr( + "tfe_workspace.foobar", "trigger_prefixes.#", "0"), + ), + }, + // Second update + { + Config: testAccTFEWorkspace_updateTriggerPatterns(rInt), + Check: resource.ComposeTestCheckFunc( + testAccCheckTFEWorkspaceExists( + "tfe_workspace.foobar", workspace), + resource.TestCheckResourceAttr( + "tfe_workspace.foobar", "trigger_patterns.#", "3"), + resource.TestCheckResourceAttr( + "tfe_workspace.foobar", "trigger_patterns.0", "/**/networking/*"), + resource.TestCheckResourceAttr( + "tfe_workspace.foobar", "trigger_patterns.1", "/another_module/*/test/*"), + resource.TestCheckResourceAttr( + "tfe_workspace.foobar", "trigger_patterns.2", "/**/resources/**/*"), + resource.TestCheckResourceAttr( + "tfe_workspace.foobar", "trigger_prefixes.#", "0"), + ), + }, + { + Config: testAccTFEWorkspace_updateEmptyTriggerPatterns(rInt), + Check: resource.ComposeTestCheckFunc( + testAccCheckTFEWorkspaceExists("tfe_workspace.foobar", workspace), + testAccCheckTFEWorkspaceAttributes(workspace), + resource.TestCheckResourceAttr("tfe_workspace.foobar", "trigger_patterns.#", "0"), + resource.TestCheckResourceAttr("tfe_workspace.foobar", "trigger_prefixes.#", "0"), + ), + }, + }, + }) +} + +func TestAccTFEWorkspace_patternsAndPrefixesConflicting(t *testing.T) { + rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckTFEWorkspaceDestroy, + Steps: []resource.TestStep{ + { + Config: testAccTFEWorkspace_prefixesAndPatternsConflicting(rInt), + ExpectError: regexp.MustCompile(`Conflicting configuration`), + }, + }, + }) +} + func TestAccTFEWorkspace_changeTags(t *testing.T) { workspace := &tfe.Workspace{} rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() @@ -1612,7 +1744,7 @@ resource "tfe_workspace" "foobar" { func testAccTFEWorkspace_triggerPrefixes(rInt int) string { return fmt.Sprintf(` resource "tfe_organization" "foobar" { - name = "tst-terraform-%d" + name = "tst-terraform-%d-ff-on" email = "admin@company.com" } @@ -1626,14 +1758,70 @@ resource "tfe_workspace" "foobar" { func testAccTFEWorkspace_updateEmptyTriggerPrefixes(rInt int) string { return fmt.Sprintf(` resource "tfe_organization" "foobar" { - name = "tst-terraform-%d" + name = "tst-terraform-%d-ff-on" + email = "admin@company.com" +} +resource "tfe_workspace" "foobar" { + name = "workspace-test" + organization = tfe_organization.foobar.id + auto_apply = true + trigger_prefixes = [] +}`, rInt) +} + +func testAccTFEWorkspace_triggerPatterns(rInt int) string { + return fmt.Sprintf(` +resource "tfe_organization" "foobar" { + name = "tst-terraform-%d-ff-on" + email = "admin@company.com" +} + +resource "tfe_workspace" "foobar" { + name = "workspace" + organization = tfe_organization.foobar.id + trigger_patterns = ["/modules/**/*", "/**/networking/*"] +}`, rInt) +} + +func testAccTFEWorkspace_updateTriggerPatterns(rInt int) string { + return fmt.Sprintf(` +resource "tfe_organization" "foobar" { + name = "tst-terraform-%d-ff-on" + email = "admin@company.com" +} + +resource "tfe_workspace" "foobar" { + name = "workspace" + organization = tfe_organization.foobar.id + trigger_patterns = ["/**/networking/*", "/another_module/*/test/*", "/**/resources/**/*"] +}`, rInt) +} + +func testAccTFEWorkspace_updateEmptyTriggerPatterns(rInt int) string { + return fmt.Sprintf(` +resource "tfe_organization" "foobar" { + name = "tst-terraform-%d-ff-on" email = "admin@company.com" } resource "tfe_workspace" "foobar" { name = "workspace-test" organization = tfe_organization.foobar.id auto_apply = true - trigger_prefixes = [] + trigger_patterns = [] +}`, rInt) +} + +func testAccTFEWorkspace_prefixesAndPatternsConflicting(rInt int) string { + return fmt.Sprintf(` +resource "tfe_organization" "foobar" { + name = "tst-terraform-%d-ff-on" + email = "admin@company.com" +} +resource "tfe_workspace" "foobar" { + name = "workspace-test" + organization = tfe_organization.foobar.id + trigger_prefixes = [] + trigger_patterns = [] }`, rInt) } diff --git a/website/docs/d/team_access.html.markdown b/website/docs/d/team_access.html.markdown index aec54df3e..ced0997a7 100644 --- a/website/docs/d/team_access.html.markdown +++ b/website/docs/d/team_access.html.markdown @@ -41,4 +41,4 @@ The `permissions` block contains: * `state_versions` - The permissions granted to state versions. Valid values are `none`, `read-outputs`, `read`, or `write` * `sentinel_mocks` - The permissions granted to Sentinel mocks. Valid values are `none` or `read` * `workspace_locking` - Whether permission is granted to manually lock the workspace or not. -* `run_tasks` - Whether permission is granted to manage workspace run tasks or not. +* `run_tasks` - Boolean determining whether or not to grant the team permission to manage workspace run tasks. diff --git a/website/docs/d/workspace.html.markdown b/website/docs/d/workspace.html.markdown index d907d40e1..57dbf98ef 100644 --- a/website/docs/d/workspace.html.markdown +++ b/website/docs/d/workspace.html.markdown @@ -51,7 +51,9 @@ In addition to all arguments above, the following attributes are exported: * `structured_run_output_enabled` - Indicates whether runs in this workspace use the enhanced apply UI. * `tag_names` - The names of tags added to this workspace. * `terraform_version` - The version (or version constraint) of Terraform used for this workspace. -* `trigger_prefixes` - List of repository-root-relative paths which describe all locations to be tracked for changes. +* `trigger_prefixes` - List of trigger prefixes that describe the paths Terraform Cloud monitors for changes, in addition to the working directory. Trigger prefixes are always appended to the root directory of the repository. + Terraform Cloud or Terraform Enterprise will start a run when files are changed in any directory path matching the provided set of prefixes. +* `trigger_patterns` - List of [glob patterns](https://www.terraform.io/cloud-docs/workspaces/settings/vcs#glob-patterns-for-automatic-run-triggering) that describe the files Terraform Cloud monitors for changes. Trigger patterns are always appended to the root directory of the repository. Only available for Terraform Cloud. * `vcs_repo` - Settings for the workspace's VCS repository. * `working_directory` - A relative path that Terraform will execute within. diff --git a/website/docs/d/workspace_ids.html.markdown b/website/docs/d/workspace_ids.html.markdown index e57367f52..060093aef 100644 --- a/website/docs/d/workspace_ids.html.markdown +++ b/website/docs/d/workspace_ids.html.markdown @@ -27,6 +27,12 @@ data "tfe_workspace_ids" "prod-apps" { tag_names = ["prod", "app", "aws"] organization = "my-org-name" } + +data "tfe_workspace_ids" "prod-only" { + tag_names = ["prod"] + exclude_tags = ["app"] + organization = "my-org-name" +} ``` ## Argument Reference @@ -39,11 +45,12 @@ The following arguments are supported. At least one of `names` or `tag_names` mu To select _all_ workspaces for an organization, provide a list with a single asterisk, like `["*"]`. No other use of wildcards is supported. * `tag_names` - (Optional) A list of tag names to search for. +* `exclude_tags` - (Optional) A list of tag names to exclude when searching. * `organization` - (Required) Name of the organization. ## Attributes Reference In addition to all arguments above, the following attributes are exported: -* `full_names` - A map of workspace names and their full names, which look like `/`. +* `full_names` - A map of workspace names and their full names, which look like `/`. * `ids` - A map of workspace names and their opaque, immutable IDs, which look like `ws-`. diff --git a/website/docs/index.html.markdown b/website/docs/index.html.markdown index d39caaf04..94a03fa12 100644 --- a/website/docs/index.html.markdown +++ b/website/docs/index.html.markdown @@ -74,7 +74,7 @@ automatically installed by `terraform init` in the future: terraform { required_providers { tfe = { - version = "~> 0.30.2" + version = "~> 0.33.0" } } } @@ -87,7 +87,7 @@ The above snippet using `required_providers` is for Terraform 0.13+; if you are ```hcl provider "tfe" { - version = "~> 0.30.2" + version = "~> 0.33.0" ... } ``` @@ -100,7 +100,7 @@ For more information on provider installation and constraining provider versions provider "tfe" { hostname = var.hostname token = var.token - version = "~> 0.30.2" + version = "~> 0.33.0" } # Create an organization diff --git a/website/docs/r/team_access.html.markdown b/website/docs/r/team_access.html.markdown index 552378601..3fb239a5a 100644 --- a/website/docs/r/team_access.html.markdown +++ b/website/docs/r/team_access.html.markdown @@ -48,7 +48,7 @@ The `permissions` block supports: * `state_versions` - (Required) The permission to grant the team on the workspace's state versions. Valid values are `none`, `read`, `read-outputs`, or `write`. * `sentinel_mocks` - (Required) The permission to grant the team on the workspace's generated Sentinel mocks, Valid values are `none` or `read`. * `workspace_locking` - (Required) Boolean determining whether or not to grant the team permission to manually lock/unlock the workspace. -* `run_tasks` - (Required) Whether permission is granted to manage workspace run tasks or not. +* `run_tasks` - (Required) Boolean determining whether or not to grant the team permission to manage workspace run tasks. -> **Note:** At least one of `access` or `permissions` _must_ be provided, but not both. Whichever is omitted will automatically reflect the state of the other. diff --git a/website/docs/r/workspace.html.markdown b/website/docs/r/workspace.html.markdown index e2895e8cb..f0d59c4fb 100644 --- a/website/docs/r/workspace.html.markdown +++ b/website/docs/r/workspace.html.markdown @@ -97,6 +97,7 @@ The following arguments are supported: Defaults to `true`. Setting this to `false` ensures that all runs in this workspace will display their output as text logs. * `ssh_key_id` - (Optional) The ID of an SSH key to assign to the workspace. +* `tag_names` - (Optional) A list of tag names for this workspace. Note that tags must only contain lowercase letters, numbers, colons, or hyphens. * `terraform_version` - (Optional) The version of Terraform to use for this workspace. This can be either an exact version or a [version constraint](https://www.terraform.io/docs/language/expressions/version-constraints.html) @@ -105,7 +106,7 @@ The following arguments are supported: available version. * `trigger_prefixes` - (Optional) List of repository-root-relative paths which describe all locations to be tracked for changes. -* `tag_names` - (Optional) A list of tag names for this workspace. Note that tags must only contain lowercase letters, numbers, colons, or hyphens. +* `trigger_patterns` - (Optional) List of [glob patterns](https://www.terraform.io/cloud-docs/workspaces/settings/vcs#glob-patterns-for-automatic-run-triggering) that describe the files Terraform Cloud monitors for changes. Trigger patterns are always appended to the root directory of the repository. Mutually exclusive with `trigger-prefixes`. Only available for Terraform Cloud. * `working_directory` - (Optional) A relative path that Terraform will execute within. Defaults to the root of your repository. * `vcs_repo` - (Optional) Settings for the workspace's VCS repository, enabling the [UI/VCS-driven run workflow](https://www.terraform.io/docs/cloud/run/ui.html).