From ea8ad0b15ab495fa0fad679f2aae21dc8974ee9e Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Thu, 12 Aug 2021 14:30:24 -0500 Subject: [PATCH 01/78] Initial commit of 'cloud' package The cloud package intends to implement a new integration for Terraform Cloud/Enterprise. The purpose of this integration is to better support TFC users; it will shed some overly generic UX and architecture, behavior changes that are otherwise backwards incompatible in the remote backend, and technical debt - all of which are vestiges from before Terraform Cloud existed. This initial commit is largely a porting of the existing 'remote' backend, which will serve as an underlying implementation detail and not be a typical user-level backend. This is because to re-implement the literal backend interface is orthogonal to the purpose of this integration, and can always be migrated away from later. As this backend is considered an implementation detail, it will not be registered as a declarable backend. Within these changes it is, for easy of initial development and a clean diff. --- internal/backend/init/init.go | 6 +- internal/cloud/backend.go | 1049 +++++++++++ internal/cloud/backend_apply.go | 301 +++ internal/cloud/backend_apply_test.go | 1659 +++++++++++++++++ internal/cloud/backend_cli.go | 20 + internal/cloud/backend_colorize.go | 50 + internal/cloud/backend_common.go | 574 ++++++ internal/cloud/backend_context.go | 280 +++ internal/cloud/backend_context_test.go | 235 +++ internal/cloud/backend_mock.go | 1364 ++++++++++++++ internal/cloud/backend_plan.go | 439 +++++ internal/cloud/backend_plan_test.go | 1237 ++++++++++++ internal/cloud/backend_state.go | 182 ++ internal/cloud/backend_state_test.go | 59 + internal/cloud/backend_test.go | 723 +++++++ internal/cloud/remote_test.go | 25 + internal/cloud/testdata/apply-destroy/main.tf | 1 + .../cloud/testdata/apply-no-changes/main.tf | 1 + .../testdata/apply-policy-hard-failed/main.tf | 1 + .../testdata/apply-policy-passed/main.tf | 1 + .../testdata/apply-policy-soft-failed/main.tf | 1 + .../cloud/testdata/apply-variables/main.tf | 4 + .../cloud/testdata/apply-with-error/main.tf | 5 + internal/cloud/testdata/apply/main.tf | 1 + internal/cloud/testdata/empty/.gitignore | 0 .../testdata/plan-cost-estimation/main.tf | 1 + .../cloud/testdata/plan-long-line/main.tf | 5 + .../cloud/testdata/plan-no-changes/main.tf | 1 + .../testdata/plan-policy-hard-failed/main.tf | 1 + .../cloud/testdata/plan-policy-passed/main.tf | 1 + .../testdata/plan-policy-soft-failed/main.tf | 1 + .../cloud/testdata/plan-variables/main.tf | 4 + .../cloud/testdata/plan-with-error/main.tf | 5 + .../terraform/main.tf | 1 + internal/cloud/testdata/plan/main.tf | 1 + internal/cloud/testing.go | 299 +++ 36 files changed, 8537 insertions(+), 1 deletion(-) create mode 100644 internal/cloud/backend.go create mode 100644 internal/cloud/backend_apply.go create mode 100644 internal/cloud/backend_apply_test.go create mode 100644 internal/cloud/backend_cli.go create mode 100644 internal/cloud/backend_colorize.go create mode 100644 internal/cloud/backend_common.go create mode 100644 internal/cloud/backend_context.go create mode 100644 internal/cloud/backend_context_test.go create mode 100644 internal/cloud/backend_mock.go create mode 100644 internal/cloud/backend_plan.go create mode 100644 internal/cloud/backend_plan_test.go create mode 100644 internal/cloud/backend_state.go create mode 100644 internal/cloud/backend_state_test.go create mode 100644 internal/cloud/backend_test.go create mode 100644 internal/cloud/remote_test.go create mode 100644 internal/cloud/testdata/apply-destroy/main.tf create mode 100644 internal/cloud/testdata/apply-no-changes/main.tf create mode 100644 internal/cloud/testdata/apply-policy-hard-failed/main.tf create mode 100644 internal/cloud/testdata/apply-policy-passed/main.tf create mode 100644 internal/cloud/testdata/apply-policy-soft-failed/main.tf create mode 100644 internal/cloud/testdata/apply-variables/main.tf create mode 100644 internal/cloud/testdata/apply-with-error/main.tf create mode 100644 internal/cloud/testdata/apply/main.tf create mode 100644 internal/cloud/testdata/empty/.gitignore create mode 100644 internal/cloud/testdata/plan-cost-estimation/main.tf create mode 100644 internal/cloud/testdata/plan-long-line/main.tf create mode 100644 internal/cloud/testdata/plan-no-changes/main.tf create mode 100644 internal/cloud/testdata/plan-policy-hard-failed/main.tf create mode 100644 internal/cloud/testdata/plan-policy-passed/main.tf create mode 100644 internal/cloud/testdata/plan-policy-soft-failed/main.tf create mode 100644 internal/cloud/testdata/plan-variables/main.tf create mode 100644 internal/cloud/testdata/plan-with-error/main.tf create mode 100644 internal/cloud/testdata/plan-with-working-directory/terraform/main.tf create mode 100644 internal/cloud/testdata/plan/main.tf create mode 100644 internal/cloud/testing.go diff --git a/internal/backend/init/init.go b/internal/backend/init/init.go index 5abc8754d..30a1ccfdd 100644 --- a/internal/backend/init/init.go +++ b/internal/backend/init/init.go @@ -27,6 +27,7 @@ import ( backendPg "github.com/hashicorp/terraform/internal/backend/remote-state/pg" backendS3 "github.com/hashicorp/terraform/internal/backend/remote-state/s3" backendSwift "github.com/hashicorp/terraform/internal/backend/remote-state/swift" + backendCloud "github.com/hashicorp/terraform/internal/cloud" ) // backends is the list of available backends. This is a global variable @@ -49,7 +50,6 @@ func Init(services *disco.Disco) { defer backendsLock.Unlock() backends = map[string]backend.InitFn{ - // Enhanced backends. "local": func() backend.Backend { return backendLocal.New() }, "remote": func() backend.Backend { return backendRemote.New(services) }, @@ -70,6 +70,10 @@ func Init(services *disco.Disco) { "s3": func() backend.Backend { return backendS3.New() }, "swift": func() backend.Backend { return backendSwift.New() }, + // Terraform Cloud 'backend' + // This is an implementation detail only, used for the cloud package + "cloud": func() backend.Backend { return backendCloud.New(services) }, + // Deprecated backends. "azure": func() backend.Backend { return deprecateBackend( diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go new file mode 100644 index 000000000..b6ae5e154 --- /dev/null +++ b/internal/cloud/backend.go @@ -0,0 +1,1049 @@ +package cloud + +import ( + "context" + "fmt" + "log" + "net/http" + "net/url" + "os" + "sort" + "strings" + "sync" + "time" + + tfe "github.com/hashicorp/go-tfe" + version "github.com/hashicorp/go-version" + svchost "github.com/hashicorp/terraform-svchost" + "github.com/hashicorp/terraform-svchost/disco" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/states/remote" + "github.com/hashicorp/terraform/internal/states/statemgr" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" + tfversion "github.com/hashicorp/terraform/version" + "github.com/mitchellh/cli" + "github.com/mitchellh/colorstring" + "github.com/zclconf/go-cty/cty" + + backendLocal "github.com/hashicorp/terraform/internal/backend/local" +) + +const ( + defaultHostname = "app.terraform.io" + defaultParallelism = 10 + stateServiceID = "state.v2" + tfeServiceID = "tfe.v2.1" +) + +// Cloud is an implementation of EnhancedBackend in service of the Terraform Cloud/Enterprise +// integration for Terraform CLI. This backend is not intended to be surfaced at the user level and +// is instead an implementation detail of cloud.Cloud. +type Cloud struct { + // CLI and Colorize control the CLI output. If CLI is nil then no CLI + // output will be done. If CLIColor is nil then no coloring will be done. + CLI cli.Ui + CLIColor *colorstring.Colorize + + // ContextOpts are the base context options to set when initializing a + // new Terraform context. Many of these will be overridden or merged by + // Operation. See Operation for more details. + ContextOpts *terraform.ContextOpts + + // client is the Terraform Cloud/Enterprise API client. + client *tfe.Client + + // lastRetry is set to the last time a request was retried. + lastRetry time.Time + + // hostname of Terraform Cloud or Terraform Enterprise + hostname string + + // organization is the organization that contains the target workspaces. + organization string + + // workspace is used to map the default workspace to a TFC workspace. + workspace string + + // prefix is used to filter down a set of workspaces that use a single + // configuration. + prefix string + + // services is used for service discovery + services *disco.Disco + + // local allows local operations, where Terraform Cloud serves as a state storage backend. + local backend.Enhanced + + // forceLocal, if true, will force the use of the local backend. + forceLocal bool + + // opLock locks operations + opLock sync.Mutex + + // ignoreVersionConflict, if true, will disable the requirement that the + // local Terraform version matches the remote workspace's configured + // version. This will also cause VerifyWorkspaceTerraformVersion to return + // a warning diagnostic instead of an error. + ignoreVersionConflict bool +} + +var _ backend.Backend = (*Cloud)(nil) + +// New creates a new initialized cloud backend. +func New(services *disco.Disco) *Cloud { + return &Cloud{ + services: services, + } +} + +// ConfigSchema implements backend.Enhanced. +func (b *Cloud) ConfigSchema() *configschema.Block { + return &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "hostname": { + Type: cty.String, + Optional: true, + Description: schemaDescriptions["hostname"], + }, + "organization": { + Type: cty.String, + Required: true, + Description: schemaDescriptions["organization"], + }, + "token": { + Type: cty.String, + Optional: true, + Description: schemaDescriptions["token"], + }, + }, + + BlockTypes: map[string]*configschema.NestedBlock{ + "workspaces": { + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "name": { + Type: cty.String, + Optional: true, + Description: schemaDescriptions["name"], + }, + "prefix": { + Type: cty.String, + Optional: true, + Description: schemaDescriptions["prefix"], + }, + }, + }, + Nesting: configschema.NestingSingle, + }, + }, + } +} + +// PrepareConfig implements backend.Backend. +func (b *Cloud) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + if obj.IsNull() { + return obj, diags + } + + if val := obj.GetAttr("organization"); val.IsNull() || val.AsString() == "" { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Invalid organization value", + `The "organization" attribute value must not be empty.`, + cty.Path{cty.GetAttrStep{Name: "organization"}}, + )) + } + + var name, prefix string + if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() { + if val := workspaces.GetAttr("name"); !val.IsNull() { + name = val.AsString() + } + if val := workspaces.GetAttr("prefix"); !val.IsNull() { + prefix = val.AsString() + } + } + + // Make sure that we have either a workspace name or a prefix. + if name == "" && prefix == "" { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Invalid workspaces configuration", + `Either workspace "name" or "prefix" is required.`, + cty.Path{cty.GetAttrStep{Name: "workspaces"}}, + )) + } + + // Make sure that only one of workspace name or a prefix is configured. + if name != "" && prefix != "" { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Invalid workspaces configuration", + `Only one of workspace "name" or "prefix" is allowed.`, + cty.Path{cty.GetAttrStep{Name: "workspaces"}}, + )) + } + + return obj, diags +} + +// Configure implements backend.Enhanced. +func (b *Cloud) Configure(obj cty.Value) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + if obj.IsNull() { + return diags + } + + // Get the hostname. + if val := obj.GetAttr("hostname"); !val.IsNull() && val.AsString() != "" { + b.hostname = val.AsString() + } else { + b.hostname = defaultHostname + } + + // Get the organization. + if val := obj.GetAttr("organization"); !val.IsNull() { + b.organization = val.AsString() + } + + // Get the workspaces configuration block and retrieve the + // default workspace name and prefix. + if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() { + if val := workspaces.GetAttr("name"); !val.IsNull() { + b.workspace = val.AsString() + } + if val := workspaces.GetAttr("prefix"); !val.IsNull() { + b.prefix = val.AsString() + } + } + + // Determine if we are forced to use the local backend. + b.forceLocal = os.Getenv("TF_FORCE_LOCAL_BACKEND") != "" + + serviceID := tfeServiceID + if b.forceLocal { + serviceID = stateServiceID + } + + // Discover the service URL to confirm that it provides the Terraform Cloud/Enterprise API + // and to get the version constraints. + service, constraints, err := b.discover(serviceID) + + // First check any contraints we might have received. + if constraints != nil { + diags = diags.Append(b.checkConstraints(constraints)) + if diags.HasErrors() { + return diags + } + } + + // When we don't have any constraints errors, also check for discovery + // errors before we continue. + if err != nil { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + strings.ToUpper(err.Error()[:1])+err.Error()[1:], + "", // no description is needed here, the error is clear + cty.Path{cty.GetAttrStep{Name: "hostname"}}, + )) + return diags + } + + // Retrieve the token for this host as configured in the credentials + // section of the CLI Config File. + token, err := b.token() + if err != nil { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + strings.ToUpper(err.Error()[:1])+err.Error()[1:], + "", // no description is needed here, the error is clear + cty.Path{cty.GetAttrStep{Name: "hostname"}}, + )) + return diags + } + + // Get the token from the config if no token was configured for this + // host in credentials section of the CLI Config File. + if token == "" { + if val := obj.GetAttr("token"); !val.IsNull() { + token = val.AsString() + } + } + + // Return an error if we still don't have a token at this point. + if token == "" { + loginCommand := "terraform login" + if b.hostname != defaultHostname { + loginCommand = loginCommand + " " + b.hostname + } + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Required token could not be found", + fmt.Sprintf( + "Run the following command to generate a token for %s:\n %s", + b.hostname, + loginCommand, + ), + )) + return diags + } + + cfg := &tfe.Config{ + Address: service.String(), + BasePath: service.Path, + Token: token, + Headers: make(http.Header), + RetryLogHook: b.retryLogHook, + } + + // Set the version header to the current version. + cfg.Headers.Set(tfversion.Header, tfversion.Version) + + // Create the TFC/E API client. + b.client, err = tfe.NewClient(cfg) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to create the Terraform Enterprise client", + fmt.Sprintf( + `Encountered an unexpected error while creating the `+ + `Terraform Enterprise client: %s.`, err, + ), + )) + return diags + } + + // Check if the organization exists by reading its entitlements. + entitlements, err := b.client.Organizations.Entitlements(context.Background(), b.organization) + if err != nil { + if err == tfe.ErrResourceNotFound { + err = fmt.Errorf("organization %q at host %s not found.\n\n"+ + "Please ensure that the organization and hostname are correct "+ + "and that your API token for %s is valid.", + b.organization, b.hostname, b.hostname) + } + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + fmt.Sprintf("Failed to read organization %q at host %s", b.organization, b.hostname), + fmt.Sprintf("Encountered an unexpected error while reading the "+ + "organization settings: %s", err), + cty.Path{cty.GetAttrStep{Name: "organization"}}, + )) + return diags + } + + // Configure a local backend for when we need to run operations locally. + b.local = backendLocal.NewWithBackend(b) + b.forceLocal = b.forceLocal || !entitlements.Operations + + // Enable retries for server errors as the backend is now fully configured. + b.client.RetryServerErrors(true) + + return diags +} + +// discover the TFC/E API service URL and version constraints. +func (b *Cloud) discover(serviceID string) (*url.URL, *disco.Constraints, error) { + hostname, err := svchost.ForComparison(b.hostname) + if err != nil { + return nil, nil, err + } + + host, err := b.services.Discover(hostname) + if err != nil { + return nil, nil, err + } + + service, err := host.ServiceURL(serviceID) + // Return the error, unless its a disco.ErrVersionNotSupported error. + if _, ok := err.(*disco.ErrVersionNotSupported); !ok && err != nil { + return nil, nil, err + } + + // We purposefully ignore the error and return the previous error, as + // checking for version constraints is considered optional. + constraints, _ := host.VersionConstraints(serviceID, "terraform") + + return service, constraints, err +} + +// checkConstraints checks service version constrains against our own +// version and returns rich and informational diagnostics in case any +// incompatibilities are detected. +func (b *Cloud) checkConstraints(c *disco.Constraints) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + if c == nil || c.Minimum == "" || c.Maximum == "" { + return diags + } + + // Generate a parsable constraints string. + excluding := "" + if len(c.Excluding) > 0 { + excluding = fmt.Sprintf(", != %s", strings.Join(c.Excluding, ", != ")) + } + constStr := fmt.Sprintf(">= %s%s, <= %s", c.Minimum, excluding, c.Maximum) + + // Create the constraints to check against. + constraints, err := version.NewConstraint(constStr) + if err != nil { + return diags.Append(checkConstraintsWarning(err)) + } + + // Create the version to check. + v, err := version.NewVersion(tfversion.Version) + if err != nil { + return diags.Append(checkConstraintsWarning(err)) + } + + // Return if we satisfy all constraints. + if constraints.Check(v) { + return diags + } + + // Find out what action (upgrade/downgrade) we should advice. + minimum, err := version.NewVersion(c.Minimum) + if err != nil { + return diags.Append(checkConstraintsWarning(err)) + } + + maximum, err := version.NewVersion(c.Maximum) + if err != nil { + return diags.Append(checkConstraintsWarning(err)) + } + + var excludes []*version.Version + for _, exclude := range c.Excluding { + v, err := version.NewVersion(exclude) + if err != nil { + return diags.Append(checkConstraintsWarning(err)) + } + excludes = append(excludes, v) + } + + // Sort all the excludes. + sort.Sort(version.Collection(excludes)) + + var action, toVersion string + switch { + case minimum.GreaterThan(v): + action = "upgrade" + toVersion = ">= " + minimum.String() + case maximum.LessThan(v): + action = "downgrade" + toVersion = "<= " + maximum.String() + case len(excludes) > 0: + // Get the latest excluded version. + action = "upgrade" + toVersion = "> " + excludes[len(excludes)-1].String() + } + + switch { + case len(excludes) == 1: + excluding = fmt.Sprintf(", excluding version %s", excludes[0].String()) + case len(excludes) > 1: + var vs []string + for _, v := range excludes { + vs = append(vs, v.String()) + } + excluding = fmt.Sprintf(", excluding versions %s", strings.Join(vs, ", ")) + default: + excluding = "" + } + + summary := fmt.Sprintf("Incompatible Terraform version v%s", v.String()) + details := fmt.Sprintf( + "The configured Terraform Enterprise backend is compatible with Terraform "+ + "versions >= %s, <= %s%s.", c.Minimum, c.Maximum, excluding, + ) + + if action != "" && toVersion != "" { + summary = fmt.Sprintf("Please %s Terraform to %s", action, toVersion) + details += fmt.Sprintf(" Please %s to a supported version and try again.", action) + } + + // Return the customized and informational error message. + return diags.Append(tfdiags.Sourceless(tfdiags.Error, summary, details)) +} + +// token returns the token for this host as configured in the credentials +// section of the CLI Config File. If no token was configured, an empty +// string will be returned instead. +func (b *Cloud) token() (string, error) { + hostname, err := svchost.ForComparison(b.hostname) + if err != nil { + return "", err + } + creds, err := b.services.CredentialsForHost(hostname) + if err != nil { + log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", b.hostname, err) + return "", nil + } + if creds != nil { + return creds.Token(), nil + } + return "", nil +} + +// retryLogHook is invoked each time a request is retried allowing the +// backend to log any connection issues to prevent data loss. +func (b *Cloud) retryLogHook(attemptNum int, resp *http.Response) { + if b.CLI != nil { + // Ignore the first retry to make sure any delayed output will + // be written to the console before we start logging retries. + // + // The retry logic in the TFE client will retry both rate limited + // requests and server errors, but in the cloud backend we only + // care about server errors so we ignore rate limit (429) errors. + if attemptNum == 0 || (resp != nil && resp.StatusCode == 429) { + // Reset the last retry time. + b.lastRetry = time.Now() + return + } + + if attemptNum == 1 { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(initialRetryError))) + } else { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace( + fmt.Sprintf(repeatedRetryError, time.Since(b.lastRetry).Round(time.Second))))) + } + } +} + +// Workspaces implements backend.Enhanced. +func (b *Cloud) Workspaces() ([]string, error) { + if b.prefix == "" { + return nil, backend.ErrWorkspacesNotSupported + } + return b.workspaces() +} + +// workspaces returns a filtered list of remote workspace names. +func (b *Cloud) workspaces() ([]string, error) { + options := tfe.WorkspaceListOptions{} + switch { + case b.workspace != "": + options.Search = tfe.String(b.workspace) + case b.prefix != "": + options.Search = tfe.String(b.prefix) + } + + // Create a slice to contain all the names. + var names []string + + for { + wl, err := b.client.Workspaces.List(context.Background(), b.organization, options) + if err != nil { + return nil, err + } + + for _, w := range wl.Items { + if b.workspace != "" && w.Name == b.workspace { + names = append(names, backend.DefaultStateName) + continue + } + if b.prefix != "" && strings.HasPrefix(w.Name, b.prefix) { + names = append(names, strings.TrimPrefix(w.Name, b.prefix)) + } + } + + // Exit the loop when we've seen all pages. + if wl.CurrentPage >= wl.TotalPages { + break + } + + // Update the page number to get the next page. + options.PageNumber = wl.NextPage + } + + // Sort the result so we have consistent output. + sort.StringSlice(names).Sort() + + return names, nil +} + +// DeleteWorkspace implements backend.Enhanced. +func (b *Cloud) DeleteWorkspace(name string) error { + if b.workspace == "" && name == backend.DefaultStateName { + return backend.ErrDefaultWorkspaceNotSupported + } + if b.prefix == "" && name != backend.DefaultStateName { + return backend.ErrWorkspacesNotSupported + } + + // Configure the remote workspace name. + switch { + case name == backend.DefaultStateName: + name = b.workspace + case b.prefix != "" && !strings.HasPrefix(name, b.prefix): + name = b.prefix + name + } + + client := &remoteClient{ + client: b.client, + organization: b.organization, + workspace: &tfe.Workspace{ + Name: name, + }, + } + + return client.Delete() +} + +// StateMgr implements backend.Enhanced. +func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { + if b.workspace == "" && name == backend.DefaultStateName { + return nil, backend.ErrDefaultWorkspaceNotSupported + } + if b.prefix == "" && name != backend.DefaultStateName { + return nil, backend.ErrWorkspacesNotSupported + } + + // Configure the remote workspace name. + switch { + case name == backend.DefaultStateName: + name = b.workspace + case b.prefix != "" && !strings.HasPrefix(name, b.prefix): + name = b.prefix + name + } + + workspace, err := b.client.Workspaces.Read(context.Background(), b.organization, name) + if err != nil && err != tfe.ErrResourceNotFound { + return nil, fmt.Errorf("Failed to retrieve workspace %s: %v", name, err) + } + + if err == tfe.ErrResourceNotFound { + options := tfe.WorkspaceCreateOptions{ + Name: tfe.String(name), + } + + // We only set the Terraform Version for the new workspace if this is + // a release candidate or a final release. + if tfversion.Prerelease == "" || strings.HasPrefix(tfversion.Prerelease, "rc") { + options.TerraformVersion = tfe.String(tfversion.String()) + } + + workspace, err = b.client.Workspaces.Create(context.Background(), b.organization, options) + if err != nil { + return nil, fmt.Errorf("Error creating workspace %s: %v", name, err) + } + } + + // This is a fallback error check. Most code paths should use other + // mechanisms to check the version, then set the ignoreVersionConflict + // field to true. This check is only in place to ensure that we don't + // accidentally upgrade state with a new code path, and the version check + // logic is coarser and simpler. + if !b.ignoreVersionConflict { + wsv := workspace.TerraformVersion + // Explicitly ignore the pseudo-version "latest" here, as it will cause + // plan and apply to always fail. + if wsv != tfversion.String() && wsv != "latest" { + return nil, fmt.Errorf("Remote workspace Terraform version %q does not match local Terraform version %q", workspace.TerraformVersion, tfversion.String()) + } + } + + client := &remoteClient{ + client: b.client, + organization: b.organization, + workspace: workspace, + + // This is optionally set during Terraform Enterprise runs. + runID: os.Getenv("TFE_RUN_ID"), + } + + return &remote.State{Client: client}, nil +} + +// Operation implements backend.Enhanced. +func (b *Cloud) Operation(ctx context.Context, op *backend.Operation) (*backend.RunningOperation, error) { + // Get the remote workspace name. + name := op.Workspace + switch { + case op.Workspace == backend.DefaultStateName: + name = b.workspace + case b.prefix != "" && !strings.HasPrefix(op.Workspace, b.prefix): + name = b.prefix + op.Workspace + } + + // Retrieve the workspace for this operation. + w, err := b.client.Workspaces.Read(ctx, b.organization, name) + if err != nil { + switch err { + case context.Canceled: + return nil, err + case tfe.ErrResourceNotFound: + return nil, fmt.Errorf( + "workspace %s not found\n\n"+ + "For security, Terraform Cloud returns '404 Not Found' responses for resources\n"+ + "for resources that a user doesn't have access to, in addition to resources that\n"+ + "do not exist. If the resource does exist, please check the permissions of the provided token.", + name, + ) + default: + return nil, fmt.Errorf( + "Terraform Cloud returned an unexpected error:\n\n%s", + err, + ) + } + } + + // Terraform remote version conflicts are not a concern for operations. We + // are in one of three states: + // + // - Running remotely, in which case the local version is irrelevant; + // - Workspace configured for local operations, in which case the remote + // version is meaningless; + // - Forcing local operations, which should only happen in the Terraform Cloud worker, in + // which case the Terraform versions by definition match. + b.IgnoreVersionConflict() + + // Check if we need to use the local backend to run the operation. + if b.forceLocal || !w.Operations { + // Record that we're forced to run operations locally to allow the + // command package UI to operate correctly + b.forceLocal = true + return b.local.Operation(ctx, op) + } + + // Set the remote workspace name. + op.Workspace = w.Name + + // Determine the function to call for our operation + var f func(context.Context, context.Context, *backend.Operation, *tfe.Workspace) (*tfe.Run, error) + switch op.Type { + case backend.OperationTypePlan: + f = b.opPlan + case backend.OperationTypeApply: + f = b.opApply + case backend.OperationTypeRefresh: + return nil, fmt.Errorf( + "\n\nThe \"refresh\" operation is not supported when using Terraform Cloud. " + + "Use \"terraform apply -refresh-only\" instead.") + default: + return nil, fmt.Errorf( + "\n\nTerraform Cloud does not support the %q operation.", op.Type) + } + + // Lock + b.opLock.Lock() + + // Build our running operation + // the runninCtx is only used to block until the operation returns. + runningCtx, done := context.WithCancel(context.Background()) + runningOp := &backend.RunningOperation{ + Context: runningCtx, + PlanEmpty: true, + } + + // stopCtx wraps the context passed in, and is used to signal a graceful Stop. + stopCtx, stop := context.WithCancel(ctx) + runningOp.Stop = stop + + // cancelCtx is used to cancel the operation immediately, usually + // indicating that the process is exiting. + cancelCtx, cancel := context.WithCancel(context.Background()) + runningOp.Cancel = cancel + + // Do it. + go func() { + defer done() + defer stop() + defer cancel() + + defer b.opLock.Unlock() + + r, opErr := f(stopCtx, cancelCtx, op, w) + if opErr != nil && opErr != context.Canceled { + var diags tfdiags.Diagnostics + diags = diags.Append(opErr) + op.ReportResult(runningOp, diags) + return + } + + if r == nil && opErr == context.Canceled { + runningOp.Result = backend.OperationFailure + return + } + + if r != nil { + // Retrieve the run to get its current status. + r, err := b.client.Runs.Read(cancelCtx, r.ID) + if err != nil { + var diags tfdiags.Diagnostics + diags = diags.Append(generalError("Failed to retrieve run", err)) + op.ReportResult(runningOp, diags) + return + } + + // Record if there are any changes. + runningOp.PlanEmpty = !r.HasChanges + + if opErr == context.Canceled { + if err := b.cancel(cancelCtx, op, r); err != nil { + var diags tfdiags.Diagnostics + diags = diags.Append(generalError("Failed to retrieve run", err)) + op.ReportResult(runningOp, diags) + return + } + } + + if r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored { + runningOp.Result = backend.OperationFailure + } + } + }() + + // Return the running operation. + return runningOp, nil +} + +func (b *Cloud) cancel(cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error { + if r.Actions.IsCancelable { + // Only ask if the remote operation should be canceled + // if the auto approve flag is not set. + if !op.AutoApprove { + v, err := op.UIIn.Input(cancelCtx, &terraform.InputOpts{ + Id: "cancel", + Query: "\nDo you want to cancel the remote operation?", + Description: "Only 'yes' will be accepted to cancel.", + }) + if err != nil { + return generalError("Failed asking to cancel", err) + } + if v != "yes" { + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(operationNotCanceled))) + } + return nil + } + } else { + if b.CLI != nil { + // Insert a blank line to separate the ouputs. + b.CLI.Output("") + } + } + + // Try to cancel the remote operation. + err := b.client.Runs.Cancel(cancelCtx, r.ID, tfe.RunCancelOptions{}) + if err != nil { + return generalError("Failed to cancel run", err) + } + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(operationCanceled))) + } + } + + return nil +} + +// IgnoreVersionConflict allows commands to disable the fall-back check that +// the local Terraform version matches the remote workspace's configured +// Terraform version. This should be called by commands where this check is +// unnecessary, such as those performing remote operations, or read-only +// operations. It will also be called if the user uses a command-line flag to +// override this check. +func (b *Cloud) IgnoreVersionConflict() { + b.ignoreVersionConflict = true +} + +// VerifyWorkspaceTerraformVersion compares the local Terraform version against +// the workspace's configured Terraform version. If they are equal, this means +// that there are no compatibility concerns, so it returns no diagnostics. +// +// If the versions differ, +func (b *Cloud) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + workspace, err := b.getRemoteWorkspace(context.Background(), workspaceName) + if err != nil { + // If the workspace doesn't exist, there can be no compatibility + // problem, so we can return. This is most likely to happen when + // migrating state from a local backend to a new workspace. + if err == tfe.ErrResourceNotFound { + return nil + } + + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Error looking up workspace", + fmt.Sprintf("Workspace read failed: %s", err), + )) + return diags + } + + // If the workspace has the pseudo-version "latest", all bets are off. We + // cannot reasonably determine what the intended Terraform version is, so + // we'll skip version verification. + if workspace.TerraformVersion == "latest" { + return nil + } + + // If the workspace has remote operations disabled, the remote Terraform + // version is effectively meaningless, so we'll skip version verification. + if workspace.Operations == false { + return nil + } + + remoteVersion, err := version.NewSemver(workspace.TerraformVersion) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Error looking up workspace", + fmt.Sprintf("Invalid Terraform version: %s", err), + )) + return diags + } + + v014 := version.Must(version.NewSemver("0.14.0")) + if tfversion.SemVer.LessThan(v014) || remoteVersion.LessThan(v014) { + // Versions of Terraform prior to 0.14.0 will refuse to load state files + // written by a newer version of Terraform, even if it is only a patch + // level difference. As a result we require an exact match. + if tfversion.SemVer.Equal(remoteVersion) { + return diags + } + } + if tfversion.SemVer.GreaterThanOrEqual(v014) && remoteVersion.GreaterThanOrEqual(v014) { + // Versions of Terraform after 0.14.0 should be compatible with each + // other. At the time this code was written, the only constraints we + // are aware of are: + // + // - 0.14.0 is guaranteed to be compatible with versions up to but not + // including 1.1.0 + v110 := version.Must(version.NewSemver("1.1.0")) + if tfversion.SemVer.LessThan(v110) && remoteVersion.LessThan(v110) { + return diags + } + // - Any new Terraform state version will require at least minor patch + // increment, so x.y.* will always be compatible with each other + tfvs := tfversion.SemVer.Segments64() + rwvs := remoteVersion.Segments64() + if len(tfvs) == 3 && len(rwvs) == 3 && tfvs[0] == rwvs[0] && tfvs[1] == rwvs[1] { + return diags + } + } + + // Even if ignoring version conflicts, it may still be useful to call this + // method and warn the user about a mismatch between the local and remote + // Terraform versions. + severity := tfdiags.Error + if b.ignoreVersionConflict { + severity = tfdiags.Warning + } + + suggestion := " If you're sure you want to upgrade the state, you can force Terraform to continue using the -ignore-remote-version flag. This may result in an unusable workspace." + if b.ignoreVersionConflict { + suggestion = "" + } + diags = diags.Append(tfdiags.Sourceless( + severity, + "Terraform version mismatch", + fmt.Sprintf( + "The local Terraform version (%s) does not match the configured version for remote workspace %s/%s (%s).%s", + tfversion.String(), + b.organization, + workspace.Name, + workspace.TerraformVersion, + suggestion, + ), + )) + + return diags +} + +func (b *Cloud) IsLocalOperations() bool { + return b.forceLocal +} + +// Colorize returns the Colorize structure that can be used for colorizing +// output. This is guaranteed to always return a non-nil value and so useful +// as a helper to wrap any potentially colored strings. +// +// TODO SvH: Rename this back to Colorize as soon as we can pass -no-color. +func (b *Cloud) cliColorize() *colorstring.Colorize { + if b.CLIColor != nil { + return b.CLIColor + } + + return &colorstring.Colorize{ + Colors: colorstring.DefaultColors, + Disable: true, + } +} + +func generalError(msg string, err error) error { + var diags tfdiags.Diagnostics + + if urlErr, ok := err.(*url.Error); ok { + err = urlErr.Err + } + + switch err { + case context.Canceled: + return err + case tfe.ErrResourceNotFound: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + fmt.Sprintf("%s: %v", msg, err), + "For security, Terraform Cloud returns '404 Not Found' responses for resources\n"+ + "for resources that a user doesn't have access to, in addition to resources that\n"+ + "do not exist. If the resource does exist, please check the permissions of the provided token.", + )) + return diags.Err() + default: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + fmt.Sprintf("%s: %v", msg, err), + `Terraform Cloud returned an unexpected error. Sometimes `+ + `this is caused by network connection problems, in which case you could retry `+ + `the command. If the issue persists please open a support ticket to get help `+ + `resolving the problem.`, + )) + return diags.Err() + } +} + +func checkConstraintsWarning(err error) tfdiags.Diagnostic { + return tfdiags.Sourceless( + tfdiags.Warning, + fmt.Sprintf("Failed to check version constraints: %v", err), + "Checking version constraints is considered optional, but this is an"+ + "unexpected error which should be reported.", + ) +} + +// The newline in this error is to make it look good in the CLI! +const initialRetryError = ` +[reset][yellow]There was an error connecting to Terraform Cloud. Please do not exit +Terraform to prevent data loss! Trying to restore the connection... +[reset] +` + +const repeatedRetryError = ` +[reset][yellow]Still trying to restore the connection... (%s elapsed)[reset] +` + +const operationCanceled = ` +[reset][red]The remote operation was successfully cancelled.[reset] +` + +const operationNotCanceled = ` +[reset][red]The remote operation was not cancelled.[reset] +` + +var schemaDescriptions = map[string]string{ + "hostname": "The Terraform Enterprise hostname to connect to. This optional argument defaults to app.terraform.io for use with Terraform Cloud.", + "organization": "The name of the organization containing the targeted workspace(s).", + "token": "The token used to authenticate with Terraform Cloud/Enterprise. Typically this argument should not be set,\n" + + "and 'terraform login' used instead; your credentials will then be fetched from your CLI configuration file or configured credential helper.", + "name": "A workspace name used to map the default workspace to a named remote workspace.\n" + + "When configured only the default workspace can be used. This option conflicts\n" + + "with \"prefix\"", + "prefix": "A prefix used to filter workspaces using a single configuration. New workspaces\n" + + "will automatically be prefixed with this prefix. If omitted only the default\n" + + "workspace can be used. This option conflicts with \"name\"", +} diff --git a/internal/cloud/backend_apply.go b/internal/cloud/backend_apply.go new file mode 100644 index 000000000..e50f1ee60 --- /dev/null +++ b/internal/cloud/backend_apply.go @@ -0,0 +1,301 @@ +package cloud + +import ( + "bufio" + "context" + "fmt" + "io" + "log" + + tfe "github.com/hashicorp/go-tfe" + version "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) { + log.Printf("[INFO] cloud: starting Apply operation") + + var diags tfdiags.Diagnostics + + // We should remove the `CanUpdate` part of this test, but for now + // (to remain compatible with tfe.v2.1) we'll leave it in here. + if !w.Permissions.CanUpdate && !w.Permissions.CanQueueApply { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Insufficient rights to apply changes", + "The provided credentials have insufficient rights to apply changes. In order "+ + "to apply changes at least write permissions on the workspace are required.", + )) + return nil, diags.Err() + } + + if w.VCSRepo != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Apply not allowed for workspaces with a VCS connection", + "A workspace that is connected to a VCS requires the VCS-driven workflow "+ + "to ensure that the VCS remains the single source of truth.", + )) + return nil, diags.Err() + } + + if op.Parallelism != defaultParallelism { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Custom parallelism values are currently not supported", + `Terraform Cloud does not support setting a custom parallelism `+ + `value at this time.`, + )) + } + + if op.PlanFile != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Applying a saved plan is currently not supported", + `Terraform Cloud currently requires configuration to be present and `+ + `does not accept an existing saved plan as an argument at this time.`, + )) + } + + if b.hasExplicitVariableValues(op) { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Run variables are currently not supported", + fmt.Sprintf( + "Terraform Cloud does not support setting run variables from command line arguments at this time. "+ + "Currently the only to way to pass variables is by "+ + "creating a '*.auto.tfvars' variables file. This file will automatically "+ + "be loaded when the workspace is configured to use "+ + "Terraform v0.10.0 or later.\n\nAdditionally you can also set variables on "+ + "the workspace in the web UI:\nhttps://%s/app/%s/%s/variables", + b.hostname, b.organization, op.Workspace, + ), + )) + } + + if !op.HasConfig() && op.PlanMode != plans.DestroyMode { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "No configuration files found", + `Apply requires configuration to be present. Applying without a configuration `+ + `would mark everything for destruction, which is normally not what is desired. `+ + `If you would like to destroy everything, please run 'terraform destroy' which `+ + `does not require any configuration files.`, + )) + } + + // For API versions prior to 2.3, RemoteAPIVersion will return an empty string, + // so if there's an error when parsing the RemoteAPIVersion, it's handled as + // equivalent to an API version < 2.3. + currentAPIVersion, parseErr := version.NewVersion(b.client.RemoteAPIVersion()) + + if !op.PlanRefresh { + desiredAPIVersion, _ := version.NewVersion("2.4") + + if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Planning without refresh is not supported", + fmt.Sprintf( + `The host %s does not support the -refresh=false option for `+ + `remote plans.`, + b.hostname, + ), + )) + } + } + + if op.PlanMode == plans.RefreshOnlyMode { + desiredAPIVersion, _ := version.NewVersion("2.4") + + if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Refresh-only mode is not supported", + fmt.Sprintf( + `The host %s does not support -refresh-only mode for `+ + `remote plans.`, + b.hostname, + ), + )) + } + } + + if len(op.ForceReplace) != 0 { + desiredAPIVersion, _ := version.NewVersion("2.4") + + if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Planning resource replacements is not supported", + fmt.Sprintf( + `The host %s does not support the -replace option for `+ + `remote plans.`, + b.hostname, + ), + )) + } + } + + if len(op.Targets) != 0 { + desiredAPIVersion, _ := version.NewVersion("2.3") + + if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Resource targeting is not supported", + fmt.Sprintf( + `The host %s does not support the -target option for `+ + `remote plans.`, + b.hostname, + ), + )) + } + } + + // Return if there are any errors. + if diags.HasErrors() { + return nil, diags.Err() + } + + // Run the plan phase. + r, err := b.plan(stopCtx, cancelCtx, op, w) + if err != nil { + return r, err + } + + // This check is also performed in the plan method to determine if + // the policies should be checked, but we need to check the values + // here again to determine if we are done and should return. + if !r.HasChanges || r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored { + return r, nil + } + + // Retrieve the run to get its current status. + r, err = b.client.Runs.Read(stopCtx, r.ID) + if err != nil { + return r, generalError("Failed to retrieve run", err) + } + + // Return if the run cannot be confirmed. + if !w.AutoApply && !r.Actions.IsConfirmable { + return r, nil + } + + // Since we already checked the permissions before creating the run + // this should never happen. But it doesn't hurt to keep this in as + // a safeguard for any unexpected situations. + if !w.AutoApply && !r.Permissions.CanApply { + // Make sure we discard the run if possible. + if r.Actions.IsDiscardable { + err = b.client.Runs.Discard(stopCtx, r.ID, tfe.RunDiscardOptions{}) + if err != nil { + switch op.PlanMode { + case plans.DestroyMode: + return r, generalError("Failed to discard destroy", err) + default: + return r, generalError("Failed to discard apply", err) + } + } + } + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Insufficient rights to approve the pending changes", + fmt.Sprintf("There are pending changes, but the provided credentials have "+ + "insufficient rights to approve them. The run will be discarded to prevent "+ + "it from blocking the queue waiting for external approval. To queue a run "+ + "that can be approved by someone else, please use the 'Queue Plan' button in "+ + "the web UI:\nhttps://%s/app/%s/%s/runs", b.hostname, b.organization, op.Workspace), + )) + return r, diags.Err() + } + + mustConfirm := (op.UIIn != nil && op.UIOut != nil) && !op.AutoApprove + + if !w.AutoApply { + if mustConfirm { + opts := &terraform.InputOpts{Id: "approve"} + + if op.PlanMode == plans.DestroyMode { + opts.Query = "\nDo you really want to destroy all resources in workspace \"" + op.Workspace + "\"?" + opts.Description = "Terraform will destroy all your managed infrastructure, as shown above.\n" + + "There is no undo. Only 'yes' will be accepted to confirm." + } else { + opts.Query = "\nDo you want to perform these actions in workspace \"" + op.Workspace + "\"?" + opts.Description = "Terraform will perform the actions described above.\n" + + "Only 'yes' will be accepted to approve." + } + + err = b.confirm(stopCtx, op, opts, r, "yes") + if err != nil && err != errRunApproved { + return r, err + } + } + + if err != errRunApproved { + if err = b.client.Runs.Apply(stopCtx, r.ID, tfe.RunApplyOptions{}); err != nil { + return r, generalError("Failed to approve the apply command", err) + } + } + } + + // If we don't need to ask for confirmation, insert a blank + // line to separate the ouputs. + if w.AutoApply || !mustConfirm { + if b.CLI != nil { + b.CLI.Output("") + } + } + + r, err = b.waitForRun(stopCtx, cancelCtx, op, "apply", r, w) + if err != nil { + return r, err + } + + logs, err := b.client.Applies.Logs(stopCtx, r.Apply.ID) + if err != nil { + return r, generalError("Failed to retrieve logs", err) + } + reader := bufio.NewReaderSize(logs, 64*1024) + + if b.CLI != nil { + skip := 0 + for next := true; next; { + var l, line []byte + + for isPrefix := true; isPrefix; { + l, isPrefix, err = reader.ReadLine() + if err != nil { + if err != io.EOF { + return r, generalError("Failed to read logs", err) + } + next = false + } + line = append(line, l...) + } + + // Skip the first 3 lines to prevent duplicate output. + if skip < 3 { + skip++ + continue + } + + if next || len(line) > 0 { + b.CLI.Output(b.Colorize().Color(string(line))) + } + } + } + + return r, nil +} + +const applyDefaultHeader = ` +[reset][yellow]Running apply in Terraform Cloud. Output will stream here. Pressing Ctrl-C +will cancel the remote apply if it's still pending. If the apply started it +will stop streaming the logs, but will not stop the apply running remotely.[reset] + +Preparing the remote apply... +` diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go new file mode 100644 index 000000000..140dafa8a --- /dev/null +++ b/internal/cloud/backend_apply_test.go @@ -0,0 +1,1659 @@ +package cloud + +import ( + "context" + "os" + "os/signal" + "strings" + "syscall" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + tfe "github.com/hashicorp/go-tfe" + version "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/command/clistate" + "github.com/hashicorp/terraform/internal/command/views" + "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/plans/planfile" + "github.com/hashicorp/terraform/internal/states/statemgr" + "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/internal/terraform" + tfversion "github.com/hashicorp/terraform/version" + "github.com/mitchellh/cli" +) + +func testOperationApply(t *testing.T, configDir string) (*backend.Operation, func(), func(*testing.T) *terminal.TestOutput) { + t.Helper() + + return testOperationApplyWithTimeout(t, configDir, 0) +} + +func testOperationApplyWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backend.Operation, func(), func(*testing.T) *terminal.TestOutput) { + t.Helper() + + _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir) + + streams, done := terminal.StreamsForTesting(t) + view := views.NewView(streams) + stateLockerView := views.NewStateLocker(arguments.ViewHuman, view) + operationView := views.NewOperation(arguments.ViewHuman, false, view) + + return &backend.Operation{ + ConfigDir: configDir, + ConfigLoader: configLoader, + Parallelism: defaultParallelism, + PlanRefresh: true, + StateLocker: clistate.NewLocker(timeout, stateLockerView), + Type: backend.OperationTypeApply, + View: operationView, + }, configCleanup, done +} + +func TestCloud_applyBasic(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("expected apply summery in output: %s", output) + } + + stateMgr, _ := b.StateMgr(backend.DefaultStateName) + // An error suggests that the state was not unlocked after apply + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { + t.Fatalf("unexpected error locking state after apply: %s", err.Error()) + } +} + +func TestCloud_applyCanceled(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + // Stop the run to simulate a Ctrl-C. + run.Stop() + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + + stateMgr, _ := b.StateMgr(backend.DefaultStateName) + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { + t.Fatalf("unexpected error locking state after cancelling apply: %s", err.Error()) + } +} + +func TestCloud_applyWithoutPermissions(t *testing.T) { + b, bCleanup := testBackendNoDefault(t) + defer bCleanup() + + // Create a named workspace without permissions. + w, err := b.client.Workspaces.Create( + context.Background(), + b.organization, + tfe.WorkspaceCreateOptions{ + Name: tfe.String(b.prefix + "prod"), + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + w.Permissions.CanQueueApply = false + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + + op.UIOut = b.CLI + op.Workspace = "prod" + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "Insufficient rights to apply changes") { + t.Fatalf("expected a permissions error, got: %v", errOutput) + } +} + +func TestCloud_applyWithVCS(t *testing.T) { + b, bCleanup := testBackendNoDefault(t) + defer bCleanup() + + // Create a named workspace with a VCS. + _, err := b.client.Workspaces.Create( + context.Background(), + b.organization, + tfe.WorkspaceCreateOptions{ + Name: tfe.String(b.prefix + "prod"), + VCSRepo: &tfe.VCSRepoOptions{}, + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + + op.Workspace = "prod" + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "not allowed for workspaces with a VCS") { + t.Fatalf("expected a VCS error, got: %v", errOutput) + } +} + +func TestCloud_applyWithParallelism(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + + op.Parallelism = 3 + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "parallelism values are currently not supported") { + t.Fatalf("expected a parallelism error, got: %v", errOutput) + } +} + +func TestCloud_applyWithPlan(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + + op.PlanFile = &planfile.Reader{} + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "saved plan is currently not supported") { + t.Fatalf("expected a saved plan error, got: %v", errOutput) + } +} + +func TestCloud_applyWithoutRefresh(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + op.PlanRefresh = false + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected plan to be non-empty") + } + + // We should find a run inside the mock client that has refresh set + // to false. + runsAPI := b.client.Runs.(*mockRuns) + if got, want := len(runsAPI.runs), 1; got != want { + t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) + } + for _, run := range runsAPI.runs { + if diff := cmp.Diff(false, run.Refresh); diff != "" { + t.Errorf("wrong Refresh setting in the created run\n%s", diff) + } + } +} + +func TestCloud_applyWithoutRefreshIncompatibleAPIVersion(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + + b.client.SetFakeRemoteAPIVersion("2.3") + + op.PlanRefresh = false + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "Planning without refresh is not supported") { + t.Fatalf("expected a not supported error, got: %v", errOutput) + } +} + +func TestCloud_applyWithRefreshOnly(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + op.PlanMode = plans.RefreshOnlyMode + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected plan to be non-empty") + } + + // We should find a run inside the mock client that has refresh-only set + // to true. + runsAPI := b.client.Runs.(*mockRuns) + if got, want := len(runsAPI.runs), 1; got != want { + t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) + } + for _, run := range runsAPI.runs { + if diff := cmp.Diff(true, run.RefreshOnly); diff != "" { + t.Errorf("wrong RefreshOnly setting in the created run\n%s", diff) + } + } +} + +func TestCloud_applyWithRefreshOnlyIncompatibleAPIVersion(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + + b.client.SetFakeRemoteAPIVersion("2.3") + + op.PlanMode = plans.RefreshOnlyMode + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "Refresh-only mode is not supported") { + t.Fatalf("expected a not supported error, got: %v", errOutput) + } +} + +func TestCloud_applyWithTarget(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + addr, _ := addrs.ParseAbsResourceStr("null_resource.foo") + + op.Targets = []addrs.Targetable{addr} + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatal("expected apply operation to succeed") + } + if run.PlanEmpty { + t.Fatalf("expected plan to be non-empty") + } + + // We should find a run inside the mock client that has the same + // target address we requested above. + runsAPI := b.client.Runs.(*mockRuns) + if got, want := len(runsAPI.runs), 1; got != want { + t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) + } + for _, run := range runsAPI.runs { + if diff := cmp.Diff([]string{"null_resource.foo"}, run.TargetAddrs); diff != "" { + t.Errorf("wrong TargetAddrs in the created run\n%s", diff) + } + } +} + +func TestCloud_applyWithTargetIncompatibleAPIVersion(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + + // Set the tfe client's RemoteAPIVersion to an empty string, to mimic + // API versions prior to 2.3. + b.client.SetFakeRemoteAPIVersion("") + + addr, _ := addrs.ParseAbsResourceStr("null_resource.foo") + + op.Targets = []addrs.Targetable{addr} + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "Resource targeting is not supported") { + t.Fatalf("expected a targeting error, got: %v", errOutput) + } +} + +func TestCloud_applyWithReplace(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + addr, _ := addrs.ParseAbsResourceInstanceStr("null_resource.foo") + + op.ForceReplace = []addrs.AbsResourceInstance{addr} + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatal("expected plan operation to succeed") + } + if run.PlanEmpty { + t.Fatalf("expected plan to be non-empty") + } + + // We should find a run inside the mock client that has the same + // refresh address we requested above. + runsAPI := b.client.Runs.(*mockRuns) + if got, want := len(runsAPI.runs), 1; got != want { + t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) + } + for _, run := range runsAPI.runs { + if diff := cmp.Diff([]string{"null_resource.foo"}, run.ReplaceAddrs); diff != "" { + t.Errorf("wrong ReplaceAddrs in the created run\n%s", diff) + } + } +} + +func TestCloud_applyWithReplaceIncompatibleAPIVersion(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + + b.client.SetFakeRemoteAPIVersion("2.3") + + addr, _ := addrs.ParseAbsResourceInstanceStr("null_resource.foo") + + op.ForceReplace = []addrs.AbsResourceInstance{addr} + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "Planning resource replacements is not supported") { + t.Fatalf("expected a not supported error, got: %v", errOutput) + } +} + +func TestCloud_applyWithVariables(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-variables") + defer configCleanup() + + op.Variables = testVariables(terraform.ValueFromNamedFile, "foo", "bar") + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "variables are currently not supported") { + t.Fatalf("expected a variables error, got: %v", errOutput) + } +} + +func TestCloud_applyNoConfig(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/empty") + defer configCleanup() + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "configuration files found") { + t.Fatalf("expected configuration files error, got: %v", errOutput) + } + + stateMgr, _ := b.StateMgr(backend.DefaultStateName) + // An error suggests that the state was not unlocked after apply + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { + t.Fatalf("unexpected error locking state after failed apply: %s", err.Error()) + } +} + +func TestCloud_applyNoChanges(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-no-changes") + defer configCleanup() + defer done(t) + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "No changes. Infrastructure is up-to-date.") { + t.Fatalf("expected no changes in plan summery: %s", output) + } + if !strings.Contains(output, "Sentinel Result: true") { + t.Fatalf("expected policy check result in output: %s", output) + } +} + +func TestCloud_applyNoApprove(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + + input := testInput(t, map[string]string{ + "approve": "no", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "Apply discarded") { + t.Fatalf("expected an apply discarded error, got: %v", errOutput) + } +} + +func TestCloud_applyAutoApprove(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "no", + }) + + op.AutoApprove = true + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) != 1 { + t.Fatalf("expected an unused answer, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("expected apply summery in output: %s", output) + } +} + +func TestCloud_applyApprovedExternally(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "wait-for-external-update", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + ctx := context.Background() + + run, err := b.Operation(ctx, op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + // Wait 50 milliseconds to make sure the run started. + time.Sleep(50 * time.Millisecond) + + wl, err := b.client.Workspaces.List( + ctx, + b.organization, + tfe.WorkspaceListOptions{ + ListOptions: tfe.ListOptions{PageNumber: 2, PageSize: 10}, + }, + ) + if err != nil { + t.Fatalf("unexpected error listing workspaces: %v", err) + } + if len(wl.Items) != 1 { + t.Fatalf("expected 1 workspace, got %d workspaces", len(wl.Items)) + } + + rl, err := b.client.Runs.List(ctx, wl.Items[0].ID, tfe.RunListOptions{}) + if err != nil { + t.Fatalf("unexpected error listing runs: %v", err) + } + if len(rl.Items) != 1 { + t.Fatalf("expected 1 run, got %d runs", len(rl.Items)) + } + + err = b.client.Runs.Apply(context.Background(), rl.Items[0].ID, tfe.RunApplyOptions{}) + if err != nil { + t.Fatalf("unexpected error approving run: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "approved using the UI or API") { + t.Fatalf("expected external approval in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("expected apply summery in output: %s", output) + } +} + +func TestCloud_applyDiscardedExternally(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "wait-for-external-update", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + ctx := context.Background() + + run, err := b.Operation(ctx, op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + // Wait 50 milliseconds to make sure the run started. + time.Sleep(50 * time.Millisecond) + + wl, err := b.client.Workspaces.List( + ctx, + b.organization, + tfe.WorkspaceListOptions{ + ListOptions: tfe.ListOptions{PageNumber: 2, PageSize: 10}, + }, + ) + if err != nil { + t.Fatalf("unexpected error listing workspaces: %v", err) + } + if len(wl.Items) != 1 { + t.Fatalf("expected 1 workspace, got %d workspaces", len(wl.Items)) + } + + rl, err := b.client.Runs.List(ctx, wl.Items[0].ID, tfe.RunListOptions{}) + if err != nil { + t.Fatalf("unexpected error listing runs: %v", err) + } + if len(rl.Items) != 1 { + t.Fatalf("expected 1 run, got %d runs", len(rl.Items)) + } + + err = b.client.Runs.Discard(context.Background(), rl.Items[0].ID, tfe.RunDiscardOptions{}) + if err != nil { + t.Fatalf("unexpected error discarding run: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "discarded using the UI or API") { + t.Fatalf("expected external discard output: %s", output) + } + if strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("unexpected apply summery in output: %s", output) + } +} + +func TestCloud_applyWithAutoApply(t *testing.T) { + b, bCleanup := testBackendNoDefault(t) + defer bCleanup() + + // Create a named workspace that auto applies. + _, err := b.client.Workspaces.Create( + context.Background(), + b.organization, + tfe.WorkspaceCreateOptions{ + AutoApply: tfe.Bool(true), + Name: tfe.String(b.prefix + "prod"), + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = "prod" + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) != 1 { + t.Fatalf("expected an unused answer, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("expected apply summery in output: %s", output) + } +} + +func TestCloud_applyForceLocal(t *testing.T) { + // Set TF_FORCE_LOCAL_BACKEND so the cloud backend will use + // the local backend with itself as embedded backend. + if err := os.Setenv("TF_FORCE_LOCAL_BACKEND", "1"); err != nil { + t.Fatalf("error setting environment variable TF_FORCE_LOCAL_BACKEND: %v", err) + } + defer os.Unsetenv("TF_FORCE_LOCAL_BACKEND") + + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + op.View = view + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("unexpected TFC header in output: %s", output) + } + if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } + if !run.State.HasResources() { + t.Fatalf("expected resources in state") + } +} + +func TestCloud_applyWorkspaceWithoutOperations(t *testing.T) { + b, bCleanup := testBackendNoDefault(t) + defer bCleanup() + + ctx := context.Background() + + // Create a named workspace that doesn't allow operations. + _, err := b.client.Workspaces.Create( + ctx, + b.organization, + tfe.WorkspaceCreateOptions{ + Name: tfe.String(b.prefix + "no-operations"), + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = "no-operations" + + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + op.View = view + + run, err := b.Operation(ctx, op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("unexpected TFC header in output: %s", output) + } + if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } + if !run.State.HasResources() { + t.Fatalf("expected resources in state") + } +} + +func TestCloud_applyLockTimeout(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + ctx := context.Background() + + // Retrieve the workspace used to run this operation in. + w, err := b.client.Workspaces.Read(ctx, b.organization, b.workspace) + if err != nil { + t.Fatalf("error retrieving workspace: %v", err) + } + + // Create a new configuration version. + c, err := b.client.ConfigurationVersions.Create(ctx, w.ID, tfe.ConfigurationVersionCreateOptions{}) + if err != nil { + t.Fatalf("error creating configuration version: %v", err) + } + + // Create a pending run to block this run. + _, err = b.client.Runs.Create(ctx, tfe.RunCreateOptions{ + ConfigurationVersion: c, + Workspace: w, + }) + if err != nil { + t.Fatalf("error creating pending run: %v", err) + } + + op, configCleanup, done := testOperationApplyWithTimeout(t, "./testdata/apply", 50*time.Millisecond) + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "cancel": "yes", + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + _, err = b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + sigint := make(chan os.Signal, 1) + signal.Notify(sigint, syscall.SIGINT) + select { + case <-sigint: + // Stop redirecting SIGINT signals. + signal.Stop(sigint) + case <-time.After(200 * time.Millisecond): + t.Fatalf("expected lock timeout after 50 milliseconds, waited 200 milliseconds") + } + + if len(input.answers) != 2 { + t.Fatalf("expected unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "Lock timeout exceeded") { + t.Fatalf("expected lock timout error in output: %s", output) + } + if strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("unexpected plan summery in output: %s", output) + } + if strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("unexpected apply summery in output: %s", output) + } +} + +func TestCloud_applyDestroy(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-destroy") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.PlanMode = plans.DestroyMode + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "0 to add, 0 to change, 1 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "0 added, 0 changed, 1 destroyed") { + t.Fatalf("expected apply summery in output: %s", output) + } +} + +func TestCloud_applyDestroyNoConfig(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op, configCleanup, done := testOperationApply(t, "./testdata/empty") + defer configCleanup() + defer done(t) + + op.PlanMode = plans.DestroyMode + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } +} + +func TestCloud_applyPolicyPass(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-policy-passed") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: true") { + t.Fatalf("expected policy check result in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("expected apply summery in output: %s", output) + } +} + +func TestCloud_applyPolicyHardFail(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-policy-hard-failed") + defer configCleanup() + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + viewOutput := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + if len(input.answers) != 1 { + t.Fatalf("expected an unused answers, got: %v", input.answers) + } + + errOutput := viewOutput.Stderr() + if !strings.Contains(errOutput, "hard failed") { + t.Fatalf("expected a policy check error, got: %v", errOutput) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("expected policy check result in output: %s", output) + } + if strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("unexpected apply summery in output: %s", output) + } +} + +func TestCloud_applyPolicySoftFail(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-policy-soft-failed") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "override": "override", + "approve": "yes", + }) + + op.AutoApprove = false + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("expected policy check result in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("expected apply summery in output: %s", output) + } +} + +func TestCloud_applyPolicySoftFailAutoApproveSuccess(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-policy-soft-failed") + defer configCleanup() + + input := testInput(t, map[string]string{}) + + op.AutoApprove = true + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + viewOutput := done(t) + if run.Result != backend.OperationSuccess { + t.Fatal("expected apply operation to success due to auto-approve") + } + + if run.PlanEmpty { + t.Fatalf("expected plan to not be empty, plan opertion completed without error") + } + + if len(input.answers) != 0 { + t.Fatalf("expected no answers, got: %v", input.answers) + } + + errOutput := viewOutput.Stderr() + if strings.Contains(errOutput, "soft failed") { + t.Fatalf("expected no policy check errors, instead got: %v", errOutput) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("expected policy check to be false, insead got: %s", output) + } + if !strings.Contains(output, "Apply complete!") { + t.Fatalf("expected apply to be complete, instead got: %s", output) + } + + if !strings.Contains(output, "Resources: 1 added, 0 changed, 0 destroyed") { + t.Fatalf("expected resources, instead got: %s", output) + } +} + +func TestCloud_applyPolicySoftFailAutoApply(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + // Create a named workspace that auto applies. + _, err := b.client.Workspaces.Create( + context.Background(), + b.organization, + tfe.WorkspaceCreateOptions{ + AutoApply: tfe.Bool(true), + Name: tfe.String(b.prefix + "prod"), + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-policy-soft-failed") + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "override": "override", + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = "prod" + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) != 1 { + t.Fatalf("expected an unused answer, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("expected policy check result in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("expected apply summery in output: %s", output) + } +} + +func TestCloud_applyWithRemoteError(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply-with-error") + defer configCleanup() + defer done(t) + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if run.Result.ExitStatus() != 1 { + t.Fatalf("expected exit code 1, got %d", run.Result.ExitStatus()) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "null_resource.foo: 1 error") { + t.Fatalf("expected apply error in output: %s", output) + } +} + +func TestCloud_applyVersionCheck(t *testing.T) { + testCases := map[string]struct { + localVersion string + remoteVersion string + forceLocal bool + hasOperations bool + wantErr string + }{ + "versions can be different for remote apply": { + localVersion: "0.14.0", + remoteVersion: "0.13.5", + hasOperations: true, + }, + "versions can be different for local apply": { + localVersion: "0.14.0", + remoteVersion: "0.13.5", + hasOperations: false, + }, + "force local with remote operations and different versions is acceptable": { + localVersion: "0.14.0", + remoteVersion: "0.14.0-acme-provider-bundle", + forceLocal: true, + hasOperations: true, + }, + "no error if versions are identical": { + localVersion: "0.14.0", + remoteVersion: "0.14.0", + forceLocal: true, + hasOperations: true, + }, + "no error if force local but workspace has remote operations disabled": { + localVersion: "0.14.0", + remoteVersion: "0.13.5", + forceLocal: true, + hasOperations: false, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + // SETUP: Save original local version state and restore afterwards + p := tfversion.Prerelease + v := tfversion.Version + s := tfversion.SemVer + defer func() { + tfversion.Prerelease = p + tfversion.Version = v + tfversion.SemVer = s + }() + + // SETUP: Set local version for the test case + tfversion.Prerelease = "" + tfversion.Version = tc.localVersion + tfversion.SemVer = version.Must(version.NewSemver(tc.localVersion)) + + // SETUP: Set force local for the test case + b.forceLocal = tc.forceLocal + + ctx := context.Background() + + // SETUP: set the operations and Terraform Version fields on the + // remote workspace + _, err := b.client.Workspaces.Update( + ctx, + b.organization, + b.workspace, + tfe.WorkspaceUpdateOptions{ + Operations: tfe.Bool(tc.hasOperations), + TerraformVersion: tfe.String(tc.remoteVersion), + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + + // RUN: prepare the apply operation and run it + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + op.View = view + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(ctx, op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + // RUN: wait for completion + <-run.Done() + output := done(t) + + if tc.wantErr != "" { + // ASSERT: if the test case wants an error, check for failure + // and the error message + if run.Result != backend.OperationFailure { + t.Fatalf("expected run to fail, but result was %#v", run.Result) + } + errOutput := output.Stderr() + if !strings.Contains(errOutput, tc.wantErr) { + t.Fatalf("missing error %q\noutput: %s", tc.wantErr, errOutput) + } + } else { + // ASSERT: otherwise, check for success and appropriate output + // based on whether the run should be local or remote + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + output := b.CLI.(*cli.MockUi).OutputWriter.String() + hasRemote := strings.Contains(output, "Running apply in Terraform Cloud") + hasSummary := strings.Contains(output, "1 added, 0 changed, 0 destroyed") + hasResources := run.State.HasResources() + if !tc.forceLocal && tc.hasOperations { + if !hasRemote { + t.Errorf("missing TFC header in output: %s", output) + } + if !hasSummary { + t.Errorf("expected apply summary in output: %s", output) + } + } else { + if hasRemote { + t.Errorf("unexpected TFC header in output: %s", output) + } + if !hasResources { + t.Errorf("expected resources in state") + } + } + } + }) + } +} diff --git a/internal/cloud/backend_cli.go b/internal/cloud/backend_cli.go new file mode 100644 index 000000000..8dac9bf3e --- /dev/null +++ b/internal/cloud/backend_cli.go @@ -0,0 +1,20 @@ +package cloud + +import ( + "github.com/hashicorp/terraform/internal/backend" +) + +// CLIInit implements backend.CLI +func (b *Cloud) CLIInit(opts *backend.CLIOpts) error { + if cli, ok := b.local.(backend.CLI); ok { + if err := cli.CLIInit(opts); err != nil { + return err + } + } + + b.CLI = opts.CLI + b.CLIColor = opts.CLIColor + b.ContextOpts = opts.ContextOpts + + return nil +} diff --git a/internal/cloud/backend_colorize.go b/internal/cloud/backend_colorize.go new file mode 100644 index 000000000..6fb3c98c3 --- /dev/null +++ b/internal/cloud/backend_colorize.go @@ -0,0 +1,50 @@ +package cloud + +import ( + "regexp" + + "github.com/mitchellh/colorstring" +) + +// TODO SvH: This file should be deleted and the type cliColorize should be +// renamed back to Colorize as soon as we can pass -no-color to the backend. + +// colorsRe is used to find ANSI escaped color codes. +var colorsRe = regexp.MustCompile("\033\\[\\d{1,3}m") + +// Colorer is the interface that must be implemented to colorize strings. +type Colorer interface { + Color(v string) string +} + +// Colorize is used to print output when the -no-color flag is used. It will +// strip all ANSI escaped color codes which are set while the operation was +// executed in Terraform Enterprise. +// +// When Terraform Enterprise supports run specific variables, this code can be +// removed as we can then pass the CLI flag to the backend and prevent the color +// codes from being written to the output. +type Colorize struct { + cliColor *colorstring.Colorize +} + +// Color will strip all ANSI escaped color codes and return a uncolored string. +func (c *Colorize) Color(v string) string { + return colorsRe.ReplaceAllString(c.cliColor.Color(v), "") +} + +// Colorize returns the Colorize structure that can be used for colorizing +// output. This is guaranteed to always return a non-nil value and so is useful +// as a helper to wrap any potentially colored strings. +func (b *Cloud) Colorize() Colorer { + if b.CLIColor != nil && !b.CLIColor.Disable { + return b.CLIColor + } + if b.CLIColor != nil { + return &Colorize{cliColor: b.CLIColor} + } + return &Colorize{cliColor: &colorstring.Colorize{ + Colors: colorstring.DefaultColors, + Disable: true, + }} +} diff --git a/internal/cloud/backend_common.go b/internal/cloud/backend_common.go new file mode 100644 index 000000000..92f28a1dd --- /dev/null +++ b/internal/cloud/backend_common.go @@ -0,0 +1,574 @@ +package cloud + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "math" + "strconv" + "strings" + "time" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/terraform" +) + +var ( + errApplyDiscarded = errors.New("Apply discarded.") + errDestroyDiscarded = errors.New("Destroy discarded.") + errRunApproved = errors.New("approved using the UI or API") + errRunDiscarded = errors.New("discarded using the UI or API") + errRunOverridden = errors.New("overridden using the UI or API") +) + +var ( + backoffMin = 1000.0 + backoffMax = 3000.0 + + runPollInterval = 3 * time.Second +) + +// backoff will perform exponential backoff based on the iteration and +// limited by the provided min and max (in milliseconds) durations. +func backoff(min, max float64, iter int) time.Duration { + backoff := math.Pow(2, float64(iter)/5) * min + if backoff > max { + backoff = max + } + return time.Duration(backoff) * time.Millisecond +} + +func (b *Cloud) waitForRun(stopCtx, cancelCtx context.Context, op *backend.Operation, opType string, r *tfe.Run, w *tfe.Workspace) (*tfe.Run, error) { + started := time.Now() + updated := started + for i := 0; ; i++ { + select { + case <-stopCtx.Done(): + return r, stopCtx.Err() + case <-cancelCtx.Done(): + return r, cancelCtx.Err() + case <-time.After(backoff(backoffMin, backoffMax, i)): + // Timer up, show status + } + + // Retrieve the run to get its current status. + r, err := b.client.Runs.Read(stopCtx, r.ID) + if err != nil { + return r, generalError("Failed to retrieve run", err) + } + + // Return if the run is no longer pending. + if r.Status != tfe.RunPending && r.Status != tfe.RunConfirmed { + if i == 0 && opType == "plan" && b.CLI != nil { + b.CLI.Output(b.Colorize().Color(fmt.Sprintf("Waiting for the %s to start...\n", opType))) + } + if i > 0 && b.CLI != nil { + // Insert a blank line to separate the ouputs. + b.CLI.Output("") + } + return r, nil + } + + // Check if 30 seconds have passed since the last update. + current := time.Now() + if b.CLI != nil && (i == 0 || current.Sub(updated).Seconds() > 30) { + updated = current + position := 0 + elapsed := "" + + // Calculate and set the elapsed time. + if i > 0 { + elapsed = fmt.Sprintf( + " (%s elapsed)", current.Sub(started).Truncate(30*time.Second)) + } + + // Retrieve the workspace used to run this operation in. + w, err = b.client.Workspaces.Read(stopCtx, b.organization, w.Name) + if err != nil { + return nil, generalError("Failed to retrieve workspace", err) + } + + // If the workspace is locked the run will not be queued and we can + // update the status without making any expensive calls. + if w.Locked && w.CurrentRun != nil { + cr, err := b.client.Runs.Read(stopCtx, w.CurrentRun.ID) + if err != nil { + return r, generalError("Failed to retrieve current run", err) + } + if cr.Status == tfe.RunPending { + b.CLI.Output(b.Colorize().Color( + "Waiting for the manually locked workspace to be unlocked..." + elapsed)) + continue + } + } + + // Skip checking the workspace queue when we are the current run. + if w.CurrentRun == nil || w.CurrentRun.ID != r.ID { + found := false + options := tfe.RunListOptions{} + runlist: + for { + rl, err := b.client.Runs.List(stopCtx, w.ID, options) + if err != nil { + return r, generalError("Failed to retrieve run list", err) + } + + // Loop through all runs to calculate the workspace queue position. + for _, item := range rl.Items { + if !found { + if r.ID == item.ID { + found = true + } + continue + } + + // If the run is in a final state, ignore it and continue. + switch item.Status { + case tfe.RunApplied, tfe.RunCanceled, tfe.RunDiscarded, tfe.RunErrored: + continue + case tfe.RunPlanned: + if op.Type == backend.OperationTypePlan { + continue + } + } + + // Increase the workspace queue position. + position++ + + // Stop searching when we reached the current run. + if w.CurrentRun != nil && w.CurrentRun.ID == item.ID { + break runlist + } + } + + // Exit the loop when we've seen all pages. + if rl.CurrentPage >= rl.TotalPages { + break + } + + // Update the page number to get the next page. + options.PageNumber = rl.NextPage + } + + if position > 0 { + b.CLI.Output(b.Colorize().Color(fmt.Sprintf( + "Waiting for %d run(s) to finish before being queued...%s", + position, + elapsed, + ))) + continue + } + } + + options := tfe.RunQueueOptions{} + search: + for { + rq, err := b.client.Organizations.RunQueue(stopCtx, b.organization, options) + if err != nil { + return r, generalError("Failed to retrieve queue", err) + } + + // Search through all queued items to find our run. + for _, item := range rq.Items { + if r.ID == item.ID { + position = item.PositionInQueue + break search + } + } + + // Exit the loop when we've seen all pages. + if rq.CurrentPage >= rq.TotalPages { + break + } + + // Update the page number to get the next page. + options.PageNumber = rq.NextPage + } + + if position > 0 { + c, err := b.client.Organizations.Capacity(stopCtx, b.organization) + if err != nil { + return r, generalError("Failed to retrieve capacity", err) + } + b.CLI.Output(b.Colorize().Color(fmt.Sprintf( + "Waiting for %d queued run(s) to finish before starting...%s", + position-c.Running, + elapsed, + ))) + continue + } + + b.CLI.Output(b.Colorize().Color(fmt.Sprintf( + "Waiting for the %s to start...%s", opType, elapsed))) + } + } +} + +// hasExplicitVariableValues is a best-effort check to determine whether the +// user has provided -var or -var-file arguments to a remote operation. +// +// The results may be inaccurate if the configuration is invalid or if +// individual variable values are invalid. That's okay because we only use this +// result to hint the user to set variables a different way. It's always the +// remote system's responsibility to do final validation of the input. +func (b *Cloud) hasExplicitVariableValues(op *backend.Operation) bool { + // Load the configuration using the caller-provided configuration loader. + config, _, configDiags := op.ConfigLoader.LoadConfigWithSnapshot(op.ConfigDir) + if configDiags.HasErrors() { + // If we can't load the configuration then we'll assume no explicit + // variable values just to let the remote operation start and let + // the remote system return the same set of configuration errors. + return false + } + + // We're intentionally ignoring the diagnostics here because validation + // of the variable values is the responsibilty of the remote system. Our + // goal here is just to make a best effort count of how many variable + // values are coming from -var or -var-file CLI arguments so that we can + // hint the user that those are not supported for remote operations. + variables, _ := backend.ParseVariableValues(op.Variables, config.Module.Variables) + + // Check for explicitly-defined (-var and -var-file) variables, which the + // Terraform Cloud currently does not support. All other source types are okay, + // because they are implicit from the execution context anyway and so + // their final values will come from the _remote_ execution context. + for _, v := range variables { + switch v.SourceType { + case terraform.ValueFromCLIArg, terraform.ValueFromNamedFile: + return true + } + } + + return false +} + +func (b *Cloud) costEstimate(stopCtx, cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error { + if r.CostEstimate == nil { + return nil + } + + msgPrefix := "Cost estimation" + started := time.Now() + updated := started + for i := 0; ; i++ { + select { + case <-stopCtx.Done(): + return stopCtx.Err() + case <-cancelCtx.Done(): + return cancelCtx.Err() + case <-time.After(backoff(backoffMin, backoffMax, i)): + } + + // Retrieve the cost estimate to get its current status. + ce, err := b.client.CostEstimates.Read(stopCtx, r.CostEstimate.ID) + if err != nil { + return generalError("Failed to retrieve cost estimate", err) + } + + // If the run is canceled or errored, but the cost-estimate still has + // no result, there is nothing further to render. + if ce.Status != tfe.CostEstimateFinished { + if r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored { + return nil + } + } + + // checking if i == 0 so as to avoid printing this starting horizontal-rule + // every retry, and that it only prints it on the first (i=0) attempt. + if b.CLI != nil && i == 0 { + b.CLI.Output("\n------------------------------------------------------------------------\n") + } + + switch ce.Status { + case tfe.CostEstimateFinished: + delta, err := strconv.ParseFloat(ce.DeltaMonthlyCost, 64) + if err != nil { + return generalError("Unexpected error", err) + } + + sign := "+" + if delta < 0 { + sign = "-" + } + + deltaRepr := strings.Replace(ce.DeltaMonthlyCost, "-", "", 1) + + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(msgPrefix + ":\n")) + b.CLI.Output(b.Colorize().Color(fmt.Sprintf("Resources: %d of %d estimated", ce.MatchedResourcesCount, ce.ResourcesCount))) + b.CLI.Output(b.Colorize().Color(fmt.Sprintf(" $%s/mo %s$%s", ce.ProposedMonthlyCost, sign, deltaRepr))) + + if len(r.PolicyChecks) == 0 && r.HasChanges && op.Type == backend.OperationTypeApply { + b.CLI.Output("\n------------------------------------------------------------------------") + } + } + + return nil + case tfe.CostEstimatePending, tfe.CostEstimateQueued: + // Check if 30 seconds have passed since the last update. + current := time.Now() + if b.CLI != nil && (i == 0 || current.Sub(updated).Seconds() > 30) { + updated = current + elapsed := "" + + // Calculate and set the elapsed time. + if i > 0 { + elapsed = fmt.Sprintf( + " (%s elapsed)", current.Sub(started).Truncate(30*time.Second)) + } + b.CLI.Output(b.Colorize().Color(msgPrefix + ":\n")) + b.CLI.Output(b.Colorize().Color("Waiting for cost estimate to complete..." + elapsed + "\n")) + } + continue + case tfe.CostEstimateSkippedDueToTargeting: + b.CLI.Output(b.Colorize().Color(msgPrefix + ":\n")) + b.CLI.Output("Not available for this plan, because it was created with the -target option.") + b.CLI.Output("\n------------------------------------------------------------------------") + return nil + case tfe.CostEstimateErrored: + b.CLI.Output(msgPrefix + " errored.\n") + b.CLI.Output("\n------------------------------------------------------------------------") + return nil + case tfe.CostEstimateCanceled: + return fmt.Errorf(msgPrefix + " canceled.") + default: + return fmt.Errorf("Unknown or unexpected cost estimate state: %s", ce.Status) + } + } +} + +func (b *Cloud) checkPolicy(stopCtx, cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error { + if b.CLI != nil { + b.CLI.Output("\n------------------------------------------------------------------------\n") + } + for i, pc := range r.PolicyChecks { + // Read the policy check logs. This is a blocking call that will only + // return once the policy check is complete. + logs, err := b.client.PolicyChecks.Logs(stopCtx, pc.ID) + if err != nil { + return generalError("Failed to retrieve policy check logs", err) + } + reader := bufio.NewReaderSize(logs, 64*1024) + + // Retrieve the policy check to get its current status. + pc, err := b.client.PolicyChecks.Read(stopCtx, pc.ID) + if err != nil { + return generalError("Failed to retrieve policy check", err) + } + + // If the run is canceled or errored, but the policy check still has + // no result, there is nothing further to render. + if r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored { + switch pc.Status { + case tfe.PolicyPending, tfe.PolicyQueued, tfe.PolicyUnreachable: + continue + } + } + + var msgPrefix string + switch pc.Scope { + case tfe.PolicyScopeOrganization: + msgPrefix = "Organization policy check" + case tfe.PolicyScopeWorkspace: + msgPrefix = "Workspace policy check" + default: + msgPrefix = fmt.Sprintf("Unknown policy check (%s)", pc.Scope) + } + + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(msgPrefix + ":\n")) + } + + if b.CLI != nil { + for next := true; next; { + var l, line []byte + + for isPrefix := true; isPrefix; { + l, isPrefix, err = reader.ReadLine() + if err != nil { + if err != io.EOF { + return generalError("Failed to read logs", err) + } + next = false + } + line = append(line, l...) + } + + if next || len(line) > 0 { + b.CLI.Output(b.Colorize().Color(string(line))) + } + } + } + + switch pc.Status { + case tfe.PolicyPasses: + if (r.HasChanges && op.Type == backend.OperationTypeApply || i < len(r.PolicyChecks)-1) && b.CLI != nil { + b.CLI.Output("\n------------------------------------------------------------------------") + } + continue + case tfe.PolicyErrored: + return fmt.Errorf(msgPrefix + " errored.") + case tfe.PolicyHardFailed: + return fmt.Errorf(msgPrefix + " hard failed.") + case tfe.PolicySoftFailed: + runUrl := fmt.Sprintf(runHeader, b.hostname, b.organization, op.Workspace, r.ID) + + if op.Type == backend.OperationTypePlan || op.UIOut == nil || op.UIIn == nil || + !pc.Actions.IsOverridable || !pc.Permissions.CanOverride { + return fmt.Errorf(msgPrefix + " soft failed.\n" + runUrl) + } + + if op.AutoApprove { + if _, err = b.client.PolicyChecks.Override(stopCtx, pc.ID); err != nil { + return generalError(fmt.Sprintf("Failed to override policy check.\n%s", runUrl), err) + } + } else { + opts := &terraform.InputOpts{ + Id: "override", + Query: "\nDo you want to override the soft failed policy check?", + Description: "Only 'override' will be accepted to override.", + } + err = b.confirm(stopCtx, op, opts, r, "override") + if err != nil && err != errRunOverridden { + return fmt.Errorf( + fmt.Sprintf("Failed to override: %s\n%s\n", err.Error(), runUrl), + ) + } + + if err != errRunOverridden { + if _, err = b.client.PolicyChecks.Override(stopCtx, pc.ID); err != nil { + return generalError(fmt.Sprintf("Failed to override policy check.\n%s", runUrl), err) + } + } else { + b.CLI.Output(fmt.Sprintf("The run needs to be manually overridden or discarded.\n%s\n", runUrl)) + } + } + + if b.CLI != nil { + b.CLI.Output("------------------------------------------------------------------------") + } + default: + return fmt.Errorf("Unknown or unexpected policy state: %s", pc.Status) + } + } + + return nil +} + +func (b *Cloud) confirm(stopCtx context.Context, op *backend.Operation, opts *terraform.InputOpts, r *tfe.Run, keyword string) error { + doneCtx, cancel := context.WithCancel(stopCtx) + result := make(chan error, 2) + + go func() { + // Make sure we cancel doneCtx before we return + // so the input command is also canceled. + defer cancel() + + for { + select { + case <-doneCtx.Done(): + return + case <-stopCtx.Done(): + return + case <-time.After(runPollInterval): + // Retrieve the run again to get its current status. + r, err := b.client.Runs.Read(stopCtx, r.ID) + if err != nil { + result <- generalError("Failed to retrieve run", err) + return + } + + switch keyword { + case "override": + if r.Status != tfe.RunPolicyOverride { + if r.Status == tfe.RunDiscarded { + err = errRunDiscarded + } else { + err = errRunOverridden + } + } + case "yes": + if !r.Actions.IsConfirmable { + if r.Status == tfe.RunDiscarded { + err = errRunDiscarded + } else { + err = errRunApproved + } + } + } + + if err != nil { + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color( + fmt.Sprintf("[reset][yellow]%s[reset]", err.Error()))) + } + + if err == errRunDiscarded { + err = errApplyDiscarded + if op.PlanMode == plans.DestroyMode { + err = errDestroyDiscarded + } + } + + result <- err + return + } + } + } + }() + + result <- func() error { + v, err := op.UIIn.Input(doneCtx, opts) + if err != nil && err != context.Canceled && stopCtx.Err() != context.Canceled { + return fmt.Errorf("Error asking %s: %v", opts.Id, err) + } + + // We return the error of our parent channel as we don't + // care about the error of the doneCtx which is only used + // within this function. So if the doneCtx was canceled + // because stopCtx was canceled, this will properly return + // a context.Canceled error and otherwise it returns nil. + if doneCtx.Err() == context.Canceled || stopCtx.Err() == context.Canceled { + return stopCtx.Err() + } + + // Make sure we cancel the context here so the loop that + // checks for external changes to the run is ended before + // we start to make changes ourselves. + cancel() + + if v != keyword { + // Retrieve the run again to get its current status. + r, err = b.client.Runs.Read(stopCtx, r.ID) + if err != nil { + return generalError("Failed to retrieve run", err) + } + + // Make sure we discard the run if possible. + if r.Actions.IsDiscardable { + err = b.client.Runs.Discard(stopCtx, r.ID, tfe.RunDiscardOptions{}) + if err != nil { + if op.PlanMode == plans.DestroyMode { + return generalError("Failed to discard destroy", err) + } + return generalError("Failed to discard apply", err) + } + } + + // Even if the run was discarded successfully, we still + // return an error as the apply command was canceled. + if op.PlanMode == plans.DestroyMode { + return errDestroyDiscarded + } + return errApplyDiscarded + } + + return nil + }() + + return <-result +} diff --git a/internal/cloud/backend_context.go b/internal/cloud/backend_context.go new file mode 100644 index 000000000..55f9aba54 --- /dev/null +++ b/internal/cloud/backend_context.go @@ -0,0 +1,280 @@ +package cloud + +import ( + "context" + "fmt" + "log" + "strings" + + "github.com/hashicorp/errwrap" + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/states/statemgr" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +// Context implements backend.Enhanced. +func (b *Cloud) Context(op *backend.Operation) (*terraform.Context, statemgr.Full, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + op.StateLocker = op.StateLocker.WithContext(context.Background()) + + // Get the remote workspace name. + remoteWorkspaceName := b.getRemoteWorkspaceName(op.Workspace) + + // Get the latest state. + log.Printf("[TRACE] cloud: requesting state manager for workspace %q", remoteWorkspaceName) + stateMgr, err := b.StateMgr(op.Workspace) + if err != nil { + diags = diags.Append(errwrap.Wrapf("Error loading state: {{err}}", err)) + return nil, nil, diags + } + + log.Printf("[TRACE] cloud: requesting state lock for workspace %q", remoteWorkspaceName) + if diags := op.StateLocker.Lock(stateMgr, op.Type.String()); diags.HasErrors() { + return nil, nil, diags + } + + defer func() { + // If we're returning with errors, and thus not producing a valid + // context, we'll want to avoid leaving the remote workspace locked. + if diags.HasErrors() { + diags = diags.Append(op.StateLocker.Unlock()) + } + }() + + log.Printf("[TRACE] cloud: reading remote state for workspace %q", remoteWorkspaceName) + if err := stateMgr.RefreshState(); err != nil { + diags = diags.Append(errwrap.Wrapf("Error loading state: {{err}}", err)) + return nil, nil, diags + } + + // Initialize our context options + var opts terraform.ContextOpts + if v := b.ContextOpts; v != nil { + opts = *v + } + + // Copy set options from the operation + opts.PlanMode = op.PlanMode + opts.Targets = op.Targets + opts.UIInput = op.UIIn + + // Load the latest state. If we enter contextFromPlanFile below then the + // state snapshot in the plan file must match this, or else it'll return + // error diagnostics. + log.Printf("[TRACE] cloud: retrieving remote state snapshot for workspace %q", remoteWorkspaceName) + opts.State = stateMgr.State() + + log.Printf("[TRACE] cloud: loading configuration for the current working directory") + config, configDiags := op.ConfigLoader.LoadConfig(op.ConfigDir) + diags = diags.Append(configDiags) + if configDiags.HasErrors() { + return nil, nil, diags + } + opts.Config = config + + // The underlying API expects us to use the opaque workspace id to request + // variables, so we'll need to look that up using our organization name + // and workspace name. + remoteWorkspaceID, err := b.getRemoteWorkspaceID(context.Background(), op.Workspace) + if err != nil { + diags = diags.Append(errwrap.Wrapf("Error finding remote workspace: {{err}}", err)) + return nil, nil, diags + } + + log.Printf("[TRACE] cloud: retrieving variables from workspace %s/%s (%s)", remoteWorkspaceName, b.organization, remoteWorkspaceID) + tfeVariables, err := b.client.Variables.List(context.Background(), remoteWorkspaceID, tfe.VariableListOptions{}) + if err != nil && err != tfe.ErrResourceNotFound { + diags = diags.Append(errwrap.Wrapf("Error loading variables: {{err}}", err)) + return nil, nil, diags + } + + if op.AllowUnsetVariables { + // If we're not going to use the variables in an operation we'll be + // more lax about them, stubbing out any unset ones as unknown. + // This gives us enough information to produce a consistent context, + // but not enough information to run a real operation (plan, apply, etc) + opts.Variables = stubAllVariables(op.Variables, config.Module.Variables) + } else { + if tfeVariables != nil { + if op.Variables == nil { + op.Variables = make(map[string]backend.UnparsedVariableValue) + } + for _, v := range tfeVariables.Items { + if v.Category == tfe.CategoryTerraform { + op.Variables[v.Key] = &remoteStoredVariableValue{ + definition: v, + } + } + } + } + + if op.Variables != nil { + variables, varDiags := backend.ParseVariableValues(op.Variables, config.Module.Variables) + diags = diags.Append(varDiags) + if diags.HasErrors() { + return nil, nil, diags + } + opts.Variables = variables + } + } + + tfCtx, ctxDiags := terraform.NewContext(&opts) + diags = diags.Append(ctxDiags) + + log.Printf("[TRACE] cloud: finished building terraform.Context") + + return tfCtx, stateMgr, diags +} + +func (b *Cloud) getRemoteWorkspaceName(localWorkspaceName string) string { + switch { + case localWorkspaceName == backend.DefaultStateName: + // The default workspace name is a special case, for when the backend + // is configured to with to an exact remote workspace rather than with + // a remote workspace _prefix_. + return b.workspace + case b.prefix != "" && !strings.HasPrefix(localWorkspaceName, b.prefix): + return b.prefix + localWorkspaceName + default: + return localWorkspaceName + } +} + +func (b *Cloud) getRemoteWorkspace(ctx context.Context, localWorkspaceName string) (*tfe.Workspace, error) { + remoteWorkspaceName := b.getRemoteWorkspaceName(localWorkspaceName) + + log.Printf("[TRACE] cloud: looking up workspace for %s/%s", b.organization, remoteWorkspaceName) + remoteWorkspace, err := b.client.Workspaces.Read(ctx, b.organization, remoteWorkspaceName) + if err != nil { + return nil, err + } + + return remoteWorkspace, nil +} + +func (b *Cloud) getRemoteWorkspaceID(ctx context.Context, localWorkspaceName string) (string, error) { + remoteWorkspace, err := b.getRemoteWorkspace(ctx, localWorkspaceName) + if err != nil { + return "", err + } + + return remoteWorkspace.ID, nil +} + +func stubAllVariables(vv map[string]backend.UnparsedVariableValue, decls map[string]*configs.Variable) terraform.InputValues { + ret := make(terraform.InputValues, len(decls)) + + for name, cfg := range decls { + raw, exists := vv[name] + if !exists { + ret[name] = &terraform.InputValue{ + Value: cty.UnknownVal(cfg.Type), + SourceType: terraform.ValueFromConfig, + } + continue + } + + val, diags := raw.ParseVariableValue(cfg.ParsingMode) + if diags.HasErrors() { + ret[name] = &terraform.InputValue{ + Value: cty.UnknownVal(cfg.Type), + SourceType: terraform.ValueFromConfig, + } + continue + } + ret[name] = val + } + + return ret +} + +// remoteStoredVariableValue is a backend.UnparsedVariableValue implementation +// that translates from the go-tfe representation of stored variables into +// the Terraform Core backend representation of variables. +type remoteStoredVariableValue struct { + definition *tfe.Variable +} + +var _ backend.UnparsedVariableValue = (*remoteStoredVariableValue)(nil) + +func (v *remoteStoredVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + var val cty.Value + + switch { + case v.definition.Sensitive: + // If it's marked as sensitive then it's not available for use in + // local operations. We'll use an unknown value as a placeholder for + // it so that operations that don't need it might still work, but + // we'll also produce a warning about it to add context for any + // errors that might result here. + val = cty.DynamicVal + if !v.definition.HCL { + // If it's not marked as HCL then we at least know that the + // value must be a string, so we'll set that in case it allows + // us to do some more precise type checking. + val = cty.UnknownVal(cty.String) + } + + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Warning, + fmt.Sprintf("Value for var.%s unavailable", v.definition.Key), + fmt.Sprintf("The value of variable %q is marked as sensitive in the remote workspace. This operation always runs locally, so the value for that variable is not available.", v.definition.Key), + )) + + case v.definition.HCL: + // If the variable value is marked as being in HCL syntax, we need to + // parse it the same way as it would be interpreted in a .tfvars + // file because that is how it would get passed to Terraform CLI for + // a remote operation and we want to mimic that result as closely as + // possible. + var exprDiags hcl.Diagnostics + expr, exprDiags := hclsyntax.ParseExpression([]byte(v.definition.Value), "", hcl.Pos{Line: 1, Column: 1}) + if expr != nil { + var moreDiags hcl.Diagnostics + val, moreDiags = expr.Value(nil) + exprDiags = append(exprDiags, moreDiags...) + } else { + // We'll have already put some errors in exprDiags above, so we'll + // just stub out the value here. + val = cty.DynamicVal + } + + // We don't have sufficient context to return decent error messages + // for syntax errors in the remote values, so we'll just return a + // generic message instead for now. + // (More complete error messages will still result from true remote + // operations, because they'll run on the remote system where we've + // materialized the values into a tfvars file we can report from.) + if exprDiags.HasErrors() { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + fmt.Sprintf("Invalid expression for var.%s", v.definition.Key), + fmt.Sprintf("The value of variable %q is marked in the remote workspace as being specified in HCL syntax, but the given value is not valid HCL. Stored variable values must be valid literal expressions and may not contain references to other variables or calls to functions.", v.definition.Key), + )) + } + + default: + // A variable value _not_ marked as HCL is always be a string, given + // literally. + val = cty.StringVal(v.definition.Value) + } + + return &terraform.InputValue{ + Value: val, + + // We mark these as "from input" with the rationale that entering + // variable values into the Terraform Cloud or Enterprise UI is, + // roughly speaking, a similar idea to entering variable values at + // the interactive CLI prompts. It's not a perfect correspondance, + // but it's closer than the other options. + SourceType: terraform.ValueFromInput, + }, diags +} diff --git a/internal/cloud/backend_context_test.go b/internal/cloud/backend_context_test.go new file mode 100644 index 000000000..264d10a71 --- /dev/null +++ b/internal/cloud/backend_context_test.go @@ -0,0 +1,235 @@ +package cloud + +import ( + "context" + "testing" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/command/clistate" + "github.com/hashicorp/terraform/internal/command/views" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/states/statemgr" + "github.com/hashicorp/terraform/internal/terminal" + "github.com/zclconf/go-cty/cty" +) + +func TestRemoteStoredVariableValue(t *testing.T) { + tests := map[string]struct { + Def *tfe.Variable + Want cty.Value + WantError string + }{ + "string literal": { + &tfe.Variable{ + Key: "test", + Value: "foo", + HCL: false, + Sensitive: false, + }, + cty.StringVal("foo"), + ``, + }, + "string HCL": { + &tfe.Variable{ + Key: "test", + Value: `"foo"`, + HCL: true, + Sensitive: false, + }, + cty.StringVal("foo"), + ``, + }, + "list HCL": { + &tfe.Variable{ + Key: "test", + Value: `[]`, + HCL: true, + Sensitive: false, + }, + cty.EmptyTupleVal, + ``, + }, + "null HCL": { + &tfe.Variable{ + Key: "test", + Value: `null`, + HCL: true, + Sensitive: false, + }, + cty.NullVal(cty.DynamicPseudoType), + ``, + }, + "literal sensitive": { + &tfe.Variable{ + Key: "test", + HCL: false, + Sensitive: true, + }, + cty.UnknownVal(cty.String), + ``, + }, + "HCL sensitive": { + &tfe.Variable{ + Key: "test", + HCL: true, + Sensitive: true, + }, + cty.DynamicVal, + ``, + }, + "HCL computation": { + // This (stored expressions containing computation) is not a case + // we intentionally supported, but it became possible for remote + // operations in Terraform 0.12 (due to Terraform Cloud/Enterprise + // just writing the HCL verbatim into generated `.tfvars` files). + // We support it here for consistency, and we continue to support + // it in both places for backward-compatibility. In practice, + // there's little reason to do computation in a stored variable + // value because references are not supported. + &tfe.Variable{ + Key: "test", + Value: `[for v in ["a"] : v]`, + HCL: true, + Sensitive: false, + }, + cty.TupleVal([]cty.Value{cty.StringVal("a")}), + ``, + }, + "HCL syntax error": { + &tfe.Variable{ + Key: "test", + Value: `[`, + HCL: true, + Sensitive: false, + }, + cty.DynamicVal, + `Invalid expression for var.test: The value of variable "test" is marked in the remote workspace as being specified in HCL syntax, but the given value is not valid HCL. Stored variable values must be valid literal expressions and may not contain references to other variables or calls to functions.`, + }, + "HCL with references": { + &tfe.Variable{ + Key: "test", + Value: `foo.bar`, + HCL: true, + Sensitive: false, + }, + cty.DynamicVal, + `Invalid expression for var.test: The value of variable "test" is marked in the remote workspace as being specified in HCL syntax, but the given value is not valid HCL. Stored variable values must be valid literal expressions and may not contain references to other variables or calls to functions.`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + v := &remoteStoredVariableValue{ + definition: test.Def, + } + // This ParseVariableValue implementation ignores the parsing mode, + // so we'll just always parse literal here. (The parsing mode is + // selected by the remote server, not by our local configuration.) + gotIV, diags := v.ParseVariableValue(configs.VariableParseLiteral) + if test.WantError != "" { + if !diags.HasErrors() { + t.Fatalf("missing expected error\ngot: \nwant: %s", test.WantError) + } + errStr := diags.Err().Error() + if errStr != test.WantError { + t.Fatalf("wrong error\ngot: %s\nwant: %s", errStr, test.WantError) + } + } else { + if diags.HasErrors() { + t.Fatalf("unexpected error\ngot: %s\nwant: ", diags.Err().Error()) + } + got := gotIV.Value + if !test.Want.RawEquals(got) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + } + }) + } +} + +func TestRemoteContextWithVars(t *testing.T) { + catTerraform := tfe.CategoryTerraform + catEnv := tfe.CategoryEnv + + tests := map[string]struct { + Opts *tfe.VariableCreateOptions + WantError string + }{ + "Terraform variable": { + &tfe.VariableCreateOptions{ + Category: &catTerraform, + }, + `Value for undeclared variable: A variable named "key" was assigned a value, but the root module does not declare a variable of that name. To use this value, add a "variable" block to the configuration.`, + }, + "environment variable": { + &tfe.VariableCreateOptions{ + Category: &catEnv, + }, + ``, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + configDir := "./testdata/empty" + + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir) + defer configCleanup() + + workspaceID, err := b.getRemoteWorkspaceID(context.Background(), backend.DefaultStateName) + if err != nil { + t.Fatal(err) + } + + streams, _ := terminal.StreamsForTesting(t) + view := views.NewStateLocker(arguments.ViewHuman, views.NewView(streams)) + + op := &backend.Operation{ + ConfigDir: configDir, + ConfigLoader: configLoader, + StateLocker: clistate.NewLocker(0, view), + Workspace: backend.DefaultStateName, + } + + v := test.Opts + if v.Key == nil { + key := "key" + v.Key = &key + } + b.client.Variables.Create(context.TODO(), workspaceID, *v) + + _, _, diags := b.Context(op) + + if test.WantError != "" { + if !diags.HasErrors() { + t.Fatalf("missing expected error\ngot: \nwant: %s", test.WantError) + } + errStr := diags.Err().Error() + if errStr != test.WantError { + t.Fatalf("wrong error\ngot: %s\nwant: %s", errStr, test.WantError) + } + // When Context() returns an error, it should unlock the state, + // so re-locking it is expected to succeed. + stateMgr, _ := b.StateMgr(backend.DefaultStateName) + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { + t.Fatalf("unexpected error locking state: %s", err.Error()) + } + } else { + if diags.HasErrors() { + t.Fatalf("unexpected error\ngot: %s\nwant: ", diags.Err().Error()) + } + // When Context() succeeds, this should fail w/ "workspace already locked" + stateMgr, _ := b.StateMgr(backend.DefaultStateName) + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err == nil { + t.Fatal("unexpected success locking state after Context") + } + } + }) + } +} diff --git a/internal/cloud/backend_mock.go b/internal/cloud/backend_mock.go new file mode 100644 index 000000000..0ee036bc5 --- /dev/null +++ b/internal/cloud/backend_mock.go @@ -0,0 +1,1364 @@ +package cloud + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "io/ioutil" + "math/rand" + "os" + "path/filepath" + "strings" + "sync" + "time" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/terraform" + tfversion "github.com/hashicorp/terraform/version" + "github.com/mitchellh/copystructure" +) + +type mockClient struct { + Applies *mockApplies + ConfigurationVersions *mockConfigurationVersions + CostEstimates *mockCostEstimates + Organizations *mockOrganizations + Plans *mockPlans + PolicyChecks *mockPolicyChecks + Runs *mockRuns + StateVersions *mockStateVersions + Variables *mockVariables + Workspaces *mockWorkspaces +} + +func newMockClient() *mockClient { + c := &mockClient{} + c.Applies = newMockApplies(c) + c.ConfigurationVersions = newMockConfigurationVersions(c) + c.CostEstimates = newMockCostEstimates(c) + c.Organizations = newMockOrganizations(c) + c.Plans = newMockPlans(c) + c.PolicyChecks = newMockPolicyChecks(c) + c.Runs = newMockRuns(c) + c.StateVersions = newMockStateVersions(c) + c.Variables = newMockVariables(c) + c.Workspaces = newMockWorkspaces(c) + return c +} + +type mockApplies struct { + client *mockClient + applies map[string]*tfe.Apply + logs map[string]string +} + +func newMockApplies(client *mockClient) *mockApplies { + return &mockApplies{ + client: client, + applies: make(map[string]*tfe.Apply), + logs: make(map[string]string), + } +} + +// create is a helper function to create a mock apply that uses the configured +// working directory to find the logfile. +func (m *mockApplies) create(cvID, workspaceID string) (*tfe.Apply, error) { + c, ok := m.client.ConfigurationVersions.configVersions[cvID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + if c.Speculative { + // Speculative means its plan-only so we don't create a Apply. + return nil, nil + } + + id := generateID("apply-") + url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) + + a := &tfe.Apply{ + ID: id, + LogReadURL: url, + Status: tfe.ApplyPending, + } + + w, ok := m.client.Workspaces.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + if w.AutoApply { + a.Status = tfe.ApplyRunning + } + + m.logs[url] = filepath.Join( + m.client.ConfigurationVersions.uploadPaths[cvID], + w.WorkingDirectory, + "apply.log", + ) + m.applies[a.ID] = a + + return a, nil +} + +func (m *mockApplies) Read(ctx context.Context, applyID string) (*tfe.Apply, error) { + a, ok := m.applies[applyID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + // Together with the mockLogReader this allows testing queued runs. + if a.Status == tfe.ApplyRunning { + a.Status = tfe.ApplyFinished + } + return a, nil +} + +func (m *mockApplies) Logs(ctx context.Context, applyID string) (io.Reader, error) { + a, err := m.Read(ctx, applyID) + if err != nil { + return nil, err + } + + logfile, ok := m.logs[a.LogReadURL] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + if _, err := os.Stat(logfile); os.IsNotExist(err) { + return bytes.NewBufferString("logfile does not exist"), nil + } + + logs, err := ioutil.ReadFile(logfile) + if err != nil { + return nil, err + } + + done := func() (bool, error) { + a, err := m.Read(ctx, applyID) + if err != nil { + return false, err + } + if a.Status != tfe.ApplyFinished { + return false, nil + } + return true, nil + } + + return &mockLogReader{ + done: done, + logs: bytes.NewBuffer(logs), + }, nil +} + +type mockConfigurationVersions struct { + client *mockClient + configVersions map[string]*tfe.ConfigurationVersion + uploadPaths map[string]string + uploadURLs map[string]*tfe.ConfigurationVersion +} + +func newMockConfigurationVersions(client *mockClient) *mockConfigurationVersions { + return &mockConfigurationVersions{ + client: client, + configVersions: make(map[string]*tfe.ConfigurationVersion), + uploadPaths: make(map[string]string), + uploadURLs: make(map[string]*tfe.ConfigurationVersion), + } +} + +func (m *mockConfigurationVersions) List(ctx context.Context, workspaceID string, options tfe.ConfigurationVersionListOptions) (*tfe.ConfigurationVersionList, error) { + cvl := &tfe.ConfigurationVersionList{} + for _, cv := range m.configVersions { + cvl.Items = append(cvl.Items, cv) + } + + cvl.Pagination = &tfe.Pagination{ + CurrentPage: 1, + NextPage: 1, + PreviousPage: 1, + TotalPages: 1, + TotalCount: len(cvl.Items), + } + + return cvl, nil +} + +func (m *mockConfigurationVersions) Create(ctx context.Context, workspaceID string, options tfe.ConfigurationVersionCreateOptions) (*tfe.ConfigurationVersion, error) { + id := generateID("cv-") + url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) + + cv := &tfe.ConfigurationVersion{ + ID: id, + Status: tfe.ConfigurationPending, + UploadURL: url, + } + + m.configVersions[cv.ID] = cv + m.uploadURLs[url] = cv + + return cv, nil +} + +func (m *mockConfigurationVersions) Read(ctx context.Context, cvID string) (*tfe.ConfigurationVersion, error) { + cv, ok := m.configVersions[cvID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + return cv, nil +} + +func (m *mockConfigurationVersions) Upload(ctx context.Context, url, path string) error { + cv, ok := m.uploadURLs[url] + if !ok { + return errors.New("404 not found") + } + m.uploadPaths[cv.ID] = path + cv.Status = tfe.ConfigurationUploaded + return nil +} + +type mockCostEstimates struct { + client *mockClient + estimations map[string]*tfe.CostEstimate + logs map[string]string +} + +func newMockCostEstimates(client *mockClient) *mockCostEstimates { + return &mockCostEstimates{ + client: client, + estimations: make(map[string]*tfe.CostEstimate), + logs: make(map[string]string), + } +} + +// create is a helper function to create a mock cost estimation that uses the +// configured working directory to find the logfile. +func (m *mockCostEstimates) create(cvID, workspaceID string) (*tfe.CostEstimate, error) { + id := generateID("ce-") + + ce := &tfe.CostEstimate{ + ID: id, + MatchedResourcesCount: 1, + ResourcesCount: 1, + DeltaMonthlyCost: "0.00", + ProposedMonthlyCost: "0.00", + Status: tfe.CostEstimateFinished, + } + + w, ok := m.client.Workspaces.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + logfile := filepath.Join( + m.client.ConfigurationVersions.uploadPaths[cvID], + w.WorkingDirectory, + "cost-estimate.log", + ) + + if _, err := os.Stat(logfile); os.IsNotExist(err) { + return nil, nil + } + + m.logs[ce.ID] = logfile + m.estimations[ce.ID] = ce + + return ce, nil +} + +func (m *mockCostEstimates) Read(ctx context.Context, costEstimateID string) (*tfe.CostEstimate, error) { + ce, ok := m.estimations[costEstimateID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + return ce, nil +} + +func (m *mockCostEstimates) Logs(ctx context.Context, costEstimateID string) (io.Reader, error) { + ce, ok := m.estimations[costEstimateID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + logfile, ok := m.logs[ce.ID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + if _, err := os.Stat(logfile); os.IsNotExist(err) { + return bytes.NewBufferString("logfile does not exist"), nil + } + + logs, err := ioutil.ReadFile(logfile) + if err != nil { + return nil, err + } + + ce.Status = tfe.CostEstimateFinished + + return bytes.NewBuffer(logs), nil +} + +// mockInput is a mock implementation of terraform.UIInput. +type mockInput struct { + answers map[string]string +} + +func (m *mockInput) Input(ctx context.Context, opts *terraform.InputOpts) (string, error) { + v, ok := m.answers[opts.Id] + if !ok { + return "", fmt.Errorf("unexpected input request in test: %s", opts.Id) + } + if v == "wait-for-external-update" { + select { + case <-ctx.Done(): + case <-time.After(time.Minute): + } + } + delete(m.answers, opts.Id) + return v, nil +} + +type mockOrganizations struct { + client *mockClient + organizations map[string]*tfe.Organization +} + +func newMockOrganizations(client *mockClient) *mockOrganizations { + return &mockOrganizations{ + client: client, + organizations: make(map[string]*tfe.Organization), + } +} + +func (m *mockOrganizations) List(ctx context.Context, options tfe.OrganizationListOptions) (*tfe.OrganizationList, error) { + orgl := &tfe.OrganizationList{} + for _, org := range m.organizations { + orgl.Items = append(orgl.Items, org) + } + + orgl.Pagination = &tfe.Pagination{ + CurrentPage: 1, + NextPage: 1, + PreviousPage: 1, + TotalPages: 1, + TotalCount: len(orgl.Items), + } + + return orgl, nil +} + +// mockLogReader is a mock logreader that enables testing queued runs. +type mockLogReader struct { + done func() (bool, error) + logs *bytes.Buffer +} + +func (m *mockLogReader) Read(l []byte) (int, error) { + for { + if written, err := m.read(l); err != io.ErrNoProgress { + return written, err + } + time.Sleep(1 * time.Millisecond) + } +} + +func (m *mockLogReader) read(l []byte) (int, error) { + done, err := m.done() + if err != nil { + return 0, err + } + if !done { + return 0, io.ErrNoProgress + } + return m.logs.Read(l) +} + +func (m *mockOrganizations) Create(ctx context.Context, options tfe.OrganizationCreateOptions) (*tfe.Organization, error) { + org := &tfe.Organization{Name: *options.Name} + m.organizations[org.Name] = org + return org, nil +} + +func (m *mockOrganizations) Read(ctx context.Context, name string) (*tfe.Organization, error) { + org, ok := m.organizations[name] + if !ok { + return nil, tfe.ErrResourceNotFound + } + return org, nil +} + +func (m *mockOrganizations) Update(ctx context.Context, name string, options tfe.OrganizationUpdateOptions) (*tfe.Organization, error) { + org, ok := m.organizations[name] + if !ok { + return nil, tfe.ErrResourceNotFound + } + org.Name = *options.Name + return org, nil + +} + +func (m *mockOrganizations) Delete(ctx context.Context, name string) error { + delete(m.organizations, name) + return nil +} + +func (m *mockOrganizations) Capacity(ctx context.Context, name string) (*tfe.Capacity, error) { + var pending, running int + for _, r := range m.client.Runs.runs { + if r.Status == tfe.RunPending { + pending++ + continue + } + running++ + } + return &tfe.Capacity{Pending: pending, Running: running}, nil +} + +func (m *mockOrganizations) Entitlements(ctx context.Context, name string) (*tfe.Entitlements, error) { + return &tfe.Entitlements{ + Operations: true, + PrivateModuleRegistry: true, + Sentinel: true, + StateStorage: true, + Teams: true, + VCSIntegrations: true, + }, nil +} + +func (m *mockOrganizations) RunQueue(ctx context.Context, name string, options tfe.RunQueueOptions) (*tfe.RunQueue, error) { + rq := &tfe.RunQueue{} + + for _, r := range m.client.Runs.runs { + rq.Items = append(rq.Items, r) + } + + rq.Pagination = &tfe.Pagination{ + CurrentPage: 1, + NextPage: 1, + PreviousPage: 1, + TotalPages: 1, + TotalCount: len(rq.Items), + } + + return rq, nil +} + +type mockPlans struct { + client *mockClient + logs map[string]string + planOutputs map[string]string + plans map[string]*tfe.Plan +} + +func newMockPlans(client *mockClient) *mockPlans { + return &mockPlans{ + client: client, + logs: make(map[string]string), + planOutputs: make(map[string]string), + plans: make(map[string]*tfe.Plan), + } +} + +// create is a helper function to create a mock plan that uses the configured +// working directory to find the logfile. +func (m *mockPlans) create(cvID, workspaceID string) (*tfe.Plan, error) { + id := generateID("plan-") + url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) + + p := &tfe.Plan{ + ID: id, + LogReadURL: url, + Status: tfe.PlanPending, + } + + w, ok := m.client.Workspaces.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + m.logs[url] = filepath.Join( + m.client.ConfigurationVersions.uploadPaths[cvID], + w.WorkingDirectory, + "plan.log", + ) + m.plans[p.ID] = p + + return p, nil +} + +func (m *mockPlans) Read(ctx context.Context, planID string) (*tfe.Plan, error) { + p, ok := m.plans[planID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + // Together with the mockLogReader this allows testing queued runs. + if p.Status == tfe.PlanRunning { + p.Status = tfe.PlanFinished + } + return p, nil +} + +func (m *mockPlans) Logs(ctx context.Context, planID string) (io.Reader, error) { + p, err := m.Read(ctx, planID) + if err != nil { + return nil, err + } + + logfile, ok := m.logs[p.LogReadURL] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + if _, err := os.Stat(logfile); os.IsNotExist(err) { + return bytes.NewBufferString("logfile does not exist"), nil + } + + logs, err := ioutil.ReadFile(logfile) + if err != nil { + return nil, err + } + + done := func() (bool, error) { + p, err := m.Read(ctx, planID) + if err != nil { + return false, err + } + if p.Status != tfe.PlanFinished { + return false, nil + } + return true, nil + } + + return &mockLogReader{ + done: done, + logs: bytes.NewBuffer(logs), + }, nil +} + +func (m *mockPlans) JSONOutput(ctx context.Context, planID string) ([]byte, error) { + planOutput, ok := m.planOutputs[planID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + return []byte(planOutput), nil +} + +type mockPolicyChecks struct { + client *mockClient + checks map[string]*tfe.PolicyCheck + logs map[string]string +} + +func newMockPolicyChecks(client *mockClient) *mockPolicyChecks { + return &mockPolicyChecks{ + client: client, + checks: make(map[string]*tfe.PolicyCheck), + logs: make(map[string]string), + } +} + +// create is a helper function to create a mock policy check that uses the +// configured working directory to find the logfile. +func (m *mockPolicyChecks) create(cvID, workspaceID string) (*tfe.PolicyCheck, error) { + id := generateID("pc-") + + pc := &tfe.PolicyCheck{ + ID: id, + Actions: &tfe.PolicyActions{}, + Permissions: &tfe.PolicyPermissions{}, + Scope: tfe.PolicyScopeOrganization, + Status: tfe.PolicyPending, + } + + w, ok := m.client.Workspaces.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + logfile := filepath.Join( + m.client.ConfigurationVersions.uploadPaths[cvID], + w.WorkingDirectory, + "policy.log", + ) + + if _, err := os.Stat(logfile); os.IsNotExist(err) { + return nil, nil + } + + m.logs[pc.ID] = logfile + m.checks[pc.ID] = pc + + return pc, nil +} + +func (m *mockPolicyChecks) List(ctx context.Context, runID string, options tfe.PolicyCheckListOptions) (*tfe.PolicyCheckList, error) { + _, ok := m.client.Runs.runs[runID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + pcl := &tfe.PolicyCheckList{} + for _, pc := range m.checks { + pcl.Items = append(pcl.Items, pc) + } + + pcl.Pagination = &tfe.Pagination{ + CurrentPage: 1, + NextPage: 1, + PreviousPage: 1, + TotalPages: 1, + TotalCount: len(pcl.Items), + } + + return pcl, nil +} + +func (m *mockPolicyChecks) Read(ctx context.Context, policyCheckID string) (*tfe.PolicyCheck, error) { + pc, ok := m.checks[policyCheckID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + logfile, ok := m.logs[pc.ID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + if _, err := os.Stat(logfile); os.IsNotExist(err) { + return nil, fmt.Errorf("logfile does not exist") + } + + logs, err := ioutil.ReadFile(logfile) + if err != nil { + return nil, err + } + + switch { + case bytes.Contains(logs, []byte("Sentinel Result: true")): + pc.Status = tfe.PolicyPasses + case bytes.Contains(logs, []byte("Sentinel Result: false")): + switch { + case bytes.Contains(logs, []byte("hard-mandatory")): + pc.Status = tfe.PolicyHardFailed + case bytes.Contains(logs, []byte("soft-mandatory")): + pc.Actions.IsOverridable = true + pc.Permissions.CanOverride = true + pc.Status = tfe.PolicySoftFailed + } + default: + // As this is an unexpected state, we say the policy errored. + pc.Status = tfe.PolicyErrored + } + + return pc, nil +} + +func (m *mockPolicyChecks) Override(ctx context.Context, policyCheckID string) (*tfe.PolicyCheck, error) { + pc, ok := m.checks[policyCheckID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + pc.Status = tfe.PolicyOverridden + return pc, nil +} + +func (m *mockPolicyChecks) Logs(ctx context.Context, policyCheckID string) (io.Reader, error) { + pc, ok := m.checks[policyCheckID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + logfile, ok := m.logs[pc.ID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + if _, err := os.Stat(logfile); os.IsNotExist(err) { + return bytes.NewBufferString("logfile does not exist"), nil + } + + logs, err := ioutil.ReadFile(logfile) + if err != nil { + return nil, err + } + + switch { + case bytes.Contains(logs, []byte("Sentinel Result: true")): + pc.Status = tfe.PolicyPasses + case bytes.Contains(logs, []byte("Sentinel Result: false")): + switch { + case bytes.Contains(logs, []byte("hard-mandatory")): + pc.Status = tfe.PolicyHardFailed + case bytes.Contains(logs, []byte("soft-mandatory")): + pc.Actions.IsOverridable = true + pc.Permissions.CanOverride = true + pc.Status = tfe.PolicySoftFailed + } + default: + // As this is an unexpected state, we say the policy errored. + pc.Status = tfe.PolicyErrored + } + + return bytes.NewBuffer(logs), nil +} + +type mockRuns struct { + sync.Mutex + + client *mockClient + runs map[string]*tfe.Run + workspaces map[string][]*tfe.Run + + // If modifyNewRun is non-nil, the create method will call it just before + // saving a new run in the runs map, so that a calling test can mimic + // side-effects that a real server might apply in certain situations. + modifyNewRun func(client *mockClient, options tfe.RunCreateOptions, run *tfe.Run) +} + +func newMockRuns(client *mockClient) *mockRuns { + return &mockRuns{ + client: client, + runs: make(map[string]*tfe.Run), + workspaces: make(map[string][]*tfe.Run), + } +} + +func (m *mockRuns) List(ctx context.Context, workspaceID string, options tfe.RunListOptions) (*tfe.RunList, error) { + m.Lock() + defer m.Unlock() + + w, ok := m.client.Workspaces.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + rl := &tfe.RunList{} + for _, run := range m.workspaces[w.ID] { + rc, err := copystructure.Copy(run) + if err != nil { + panic(err) + } + rl.Items = append(rl.Items, rc.(*tfe.Run)) + } + + rl.Pagination = &tfe.Pagination{ + CurrentPage: 1, + NextPage: 1, + PreviousPage: 1, + TotalPages: 1, + TotalCount: len(rl.Items), + } + + return rl, nil +} + +func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*tfe.Run, error) { + m.Lock() + defer m.Unlock() + + a, err := m.client.Applies.create(options.ConfigurationVersion.ID, options.Workspace.ID) + if err != nil { + return nil, err + } + + ce, err := m.client.CostEstimates.create(options.ConfigurationVersion.ID, options.Workspace.ID) + if err != nil { + return nil, err + } + + p, err := m.client.Plans.create(options.ConfigurationVersion.ID, options.Workspace.ID) + if err != nil { + return nil, err + } + + pc, err := m.client.PolicyChecks.create(options.ConfigurationVersion.ID, options.Workspace.ID) + if err != nil { + return nil, err + } + + r := &tfe.Run{ + ID: generateID("run-"), + Actions: &tfe.RunActions{IsCancelable: true}, + Apply: a, + CostEstimate: ce, + HasChanges: false, + Permissions: &tfe.RunPermissions{}, + Plan: p, + ReplaceAddrs: options.ReplaceAddrs, + Status: tfe.RunPending, + TargetAddrs: options.TargetAddrs, + } + + if options.Message != nil { + r.Message = *options.Message + } + + if pc != nil { + r.PolicyChecks = []*tfe.PolicyCheck{pc} + } + + if options.IsDestroy != nil { + r.IsDestroy = *options.IsDestroy + } + + if options.Refresh != nil { + r.Refresh = *options.Refresh + } + + if options.RefreshOnly != nil { + r.RefreshOnly = *options.RefreshOnly + } + + w, ok := m.client.Workspaces.workspaceIDs[options.Workspace.ID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + if w.CurrentRun == nil { + w.CurrentRun = r + } + + if m.modifyNewRun != nil { + // caller-provided callback may modify the run in-place to mimic + // side-effects that a real server might take in some situations. + m.modifyNewRun(m.client, options, r) + } + + m.runs[r.ID] = r + m.workspaces[options.Workspace.ID] = append(m.workspaces[options.Workspace.ID], r) + + return r, nil +} + +func (m *mockRuns) Read(ctx context.Context, runID string) (*tfe.Run, error) { + return m.ReadWithOptions(ctx, runID, nil) +} + +func (m *mockRuns) ReadWithOptions(ctx context.Context, runID string, _ *tfe.RunReadOptions) (*tfe.Run, error) { + m.Lock() + defer m.Unlock() + + r, ok := m.runs[runID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + pending := false + for _, r := range m.runs { + if r.ID != runID && r.Status == tfe.RunPending { + pending = true + break + } + } + + if !pending && r.Status == tfe.RunPending { + // Only update the status if there are no other pending runs. + r.Status = tfe.RunPlanning + r.Plan.Status = tfe.PlanRunning + } + + logs, _ := ioutil.ReadFile(m.client.Plans.logs[r.Plan.LogReadURL]) + if r.Status == tfe.RunPlanning && r.Plan.Status == tfe.PlanFinished { + if r.IsDestroy || bytes.Contains(logs, []byte("1 to add, 0 to change, 0 to destroy")) { + r.Actions.IsCancelable = false + r.Actions.IsConfirmable = true + r.HasChanges = true + r.Permissions.CanApply = true + } + + if bytes.Contains(logs, []byte("null_resource.foo: 1 error")) { + r.Actions.IsCancelable = false + r.HasChanges = false + r.Status = tfe.RunErrored + } + } + + // we must return a copy for the client + rc, err := copystructure.Copy(r) + if err != nil { + panic(err) + } + + return rc.(*tfe.Run), nil +} + +func (m *mockRuns) Apply(ctx context.Context, runID string, options tfe.RunApplyOptions) error { + m.Lock() + defer m.Unlock() + + r, ok := m.runs[runID] + if !ok { + return tfe.ErrResourceNotFound + } + if r.Status != tfe.RunPending { + // Only update the status if the run is not pending anymore. + r.Status = tfe.RunApplying + r.Actions.IsConfirmable = false + r.Apply.Status = tfe.ApplyRunning + } + return nil +} + +func (m *mockRuns) Cancel(ctx context.Context, runID string, options tfe.RunCancelOptions) error { + panic("not implemented") +} + +func (m *mockRuns) ForceCancel(ctx context.Context, runID string, options tfe.RunForceCancelOptions) error { + panic("not implemented") +} + +func (m *mockRuns) Discard(ctx context.Context, runID string, options tfe.RunDiscardOptions) error { + m.Lock() + defer m.Unlock() + + r, ok := m.runs[runID] + if !ok { + return tfe.ErrResourceNotFound + } + r.Status = tfe.RunDiscarded + r.Actions.IsConfirmable = false + return nil +} + +type mockStateVersions struct { + client *mockClient + states map[string][]byte + stateVersions map[string]*tfe.StateVersion + workspaces map[string][]string +} + +func newMockStateVersions(client *mockClient) *mockStateVersions { + return &mockStateVersions{ + client: client, + states: make(map[string][]byte), + stateVersions: make(map[string]*tfe.StateVersion), + workspaces: make(map[string][]string), + } +} + +func (m *mockStateVersions) List(ctx context.Context, options tfe.StateVersionListOptions) (*tfe.StateVersionList, error) { + svl := &tfe.StateVersionList{} + for _, sv := range m.stateVersions { + svl.Items = append(svl.Items, sv) + } + + svl.Pagination = &tfe.Pagination{ + CurrentPage: 1, + NextPage: 1, + PreviousPage: 1, + TotalPages: 1, + TotalCount: len(svl.Items), + } + + return svl, nil +} + +func (m *mockStateVersions) Create(ctx context.Context, workspaceID string, options tfe.StateVersionCreateOptions) (*tfe.StateVersion, error) { + id := generateID("sv-") + runID := os.Getenv("TFE_RUN_ID") + url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) + + if runID != "" && (options.Run == nil || runID != options.Run.ID) { + return nil, fmt.Errorf("option.Run.ID does not contain the ID exported by TFE_RUN_ID") + } + + sv := &tfe.StateVersion{ + ID: id, + DownloadURL: url, + Serial: *options.Serial, + } + + state, err := base64.StdEncoding.DecodeString(*options.State) + if err != nil { + return nil, err + } + + m.states[sv.DownloadURL] = state + m.stateVersions[sv.ID] = sv + m.workspaces[workspaceID] = append(m.workspaces[workspaceID], sv.ID) + + return sv, nil +} + +func (m *mockStateVersions) Read(ctx context.Context, svID string) (*tfe.StateVersion, error) { + return m.ReadWithOptions(ctx, svID, nil) +} + +func (m *mockStateVersions) ReadWithOptions(ctx context.Context, svID string, options *tfe.StateVersionReadOptions) (*tfe.StateVersion, error) { + sv, ok := m.stateVersions[svID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + return sv, nil +} + +func (m *mockStateVersions) Current(ctx context.Context, workspaceID string) (*tfe.StateVersion, error) { + return m.CurrentWithOptions(ctx, workspaceID, nil) +} + +func (m *mockStateVersions) CurrentWithOptions(ctx context.Context, workspaceID string, options *tfe.StateVersionCurrentOptions) (*tfe.StateVersion, error) { + w, ok := m.client.Workspaces.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + svs, ok := m.workspaces[w.ID] + if !ok || len(svs) == 0 { + return nil, tfe.ErrResourceNotFound + } + + sv, ok := m.stateVersions[svs[len(svs)-1]] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + return sv, nil +} + +func (m *mockStateVersions) Download(ctx context.Context, url string) ([]byte, error) { + state, ok := m.states[url] + if !ok { + return nil, tfe.ErrResourceNotFound + } + return state, nil +} + +type mockVariables struct { + client *mockClient + workspaces map[string]*tfe.VariableList +} + +var _ tfe.Variables = (*mockVariables)(nil) + +func newMockVariables(client *mockClient) *mockVariables { + return &mockVariables{ + client: client, + workspaces: make(map[string]*tfe.VariableList), + } +} + +func (m *mockVariables) List(ctx context.Context, workspaceID string, options tfe.VariableListOptions) (*tfe.VariableList, error) { + vl := m.workspaces[workspaceID] + return vl, nil +} + +func (m *mockVariables) Create(ctx context.Context, workspaceID string, options tfe.VariableCreateOptions) (*tfe.Variable, error) { + v := &tfe.Variable{ + ID: generateID("var-"), + Key: *options.Key, + Category: *options.Category, + } + if options.Value != nil { + v.Value = *options.Value + } + if options.HCL != nil { + v.HCL = *options.HCL + } + if options.Sensitive != nil { + v.Sensitive = *options.Sensitive + } + + workspace := workspaceID + + if m.workspaces[workspace] == nil { + m.workspaces[workspace] = &tfe.VariableList{} + } + + vl := m.workspaces[workspace] + vl.Items = append(vl.Items, v) + + return v, nil +} + +func (m *mockVariables) Read(ctx context.Context, workspaceID string, variableID string) (*tfe.Variable, error) { + panic("not implemented") +} + +func (m *mockVariables) Update(ctx context.Context, workspaceID string, variableID string, options tfe.VariableUpdateOptions) (*tfe.Variable, error) { + panic("not implemented") +} + +func (m *mockVariables) Delete(ctx context.Context, workspaceID string, variableID string) error { + panic("not implemented") +} + +type mockWorkspaces struct { + client *mockClient + workspaceIDs map[string]*tfe.Workspace + workspaceNames map[string]*tfe.Workspace +} + +func newMockWorkspaces(client *mockClient) *mockWorkspaces { + return &mockWorkspaces{ + client: client, + workspaceIDs: make(map[string]*tfe.Workspace), + workspaceNames: make(map[string]*tfe.Workspace), + } +} + +func (m *mockWorkspaces) List(ctx context.Context, organization string, options tfe.WorkspaceListOptions) (*tfe.WorkspaceList, error) { + dummyWorkspaces := 10 + wl := &tfe.WorkspaceList{} + + // Get the prefix from the search options. + prefix := "" + if options.Search != nil { + prefix = *options.Search + } + + // Get all the workspaces that match the prefix. + var ws []*tfe.Workspace + for _, w := range m.workspaceIDs { + if strings.HasPrefix(w.Name, prefix) { + ws = append(ws, w) + } + } + + // Return an empty result if we have no matches. + if len(ws) == 0 { + wl.Pagination = &tfe.Pagination{ + CurrentPage: 1, + } + return wl, nil + } + + // Return dummy workspaces for the first page to test pagination. + if options.PageNumber <= 1 { + for i := 0; i < dummyWorkspaces; i++ { + wl.Items = append(wl.Items, &tfe.Workspace{ + ID: generateID("ws-"), + Name: fmt.Sprintf("dummy-workspace-%d", i), + }) + } + + wl.Pagination = &tfe.Pagination{ + CurrentPage: 1, + NextPage: 2, + TotalPages: 2, + TotalCount: len(wl.Items) + len(ws), + } + + return wl, nil + } + + // Return the actual workspaces that matched as the second page. + wl.Items = ws + wl.Pagination = &tfe.Pagination{ + CurrentPage: 2, + PreviousPage: 1, + TotalPages: 2, + TotalCount: len(wl.Items) + dummyWorkspaces, + } + + return wl, nil +} + +func (m *mockWorkspaces) Create(ctx context.Context, organization string, options tfe.WorkspaceCreateOptions) (*tfe.Workspace, error) { + if strings.HasSuffix(*options.Name, "no-operations") { + options.Operations = tfe.Bool(false) + } else if options.Operations == nil { + options.Operations = tfe.Bool(true) + } + w := &tfe.Workspace{ + ID: generateID("ws-"), + Name: *options.Name, + Operations: *options.Operations, + Permissions: &tfe.WorkspacePermissions{ + CanQueueApply: true, + CanQueueRun: true, + }, + } + if options.AutoApply != nil { + w.AutoApply = *options.AutoApply + } + if options.VCSRepo != nil { + w.VCSRepo = &tfe.VCSRepo{} + } + if options.TerraformVersion != nil { + w.TerraformVersion = *options.TerraformVersion + } else { + w.TerraformVersion = tfversion.String() + } + m.workspaceIDs[w.ID] = w + m.workspaceNames[w.Name] = w + return w, nil +} + +func (m *mockWorkspaces) Read(ctx context.Context, organization, workspace string) (*tfe.Workspace, error) { + // custom error for TestCloud_plan500 in backend_plan_test.go + if workspace == "network-error" { + return nil, errors.New("I'm a little teacup") + } + + w, ok := m.workspaceNames[workspace] + if !ok { + return nil, tfe.ErrResourceNotFound + } + return w, nil +} + +func (m *mockWorkspaces) ReadByID(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { + w, ok := m.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + return w, nil +} + +func (m *mockWorkspaces) Update(ctx context.Context, organization, workspace string, options tfe.WorkspaceUpdateOptions) (*tfe.Workspace, error) { + w, ok := m.workspaceNames[workspace] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + if options.Operations != nil { + w.Operations = *options.Operations + } + if options.Name != nil { + w.Name = *options.Name + } + if options.TerraformVersion != nil { + w.TerraformVersion = *options.TerraformVersion + } + if options.WorkingDirectory != nil { + w.WorkingDirectory = *options.WorkingDirectory + } + + delete(m.workspaceNames, workspace) + m.workspaceNames[w.Name] = w + + return w, nil +} + +func (m *mockWorkspaces) UpdateByID(ctx context.Context, workspaceID string, options tfe.WorkspaceUpdateOptions) (*tfe.Workspace, error) { + w, ok := m.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + if options.Name != nil { + w.Name = *options.Name + } + if options.TerraformVersion != nil { + w.TerraformVersion = *options.TerraformVersion + } + if options.WorkingDirectory != nil { + w.WorkingDirectory = *options.WorkingDirectory + } + + delete(m.workspaceNames, w.Name) + m.workspaceNames[w.Name] = w + + return w, nil +} + +func (m *mockWorkspaces) Delete(ctx context.Context, organization, workspace string) error { + if w, ok := m.workspaceNames[workspace]; ok { + delete(m.workspaceIDs, w.ID) + } + delete(m.workspaceNames, workspace) + return nil +} + +func (m *mockWorkspaces) DeleteByID(ctx context.Context, workspaceID string) error { + if w, ok := m.workspaceIDs[workspaceID]; ok { + delete(m.workspaceIDs, w.Name) + } + delete(m.workspaceIDs, workspaceID) + return nil +} + +func (m *mockWorkspaces) RemoveVCSConnection(ctx context.Context, organization, workspace string) (*tfe.Workspace, error) { + w, ok := m.workspaceNames[workspace] + if !ok { + return nil, tfe.ErrResourceNotFound + } + w.VCSRepo = nil + return w, nil +} + +func (m *mockWorkspaces) RemoveVCSConnectionByID(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { + w, ok := m.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + w.VCSRepo = nil + return w, nil +} + +func (m *mockWorkspaces) Lock(ctx context.Context, workspaceID string, options tfe.WorkspaceLockOptions) (*tfe.Workspace, error) { + w, ok := m.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + if w.Locked { + return nil, tfe.ErrWorkspaceLocked + } + w.Locked = true + return w, nil +} + +func (m *mockWorkspaces) Unlock(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { + w, ok := m.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + if !w.Locked { + return nil, tfe.ErrWorkspaceNotLocked + } + w.Locked = false + return w, nil +} + +func (m *mockWorkspaces) ForceUnlock(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { + w, ok := m.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + if !w.Locked { + return nil, tfe.ErrWorkspaceNotLocked + } + w.Locked = false + return w, nil +} + +func (m *mockWorkspaces) AssignSSHKey(ctx context.Context, workspaceID string, options tfe.WorkspaceAssignSSHKeyOptions) (*tfe.Workspace, error) { + panic("not implemented") +} + +func (m *mockWorkspaces) UnassignSSHKey(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { + panic("not implemented") +} + +func (m *mockWorkspaces) RemoteStateConsumers(ctx context.Context, workspaceID string) (*tfe.WorkspaceList, error) { + panic("not implemented") +} + +func (m *mockWorkspaces) AddRemoteStateConsumers(ctx context.Context, workspaceID string, options tfe.WorkspaceAddRemoteStateConsumersOptions) error { + panic("not implemented") +} + +func (m *mockWorkspaces) RemoveRemoteStateConsumers(ctx context.Context, workspaceID string, options tfe.WorkspaceRemoveRemoteStateConsumersOptions) error { + panic("not implemented") +} + +func (m *mockWorkspaces) UpdateRemoteStateConsumers(ctx context.Context, workspaceID string, options tfe.WorkspaceUpdateRemoteStateConsumersOptions) error { + panic("not implemented") +} + +func (m *mockWorkspaces) Readme(ctx context.Context, workspaceID string) (io.Reader, error) { + panic("not implemented") +} + +const alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +func generateID(s string) string { + b := make([]byte, 16) + for i := range b { + b[i] = alphanumeric[rand.Intn(len(alphanumeric))] + } + return s + string(b) +} diff --git a/internal/cloud/backend_plan.go b/internal/cloud/backend_plan.go new file mode 100644 index 000000000..025b36776 --- /dev/null +++ b/internal/cloud/backend_plan.go @@ -0,0 +1,439 @@ +package cloud + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + tfe "github.com/hashicorp/go-tfe" + version "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +var planConfigurationVersionsPollInterval = 500 * time.Millisecond + +func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) { + log.Printf("[INFO] cloud: starting Plan operation") + + var diags tfdiags.Diagnostics + + if !w.Permissions.CanQueueRun { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Insufficient rights to generate a plan", + "The provided credentials have insufficient rights to generate a plan. In order "+ + "to generate plans, at least plan permissions on the workspace are required.", + )) + return nil, diags.Err() + } + + if op.Parallelism != defaultParallelism { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Custom parallelism values are currently not supported", + `Terraform Cloud does not support setting a custom parallelism `+ + `value at this time.`, + )) + } + + if op.PlanFile != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Displaying a saved plan is currently not supported", + `Terraform Cloud currently requires configuration to be present and `+ + `does not accept an existing saved plan as an argument at this time.`, + )) + } + + if op.PlanOutPath != "" { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Saving a generated plan is currently not supported", + `Terraform Cloud does not support saving the generated execution `+ + `plan locally at this time.`, + )) + } + + if b.hasExplicitVariableValues(op) { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Run variables are currently not supported", + fmt.Sprintf( + "Terraform Cloud does not support setting run variables from command line arguments at this time. "+ + "Currently the only to way to pass variables is by "+ + "creating a '*.auto.tfvars' variables file. This file will automatically "+ + "be loaded when the workspace is configured to use "+ + "Terraform v0.10.0 or later.\n\nAdditionally you can also set variables on "+ + "the workspace in the web UI:\nhttps://%s/app/%s/%s/variables", + b.hostname, b.organization, op.Workspace, + ), + )) + } + + if !op.HasConfig() && op.PlanMode != plans.DestroyMode { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "No configuration files found", + `Plan requires configuration to be present. Planning without a configuration `+ + `would mark everything for destruction, which is normally not what is desired. `+ + `If you would like to destroy everything, please run plan with the "-destroy" `+ + `flag or create a single empty configuration file. Otherwise, please create `+ + `a Terraform configuration file in the path being executed and try again.`, + )) + } + + // For API versions prior to 2.3, RemoteAPIVersion will return an empty string, + // so if there's an error when parsing the RemoteAPIVersion, it's handled as + // equivalent to an API version < 2.3. + currentAPIVersion, parseErr := version.NewVersion(b.client.RemoteAPIVersion()) + + if len(op.Targets) != 0 { + desiredAPIVersion, _ := version.NewVersion("2.3") + + if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Resource targeting is not supported", + fmt.Sprintf( + `The host %s does not support the -target option for `+ + `remote plans.`, + b.hostname, + ), + )) + } + } + + if !op.PlanRefresh { + desiredAPIVersion, _ := version.NewVersion("2.4") + + if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Planning without refresh is not supported", + fmt.Sprintf( + `The host %s does not support the -refresh=false option for `+ + `remote plans.`, + b.hostname, + ), + )) + } + } + + if len(op.ForceReplace) != 0 { + desiredAPIVersion, _ := version.NewVersion("2.4") + + if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Planning resource replacements is not supported", + fmt.Sprintf( + `The host %s does not support the -replace option for `+ + `remote plans.`, + b.hostname, + ), + )) + } + } + + if op.PlanMode == plans.RefreshOnlyMode { + desiredAPIVersion, _ := version.NewVersion("2.4") + + if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Refresh-only mode is not supported", + fmt.Sprintf( + `The host %s does not support -refresh-only mode for `+ + `remote plans.`, + b.hostname, + ), + )) + } + } + + // Return if there are any errors. + if diags.HasErrors() { + return nil, diags.Err() + } + + return b.plan(stopCtx, cancelCtx, op, w) +} + +func (b *Cloud) plan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) { + if b.CLI != nil { + header := planDefaultHeader + if op.Type == backend.OperationTypeApply { + header = applyDefaultHeader + } + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(header) + "\n")) + } + + configOptions := tfe.ConfigurationVersionCreateOptions{ + AutoQueueRuns: tfe.Bool(false), + Speculative: tfe.Bool(op.Type == backend.OperationTypePlan), + } + + cv, err := b.client.ConfigurationVersions.Create(stopCtx, w.ID, configOptions) + if err != nil { + return nil, generalError("Failed to create configuration version", err) + } + + var configDir string + if op.ConfigDir != "" { + // De-normalize the configuration directory path. + configDir, err = filepath.Abs(op.ConfigDir) + if err != nil { + return nil, generalError( + "Failed to get absolute path of the configuration directory: %v", err) + } + + // Make sure to take the working directory into account by removing + // the working directory from the current path. This will result in + // a path that points to the expected root of the workspace. + configDir = filepath.Clean(strings.TrimSuffix( + filepath.Clean(configDir), + filepath.Clean(w.WorkingDirectory), + )) + + // If the workspace has a subdirectory as its working directory then + // our configDir will be some parent directory of the current working + // directory. Users are likely to find that surprising, so we'll + // produce an explicit message about it to be transparent about what + // we are doing and why. + if w.WorkingDirectory != "" && filepath.Base(configDir) != w.WorkingDirectory { + if b.CLI != nil { + b.CLI.Output(fmt.Sprintf(strings.TrimSpace(` +The remote workspace is configured to work with configuration at +%s relative to the target repository. + +Terraform will upload the contents of the following directory, +excluding files or directories as defined by a .terraformignore file +at %s/.terraformignore (if it is present), +in order to capture the filesystem context the remote workspace expects: + %s +`), w.WorkingDirectory, configDir, configDir) + "\n") + } + } + + } else { + // We did a check earlier to make sure we either have a config dir, + // or the plan is run with -destroy. So this else clause will only + // be executed when we are destroying and doesn't need the config. + configDir, err = ioutil.TempDir("", "tf") + if err != nil { + return nil, generalError("Failed to create temporary directory", err) + } + defer os.RemoveAll(configDir) + + // Make sure the configured working directory exists. + err = os.MkdirAll(filepath.Join(configDir, w.WorkingDirectory), 0700) + if err != nil { + return nil, generalError( + "Failed to create temporary working directory", err) + } + } + + err = b.client.ConfigurationVersions.Upload(stopCtx, cv.UploadURL, configDir) + if err != nil { + return nil, generalError("Failed to upload configuration files", err) + } + + uploaded := false + for i := 0; i < 60 && !uploaded; i++ { + select { + case <-stopCtx.Done(): + return nil, context.Canceled + case <-cancelCtx.Done(): + return nil, context.Canceled + case <-time.After(planConfigurationVersionsPollInterval): + cv, err = b.client.ConfigurationVersions.Read(stopCtx, cv.ID) + if err != nil { + return nil, generalError("Failed to retrieve configuration version", err) + } + + if cv.Status == tfe.ConfigurationUploaded { + uploaded = true + } + } + } + + if !uploaded { + return nil, generalError( + "Failed to upload configuration files", errors.New("operation timed out")) + } + + runOptions := tfe.RunCreateOptions{ + ConfigurationVersion: cv, + Refresh: tfe.Bool(op.PlanRefresh), + Workspace: w, + } + + switch op.PlanMode { + case plans.NormalMode: + // okay, but we don't need to do anything special for this + case plans.RefreshOnlyMode: + runOptions.RefreshOnly = tfe.Bool(true) + case plans.DestroyMode: + runOptions.IsDestroy = tfe.Bool(true) + default: + // Shouldn't get here because we should update this for each new + // plan mode we add, mapping it to the corresponding RunCreateOptions + // field. + return nil, generalError( + "Invalid plan mode", + fmt.Errorf("Terraform Cloud doesn't support %s", op.PlanMode), + ) + } + + if len(op.Targets) != 0 { + runOptions.TargetAddrs = make([]string, 0, len(op.Targets)) + for _, addr := range op.Targets { + runOptions.TargetAddrs = append(runOptions.TargetAddrs, addr.String()) + } + } + + if len(op.ForceReplace) != 0 { + runOptions.ReplaceAddrs = make([]string, 0, len(op.ForceReplace)) + for _, addr := range op.ForceReplace { + runOptions.ReplaceAddrs = append(runOptions.ReplaceAddrs, addr.String()) + } + } + + r, err := b.client.Runs.Create(stopCtx, runOptions) + if err != nil { + return r, generalError("Failed to create run", err) + } + + // When the lock timeout is set, if the run is still pending and + // cancellable after that period, we attempt to cancel it. + if lockTimeout := op.StateLocker.Timeout(); lockTimeout > 0 { + go func() { + select { + case <-stopCtx.Done(): + return + case <-cancelCtx.Done(): + return + case <-time.After(lockTimeout): + // Retrieve the run to get its current status. + r, err := b.client.Runs.Read(cancelCtx, r.ID) + if err != nil { + log.Printf("[ERROR] error reading run: %v", err) + return + } + + if r.Status == tfe.RunPending && r.Actions.IsCancelable { + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(lockTimeoutErr))) + } + + // We abuse the auto aprove flag to indicate that we do not + // want to ask if the remote operation should be canceled. + op.AutoApprove = true + + p, err := os.FindProcess(os.Getpid()) + if err != nil { + log.Printf("[ERROR] error searching process ID: %v", err) + return + } + p.Signal(syscall.SIGINT) + } + } + }() + } + + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(fmt.Sprintf( + runHeader, b.hostname, b.organization, op.Workspace, r.ID)) + "\n")) + } + + r, err = b.waitForRun(stopCtx, cancelCtx, op, "plan", r, w) + if err != nil { + return r, err + } + + logs, err := b.client.Plans.Logs(stopCtx, r.Plan.ID) + if err != nil { + return r, generalError("Failed to retrieve logs", err) + } + reader := bufio.NewReaderSize(logs, 64*1024) + + if b.CLI != nil { + for next := true; next; { + var l, line []byte + + for isPrefix := true; isPrefix; { + l, isPrefix, err = reader.ReadLine() + if err != nil { + if err != io.EOF { + return r, generalError("Failed to read logs", err) + } + next = false + } + line = append(line, l...) + } + + if next || len(line) > 0 { + b.CLI.Output(b.Colorize().Color(string(line))) + } + } + } + + // Retrieve the run to get its current status. + r, err = b.client.Runs.Read(stopCtx, r.ID) + if err != nil { + return r, generalError("Failed to retrieve run", err) + } + + // If the run is canceled or errored, we still continue to the + // cost-estimation and policy check phases to ensure we render any + // results available. In the case of a hard-failed policy check, the + // status of the run will be "errored", but there is still policy + // information which should be shown. + + // Show any cost estimation output. + if r.CostEstimate != nil { + err = b.costEstimate(stopCtx, cancelCtx, op, r) + if err != nil { + return r, err + } + } + + // Check any configured sentinel policies. + if len(r.PolicyChecks) > 0 { + err = b.checkPolicy(stopCtx, cancelCtx, op, r) + if err != nil { + return r, err + } + } + + return r, nil +} + +const planDefaultHeader = ` +[reset][yellow]Running plan in Terraform Cloud. Output will stream here. Pressing Ctrl-C +will stop streaming the logs, but will not stop the plan running remotely.[reset] + +Preparing the remote plan... +` + +const runHeader = ` +[reset][yellow]To view this run in a browser, visit: +https://%s/app/%s/%s/runs/%s[reset] +` + +// The newline in this error is to make it look good in the CLI! +const lockTimeoutErr = ` +[reset][red]Lock timeout exceeded, sending interrupt to cancel the remote operation. +[reset] +` diff --git a/internal/cloud/backend_plan_test.go b/internal/cloud/backend_plan_test.go new file mode 100644 index 000000000..790c8cc03 --- /dev/null +++ b/internal/cloud/backend_plan_test.go @@ -0,0 +1,1237 @@ +package cloud + +import ( + "context" + "os" + "os/signal" + "strings" + "syscall" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/command/clistate" + "github.com/hashicorp/terraform/internal/command/views" + "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/plans/planfile" + "github.com/hashicorp/terraform/internal/states/statemgr" + "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/mitchellh/cli" +) + +func testOperationPlan(t *testing.T, configDir string) (*backend.Operation, func(), func(*testing.T) *terminal.TestOutput) { + t.Helper() + + return testOperationPlanWithTimeout(t, configDir, 0) +} + +func testOperationPlanWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backend.Operation, func(), func(*testing.T) *terminal.TestOutput) { + t.Helper() + + _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir) + + streams, done := terminal.StreamsForTesting(t) + view := views.NewView(streams) + stateLockerView := views.NewStateLocker(arguments.ViewHuman, view) + operationView := views.NewOperation(arguments.ViewHuman, false, view) + + return &backend.Operation{ + ConfigDir: configDir, + ConfigLoader: configLoader, + Parallelism: defaultParallelism, + PlanRefresh: true, + StateLocker: clistate.NewLocker(timeout, stateLockerView), + Type: backend.OperationTypePlan, + View: operationView, + }, configCleanup, done +} + +func TestCloud_planBasic(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatal("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } + + stateMgr, _ := b.StateMgr(backend.DefaultStateName) + // An error suggests that the state was not unlocked after the operation finished + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { + t.Fatalf("unexpected error locking state after successful plan: %s", err.Error()) + } +} + +func TestCloud_planCanceled(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + // Stop the run to simulate a Ctrl-C. + run.Stop() + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + + stateMgr, _ := b.StateMgr(backend.DefaultStateName) + // An error suggests that the state was not unlocked after the operation finished + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { + t.Fatalf("unexpected error locking state after cancelled plan: %s", err.Error()) + } +} + +func TestCloud_planLongLine(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-long-line") + defer configCleanup() + defer done(t) + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatal("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planWithoutPermissions(t *testing.T) { + b, bCleanup := testBackendNoDefault(t) + defer bCleanup() + + // Create a named workspace without permissions. + w, err := b.client.Workspaces.Create( + context.Background(), + b.organization, + tfe.WorkspaceCreateOptions{ + Name: tfe.String(b.prefix + "prod"), + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + w.Permissions.CanQueueRun = false + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + + op.Workspace = "prod" + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "Insufficient rights to generate a plan") { + t.Fatalf("expected a permissions error, got: %v", errOutput) + } +} + +func TestCloud_planWithParallelism(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + + op.Parallelism = 3 + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "parallelism values are currently not supported") { + t.Fatalf("expected a parallelism error, got: %v", errOutput) + } +} + +func TestCloud_planWithPlan(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + + op.PlanFile = &planfile.Reader{} + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "saved plan is currently not supported") { + t.Fatalf("expected a saved plan error, got: %v", errOutput) + } +} + +func TestCloud_planWithPath(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + + op.PlanOutPath = "./testdata/plan" + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "generated plan is currently not supported") { + t.Fatalf("expected a generated plan error, got: %v", errOutput) + } +} + +func TestCloud_planWithoutRefresh(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + op.PlanRefresh = false + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatal("expected a non-empty plan") + } + + // We should find a run inside the mock client that has refresh set + // to false. + runsAPI := b.client.Runs.(*mockRuns) + if got, want := len(runsAPI.runs), 1; got != want { + t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) + } + for _, run := range runsAPI.runs { + if diff := cmp.Diff(false, run.Refresh); diff != "" { + t.Errorf("wrong Refresh setting in the created run\n%s", diff) + } + } +} + +func TestCloud_planWithoutRefreshIncompatibleAPIVersion(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + + b.client.SetFakeRemoteAPIVersion("2.3") + + op.PlanRefresh = false + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "Planning without refresh is not supported") { + t.Fatalf("expected not supported error, got: %v", errOutput) + } +} + +func TestCloud_planWithRefreshOnly(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + op.PlanMode = plans.RefreshOnlyMode + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatal("expected a non-empty plan") + } + + // We should find a run inside the mock client that has refresh-only set + // to true. + runsAPI := b.client.Runs.(*mockRuns) + if got, want := len(runsAPI.runs), 1; got != want { + t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) + } + for _, run := range runsAPI.runs { + if diff := cmp.Diff(true, run.RefreshOnly); diff != "" { + t.Errorf("wrong RefreshOnly setting in the created run\n%s", diff) + } + } +} + +func TestCloud_planWithRefreshOnlyIncompatibleAPIVersion(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + + b.client.SetFakeRemoteAPIVersion("2.3") + + op.PlanMode = plans.RefreshOnlyMode + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "Refresh-only mode is not supported") { + t.Fatalf("expected not supported error, got: %v", errOutput) + } +} + +func TestCloud_planWithTarget(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + // When the backend code creates a new run, we'll tweak it so that it + // has a cost estimation object with the "skipped_due_to_targeting" status, + // emulating how a real server is expected to behave in that case. + b.client.Runs.(*mockRuns).modifyNewRun = func(client *mockClient, options tfe.RunCreateOptions, run *tfe.Run) { + const fakeID = "fake" + // This is the cost estimate object embedded in the run itself which + // the backend will use to learn the ID to request from the cost + // estimates endpoint. It's pending to simulate what a freshly-created + // run is likely to look like. + run.CostEstimate = &tfe.CostEstimate{ + ID: fakeID, + Status: "pending", + } + // The backend will then use the main cost estimation API to retrieve + // the same ID indicated in the object above, where we'll then return + // the status "skipped_due_to_targeting" to trigger the special skip + // message in the backend output. + client.CostEstimates.estimations[fakeID] = &tfe.CostEstimate{ + ID: fakeID, + Status: "skipped_due_to_targeting", + } + } + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + addr, _ := addrs.ParseAbsResourceStr("null_resource.foo") + + op.Targets = []addrs.Targetable{addr} + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatal("expected plan operation to succeed") + } + if run.PlanEmpty { + t.Fatalf("expected plan to be non-empty") + } + + // testBackendDefault above attached a "mock UI" to our backend, so we + // can retrieve its non-error output via the OutputWriter in-memory buffer. + gotOutput := b.CLI.(*cli.MockUi).OutputWriter.String() + if wantOutput := "Not available for this plan, because it was created with the -target option."; !strings.Contains(gotOutput, wantOutput) { + t.Errorf("missing message about skipped cost estimation\ngot:\n%s\nwant substring: %s", gotOutput, wantOutput) + } + + // We should find a run inside the mock client that has the same + // target address we requested above. + runsAPI := b.client.Runs.(*mockRuns) + if got, want := len(runsAPI.runs), 1; got != want { + t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) + } + for _, run := range runsAPI.runs { + if diff := cmp.Diff([]string{"null_resource.foo"}, run.TargetAddrs); diff != "" { + t.Errorf("wrong TargetAddrs in the created run\n%s", diff) + } + } +} + +func TestCloud_planWithTargetIncompatibleAPIVersion(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + + // Set the tfe client's RemoteAPIVersion to an empty string, to mimic + // API versions prior to 2.3. + b.client.SetFakeRemoteAPIVersion("") + + addr, _ := addrs.ParseAbsResourceStr("null_resource.foo") + + op.Targets = []addrs.Targetable{addr} + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "Resource targeting is not supported") { + t.Fatalf("expected a targeting error, got: %v", errOutput) + } +} + +func TestCloud_planWithReplace(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + addr, _ := addrs.ParseAbsResourceInstanceStr("null_resource.foo") + + op.ForceReplace = []addrs.AbsResourceInstance{addr} + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatal("expected plan operation to succeed") + } + if run.PlanEmpty { + t.Fatalf("expected plan to be non-empty") + } + + // We should find a run inside the mock client that has the same + // refresh address we requested above. + runsAPI := b.client.Runs.(*mockRuns) + if got, want := len(runsAPI.runs), 1; got != want { + t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) + } + for _, run := range runsAPI.runs { + if diff := cmp.Diff([]string{"null_resource.foo"}, run.ReplaceAddrs); diff != "" { + t.Errorf("wrong ReplaceAddrs in the created run\n%s", diff) + } + } +} + +func TestCloud_planWithReplaceIncompatibleAPIVersion(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + + b.client.SetFakeRemoteAPIVersion("2.3") + + addr, _ := addrs.ParseAbsResourceInstanceStr("null_resource.foo") + + op.ForceReplace = []addrs.AbsResourceInstance{addr} + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "Planning resource replacements is not supported") { + t.Fatalf("expected not supported error, got: %v", errOutput) + } +} + +func TestCloud_planWithVariables(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-variables") + defer configCleanup() + + op.Variables = testVariables(terraform.ValueFromCLIArg, "foo", "bar") + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "variables are currently not supported") { + t.Fatalf("expected a variables error, got: %v", errOutput) + } +} + +func TestCloud_planNoConfig(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/empty") + defer configCleanup() + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "configuration files found") { + t.Fatalf("expected configuration files error, got: %v", errOutput) + } +} + +func TestCloud_planNoChanges(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-no-changes") + defer configCleanup() + defer done(t) + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "No changes. Infrastructure is up-to-date.") { + t.Fatalf("expected no changes in plan summary: %s", output) + } + if !strings.Contains(output, "Sentinel Result: true") { + t.Fatalf("expected policy check result in output: %s", output) + } +} + +func TestCloud_planForceLocal(t *testing.T) { + // Set TF_FORCE_LOCAL_BACKEND so the cloud backend will use + // the local backend with itself as embedded backend. + if err := os.Setenv("TF_FORCE_LOCAL_BACKEND", "1"); err != nil { + t.Fatalf("error setting environment variable TF_FORCE_LOCAL_BACKEND: %v", err) + } + defer os.Unsetenv("TF_FORCE_LOCAL_BACKEND") + + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + op.Workspace = backend.DefaultStateName + + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + op.View = view + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("unexpected TFC header in output: %s", output) + } + if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planWithoutOperationsEntitlement(t *testing.T) { + b, bCleanup := testBackendNoOperations(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + op.Workspace = backend.DefaultStateName + + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + op.View = view + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("unexpected TFC header in output: %s", output) + } + if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planWorkspaceWithoutOperations(t *testing.T) { + b, bCleanup := testBackendNoDefault(t) + defer bCleanup() + + ctx := context.Background() + + // Create a named workspace that doesn't allow operations. + _, err := b.client.Workspaces.Create( + ctx, + b.organization, + tfe.WorkspaceCreateOptions{ + Name: tfe.String(b.prefix + "no-operations"), + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + op.Workspace = "no-operations" + + streams, done := terminal.StreamsForTesting(t) + view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) + op.View = view + + run, err := b.Operation(ctx, op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("unexpected TFC header in output: %s", output) + } + if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planLockTimeout(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + ctx := context.Background() + + // Retrieve the workspace used to run this operation in. + w, err := b.client.Workspaces.Read(ctx, b.organization, b.workspace) + if err != nil { + t.Fatalf("error retrieving workspace: %v", err) + } + + // Create a new configuration version. + c, err := b.client.ConfigurationVersions.Create(ctx, w.ID, tfe.ConfigurationVersionCreateOptions{}) + if err != nil { + t.Fatalf("error creating configuration version: %v", err) + } + + // Create a pending run to block this run. + _, err = b.client.Runs.Create(ctx, tfe.RunCreateOptions{ + ConfigurationVersion: c, + Workspace: w, + }) + if err != nil { + t.Fatalf("error creating pending run: %v", err) + } + + op, configCleanup, done := testOperationPlanWithTimeout(t, "./testdata/plan", 50) + defer configCleanup() + defer done(t) + + input := testInput(t, map[string]string{ + "cancel": "yes", + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + _, err = b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + sigint := make(chan os.Signal, 1) + signal.Notify(sigint, syscall.SIGINT) + select { + case <-sigint: + // Stop redirecting SIGINT signals. + signal.Stop(sigint) + case <-time.After(200 * time.Millisecond): + t.Fatalf("expected lock timeout after 50 milliseconds, waited 200 milliseconds") + } + + if len(input.answers) != 2 { + t.Fatalf("expected unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "Lock timeout exceeded") { + t.Fatalf("expected lock timout error in output: %s", output) + } + if strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("unexpected plan summary in output: %s", output) + } +} + +func TestCloud_planDestroy(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + op.PlanMode = plans.DestroyMode + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } +} + +func TestCloud_planDestroyNoConfig(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/empty") + defer configCleanup() + defer done(t) + + op.PlanMode = plans.DestroyMode + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } +} + +func TestCloud_planWithWorkingDirectory(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + options := tfe.WorkspaceUpdateOptions{ + WorkingDirectory: tfe.String("terraform"), + } + + // Configure the workspace to use a custom working directory. + _, err := b.client.Workspaces.Update(context.Background(), b.organization, b.workspace, options) + if err != nil { + t.Fatalf("error configuring working directory: %v", err) + } + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-with-working-directory/terraform") + defer configCleanup() + defer done(t) + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "The remote workspace is configured to work with configuration") { + t.Fatalf("expected working directory warning: %s", output) + } + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planWithWorkingDirectoryFromCurrentPath(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + options := tfe.WorkspaceUpdateOptions{ + WorkingDirectory: tfe.String("terraform"), + } + + // Configure the workspace to use a custom working directory. + _, err := b.client.Workspaces.Update(context.Background(), b.organization, b.workspace, options) + if err != nil { + t.Fatalf("error configuring working directory: %v", err) + } + + wd, err := os.Getwd() + if err != nil { + t.Fatalf("error getting current working directory: %v", err) + } + + // We need to change into the configuration directory to make sure + // the logic to upload the correct slug is working as expected. + if err := os.Chdir("./testdata/plan-with-working-directory/terraform"); err != nil { + t.Fatalf("error changing directory: %v", err) + } + defer os.Chdir(wd) // Make sure we change back again when were done. + + // For this test we need to give our current directory instead of the + // full path to the configuration as we already changed directories. + op, configCleanup, done := testOperationPlan(t, ".") + defer configCleanup() + defer done(t) + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planCostEstimation(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-cost-estimation") + defer configCleanup() + defer done(t) + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "Resources: 1 of 1 estimated") { + t.Fatalf("expected cost estimate result in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planPolicyPass(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-policy-passed") + defer configCleanup() + defer done(t) + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: true") { + t.Fatalf("expected policy check result in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planPolicyHardFail(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-policy-hard-failed") + defer configCleanup() + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + viewOutput := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := viewOutput.Stderr() + if !strings.Contains(errOutput, "hard failed") { + t.Fatalf("expected a policy check error, got: %v", errOutput) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("expected policy check result in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planPolicySoftFail(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-policy-soft-failed") + defer configCleanup() + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + viewOutput := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := viewOutput.Stderr() + if !strings.Contains(errOutput, "soft failed") { + t.Fatalf("expected a policy check error, got: %v", errOutput) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("expected policy check result in output: %s", output) + } + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", output) + } +} + +func TestCloud_planWithRemoteError(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-with-error") + defer configCleanup() + defer done(t) + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if run.Result.ExitStatus() != 1 { + t.Fatalf("expected exit code 1, got %d", run.Result.ExitStatus()) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("expected TFC header in output: %s", output) + } + if !strings.Contains(output, "null_resource.foo: 1 error") { + t.Fatalf("expected plan error in output: %s", output) + } +} + +func TestCloud_planOtherError(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + defer done(t) + + op.Workspace = "network-error" // custom error response in backend_mock.go + + _, err := b.Operation(context.Background(), op) + if err == nil { + t.Errorf("expected error, got success") + } + + if !strings.Contains(err.Error(), + "Terraform Cloud returned an unexpected error:\n\nI'm a little teacup") { + t.Fatalf("expected error message, got: %s", err.Error()) + } +} diff --git a/internal/cloud/backend_state.go b/internal/cloud/backend_state.go new file mode 100644 index 000000000..055a808e5 --- /dev/null +++ b/internal/cloud/backend_state.go @@ -0,0 +1,182 @@ +package cloud + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/base64" + "fmt" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/states/remote" + "github.com/hashicorp/terraform/internal/states/statefile" + "github.com/hashicorp/terraform/internal/states/statemgr" +) + +type remoteClient struct { + client *tfe.Client + lockInfo *statemgr.LockInfo + organization string + runID string + stateUploadErr bool + workspace *tfe.Workspace + forcePush bool +} + +// Get the remote state. +func (r *remoteClient) Get() (*remote.Payload, error) { + ctx := context.Background() + + sv, err := r.client.StateVersions.Current(ctx, r.workspace.ID) + if err != nil { + if err == tfe.ErrResourceNotFound { + // If no state exists, then return nil. + return nil, nil + } + return nil, fmt.Errorf("Error retrieving state: %v", err) + } + + state, err := r.client.StateVersions.Download(ctx, sv.DownloadURL) + if err != nil { + return nil, fmt.Errorf("Error downloading state: %v", err) + } + + // If the state is empty, then return nil. + if len(state) == 0 { + return nil, nil + } + + // Get the MD5 checksum of the state. + sum := md5.Sum(state) + + return &remote.Payload{ + Data: state, + MD5: sum[:], + }, nil +} + +// Put the remote state. +func (r *remoteClient) Put(state []byte) error { + ctx := context.Background() + + // Read the raw state into a Terraform state. + stateFile, err := statefile.Read(bytes.NewReader(state)) + if err != nil { + return fmt.Errorf("Error reading state: %s", err) + } + + options := tfe.StateVersionCreateOptions{ + Lineage: tfe.String(stateFile.Lineage), + Serial: tfe.Int64(int64(stateFile.Serial)), + MD5: tfe.String(fmt.Sprintf("%x", md5.Sum(state))), + State: tfe.String(base64.StdEncoding.EncodeToString(state)), + Force: tfe.Bool(r.forcePush), + } + + // If we have a run ID, make sure to add it to the options + // so the state will be properly associated with the run. + if r.runID != "" { + options.Run = &tfe.Run{ID: r.runID} + } + + // Create the new state. + _, err = r.client.StateVersions.Create(ctx, r.workspace.ID, options) + if err != nil { + r.stateUploadErr = true + return fmt.Errorf("Error uploading state: %v", err) + } + + return nil +} + +// Delete the remote state. +func (r *remoteClient) Delete() error { + err := r.client.Workspaces.Delete(context.Background(), r.organization, r.workspace.Name) + if err != nil && err != tfe.ErrResourceNotFound { + return fmt.Errorf("Error deleting workspace %s: %v", r.workspace.Name, err) + } + + return nil +} + +// EnableForcePush to allow the remote client to overwrite state +// by implementing remote.ClientForcePusher +func (r *remoteClient) EnableForcePush() { + r.forcePush = true +} + +// Lock the remote state. +func (r *remoteClient) Lock(info *statemgr.LockInfo) (string, error) { + ctx := context.Background() + + lockErr := &statemgr.LockError{Info: r.lockInfo} + + // Lock the workspace. + _, err := r.client.Workspaces.Lock(ctx, r.workspace.ID, tfe.WorkspaceLockOptions{ + Reason: tfe.String("Locked by Terraform"), + }) + if err != nil { + if err == tfe.ErrWorkspaceLocked { + lockErr.Info = info + err = fmt.Errorf("%s (lock ID: \"%s/%s\")", err, r.organization, r.workspace.Name) + } + lockErr.Err = err + return "", lockErr + } + + r.lockInfo = info + + return r.lockInfo.ID, nil +} + +// Unlock the remote state. +func (r *remoteClient) Unlock(id string) error { + ctx := context.Background() + + // We first check if there was an error while uploading the latest + // state. If so, we will not unlock the workspace to prevent any + // changes from being applied until the correct state is uploaded. + if r.stateUploadErr { + return nil + } + + lockErr := &statemgr.LockError{Info: r.lockInfo} + + // With lock info this should be treated as a normal unlock. + if r.lockInfo != nil { + // Verify the expected lock ID. + if r.lockInfo.ID != id { + lockErr.Err = fmt.Errorf("lock ID does not match existing lock") + return lockErr + } + + // Unlock the workspace. + _, err := r.client.Workspaces.Unlock(ctx, r.workspace.ID) + if err != nil { + lockErr.Err = err + return lockErr + } + + return nil + } + + // Verify the optional force-unlock lock ID. + if r.organization+"/"+r.workspace.Name != id { + lockErr.Err = fmt.Errorf( + "lock ID %q does not match existing lock ID \"%s/%s\"", + id, + r.organization, + r.workspace.Name, + ) + return lockErr + } + + // Force unlock the workspace. + _, err := r.client.Workspaces.ForceUnlock(ctx, r.workspace.ID) + if err != nil { + lockErr.Err = err + return lockErr + } + + return nil +} diff --git a/internal/cloud/backend_state_test.go b/internal/cloud/backend_state_test.go new file mode 100644 index 000000000..dc2a45723 --- /dev/null +++ b/internal/cloud/backend_state_test.go @@ -0,0 +1,59 @@ +package cloud + +import ( + "bytes" + "os" + "testing" + + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/states/remote" + "github.com/hashicorp/terraform/internal/states/statefile" +) + +func TestRemoteClient_impl(t *testing.T) { + var _ remote.Client = new(remoteClient) +} + +func TestRemoteClient(t *testing.T) { + client := testRemoteClient(t) + remote.TestClient(t, client) +} + +func TestRemoteClient_stateLock(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + s1, err := b.StateMgr(backend.DefaultStateName) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + s2, err := b.StateMgr(backend.DefaultStateName) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + remote.TestRemoteLocks(t, s1.(*remote.State).Client, s2.(*remote.State).Client) +} + +func TestRemoteClient_withRunID(t *testing.T) { + // Set the TFE_RUN_ID environment variable before creating the client! + if err := os.Setenv("TFE_RUN_ID", generateID("run-")); err != nil { + t.Fatalf("error setting env var TFE_RUN_ID: %v", err) + } + + // Create a new test client. + client := testRemoteClient(t) + + // Create a new empty state. + sf := statefile.New(states.NewState(), "", 0) + var buf bytes.Buffer + statefile.Write(sf, &buf) + + // Store the new state to verify (this will be done + // by the mock that is used) that the run ID is set. + if err := client.Put(buf.Bytes()); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go new file mode 100644 index 000000000..7862ad0a3 --- /dev/null +++ b/internal/cloud/backend_test.go @@ -0,0 +1,723 @@ +package cloud + +import ( + "context" + "fmt" + "reflect" + "strings" + "testing" + + tfe "github.com/hashicorp/go-tfe" + version "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-svchost/disco" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/tfdiags" + tfversion "github.com/hashicorp/terraform/version" + "github.com/zclconf/go-cty/cty" + + backendLocal "github.com/hashicorp/terraform/internal/backend/local" +) + +func TestCloud(t *testing.T) { + var _ backend.Enhanced = New(nil) + var _ backend.CLI = New(nil) +} + +func TestCloud_backendDefault(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + backend.TestBackendStates(t, b) + backend.TestBackendStateLocks(t, b, b) + backend.TestBackendStateForceUnlock(t, b, b) +} + +func TestCloud_backendNoDefault(t *testing.T) { + b, bCleanup := testBackendNoDefault(t) + defer bCleanup() + + backend.TestBackendStates(t, b) +} + +func TestCloud_config(t *testing.T) { + cases := map[string]struct { + config cty.Value + confErr string + valErr string + }{ + "with_a_nonexisting_organization": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("nonexisting"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + }), + }), + confErr: "organization \"nonexisting\" at host app.terraform.io not found", + }, + "with_an_unknown_host": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.StringVal("nonexisting.local"), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + }), + }), + confErr: "Failed to request discovery document", + }, + // localhost advertises TFE services, but has no token in the credentials + "without_a_token": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.StringVal("localhost"), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + }), + }), + confErr: "terraform login localhost", + }, + "with_a_name": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + }), + }), + }, + "with_a_prefix": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "prefix": cty.StringVal("my-app-"), + }), + }), + }, + "without_either_a_name_and_a_prefix": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "prefix": cty.NullVal(cty.String), + }), + }), + valErr: `Either workspace "name" or "prefix" is required`, + }, + "with_both_a_name_and_a_prefix": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.StringVal("my-app-"), + }), + }), + valErr: `Only one of workspace "name" or "prefix" is allowed`, + }, + "null config": { + config: cty.NullVal(cty.EmptyObject), + }, + } + + for name, tc := range cases { + s := testServer(t) + b := New(testDisco(s)) + + // Validate + _, valDiags := b.PrepareConfig(tc.config) + if (valDiags.Err() != nil || tc.valErr != "") && + (valDiags.Err() == nil || !strings.Contains(valDiags.Err().Error(), tc.valErr)) { + t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err()) + } + + // Configure + confDiags := b.Configure(tc.config) + if (confDiags.Err() != nil || tc.confErr != "") && + (confDiags.Err() == nil || !strings.Contains(confDiags.Err().Error(), tc.confErr)) { + t.Fatalf("%s: unexpected configure result: %v", name, confDiags.Err()) + } + } +} + +func TestCloud_versionConstraints(t *testing.T) { + cases := map[string]struct { + config cty.Value + prerelease string + version string + result string + }{ + "compatible version": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + }), + }), + version: "0.11.1", + }, + "version too old": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + }), + }), + version: "0.0.1", + result: "upgrade Terraform to >= 0.1.0", + }, + "version too new": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + }), + }), + version: "10.0.1", + result: "downgrade Terraform to <= 10.0.0", + }, + } + + // Save and restore the actual version. + p := tfversion.Prerelease + v := tfversion.Version + defer func() { + tfversion.Prerelease = p + tfversion.Version = v + }() + + for name, tc := range cases { + s := testServer(t) + b := New(testDisco(s)) + + // Set the version for this test. + tfversion.Prerelease = tc.prerelease + tfversion.Version = tc.version + + // Validate + _, valDiags := b.PrepareConfig(tc.config) + if valDiags.HasErrors() { + t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err()) + } + + // Configure + confDiags := b.Configure(tc.config) + if (confDiags.Err() != nil || tc.result != "") && + (confDiags.Err() == nil || !strings.Contains(confDiags.Err().Error(), tc.result)) { + t.Fatalf("%s: unexpected configure result: %v", name, confDiags.Err()) + } + } +} + +func TestCloud_localBackend(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + local, ok := b.local.(*backendLocal.Local) + if !ok { + t.Fatalf("expected b.local to be \"*local.Local\", got: %T", b.local) + } + + cloud, ok := local.Backend.(*Cloud) + if !ok { + t.Fatalf("expected local.Backend to be *cloud.Cloud, got: %T", cloud) + } +} + +func TestCloud_addAndRemoveWorkspacesDefault(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + if _, err := b.Workspaces(); err != backend.ErrWorkspacesNotSupported { + t.Fatalf("expected error %v, got %v", backend.ErrWorkspacesNotSupported, err) + } + + if _, err := b.StateMgr(backend.DefaultStateName); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if _, err := b.StateMgr("prod"); err != backend.ErrWorkspacesNotSupported { + t.Fatalf("expected error %v, got %v", backend.ErrWorkspacesNotSupported, err) + } + + if err := b.DeleteWorkspace(backend.DefaultStateName); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if err := b.DeleteWorkspace("prod"); err != backend.ErrWorkspacesNotSupported { + t.Fatalf("expected error %v, got %v", backend.ErrWorkspacesNotSupported, err) + } +} + +func TestCloud_addAndRemoveWorkspacesNoDefault(t *testing.T) { + b, bCleanup := testBackendNoDefault(t) + defer bCleanup() + + states, err := b.Workspaces() + if err != nil { + t.Fatal(err) + } + + expectedWorkspaces := []string(nil) + if !reflect.DeepEqual(states, expectedWorkspaces) { + t.Fatalf("expected states %#+v, got %#+v", expectedWorkspaces, states) + } + + if _, err := b.StateMgr(backend.DefaultStateName); err != backend.ErrDefaultWorkspaceNotSupported { + t.Fatalf("expected error %v, got %v", backend.ErrDefaultWorkspaceNotSupported, err) + } + + expectedA := "test_A" + if _, err := b.StateMgr(expectedA); err != nil { + t.Fatal(err) + } + + states, err = b.Workspaces() + if err != nil { + t.Fatal(err) + } + + expectedWorkspaces = append(expectedWorkspaces, expectedA) + if !reflect.DeepEqual(states, expectedWorkspaces) { + t.Fatalf("expected %#+v, got %#+v", expectedWorkspaces, states) + } + + expectedB := "test_B" + if _, err := b.StateMgr(expectedB); err != nil { + t.Fatal(err) + } + + states, err = b.Workspaces() + if err != nil { + t.Fatal(err) + } + + expectedWorkspaces = append(expectedWorkspaces, expectedB) + if !reflect.DeepEqual(states, expectedWorkspaces) { + t.Fatalf("expected %#+v, got %#+v", expectedWorkspaces, states) + } + + if err := b.DeleteWorkspace(backend.DefaultStateName); err != backend.ErrDefaultWorkspaceNotSupported { + t.Fatalf("expected error %v, got %v", backend.ErrDefaultWorkspaceNotSupported, err) + } + + if err := b.DeleteWorkspace(expectedA); err != nil { + t.Fatal(err) + } + + states, err = b.Workspaces() + if err != nil { + t.Fatal(err) + } + + expectedWorkspaces = []string{expectedB} + if !reflect.DeepEqual(states, expectedWorkspaces) { + t.Fatalf("expected %#+v got %#+v", expectedWorkspaces, states) + } + + if err := b.DeleteWorkspace(expectedB); err != nil { + t.Fatal(err) + } + + states, err = b.Workspaces() + if err != nil { + t.Fatal(err) + } + + expectedWorkspaces = []string(nil) + if !reflect.DeepEqual(states, expectedWorkspaces) { + t.Fatalf("expected %#+v, got %#+v", expectedWorkspaces, states) + } +} + +func TestCloud_checkConstraints(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + cases := map[string]struct { + constraints *disco.Constraints + prerelease string + version string + result string + }{ + "compatible version": { + constraints: &disco.Constraints{ + Minimum: "0.11.0", + Maximum: "0.11.11", + }, + version: "0.11.1", + result: "", + }, + "version too old": { + constraints: &disco.Constraints{ + Minimum: "0.11.0", + Maximum: "0.11.11", + }, + version: "0.10.1", + result: "upgrade Terraform to >= 0.11.0", + }, + "version too new": { + constraints: &disco.Constraints{ + Minimum: "0.11.0", + Maximum: "0.11.11", + }, + version: "0.12.0", + result: "downgrade Terraform to <= 0.11.11", + }, + "version excluded - ordered": { + constraints: &disco.Constraints{ + Minimum: "0.11.0", + Excluding: []string{"0.11.7", "0.11.8"}, + Maximum: "0.11.11", + }, + version: "0.11.7", + result: "upgrade Terraform to > 0.11.8", + }, + "version excluded - unordered": { + constraints: &disco.Constraints{ + Minimum: "0.11.0", + Excluding: []string{"0.11.8", "0.11.6"}, + Maximum: "0.11.11", + }, + version: "0.11.6", + result: "upgrade Terraform to > 0.11.8", + }, + "list versions": { + constraints: &disco.Constraints{ + Minimum: "0.11.0", + Maximum: "0.11.11", + }, + version: "0.10.1", + result: "versions >= 0.11.0, <= 0.11.11.", + }, + "list exclusion": { + constraints: &disco.Constraints{ + Minimum: "0.11.0", + Excluding: []string{"0.11.6"}, + Maximum: "0.11.11", + }, + version: "0.11.6", + result: "excluding version 0.11.6.", + }, + "list exclusions": { + constraints: &disco.Constraints{ + Minimum: "0.11.0", + Excluding: []string{"0.11.8", "0.11.6"}, + Maximum: "0.11.11", + }, + version: "0.11.6", + result: "excluding versions 0.11.6, 0.11.8.", + }, + } + + // Save and restore the actual version. + p := tfversion.Prerelease + v := tfversion.Version + defer func() { + tfversion.Prerelease = p + tfversion.Version = v + }() + + for name, tc := range cases { + // Set the version for this test. + tfversion.Prerelease = tc.prerelease + tfversion.Version = tc.version + + // Check the constraints. + diags := b.checkConstraints(tc.constraints) + if (diags.Err() != nil || tc.result != "") && + (diags.Err() == nil || !strings.Contains(diags.Err().Error(), tc.result)) { + t.Fatalf("%s: unexpected constraints result: %v", name, diags.Err()) + } + } +} + +func TestCloud_StateMgr_versionCheck(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + // Some fixed versions for testing with. This logic is a simple string + // comparison, so we don't need many test cases. + v0135 := version.Must(version.NewSemver("0.13.5")) + v0140 := version.Must(version.NewSemver("0.14.0")) + + // Save original local version state and restore afterwards + p := tfversion.Prerelease + v := tfversion.Version + s := tfversion.SemVer + defer func() { + tfversion.Prerelease = p + tfversion.Version = v + tfversion.SemVer = s + }() + + // For this test, the local Terraform version is set to 0.14.0 + tfversion.Prerelease = "" + tfversion.Version = v0140.String() + tfversion.SemVer = v0140 + + // Update the mock remote workspace Terraform version to match the local + // Terraform version + if _, err := b.client.Workspaces.Update( + context.Background(), + b.organization, + b.workspace, + tfe.WorkspaceUpdateOptions{ + TerraformVersion: tfe.String(v0140.String()), + }, + ); err != nil { + t.Fatalf("error: %v", err) + } + + // This should succeed + if _, err := b.StateMgr(backend.DefaultStateName); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // Now change the remote workspace to a different Terraform version + if _, err := b.client.Workspaces.Update( + context.Background(), + b.organization, + b.workspace, + tfe.WorkspaceUpdateOptions{ + TerraformVersion: tfe.String(v0135.String()), + }, + ); err != nil { + t.Fatalf("error: %v", err) + } + + // This should fail + want := `Remote workspace Terraform version "0.13.5" does not match local Terraform version "0.14.0"` + if _, err := b.StateMgr(backend.DefaultStateName); err.Error() != want { + t.Fatalf("wrong error\n got: %v\nwant: %v", err.Error(), want) + } +} + +func TestCloud_StateMgr_versionCheckLatest(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + v0140 := version.Must(version.NewSemver("0.14.0")) + + // Save original local version state and restore afterwards + p := tfversion.Prerelease + v := tfversion.Version + s := tfversion.SemVer + defer func() { + tfversion.Prerelease = p + tfversion.Version = v + tfversion.SemVer = s + }() + + // For this test, the local Terraform version is set to 0.14.0 + tfversion.Prerelease = "" + tfversion.Version = v0140.String() + tfversion.SemVer = v0140 + + // Update the remote workspace to the pseudo-version "latest" + if _, err := b.client.Workspaces.Update( + context.Background(), + b.organization, + b.workspace, + tfe.WorkspaceUpdateOptions{ + TerraformVersion: tfe.String("latest"), + }, + ); err != nil { + t.Fatalf("error: %v", err) + } + + // This should succeed despite not being a string match + if _, err := b.StateMgr(backend.DefaultStateName); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestCloud_VerifyWorkspaceTerraformVersion(t *testing.T) { + testCases := []struct { + local string + remote string + operations bool + wantErr bool + }{ + {"0.13.5", "0.13.5", true, false}, + {"0.14.0", "0.13.5", true, true}, + {"0.14.0", "0.13.5", false, false}, + {"0.14.0", "0.14.1", true, false}, + {"0.14.0", "1.0.99", true, false}, + {"0.14.0", "1.1.0", true, true}, + {"1.2.0", "1.2.99", true, false}, + {"1.2.0", "1.3.0", true, true}, + {"0.15.0", "latest", true, false}, + } + for _, tc := range testCases { + t.Run(fmt.Sprintf("local %s, remote %s", tc.local, tc.remote), func(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + local := version.Must(version.NewSemver(tc.local)) + + // Save original local version state and restore afterwards + p := tfversion.Prerelease + v := tfversion.Version + s := tfversion.SemVer + defer func() { + tfversion.Prerelease = p + tfversion.Version = v + tfversion.SemVer = s + }() + + // Override local version as specified + tfversion.Prerelease = "" + tfversion.Version = local.String() + tfversion.SemVer = local + + // Update the mock remote workspace Terraform version to the + // specified remote version + if _, err := b.client.Workspaces.Update( + context.Background(), + b.organization, + b.workspace, + tfe.WorkspaceUpdateOptions{ + Operations: tfe.Bool(tc.operations), + TerraformVersion: tfe.String(tc.remote), + }, + ); err != nil { + t.Fatalf("error: %v", err) + } + + diags := b.VerifyWorkspaceTerraformVersion(backend.DefaultStateName) + if tc.wantErr { + if len(diags) != 1 { + t.Fatal("expected diag, but none returned") + } + if got := diags.Err().Error(); !strings.Contains(got, "Terraform version mismatch") { + t.Fatalf("unexpected error: %s", got) + } + } else { + if len(diags) != 0 { + t.Fatalf("unexpected diags: %s", diags.Err()) + } + } + }) + } +} + +func TestCloud_VerifyWorkspaceTerraformVersion_workspaceErrors(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + // Attempting to check the version against a workspace which doesn't exist + // should result in no errors + diags := b.VerifyWorkspaceTerraformVersion("invalid-workspace") + if len(diags) != 0 { + t.Fatalf("unexpected error: %s", diags.Err()) + } + + // Use a special workspace ID to trigger a 500 error, which should result + // in a failed check + diags = b.VerifyWorkspaceTerraformVersion("network-error") + if len(diags) != 1 { + t.Fatal("expected diag, but none returned") + } + if got := diags.Err().Error(); !strings.Contains(got, "Error looking up workspace: Workspace read failed") { + t.Fatalf("unexpected error: %s", got) + } + + // Update the mock remote workspace Terraform version to an invalid version + if _, err := b.client.Workspaces.Update( + context.Background(), + b.organization, + b.workspace, + tfe.WorkspaceUpdateOptions{ + TerraformVersion: tfe.String("1.0.cheetarah"), + }, + ); err != nil { + t.Fatalf("error: %v", err) + } + diags = b.VerifyWorkspaceTerraformVersion(backend.DefaultStateName) + + if len(diags) != 1 { + t.Fatal("expected diag, but none returned") + } + if got := diags.Err().Error(); !strings.Contains(got, "Error looking up workspace: Invalid Terraform version") { + t.Fatalf("unexpected error: %s", got) + } +} + +func TestCloud_VerifyWorkspaceTerraformVersion_ignoreFlagSet(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + // If the ignore flag is set, the behaviour changes + b.IgnoreVersionConflict() + + // Different local & remote versions to cause an error + local := version.Must(version.NewSemver("0.14.0")) + remote := version.Must(version.NewSemver("0.13.5")) + + // Save original local version state and restore afterwards + p := tfversion.Prerelease + v := tfversion.Version + s := tfversion.SemVer + defer func() { + tfversion.Prerelease = p + tfversion.Version = v + tfversion.SemVer = s + }() + + // Override local version as specified + tfversion.Prerelease = "" + tfversion.Version = local.String() + tfversion.SemVer = local + + // Update the mock remote workspace Terraform version to the + // specified remote version + if _, err := b.client.Workspaces.Update( + context.Background(), + b.organization, + b.workspace, + tfe.WorkspaceUpdateOptions{ + TerraformVersion: tfe.String(remote.String()), + }, + ); err != nil { + t.Fatalf("error: %v", err) + } + + diags := b.VerifyWorkspaceTerraformVersion(backend.DefaultStateName) + if len(diags) != 1 { + t.Fatal("expected diag, but none returned") + } + + if got, want := diags[0].Severity(), tfdiags.Warning; got != want { + t.Errorf("wrong severity: got %#v, want %#v", got, want) + } + if got, want := diags[0].Description().Summary, "Terraform version mismatch"; got != want { + t.Errorf("wrong summary: got %s, want %s", got, want) + } + wantDetail := "The local Terraform version (0.14.0) does not match the configured version for remote workspace hashicorp/prod (0.13.5)." + if got := diags[0].Description().Detail; got != wantDetail { + t.Errorf("wrong summary: got %s, want %s", got, wantDetail) + } +} diff --git a/internal/cloud/remote_test.go b/internal/cloud/remote_test.go new file mode 100644 index 000000000..b0c44d60a --- /dev/null +++ b/internal/cloud/remote_test.go @@ -0,0 +1,25 @@ +package cloud + +import ( + "flag" + "os" + "testing" + "time" + + _ "github.com/hashicorp/terraform/internal/logging" +) + +func TestMain(m *testing.M) { + flag.Parse() + + // Make sure TF_FORCE_LOCAL_BACKEND is unset + os.Unsetenv("TF_FORCE_LOCAL_BACKEND") + + // Reduce delays to make tests run faster + backoffMin = 1.0 + backoffMax = 1.0 + planConfigurationVersionsPollInterval = 1 * time.Millisecond + runPollInterval = 1 * time.Millisecond + + os.Exit(m.Run()) +} diff --git a/internal/cloud/testdata/apply-destroy/main.tf b/internal/cloud/testdata/apply-destroy/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/apply-destroy/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/apply-no-changes/main.tf b/internal/cloud/testdata/apply-no-changes/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/apply-no-changes/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/apply-policy-hard-failed/main.tf b/internal/cloud/testdata/apply-policy-hard-failed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/apply-policy-hard-failed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/apply-policy-passed/main.tf b/internal/cloud/testdata/apply-policy-passed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/apply-policy-passed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/apply-policy-soft-failed/main.tf b/internal/cloud/testdata/apply-policy-soft-failed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/apply-policy-soft-failed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/apply-variables/main.tf b/internal/cloud/testdata/apply-variables/main.tf new file mode 100644 index 000000000..955e8b4c0 --- /dev/null +++ b/internal/cloud/testdata/apply-variables/main.tf @@ -0,0 +1,4 @@ +variable "foo" {} +variable "bar" {} + +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/apply-with-error/main.tf b/internal/cloud/testdata/apply-with-error/main.tf new file mode 100644 index 000000000..bc45f28f5 --- /dev/null +++ b/internal/cloud/testdata/apply-with-error/main.tf @@ -0,0 +1,5 @@ +resource "null_resource" "foo" { + triggers { + random = "${guid()}" + } +} diff --git a/internal/cloud/testdata/apply/main.tf b/internal/cloud/testdata/apply/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/apply/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/empty/.gitignore b/internal/cloud/testdata/empty/.gitignore new file mode 100644 index 000000000..e69de29bb diff --git a/internal/cloud/testdata/plan-cost-estimation/main.tf b/internal/cloud/testdata/plan-cost-estimation/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/plan-cost-estimation/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/plan-long-line/main.tf b/internal/cloud/testdata/plan-long-line/main.tf new file mode 100644 index 000000000..0a8d623a9 --- /dev/null +++ b/internal/cloud/testdata/plan-long-line/main.tf @@ -0,0 +1,5 @@ +resource "null_resource" "foo" { + triggers { + long_line = "[{'_id':'5c5ab0ed7de45e993ffb9eeb','index':0,'guid':'e734d772-6b5a-4cb0-805c-91cd5e560e20','isActive':false,'balance':'$1,472.03','picture':'http://placehold.it/32x32','age':30,'eyeColor':'blue','name':{'first':'Darlene','last':'Garza'},'company':'GEEKOSIS','email':'darlene.garza@geekosis.io','phone':'+1 (850) 506-3347','address':'165 Kiely Place, Como, New Mexico, 4335','about':'Officia ullamco et sunt magna voluptate culpa cupidatat ea tempor laboris cupidatat ea anim laboris. Minim enim quis enim esse laborum est veniam. Lorem excepteur elit Lorem cupidatat elit ea anim irure fugiat fugiat sunt mollit. Consectetur ad nulla dolor amet esse occaecat aliquip sit. Magna sit elit adipisicing ut reprehenderit anim exercitation sit quis ea pariatur Lorem magna dolore.','registered':'Wednesday, March 11, 2015 12:58 PM','latitude':'20.729127','longitude':'-127.343593','tags':['minim','in','deserunt','occaecat','fugiat'],'greeting':'Hello, Darlene! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eda9117d15f1c1f112','index':1,'guid':'f0d1eed2-c6a9-4535-8800-d4bd53fe7eee','isActive':true,'balance':'$2,901.90','picture':'http://placehold.it/32x32','age':28,'eyeColor':'brown','name':{'first':'Flora','last':'Short'},'company':'SIGNITY','email':'flora.short@signity.me','phone':'+1 (840) 520-2666','address':'636 Johnson Avenue, Gerber, Wisconsin, 9139','about':'Veniam dolore deserunt Lorem aliqua qui eiusmod. Amet tempor fugiat duis incididunt amet adipisicing. Id ea nisi veniam eiusmod.','registered':'Wednesday, May 2, 2018 5:59 AM','latitude':'-63.267612','longitude':'4.224102','tags':['veniam','incididunt','id','aliqua','reprehenderit'],'greeting':'Hello, Flora! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed83fd574d8041fa16','index':2,'guid':'29499a07-414a-436f-ba62-6634ca16bdcc','isActive':true,'balance':'$2,781.28','picture':'http://placehold.it/32x32','age':22,'eyeColor':'green','name':{'first':'Trevino','last':'Marks'},'company':'KEGULAR','email':'trevino.marks@kegular.com','phone':'+1 (843) 571-2269','address':'200 Alabama Avenue, Grenelefe, Florida, 7963','about':'Occaecat nisi exercitation Lorem mollit laborum magna adipisicing culpa dolor proident dolore. Non consequat ea amet et id mollit incididunt minim anim amet nostrud labore tempor. Proident eu sint commodo nisi consequat voluptate do fugiat proident. Laboris eiusmod veniam non et elit nulla nisi labore incididunt Lorem consequat consectetur voluptate.','registered':'Saturday, January 25, 2014 5:56 AM','latitude':'65.044005','longitude':'-127.454864','tags':['anim','duis','velit','pariatur','enim'],'greeting':'Hello, Trevino! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed784eb6e350ff0a07','index':3,'guid':'40ed47e2-1747-4665-ab59-cdb3630a7642','isActive':true,'balance':'$2,000.78','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Solis','last':'Mckinney'},'company':'QABOOS','email':'solis.mckinney@qaboos.org','phone':'+1 (924) 405-2560','address':'712 Herkimer Court, Klondike, Ohio, 8133','about':'Minim ad anim minim tempor mollit magna tempor et non commodo amet. Nisi cupidatat labore culpa consectetur exercitation laborum adipisicing fugiat officia adipisicing consequat non. Qui voluptate tempor laboris exercitation qui non adipisicing occaecat voluptate sunt do nostrud velit. Consequat tempor officia laboris tempor irure cupidatat aliquip voluptate nostrud velit ex nulla tempor laboris. Qui pariatur pariatur enim aliquip velit. Officia mollit ullamco laboris velit velit eiusmod enim amet incididunt consectetur sunt.','registered':'Wednesday, April 12, 2017 6:59 AM','latitude':'-25.055596','longitude':'-140.126525','tags':['ipsum','adipisicing','amet','nulla','dolore'],'greeting':'Hello, Solis! You have 5 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed02ce1ea9a2155d51','index':4,'guid':'1b5fb7d3-3b9a-4382-81b5-9ab01a27e74b','isActive':true,'balance':'$1,373.67','picture':'http://placehold.it/32x32','age':28,'eyeColor':'green','name':{'first':'Janell','last':'Battle'},'company':'GEEKMOSIS','email':'janell.battle@geekmosis.net','phone':'+1 (810) 591-3014','address':'517 Onderdonk Avenue, Shrewsbury, District Of Columbia, 2335','about':'Reprehenderit ad proident do anim qui officia magna magna duis cillum esse minim est. Excepteur ipsum anim ad laboris. In occaecat dolore nulla ea Lorem tempor et culpa in sint. Officia eu eu incididunt sit amet. Culpa duis id reprehenderit ut anim sit sunt. Duis dolore proident velit incididunt adipisicing pariatur fugiat incididunt eiusmod eu veniam irure.','registered':'Thursday, February 8, 2018 1:44 AM','latitude':'-33.254864','longitude':'-154.145885','tags':['aute','deserunt','ipsum','eiusmod','laborum'],'greeting':'Hello, Janell! You have 5 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edab58604bd7d3dd1c','index':5,'guid':'6354c035-af22-44c9-8be9-b2ea9decc24d','isActive':true,'balance':'$3,535.68','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Combs','last':'Kirby'},'company':'LUXURIA','email':'combs.kirby@luxuria.name','phone':'+1 (900) 498-3266','address':'377 Kingsland Avenue, Ruckersville, Maine, 9916','about':'Lorem duis ipsum pariatur aliquip sunt. Commodo esse laborum incididunt mollit quis est laboris ea ea quis fugiat. Enim elit ullamco velit et fugiat veniam irure deserunt aliqua ad irure veniam.','registered':'Tuesday, February 21, 2017 4:04 PM','latitude':'-70.20591','longitude':'162.546871','tags':['reprehenderit','est','enim','aute','ad'],'greeting':'Hello, Combs! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edf7fafeffc6357c51','index':6,'guid':'02523e0b-cc90-4309-b6b2-f493dc6076f6','isActive':false,'balance':'$3,754.30','picture':'http://placehold.it/32x32','age':29,'eyeColor':'green','name':{'first':'Macias','last':'Calderon'},'company':'AMTAP','email':'macias.calderon@amtap.us','phone':'+1 (996) 569-3667','address':'305 Royce Street, Glidden, Iowa, 9248','about':'Exercitation nulla deserunt pariatur adipisicing. In commodo deserunt incididunt ut velit minim qui ut quis. Labore elit ullamco eiusmod voluptate in eu do est fugiat aute mollit deserunt. Eu duis proident velit fugiat velit ut. Ut non esse amet laborum nisi tempor in nulla.','registered':'Thursday, October 23, 2014 10:28 PM','latitude':'32.371629','longitude':'60.155135','tags':['commodo','elit','velit','excepteur','aliqua'],'greeting':'Hello, Macias! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed0e8a6109e7fabf17','index':7,'guid':'675ff6b6-197b-4154-9775-813d661df822','isActive':false,'balance':'$2,850.62','picture':'http://placehold.it/32x32','age':37,'eyeColor':'green','name':{'first':'Stefanie','last':'Rivers'},'company':'RECRITUBE','email':'stefanie.rivers@recritube.biz','phone':'+1 (994) 591-3551','address':'995 Campus Road, Abrams, Virginia, 3251','about':'Esse aute non laborum Lorem nulla irure. Veniam elit aute ut et dolor non deserunt laboris tempor. Ipsum quis cupidatat laborum laboris voluptate esse duis eiusmod excepteur consectetur commodo ullamco qui occaecat. Culpa velit cillum occaecat minim nisi.','registered':'Thursday, June 9, 2016 3:40 PM','latitude':'-18.526825','longitude':'149.670782','tags':['occaecat','sunt','reprehenderit','ipsum','magna'],'greeting':'Hello, Stefanie! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edf7d9bc2db4e476e3','index':8,'guid':'adaefc55-f6ea-4bd1-a147-0e31c3ce7a21','isActive':true,'balance':'$2,555.13','picture':'http://placehold.it/32x32','age':20,'eyeColor':'blue','name':{'first':'Hillary','last':'Lancaster'},'company':'OLUCORE','email':'hillary.lancaster@olucore.ca','phone':'+1 (964) 474-3018','address':'232 Berriman Street, Kaka, Massachusetts, 6792','about':'Veniam ad laboris quis reprehenderit aliquip nisi sunt excepteur ea aute laborum excepteur incididunt. Nisi exercitation aliquip do culpa commodo ex officia ut enim mollit in deserunt in amet. Anim eu deserunt dolore non cupidatat ut enim incididunt aute dolore voluptate. Do cillum mollit laborum non incididunt occaecat aute voluptate nisi irure.','registered':'Thursday, June 4, 2015 9:45 PM','latitude':'88.075919','longitude':'-148.951368','tags':['reprehenderit','veniam','ad','aute','anim'],'greeting':'Hello, Hillary! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed7b7192ad6a0f267c','index':9,'guid':'0ca9b8ea-f671-474e-be26-4a49cae4838a','isActive':true,'balance':'$3,684.51','picture':'http://placehold.it/32x32','age':40,'eyeColor':'brown','name':{'first':'Jill','last':'Conner'},'company':'EXOZENT','email':'jill.conner@exozent.info','phone':'+1 (887) 467-2168','address':'751 Thames Street, Juarez, American Samoa, 8386','about':'Enim voluptate et non est in magna laborum aliqua enim aliqua est non nostrud. Tempor est nulla ipsum consectetur esse nostrud est id. Consequat do voluptate cupidatat eu fugiat et fugiat velit id. Sint dolore ad qui tempor anim eu amet consectetur do elit aute adipisicing consequat ex.','registered':'Sunday, October 22, 2017 7:35 AM','latitude':'84.384911','longitude':'40.305648','tags':['tempor','sint','irure','et','ex'],'greeting':'Hello, Jill! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed713fe676575aa72b','index':10,'guid':'c28023cf-cc57-4c2e-8d91-dfbe6bafadcd','isActive':false,'balance':'$2,792.45','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Hurley','last':'George'},'company':'ZAJ','email':'hurley.george@zaj.tv','phone':'+1 (984) 547-3284','address':'727 Minna Street, Lacomb, Colorado, 2557','about':'Ex velit cupidatat veniam culpa. Eiusmod ut fugiat adipisicing incididunt consectetur exercitation Lorem exercitation ex. Incididunt anim aute incididunt fugiat cupidatat qui eu non reprehenderit. Eiusmod dolor nisi culpa excepteur ut velit minim dolor voluptate amet commodo culpa in.','registered':'Thursday, February 16, 2017 6:41 AM','latitude':'25.989949','longitude':'10.200053','tags':['minim','ut','sunt','consequat','ullamco'],'greeting':'Hello, Hurley! You have 8 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed1e56732746c70d8b','index':11,'guid':'e9766f13-766c-4450-b4d2-8b04580f60b7','isActive':true,'balance':'$3,874.26','picture':'http://placehold.it/32x32','age':35,'eyeColor':'green','name':{'first':'Leticia','last':'Pace'},'company':'HONOTRON','email':'leticia.pace@honotron.co.uk','phone':'+1 (974) 536-3322','address':'365 Goodwin Place, Savage, Nevada, 9191','about':'Nisi Lorem aliqua esse eiusmod magna. Ad minim incididunt proident ut Lorem cupidatat qui velit aliqua ullamco et ipsum in. Aliquip elit consectetur pariatur esse exercitation et officia quis. Occaecat tempor proident cillum anim ad commodo velit ut voluptate. Tempor et occaecat sit sint aliquip tempor nulla velit magna nisi proident exercitation Lorem id.','registered':'Saturday, August 4, 2018 5:05 AM','latitude':'70.620386','longitude':'-86.335813','tags':['occaecat','velit','labore','laboris','esse'],'greeting':'Hello, Leticia! You have 8 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed941337fe42f47426','index':12,'guid':'6d390762-17ea-4b58-9a36-b0c9a8748a42','isActive':true,'balance':'$1,049.61','picture':'http://placehold.it/32x32','age':38,'eyeColor':'green','name':{'first':'Rose','last':'Humphrey'},'company':'MYOPIUM','email':'rose.humphrey@myopium.io','phone':'+1 (828) 426-3086','address':'389 Sapphire Street, Saticoy, Marshall Islands, 1423','about':'Aliquip enim excepteur adipisicing ex. Consequat aliqua consequat nostrud do occaecat deserunt excepteur sit et ipsum sunt dolor eu. Dolore laborum commodo excepteur tempor ad adipisicing proident excepteur magna non Lorem proident consequat aute. Fugiat minim consequat occaecat voluptate esse velit officia laboris nostrud nisi ut voluptate.','registered':'Monday, April 16, 2018 12:38 PM','latitude':'-47.083742','longitude':'109.022423','tags':['aute','non','sit','adipisicing','mollit'],'greeting':'Hello, Rose! You have 9 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd0c02fc3fdc01a40','index':13,'guid':'07755618-6fdf-4b33-af50-364c18909227','isActive':true,'balance':'$1,823.61','picture':'http://placehold.it/32x32','age':36,'eyeColor':'green','name':{'first':'Judith','last':'Hale'},'company':'COLLAIRE','email':'judith.hale@collaire.me','phone':'+1 (922) 508-2843','address':'193 Coffey Street, Castleton, North Dakota, 3638','about':'Minim non ullamco ad anim nostrud dolore nostrud veniam consequat id eiusmod veniam laboris. Lorem irure esse mollit non velit aute id cupidatat est mollit occaecat magna excepteur. Adipisicing tempor nisi sit aliquip tempor pariatur tempor eu consectetur nulla amet nulla. Quis nisi nisi ea incididunt culpa et do. Esse officia eu pariatur velit sunt quis proident amet consectetur consequat. Nisi excepteur culpa nulla sit dolor deserunt excepteur dolor consequat elit cillum tempor Lorem.','registered':'Wednesday, August 24, 2016 12:29 AM','latitude':'-80.15514','longitude':'39.91007','tags':['consectetur','incididunt','aliquip','dolor','consequat'],'greeting':'Hello, Judith! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edb3e1e29caa4f728b','index':14,'guid':'2c6617a2-e7a9-4ff7-a8b9-e99554fe70fe','isActive':true,'balance':'$1,971.00','picture':'http://placehold.it/32x32','age':39,'eyeColor':'blue','name':{'first':'Estes','last':'Sweet'},'company':'GEEKKO','email':'estes.sweet@geekko.com','phone':'+1 (866) 448-3032','address':'847 Cove Lane, Kula, Mississippi, 9178','about':'Veniam consectetur occaecat est excepteur consequat ipsum cillum sit consectetur. Ut cupidatat et reprehenderit dolore enim do cillum qui pariatur ad laborum incididunt esse. Fugiat sunt dolor veniam laboris ipsum deserunt proident reprehenderit laboris non nostrud. Magna excepteur sint magna laborum tempor sit exercitation ipsum labore est ullamco ullamco. Cillum voluptate cillum ea laborum Lorem. Excepteur sint ut nisi est esse non. Minim excepteur ullamco velit nisi ut in elit exercitation ut dolore.','registered':'Sunday, August 12, 2018 5:06 PM','latitude':'-9.57771','longitude':'-159.94577','tags':['culpa','dolor','velit','anim','pariatur'],'greeting':'Hello, Estes! You have 7 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0edbcf088c6fd593091','index':15,'guid':'2cc79958-1b40-4e2c-907a-433903fd3da9','isActive':false,'balance':'$3,751.53','picture':'http://placehold.it/32x32','age':34,'eyeColor':'brown','name':{'first':'Kemp','last':'Spence'},'company':'EXOBLUE','email':'kemp.spence@exoblue.org','phone':'+1 (864) 487-2992','address':'217 Clay Street, Monument, North Carolina, 1460','about':'Nostrud duis cillum sint non commodo dolor aute aliqua adipisicing ad nulla non excepteur proident. Fugiat labore elit tempor cillum veniam reprehenderit laboris consectetur dolore amet qui cupidatat. Amet aliqua elit anim et consequat commodo excepteur officia anim aliqua ea eu labore cillum. Et ex dolor duis dolore commodo veniam et nisi.','registered':'Monday, October 29, 2018 5:23 AM','latitude':'-70.304222','longitude':'83.582371','tags':['velit','duis','consequat','incididunt','duis'],'greeting':'Hello, Kemp! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed6400479feb3de505','index':16,'guid':'91ccae6d-a3ea-43cf-bb00-3f2729256cc9','isActive':false,'balance':'$2,477.79','picture':'http://placehold.it/32x32','age':40,'eyeColor':'blue','name':{'first':'Ronda','last':'Burris'},'company':'EQUITOX','email':'ronda.burris@equitox.net','phone':'+1 (817) 553-3228','address':'708 Lawton Street, Deputy, Wyoming, 8598','about':'Excepteur voluptate aliquip consequat cillum est duis sit cillum eu eiusmod et laborum ullamco. Et minim reprehenderit aute voluptate amet ullamco. Amet sit enim ad irure deserunt nostrud anim veniam consequat dolor commodo. Consequat do occaecat do exercitation ullamco dolor ut. Id laboris consequat est dolor dolore tempor ullamco anim do ut nulla deserunt labore. Mollit ex Lorem ullamco mollit.','registered':'Monday, April 23, 2018 5:27 PM','latitude':'-31.227208','longitude':'0.63785','tags':['ipsum','magna','consectetur','sit','irure'],'greeting':'Hello, Ronda! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eddbeab2e53e04d563','index':17,'guid':'a86d4eb6-6bd8-48c2-a8fc-1c933c835852','isActive':false,'balance':'$3,709.03','picture':'http://placehold.it/32x32','age':37,'eyeColor':'blue','name':{'first':'Rosario','last':'Dillard'},'company':'BARKARAMA','email':'rosario.dillard@barkarama.name','phone':'+1 (933) 525-3898','address':'730 Chauncey Street, Forbestown, South Carolina, 6894','about':'Est eu fugiat aliquip ea ad qui ad mollit ad tempor voluptate et incididunt reprehenderit. Incididunt fugiat commodo minim adipisicing culpa consectetur duis eu ut commodo consequat voluptate labore. Nostrud irure labore adipisicing irure quis magna consequat dolor Lorem sint enim. Sint excepteur eu dolore elit ut do mollit sunt enim est. Labore id nostrud sint Lorem esse nostrud.','registered':'Friday, December 25, 2015 8:59 PM','latitude':'37.440827','longitude':'44.580474','tags':['Lorem','sit','ipsum','ea','ut'],'greeting':'Hello, Rosario! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eddf8e9b9c031d04e8','index':18,'guid':'a96f997c-daf8-40d4-92e1-be07e2cf0f60','isActive':false,'balance':'$1,878.37','picture':'http://placehold.it/32x32','age':37,'eyeColor':'brown','name':{'first':'Sondra','last':'Gonzales'},'company':'XUMONK','email':'sondra.gonzales@xumonk.us','phone':'+1 (838) 560-2255','address':'230 Cox Place, Geyserville, Georgia, 6805','about':'Laborum sunt voluptate ea laboris nostrud. Amet deserunt aliqua Lorem voluptate velit deserunt occaecat minim ullamco. Lorem occaecat sit labore adipisicing ad magna mollit labore ullamco proident. Ea velit do proident fugiat esse commodo ex nostrud eu mollit pariatur. Labore laborum qui voluptate quis proident reprehenderit tempor dolore duis deserunt esse aliqua aliquip. Non veniam enim pariatur cupidatat ipsum dolore est reprehenderit. Non exercitation adipisicing proident magna elit occaecat non magna.','registered':'Sunday, June 26, 2016 4:02 AM','latitude':'62.247742','longitude':'-44.90666','tags':['ea','aute','in','voluptate','magna'],'greeting':'Hello, Sondra! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed2c1bcd06781f677e','index':19,'guid':'6ac47a16-eed4-4460-92ee-e0dd33c1fbb5','isActive':false,'balance':'$3,730.64','picture':'http://placehold.it/32x32','age':20,'eyeColor':'brown','name':{'first':'Anastasia','last':'Vega'},'company':'FIREWAX','email':'anastasia.vega@firewax.biz','phone':'+1 (867) 493-3698','address':'803 Arlington Avenue, Rosburg, Northern Mariana Islands, 8769','about':'Sint ex nisi tempor sunt voluptate non et eiusmod irure. Aute reprehenderit dolor mollit aliqua Lorem voluptate occaecat. Sint laboris deserunt Lorem incididunt nulla cupidatat do.','registered':'Friday, March 18, 2016 12:02 PM','latitude':'-32.010216','longitude':'-87.874753','tags':['aliquip','mollit','mollit','ad','laborum'],'greeting':'Hello, Anastasia! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed727fd645854bbf43','index':20,'guid':'67bd8cdb-ce6b-455c-944c-a80e17c6fa75','isActive':true,'balance':'$2,868.06','picture':'http://placehold.it/32x32','age':29,'eyeColor':'green','name':{'first':'Lucinda','last':'Cox'},'company':'ENDIPINE','email':'lucinda.cox@endipine.ca','phone':'+1 (990) 428-3002','address':'412 Thatford Avenue, Lafferty, New Jersey, 5271','about':'Esse nulla sunt ut consequat aute mollit. Est occaecat sunt nisi irure id anim est commodo. Elit mollit amet dolore sunt adipisicing ea laborum quis ea reprehenderit non consequat dolore. Minim sunt occaecat quis aute commodo dolore quis commodo proident. Sunt sint duis ullamco sit ea esse Lorem. Consequat pariatur eiusmod laboris adipisicing labore in laboris adipisicing adipisicing consequat aute ea et.','registered':'Friday, May 1, 2015 10:16 PM','latitude':'-14.200957','longitude':'-82.211386','tags':['do','sit','qui','officia','aliquip'],'greeting':'Hello, Lucinda! You have 9 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed5a97284eb2cbd3a8','index':21,'guid':'f9fc999d-515c-4fc4-b339-76300e1b4bf2','isActive':true,'balance':'$1,172.57','picture':'http://placehold.it/32x32','age':35,'eyeColor':'brown','name':{'first':'Conrad','last':'Bradley'},'company':'FUELWORKS','email':'conrad.bradley@fuelworks.info','phone':'+1 (956) 561-3226','address':'685 Fenimore Street, Esmont, Maryland, 7523','about':'Labore reprehenderit anim nisi sunt do nisi in. Est anim cillum id minim exercitation ullamco voluptate ipsum eu. Elit culpa consequat reprehenderit laborum in eu. Laboris amet voluptate laboris qui voluptate duis minim reprehenderit. Commodo sunt irure dolore sunt occaecat velit nisi eu minim minim.','registered':'Wednesday, January 18, 2017 11:13 PM','latitude':'31.665993','longitude':'38.868968','tags':['excepteur','exercitation','est','nisi','mollit'],'greeting':'Hello, Conrad! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edc4eaf6f760c38218','index':22,'guid':'8794ef5f-da2f-46f0-a755-c18a16409fd5','isActive':false,'balance':'$3,594.73','picture':'http://placehold.it/32x32','age':27,'eyeColor':'blue','name':{'first':'Marquez','last':'Vargas'},'company':'MALATHION','email':'marquez.vargas@malathion.tv','phone':'+1 (976) 438-3126','address':'296 Hall Street, National, Texas, 2067','about':'Proident cillum aute minim fugiat sunt aliqua non occaecat est duis id id tempor. Qui deserunt nisi amet pariatur proident eu laboris esse adipisicing magna. Anim anim mollit aute non magna nisi aute magna labore ullamco reprehenderit voluptate et ad. Proident adipisicing aute eiusmod nostrud nostrud deserunt culpa. Elit eu ullamco nisi aliqua dolor sint pariatur excepteur sit consectetur tempor. Consequat Lorem ullamco commodo veniam qui sint magna. Sit mollit ad aliquip est id eu officia id adipisicing duis ad.','registered':'Tuesday, November 17, 2015 6:16 PM','latitude':'-36.443667','longitude':'22.336776','tags':['aliquip','veniam','ipsum','Lorem','ex'],'greeting':'Hello, Marquez! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0edd7c718518ee0466a','index':23,'guid':'ad8781a2-059e-4288-9879-309d53a99bf5','isActive':true,'balance':'$3,570.68','picture':'http://placehold.it/32x32','age':21,'eyeColor':'brown','name':{'first':'Snider','last':'Frost'},'company':'ZILODYNE','email':'snider.frost@zilodyne.co.uk','phone':'+1 (913) 485-3275','address':'721 Lincoln Road, Richmond, Utah, 672','about':'Minim enim Lorem esse incididunt do reprehenderit velit laborum ullamco. In aute eiusmod esse aliqua et labore tempor sunt ex mollit veniam tempor. Nulla elit cillum qui ullamco dolore amet deserunt magna amet laborum.','registered':'Saturday, August 23, 2014 12:58 AM','latitude':'-88.682554','longitude':'74.063179','tags':['nulla','ea','sint','aliquip','duis'],'greeting':'Hello, Snider! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edf026fece8e2c0970','index':24,'guid':'1b7d81e1-1dba-4322-bb1a-eaa6a24cccea','isActive':false,'balance':'$2,037.91','picture':'http://placehold.it/32x32','age':28,'eyeColor':'green','name':{'first':'Snyder','last':'Fletcher'},'company':'COMTEST','email':'snyder.fletcher@comtest.io','phone':'+1 (830) 538-3860','address':'221 Lewis Place, Zortman, Idaho, 572','about':'Elit anim enim esse dolore exercitation. Laboris esse sint adipisicing fugiat sint do occaecat ut voluptate sint nulla. Ad sint ut reprehenderit nostrud irure id consectetur officia velit consequat.','registered':'Sunday, January 1, 2017 1:13 AM','latitude':'-54.742604','longitude':'69.534932','tags':['exercitation','commodo','in','id','aliqua'],'greeting':'Hello, Snyder! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed4b9a7f83da6d2dfd','index':25,'guid':'0b2cc6b6-0044-4b1c-aa31-bd72963457a0','isActive':false,'balance':'$1,152.76','picture':'http://placehold.it/32x32','age':27,'eyeColor':'blue','name':{'first':'Regina','last':'James'},'company':'TELPOD','email':'regina.james@telpod.me','phone':'+1 (989) 455-3228','address':'688 Essex Street, Clayville, Alabama, 2772','about':'Eiusmod elit culpa reprehenderit ea veniam. Officia irure culpa duis aute ut. Irure duis cillum officia ea pariatur velit ut dolor incididunt reprehenderit ex elit laborum. Est pariatur veniam ad irure. Labore velit sunt esse laboris aliqua velit deserunt deserunt sit. Elit eiusmod ad laboris aliquip minim irure excepteur enim quis. Quis incididunt adipisicing ut magna cupidatat sit amet culpa.','registered':'Tuesday, April 25, 2017 10:16 PM','latitude':'-75.088027','longitude':'47.209828','tags':['elit','nisi','est','voluptate','proident'],'greeting':'Hello, Regina! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed10884f32f779f2bf','index':26,'guid':'1f6fb522-0002-46ff-8dac-451247f28168','isActive':true,'balance':'$1,948.79','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Collins','last':'Mcpherson'},'company':'DIGIGEN','email':'collins.mcpherson@digigen.com','phone':'+1 (991) 519-2334','address':'317 Merit Court, Sanford, Michigan, 6468','about':'Magna qui culpa dolor officia labore mollit ex excepteur duis eiusmod. Ea cupidatat ex ipsum mollit do minim duis. Nisi eiusmod minim tempor id esse commodo sunt sunt ullamco ut do laborum ullamco magna. Aliquip laborum dolor officia officia eu nostrud velit minim est anim. Ex elit laborum sunt magna exercitation nisi cillum sunt aute qui ea ullamco. Cupidatat ea sunt aute dolor duis nisi Lorem ullamco eiusmod. Sit ea velit ad veniam aliqua ad elit cupidatat ut magna in.','registered':'Friday, June 10, 2016 4:38 PM','latitude':'25.513996','longitude':'14.911124','tags':['exercitation','non','sit','velit','officia'],'greeting':'Hello, Collins! You have 5 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed8a575110efb15c6c','index':27,'guid':'2a904c82-068b-4ded-9ae6-cfeb6d7e62c9','isActive':true,'balance':'$3,427.91','picture':'http://placehold.it/32x32','age':24,'eyeColor':'green','name':{'first':'Mckay','last':'Barrera'},'company':'COMVEYER','email':'mckay.barrera@comveyer.org','phone':'+1 (853) 470-2560','address':'907 Glenwood Road, Churchill, Oregon, 8583','about':'In voluptate esse dolore enim sint quis dolor do exercitation sint et labore nisi. Eiusmod tempor exercitation dolore elit sit velit sint et. Sit magna adipisicing eiusmod do anim velit deserunt laboris ad ea pariatur. Irure nisi anim mollit elit commodo nulla. Aute eiusmod sit nulla eiusmod. Eiusmod est officia commodo mollit laboris do deserunt eu do nisi amet. Proident ad duis eiusmod laboris Lorem ut culpa pariatur Lorem reprehenderit minim aliquip irure sunt.','registered':'Saturday, December 19, 2015 2:49 PM','latitude':'-55.243287','longitude':'138.035406','tags':['non','quis','laboris','enim','nisi'],'greeting':'Hello, Mckay! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edcd49ab6a73ff7f32','index':28,'guid':'5d3e0dae-3f58-437f-b12d-de24667a904d','isActive':true,'balance':'$3,270.52','picture':'http://placehold.it/32x32','age':35,'eyeColor':'blue','name':{'first':'Mabel','last':'Leonard'},'company':'QUADEEBO','email':'mabel.leonard@quadeebo.net','phone':'+1 (805) 432-2356','address':'965 Underhill Avenue, Falconaire, Minnesota, 4450','about':'Cupidatat amet sunt est ipsum occaecat sit fugiat excepteur Lorem Lorem ex ea ipsum. Ad incididunt est irure magna excepteur occaecat nostrud. Minim dolor id anim ipsum qui nostrud ullamco aute ex Lorem magna deserunt excepteur Lorem.','registered':'Saturday, March 28, 2015 5:55 AM','latitude':'27.388359','longitude':'156.408728','tags':['quis','velit','deserunt','dolore','sit'],'greeting':'Hello, Mabel! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edde16ac2dc2fbb6c1','index':29,'guid':'d50c2233-70fc-4748-8ebf-02d45ac2a446','isActive':false,'balance':'$3,100.70','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Pace','last':'Duke'},'company':'SEQUITUR','email':'pace.duke@sequitur.name','phone':'+1 (983) 568-3119','address':'895 Melrose Street, Reno, Connecticut, 6259','about':'Ex veniam aliquip exercitation mollit elit est minim veniam aliqua labore deserunt. Dolor sunt sint cillum Lorem nisi ea irure cupidatat. Velit ut culpa cupidatat consequat cillum. Sint voluptate quis laboris qui incididunt do elit Lorem qui ullamco ut eu pariatur occaecat.','registered':'Saturday, August 18, 2018 2:18 PM','latitude':'31.930443','longitude':'-129.494784','tags':['culpa','est','nostrud','quis','aliquip'],'greeting':'Hello, Pace! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edb908d85642ba77e8','index':30,'guid':'3edb6e42-367a-403d-a511-eb78bcc11f60','isActive':true,'balance':'$1,912.07','picture':'http://placehold.it/32x32','age':24,'eyeColor':'green','name':{'first':'Cohen','last':'Morrison'},'company':'POWERNET','email':'cohen.morrison@powernet.us','phone':'+1 (888) 597-2141','address':'565 Troutman Street, Idledale, West Virginia, 3196','about':'Ullamco voluptate duis commodo amet occaecat consequat et occaecat dolore nulla eu. Do aliqua sunt deserunt occaecat laboris labore voluptate cupidatat ullamco exercitation aliquip elit voluptate anim. Occaecat deserunt in labore cillum aute deserunt ea excepteur laboris sunt. Officia irure sint incididunt labore sint ipsum ullamco ea elit. Fugiat nostrud sunt ut officia mollit proident sunt dolor fugiat esse tempor do.','registered':'Friday, January 1, 2016 5:42 AM','latitude':'-20.01215','longitude':'26.361552','tags':['consectetur','sunt','nulla','reprehenderit','dolore'],'greeting':'Hello, Cohen! You have 10 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed91c77aa25a64a757','index':31,'guid':'8999a97b-0035-4f19-b555-91dd69aaa9b8','isActive':false,'balance':'$3,097.67','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Stout','last':'Valdez'},'company':'UPLINX','email':'stout.valdez@uplinx.biz','phone':'+1 (854) 480-3633','address':'880 Chestnut Avenue, Lowgap, Hawaii, 1537','about':'Cupidatat enim dolore non voluptate. Aliqua ut non Lorem in exercitation reprehenderit voluptate. Excepteur deserunt tempor laboris quis.','registered':'Wednesday, March 16, 2016 6:53 AM','latitude':'50.328393','longitude':'-25.990308','tags':['ea','fugiat','duis','consectetur','enim'],'greeting':'Hello, Stout! You have 5 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed0f52176c8c3e1bed','index':32,'guid':'743abcbd-1fab-4aed-8cb7-3c935eb64c74','isActive':false,'balance':'$1,118.54','picture':'http://placehold.it/32x32','age':30,'eyeColor':'blue','name':{'first':'Ortega','last':'Joseph'},'company':'APEXIA','email':'ortega.joseph@apexia.ca','phone':'+1 (872) 596-3024','address':'304 Canda Avenue, Mulino, New York, 8721','about':'Ipsum elit id cupidatat minim nisi minim. Ea ex amet ea ipsum Lorem deserunt. Occaecat cupidatat magna cillum aliquip sint id quis amet nostrud officia enim laborum. Aliqua deserunt amet commodo laboris labore mollit est. Officia voluptate Lorem esse mollit aliquip laboris cupidatat minim et. Labore esse incididunt officia nostrud pariatur reprehenderit.','registered':'Tuesday, January 31, 2017 6:06 AM','latitude':'43.861714','longitude':'33.771783','tags':['ut','Lorem','esse','quis','fugiat'],'greeting':'Hello, Ortega! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed2c00cdd101b6cd52','index':33,'guid':'4f6f99cf-f692-4d03-b23a-26f2b27273bd','isActive':true,'balance':'$1,682.91','picture':'http://placehold.it/32x32','age':20,'eyeColor':'blue','name':{'first':'Sampson','last':'Taylor'},'company':'GEOFORMA','email':'sampson.taylor@geoforma.info','phone':'+1 (911) 482-2993','address':'582 Kent Street, Umapine, Virgin Islands, 5300','about':'Voluptate laboris occaecat laboris tempor cillum quis cupidatat qui pariatur. Lorem minim commodo mollit adipisicing Lorem ut dolor consectetur ipsum. Sint sit voluptate labore aliqua ex labore velit. Ullamco tempor consectetur voluptate deserunt voluptate minim enim. Cillum commodo duis reprehenderit eu duis.','registered':'Thursday, November 9, 2017 11:24 PM','latitude':'24.949379','longitude':'155.034468','tags':['Lorem','cupidatat','elit','reprehenderit','commodo'],'greeting':'Hello, Sampson! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed4b7210ba0bc0d508','index':34,'guid':'73fd415f-f8cf-43e0-a86c-e725d000abd4','isActive':false,'balance':'$1,289.37','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Shari','last':'Melendez'},'company':'DIGIPRINT','email':'shari.melendez@digiprint.tv','phone':'+1 (914) 475-3995','address':'950 Wolf Place, Enetai, Alaska, 693','about':'Dolor incididunt et est commodo aliquip labore ad ullamco. Velit ex cillum nulla elit ex esse. Consectetur mollit fugiat cillum proident elit sunt non officia cillum ex laboris sint eu. Esse nulla eu officia in Lorem sint minim esse velit. Est Lorem ipsum enim aute. Elit minim eiusmod officia reprehenderit officia ut irure Lorem.','registered':'Wednesday, August 23, 2017 11:12 PM','latitude':'-70.347863','longitude':'94.812072','tags':['ea','ex','fugiat','duis','eu'],'greeting':'Hello, Shari! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed85ac364619d892ef','index':35,'guid':'c1905f34-14ff-4bd8-b683-02cac4d52623','isActive':false,'balance':'$2,538.50','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Santiago','last':'Joyner'},'company':'BRAINCLIP','email':'santiago.joyner@brainclip.co.uk','phone':'+1 (835) 405-2676','address':'554 Rose Street, Muir, Kentucky, 7752','about':'Quis culpa dolore fugiat magna culpa non deserunt consectetur elit. Id cupidatat occaecat duis irure ullamco elit in labore magna pariatur cillum est. Mollit dolore velit ipsum anim aliqua culpa sint. Occaecat aute anim ut sunt eu.','registered':'Thursday, January 18, 2018 4:49 PM','latitude':'57.057918','longitude':'-50.472596','tags':['ullamco','ullamco','sunt','voluptate','irure'],'greeting':'Hello, Santiago! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed1763f56b1121fa88','index':36,'guid':'a7f50659-4ae3-4f3e-a9d8-087e05334b51','isActive':false,'balance':'$1,435.16','picture':'http://placehold.it/32x32','age':37,'eyeColor':'blue','name':{'first':'Adeline','last':'Hoffman'},'company':'BITREX','email':'adeline.hoffman@bitrex.io','phone':'+1 (823) 488-3201','address':'221 Corbin Place, Edmund, Palau, 193','about':'Magna ullamco consectetur velit adipisicing cillum ea. Est qui incididunt est ullamco ex aute exercitation irure. Cupidatat consectetur proident qui fugiat do. Labore magna aliqua consectetur fugiat. Excepteur deserunt sit qui dolor fugiat aute sunt anim ipsum magna ea commodo qui. Minim eu adipisicing ut irure excepteur eiusmod aliqua. Voluptate nisi ad consequat qui.','registered':'Tuesday, June 14, 2016 9:26 AM','latitude':'-53.123355','longitude':'88.180776','tags':['non','est','commodo','ut','aliquip'],'greeting':'Hello, Adeline! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed945d079f63e3185e','index':37,'guid':'1f4619e0-9289-4bea-a9db-a75f4cba1138','isActive':true,'balance':'$2,019.54','picture':'http://placehold.it/32x32','age':36,'eyeColor':'blue','name':{'first':'Porter','last':'Morse'},'company':'COMVOY','email':'porter.morse@comvoy.me','phone':'+1 (933) 562-3220','address':'416 India Street, Bourg, Rhode Island, 2266','about':'Et sint anim et sunt. Non mollit sunt cillum veniam sunt sint amet non mollit. Fugiat ea ullamco pariatur deserunt ex do minim irure irure.','registered':'Saturday, July 16, 2016 10:03 PM','latitude':'-81.782545','longitude':'69.783509','tags':['irure','consequat','veniam','nulla','velit'],'greeting':'Hello, Porter! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed411dd0f06c66bba6','index':38,'guid':'93c900f0-54c0-4c4c-b21d-d59d8d7c6177','isActive':true,'balance':'$3,764.84','picture':'http://placehold.it/32x32','age':26,'eyeColor':'green','name':{'first':'Fitzgerald','last':'Logan'},'company':'UTARIAN','email':'fitzgerald.logan@utarian.com','phone':'+1 (815) 461-2709','address':'498 Logan Street, Tonopah, Arkansas, 6652','about':'Quis Lorem sit est et dolor est esse in veniam. Mollit anim nostrud laboris consequat voluptate qui ad ipsum sint laborum exercitation quis ipsum. Incididunt cupidatat esse ea amet deserunt consequat eu proident duis adipisicing pariatur. Amet deserunt mollit aliquip mollit consequat sunt quis labore laboris quis. Magna cillum fugiat anim velit Lorem duis. Lorem duis amet veniam occaecat est excepteur ut ea velit esse non pariatur. Do veniam quis eu consequat ad duis incididunt minim dolore sit non minim adipisicing et.','registered':'Wednesday, August 9, 2017 9:20 PM','latitude':'24.480657','longitude':'-108.693421','tags':['dolore','ad','occaecat','quis','labore'],'greeting':'Hello, Fitzgerald! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edbb6f14559d8a7b28','index':39,'guid':'9434f48b-70a0-4161-8d06-c53bf8b9df94','isActive':true,'balance':'$3,713.47','picture':'http://placehold.it/32x32','age':25,'eyeColor':'blue','name':{'first':'Mcconnell','last':'Nash'},'company':'TETAK','email':'mcconnell.nash@tetak.org','phone':'+1 (956) 477-3586','address':'853 Turnbull Avenue, Clarence, Missouri, 1599','about':'Culpa excepteur minim anim magna dolor dolore ad ex eu. In cupidatat cillum elit dolore in est minim dolore consectetur reprehenderit voluptate laborum. Deserunt id velit ad dolor mollit.','registered':'Saturday, November 10, 2018 9:27 AM','latitude':'1.691589','longitude':'143.704377','tags':['ut','deserunt','sit','cupidatat','ea'],'greeting':'Hello, Mcconnell! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed1a87ea0390733ffa','index':40,'guid':'ec8a55f7-7114-4787-b1ff-4e631731bc2c','isActive':true,'balance':'$2,200.71','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Kitty','last':'Meyers'},'company':'FIBEROX','email':'kitty.meyers@fiberox.net','phone':'+1 (864) 458-3826','address':'537 Georgia Avenue, Thermal, Illinois, 7930','about':'Non excepteur laboris Lorem magna adipisicing exercitation. Anim esse in pariatur minim ipsum qui voluptate irure. Pariatur Lorem pariatur esse commodo aute adipisicing anim commodo. Exercitation nostrud aliqua duis et amet amet tempor.','registered':'Tuesday, September 13, 2016 8:16 PM','latitude':'19.59506','longitude':'-57.814297','tags':['duis','ullamco','velit','sint','consequat'],'greeting':'Hello, Kitty! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed4dc76717bf1217b3','index':41,'guid':'40521cde-f835-4620-902b-af7abf185d8d','isActive':false,'balance':'$2,907.02','picture':'http://placehold.it/32x32','age':26,'eyeColor':'green','name':{'first':'Klein','last':'Goodwin'},'company':'PLASTO','email':'klein.goodwin@plasto.name','phone':'+1 (950) 563-3104','address':'764 Devoe Street, Lindcove, Oklahoma, 458','about':'Amet aliqua magna ea veniam non aliquip irure esse id ipsum cillum sint tempor dolor. Ullamco deserunt fugiat amet pariatur culpa nostrud commodo commodo. Ad occaecat magna adipisicing voluptate. Minim ad adipisicing cupidatat elit nostrud eu irure. Cupidatat occaecat aute magna consectetur dolore anim et. Ex voluptate velit exercitation laborum ad ullamco ad. Aliquip nulla ipsum dolore cillum qui nostrud eu adipisicing amet tempor do.','registered':'Tuesday, February 13, 2018 3:56 PM','latitude':'-27.168725','longitude':'-29.499285','tags':['minim','labore','do','deserunt','dolor'],'greeting':'Hello, Klein! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed1ac77396b29aee9e','index':42,'guid':'7cfc03e3-30e9-4ae1-a1f5-f6c3223ca770','isActive':true,'balance':'$2,986.47','picture':'http://placehold.it/32x32','age':22,'eyeColor':'brown','name':{'first':'Isabelle','last':'Bishop'},'company':'GEEKNET','email':'isabelle.bishop@geeknet.us','phone':'+1 (908) 418-2642','address':'729 Willmohr Street, Aguila, Montana, 7510','about':'In nulla commodo nostrud sint. Elit et occaecat et aliqua aliquip magna esse commodo duis Lorem dolor magna enim deserunt. Ipsum pariatur reprehenderit ipsum adipisicing mollit incididunt ut. Sunt in consequat ex ut minim non qui anim labore. Deserunt minim voluptate in nulla occaecat.','registered':'Monday, September 15, 2014 6:22 AM','latitude':'-81.686947','longitude':'38.409291','tags':['proident','est','aliqua','veniam','anim'],'greeting':'Hello, Isabelle! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edb3a070c9469a4893','index':43,'guid':'3dec76b4-0b55-4765-a2fd-b8dbd9c82f8f','isActive':true,'balance':'$2,501.24','picture':'http://placehold.it/32x32','age':31,'eyeColor':'blue','name':{'first':'Josefina','last':'Turner'},'company':'COMSTAR','email':'josefina.turner@comstar.biz','phone':'+1 (908) 566-3029','address':'606 Schenck Place, Brutus, Vermont, 8681','about':'Enim consectetur pariatur sint dolor nostrud est deserunt nulla quis pariatur sit. Ad aute incididunt nisi excepteur duis est velit voluptate ullamco occaecat magna reprehenderit aliquip. Proident deserunt consectetur non et exercitation elit dolore enim aliqua incididunt anim amet. Ex esse sint commodo minim aliqua ut irure. Proident ex culpa voluptate fugiat nisi. Sint commodo laboris excepteur minim ipsum labore tempor quis magna.','registered':'Saturday, December 31, 2016 6:38 AM','latitude':'35.275088','longitude':'24.30485','tags':['minim','ut','irure','Lorem','veniam'],'greeting':'Hello, Josefina! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed1aa7d74128ee3d0f','index':44,'guid':'10599279-c367-46c4-9f7a-744c2e4bf6c9','isActive':true,'balance':'$1,753.06','picture':'http://placehold.it/32x32','age':27,'eyeColor':'blue','name':{'first':'Lily','last':'Haynes'},'company':'KIOSK','email':'lily.haynes@kiosk.ca','phone':'+1 (872) 451-2301','address':'509 Balfour Place, Grazierville, New Hampshire, 2750','about':'Nisi aliquip occaecat nostrud do sint qui nisi officia Lorem. Ad et et laboris nisi dolore aliqua eu. Aliqua veniam quis eu pariatur incididunt mollit id deserunt officia eiusmod. Consequat adipisicing do nisi voluptate eiusmod minim pariatur minim nisi nostrud culpa cupidatat. Irure consectetur id consequat adipisicing ullamco occaecat do. Ex proident ea quis nulla incididunt sunt excepteur incididunt. Aliquip minim nostrud non anim Lorem.','registered':'Tuesday, November 20, 2018 9:28 AM','latitude':'-12.677798','longitude':'114.506787','tags':['culpa','amet','elit','officia','irure'],'greeting':'Hello, Lily! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed74c76f2e84e201ce','index':45,'guid':'ec0a68d4-629e-46c9-9af7-f6ea867f02ba','isActive':true,'balance':'$1,477.93','picture':'http://placehold.it/32x32','age':23,'eyeColor':'green','name':{'first':'Shauna','last':'Pitts'},'company':'SPACEWAX','email':'shauna.pitts@spacewax.info','phone':'+1 (841) 406-2360','address':'348 Tabor Court, Westwood, Puerto Rico, 8297','about':'Aliquip irure officia magna ea magna mollit ea non amet deserunt. Veniam mollit labore culpa magna aliqua quis consequat est consectetur ea reprehenderit nostrud consequat aliqua. Mollit do ipsum mollit eiusmod.','registered':'Thursday, October 2, 2014 2:48 AM','latitude':'-55.17388','longitude':'-13.370494','tags':['anim','consectetur','cillum','veniam','duis'],'greeting':'Hello, Shauna! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed419e718484b16722','index':46,'guid':'b2d6101d-5646-43f4-8207-284494e5a990','isActive':false,'balance':'$2,006.96','picture':'http://placehold.it/32x32','age':27,'eyeColor':'brown','name':{'first':'Lawrence','last':'Boyer'},'company':'SKYPLEX','email':'lawrence.boyer@skyplex.tv','phone':'+1 (953) 548-2618','address':'464 Pilling Street, Blandburg, Arizona, 5531','about':'Culpa sit minim pariatur mollit cupidatat sunt duis. Nisi ea proident veniam exercitation adipisicing Lorem aliquip amet dolor voluptate in nisi. Non commodo anim sunt est fugiat laborum nisi aliqua non Lorem exercitation dolor. Laboris dolore do minim ut eiusmod enim magna cillum laborum consectetur aliquip minim enim Lorem. Veniam ex veniam occaecat aliquip elit aliquip est eiusmod minim minim adipisicing.','registered':'Wednesday, July 30, 2014 2:17 AM','latitude':'-78.681255','longitude':'139.960626','tags':['consequat','Lorem','incididunt','dolor','esse'],'greeting':'Hello, Lawrence! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed08a9024998292c70','index':47,'guid':'277de142-ebeb-4828-906a-7fd8bc0a738a','isActive':true,'balance':'$1,273.19','picture':'http://placehold.it/32x32','age':27,'eyeColor':'brown','name':{'first':'Sonya','last':'Stafford'},'company':'AQUACINE','email':'sonya.stafford@aquacine.co.uk','phone':'+1 (824) 581-3927','address':'641 Bowery Street, Hillsboro, Delaware, 7893','about':'Culpa labore ex reprehenderit mollit cupidatat dolore et ut quis in. Sint esse culpa enim culpa tempor exercitation veniam minim consectetur. Sunt est laboris minim quis incididunt exercitation laboris cupidatat fugiat ad. Deserunt ipsum do dolor cillum excepteur incididunt.','registered':'Thursday, March 26, 2015 1:10 PM','latitude':'-84.750592','longitude':'165.493533','tags':['minim','officia','dolore','ipsum','est'],'greeting':'Hello, Sonya! You have 8 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd5037f2c79ecde68','index':48,'guid':'2dc6532f-9a26-49aa-b444-8923896db89c','isActive':false,'balance':'$3,168.93','picture':'http://placehold.it/32x32','age':36,'eyeColor':'brown','name':{'first':'Marguerite','last':'Stuart'},'company':'ACCUFARM','email':'marguerite.stuart@accufarm.io','phone':'+1 (848) 535-2253','address':'301 Menahan Street, Sunnyside, Nebraska, 4809','about':'Deserunt sint labore voluptate amet anim culpa nostrud adipisicing enim cupidatat ullamco exercitation fugiat est. Magna dolor aute incididunt ea ad adipisicing. Do cupidatat ut officia officia culpa sit do.','registered':'Thursday, May 8, 2014 1:25 PM','latitude':'21.82277','longitude':'-7.368347','tags':['labore','nulla','ullamco','irure','adipisicing'],'greeting':'Hello, Marguerite! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edb26d315635818dae','index':49,'guid':'083a5eda-0a70-4f89-87f7-2cd386c0f22a','isActive':false,'balance':'$2,576.25','picture':'http://placehold.it/32x32','age':38,'eyeColor':'blue','name':{'first':'Louella','last':'Holloway'},'company':'BEDDER','email':'louella.holloway@bedder.me','phone':'+1 (801) 425-3761','address':'545 Lafayette Avenue, Caledonia, Louisiana, 2816','about':'Qui exercitation occaecat dolore mollit. Fugiat cupidatat proident culpa fugiat quis. In cupidatat commodo elit ea enim occaecat esse exercitation nostrud occaecat veniam laboris fugiat. Nisi sunt reprehenderit aliqua reprehenderit tempor id dolore ullamco pariatur reprehenderit et eu ex pariatur.','registered':'Wednesday, November 5, 2014 1:10 AM','latitude':'36.385637','longitude':'77.949423','tags':['eu','irure','velit','non','aliquip'],'greeting':'Hello, Louella! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed77cd60a1abc1ecce','index':50,'guid':'2887c3c1-3eba-4237-a0db-1977eed94554','isActive':true,'balance':'$1,633.51','picture':'http://placehold.it/32x32','age':22,'eyeColor':'green','name':{'first':'Bates','last':'Carrillo'},'company':'ZOMBOID','email':'bates.carrillo@zomboid.com','phone':'+1 (934) 405-2006','address':'330 Howard Alley, Troy, Kansas, 4881','about':'Voluptate esse est ullamco anim tempor ea reprehenderit. Occaecat pariatur deserunt cillum laboris labore id exercitation esse ipsum ipsum ex aliquip. Sunt non elit est ea occaecat. Magna deserunt commodo aliqua ipsum est cillum dolor nisi. Ex duis est tempor tempor laboris do do quis id magna. Dolor do est elit eu laborum ullamco culpa consequat velit eiusmod tempor.','registered':'Saturday, May 28, 2016 3:56 AM','latitude':'83.310134','longitude':'-105.862836','tags':['est','commodo','ea','commodo','sunt'],'greeting':'Hello, Bates! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed5ec0ec299b471fb5','index':51,'guid':'512b5e67-f785-492e-9d94-e43ef8b399b8','isActive':false,'balance':'$3,032.22','picture':'http://placehold.it/32x32','age':30,'eyeColor':'blue','name':{'first':'Floyd','last':'Yang'},'company':'FRENEX','email':'floyd.yang@frenex.org','phone':'+1 (924) 566-3304','address':'418 Quay Street, Chumuckla, Guam, 7743','about':'Irure sit velit exercitation dolore est nisi incididunt ut quis consectetur incididunt est dolor. Aute nisi enim esse aliquip enim culpa commodo consectetur. Duis laborum magna ad duis ipsum aliqua eiusmod cillum. Consectetur et duis eiusmod irure ad est nisi incididunt eiusmod labore. Pariatur proident in Lorem adipisicing mollit proident excepteur nulla do nostrud mollit eiusmod. Duis ad dolore irure fugiat anim laboris ipsum et sit duis ipsum voluptate. Lorem non aute exercitation qui ullamco officia minim sint pariatur ut dolor.','registered':'Wednesday, January 18, 2017 2:01 AM','latitude':'45.888721','longitude':'-41.232793','tags':['elit','in','esse','ea','officia'],'greeting':'Hello, Floyd! You have 5 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed51e26ca89e5caf49','index':52,'guid':'4e0907f6-facc-46df-8952-73561a53fe33','isActive':true,'balance':'$3,767.41','picture':'http://placehold.it/32x32','age':25,'eyeColor':'blue','name':{'first':'Gardner','last':'Carey'},'company':'KLUGGER','email':'gardner.carey@klugger.net','phone':'+1 (876) 481-3502','address':'131 Utica Avenue, Cannondale, Federated States Of Micronesia, 610','about':'Amet ad pariatur excepteur anim ex officia commodo proident aliqua occaecat consequat Lorem officia sit. Id minim velit nisi laboris nisi nulla incididunt eiusmod velit. Deserunt labore quis et tempor. Et labore exercitation laborum officia ullamco nostrud adipisicing laboris esse laborum aute anim elit. Sunt ad officia tempor esse et quis aliquip irure pariatur laborum id quis ex. Eu consequat nisi deserunt id eu proident ex minim aute nulla tempor ex.','registered':'Friday, February 21, 2014 6:42 AM','latitude':'-54.740231','longitude':'15.01484','tags':['commodo','laboris','occaecat','aliquip','adipisicing'],'greeting':'Hello, Gardner! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed52e3c9407105093a','index':53,'guid':'1d3b9e7a-1bc3-40ea-b808-1c33f0d48c70','isActive':true,'balance':'$1,113.30','picture':'http://placehold.it/32x32','age':26,'eyeColor':'blue','name':{'first':'Herman','last':'Rogers'},'company':'TALENDULA','email':'herman.rogers@talendula.name','phone':'+1 (818) 521-2005','address':'541 Norman Avenue, Winfred, Tennessee, 447','about':'Culpa ex laborum non ad ullamco officia. Nisi mollit mollit voluptate sit sint ullamco. Lorem exercitation nulla anim eiusmod deserunt magna sint. Officia sunt eiusmod aliqua reprehenderit sunt mollit sit cupidatat sint.','registered':'Wednesday, July 11, 2018 1:05 AM','latitude':'-20.708105','longitude':'-151.294563','tags':['exercitation','minim','officia','qui','enim'],'greeting':'Hello, Herman! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edfcb123d545b6edb4','index':54,'guid':'c0e0c669-4eed-43ee-bdd0-78fe6e9ca4d5','isActive':true,'balance':'$3,309.64','picture':'http://placehold.it/32x32','age':22,'eyeColor':'green','name':{'first':'Whitley','last':'Stark'},'company':'MUSAPHICS','email':'whitley.stark@musaphics.us','phone':'+1 (803) 476-2151','address':'548 Cobek Court, Chamizal, Indiana, 204','about':'Adipisicing veniam dolor ex sint sit id eu voluptate. Excepteur veniam proident exercitation id eu et sunt pariatur. Qui occaecat culpa aliqua nisi excepteur minim veniam. Est duis nulla laborum excepteur cillum pariatur sint incididunt. Velit commodo eu incididunt voluptate. Amet laboris laboris id adipisicing labore eiusmod consequat minim cillum et.','registered':'Thursday, March 27, 2014 9:10 AM','latitude':'71.219596','longitude':'51.012855','tags':['reprehenderit','mollit','laborum','voluptate','aliquip'],'greeting':'Hello, Whitley! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed81510dfc61602fcf','index':55,'guid':'7ec5c24d-f169-4399-a2a3-300c0f45e52e','isActive':false,'balance':'$3,721.04','picture':'http://placehold.it/32x32','age':23,'eyeColor':'green','name':{'first':'Gretchen','last':'Wade'},'company':'EWEVILLE','email':'gretchen.wade@eweville.biz','phone':'+1 (977) 598-3700','address':'721 Colonial Road, Brookfield, South Dakota, 3888','about':'Fugiat consequat sint ut ut et ullamco eiusmod deserunt pariatur. Veniam eiusmod esse fugiat mollit. Proident laboris minim qui do ipsum excepteur exercitation irure anim. Aliqua labore quis eu fugiat dolore ullamco velit Lorem voluptate ipsum nostrud eiusmod laborum proident.','registered':'Friday, October 12, 2018 10:59 AM','latitude':'41.937653','longitude':'63.378531','tags':['aute','cillum','ea','ex','aute'],'greeting':'Hello, Gretchen! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edf78f77d4a7d557bb','index':56,'guid':'8718ada7-6fd0-49ef-a405-29850503948b','isActive':false,'balance':'$3,341.33','picture':'http://placehold.it/32x32','age':32,'eyeColor':'blue','name':{'first':'Naomi','last':'Frye'},'company':'MAZUDA','email':'naomi.frye@mazuda.ca','phone':'+1 (825) 427-2255','address':'741 Coyle Street, Comptche, Pennsylvania, 8441','about':'Aliqua fugiat laborum quis ullamco cupidatat sit dolor nulla dolore. Do Lorem et ipsum culpa irure sit do dolor qui sit laboris aliqua. Ex consectetur irure in veniam reprehenderit amet do elit eiusmod est magna.','registered':'Thursday, January 9, 2014 7:18 AM','latitude':'41.078645','longitude':'-50.241966','tags':['do','aliquip','eiusmod','velit','id'],'greeting':'Hello, Naomi! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edbf45db2e072a48b4','index':57,'guid':'c158ebf7-fb8b-4ea8-adbf-8c51c6486715','isActive':true,'balance':'$2,811.55','picture':'http://placehold.it/32x32','age':25,'eyeColor':'blue','name':{'first':'Lamb','last':'Johns'},'company':'DOGTOWN','email':'lamb.johns@dogtown.info','phone':'+1 (946) 530-3057','address':'559 Malbone Street, Kennedyville, California, 2052','about':'Eiusmod dolor labore cillum ad veniam elit voluptate voluptate pariatur est cupidatat. Laboris ut qui in cillum sunt dolore ut enim. Minim nostrud ex qui quis reprehenderit magna ipsum cupidatat irure minim laboris veniam irure. Fugiat velit deserunt aliquip in esse proident excepteur labore reprehenderit excepteur sunt in cupidatat exercitation. Ex pariatur irure mollit tempor non magna ex.','registered':'Friday, April 21, 2017 1:51 AM','latitude':'-61.403599','longitude':'-93.447102','tags':['aliquip','tempor','sint','enim','ipsum'],'greeting':'Hello, Lamb! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edbb9c88190cb59cf2','index':58,'guid':'f0de5ac5-eb28-491b-81c5-76d447c9055e','isActive':true,'balance':'$1,611.99','picture':'http://placehold.it/32x32','age':37,'eyeColor':'brown','name':{'first':'Lynette','last':'Cleveland'},'company':'ARTWORLDS','email':'lynette.cleveland@artworlds.tv','phone':'+1 (889) 596-3723','address':'439 Montauk Avenue, Felt, New Mexico, 9681','about':'Incididunt aliquip est aliquip est ullamco do consectetur dolor. Lorem mollit mollit dolor et ipsum ut qui veniam aute ea. Adipisicing reprehenderit culpa velit laborum adipisicing amet consectetur velit nisi. Ut qui proident ad cillum excepteur adipisicing quis labore. Duis velit culpa et excepteur eiusmod ex labore in nisi nostrud. Et ullamco minim excepteur ut enim reprehenderit consequat eiusmod laboris Lorem commodo exercitation qui laborum.','registered':'Wednesday, August 26, 2015 12:53 PM','latitude':'49.861336','longitude':'86.865926','tags':['reprehenderit','minim','in','minim','nostrud'],'greeting':'Hello, Lynette! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed5b760ddde7295fa8','index':59,'guid':'f8180d3f-c5c0-48b2-966e-a0b2a80f8e84','isActive':true,'balance':'$3,376.75','picture':'http://placehold.it/32x32','age':32,'eyeColor':'green','name':{'first':'Obrien','last':'Page'},'company':'GLASSTEP','email':'obrien.page@glasstep.co.uk','phone':'+1 (902) 583-3086','address':'183 Ridgewood Avenue, Vicksburg, Wisconsin, 7430','about':'Aute excepteur cillum exercitation duis Lorem irure labore elit. Labore magna cupidatat velit consectetur minim do Lorem in excepteur commodo ea consequat ullamco laborum. Ut in id occaecat eu quis duis id ea deserunt veniam.','registered':'Wednesday, March 29, 2017 12:13 AM','latitude':'-40.156154','longitude':'72.76301','tags':['excepteur','non','anim','nulla','anim'],'greeting':'Hello, Obrien! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed52985d3d8901d653','index':60,'guid':'d2e14fa1-8c54-4bcb-8a58-eb2e6f8d0e45','isActive':true,'balance':'$1,659.47','picture':'http://placehold.it/32x32','age':33,'eyeColor':'brown','name':{'first':'Knowles','last':'Goodman'},'company':'CENTREE','email':'knowles.goodman@centree.io','phone':'+1 (862) 563-3692','address':'504 Lott Street, Allensworth, Florida, 7148','about':'Do aliquip voluptate aliqua nostrud. Eu dolore ex occaecat pariatur aute laborum aute nulla aute amet. Excepteur sit laboris ad non anim ut officia ut ad exercitation officia dolore laboris. Esse voluptate minim deserunt nostrud exercitation laborum voluptate exercitation id laborum fugiat proident cupidatat proident. Nulla nostrud est sint adipisicing incididunt exercitation dolor sit et elit tempor occaecat sint culpa. Pariatur occaecat laboris pariatur laboris ad pariatur in cillum fugiat est fugiat. Proident eu id irure excepteur esse aute cillum adipisicing.','registered':'Wednesday, October 15, 2014 6:17 PM','latitude':'-15.73863','longitude':'87.422009','tags':['consequat','sint','tempor','veniam','culpa'],'greeting':'Hello, Knowles! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0eda00b73bdb7ea54e9','index':61,'guid':'c8a064db-0ec6-4832-9820-7280a0333709','isActive':true,'balance':'$3,701.14','picture':'http://placehold.it/32x32','age':35,'eyeColor':'brown','name':{'first':'Shepherd','last':'Todd'},'company':'ECRATIC','email':'shepherd.todd@ecratic.me','phone':'+1 (881) 444-3389','address':'450 Frank Court, Temperanceville, Ohio, 7006','about':'Voluptate cillum ad fugiat velit adipisicing sint consequat veniam Lorem reprehenderit. Cillum sit non deserunt consequat. Amet sunt pariatur non mollit ullamco proident sint dolore anim elit cupidatat anim do ullamco. Lorem Lorem incididunt ea elit consequat laboris enim duis quis Lorem id aute veniam consequat. Cillum veniam cillum sint qui Lorem fugiat culpa consequat. Est sint duis ut qui fugiat. Laborum pariatur velit et sunt mollit eiusmod excepteur culpa ex et officia.','registered':'Tuesday, October 10, 2017 2:01 AM','latitude':'82.951563','longitude':'-4.866954','tags':['eu','qui','proident','esse','ex'],'greeting':'Hello, Shepherd! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed0e51d1a7e2d9e559','index':62,'guid':'739c3d38-200d-4531-84d8-4e7c39ae5b8c','isActive':true,'balance':'$3,679.01','picture':'http://placehold.it/32x32','age':31,'eyeColor':'brown','name':{'first':'Rosalyn','last':'Heath'},'company':'ZAYA','email':'rosalyn.heath@zaya.com','phone':'+1 (865) 403-3520','address':'303 Henderson Walk, Hoehne, District Of Columbia, 4306','about':'Sint occaecat nulla mollit sint fugiat eu proident dolor labore consequat. Occaecat tempor excepteur do fugiat incididunt Lorem in ullamco dolore laborum. Cillum mollit aliquip excepteur aliquip sint sunt minim non irure irure. Cillum fugiat aliqua enim dolore. Nulla culpa culpa nostrud ad. Eiusmod culpa proident proident non est cupidatat eu sunt sit incididunt id nisi.','registered':'Wednesday, April 22, 2015 12:35 PM','latitude':'33.628504','longitude':'110.772802','tags':['consequat','ut','ex','labore','consectetur'],'greeting':'Hello, Rosalyn! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd5274c01d353d0c5','index':63,'guid':'8815fe55-8af1-4708-a62a-d554dbd74a4a','isActive':true,'balance':'$2,126.01','picture':'http://placehold.it/32x32','age':30,'eyeColor':'blue','name':{'first':'Queen','last':'Harper'},'company':'TRI@TRIBALOG','email':'queen.harper@tri@tribalog.org','phone':'+1 (903) 592-3145','address':'926 Heath Place, Wawona, Maine, 7340','about':'Laborum cupidatat commodo aliquip reprehenderit. Excepteur eu labore duis minim minim voluptate aute nostrud deserunt ut velit ullamco. Adipisicing nisi occaecat laborum proident. Id reprehenderit eiusmod cupidatat qui aute consequat amet enim commodo duis non ipsum. Amet ut aliqua magna qui proident mollit aute.','registered':'Saturday, April 9, 2016 5:12 AM','latitude':'51.814216','longitude':'177.348115','tags':['cillum','ut','dolor','do','nisi'],'greeting':'Hello, Queen! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed126298b6ce62ed56','index':64,'guid':'001c87fe-182f-450f-903b-2e29a9bb0322','isActive':true,'balance':'$3,578.29','picture':'http://placehold.it/32x32','age':20,'eyeColor':'green','name':{'first':'Pauline','last':'Mills'},'company':'CRUSTATIA','email':'pauline.mills@crustatia.net','phone':'+1 (984) 582-3899','address':'899 Revere Place, Welch, Iowa, 216','about':'Tempor eu exercitation ut id. Deserunt ex reprehenderit veniam nisi. Aute laborum veniam velit dolore ut deserunt Lorem sit esse quis dolor ex do nisi. In dolor tempor officia id. Velit nisi culpa nostrud laborum officia incididunt laborum velit non quis id exercitation exercitation. Anim elit ullamco in enim Lorem culpa aliqua Lorem.','registered':'Monday, June 2, 2014 2:03 PM','latitude':'56.427576','longitude':'172.183669','tags':['pariatur','pariatur','pariatur','fugiat','Lorem'],'greeting':'Hello, Pauline! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed3e332ad9e8a178d8','index':65,'guid':'5ad7292b-feef-4a7e-b485-142cadfbe8ea','isActive':false,'balance':'$3,916.54','picture':'http://placehold.it/32x32','age':22,'eyeColor':'brown','name':{'first':'Garrett','last':'Richmond'},'company':'XYQAG','email':'garrett.richmond@xyqag.name','phone':'+1 (952) 584-3794','address':'233 Grove Street, Summerfield, Virginia, 4735','about':'Nostrud quis pariatur occaecat laborum laboris aliqua ut fugiat dolor. Commodo tempor excepteur enim nostrud Lorem. Aute elit nulla labore ad pariatur cupidatat Lorem qui cupidatat velit deserunt excepteur esse. Excepteur nulla et nostrud quis labore est veniam enim nisi laboris ut enim. Ea esse nulla anim excepteur reprehenderit deserunt voluptate minim qui labore adipisicing amet eu enim.','registered':'Wednesday, March 5, 2014 4:35 PM','latitude':'68.665041','longitude':'148.799524','tags':['irure','reprehenderit','minim','ea','do'],'greeting':'Hello, Garrett! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed541aa2ec47466ace','index':66,'guid':'9cda6f3c-c9ab-451c-bb19-2e4c8463d011','isActive':true,'balance':'$3,352.52','picture':'http://placehold.it/32x32','age':30,'eyeColor':'brown','name':{'first':'Cobb','last':'Whitley'},'company':'UNIA','email':'cobb.whitley@unia.us','phone':'+1 (888) 490-3342','address':'864 Belmont Avenue, Needmore, Massachusetts, 8286','about':'Nisi aliquip fugiat ipsum nisi ullamco minim pariatur labore. Sint labore anim do ad ad esse eu nostrud nulla commodo anim. Cillum anim enim duis cillum non do nisi aliquip veniam voluptate commodo aliqua laborum. Exercitation in do eu qui sint aliquip. Esse adipisicing deserunt deserunt qui anim aliqua occaecat et nostrud elit ea in anim cillum. Tempor mollit proident tempor sunt est sint laborum ullamco incididunt non. Velit aliqua sunt excepteur nisi qui eiusmod ipsum dolore aliquip velit ullamco ullamco.','registered':'Friday, May 23, 2014 7:11 PM','latitude':'-32.950581','longitude':'147.772494','tags':['mollit','adipisicing','irure','ad','minim'],'greeting':'Hello, Cobb! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed8186c3d6f34c2be3','index':67,'guid':'fee98f6d-d68a-4189-8180-b6cb337e537e','isActive':false,'balance':'$1,698.42','picture':'http://placehold.it/32x32','age':20,'eyeColor':'blue','name':{'first':'Brennan','last':'Tyler'},'company':'PODUNK','email':'brennan.tyler@podunk.biz','phone':'+1 (867) 498-2727','address':'599 Harkness Avenue, Gorst, American Samoa, 322','about':'Reprehenderit id sit qui id qui aute ea sit magna in qui proident. Excepteur ad nostrud do nostrud in incididunt voluptate adipisicing sint anim. Ullamco consequat minim nulla irure ex est irure reprehenderit deserunt voluptate dolore anim sunt. Occaecat dolore voluptate voluptate elit commodo nulla laborum ad do irure.','registered':'Friday, February 9, 2018 5:40 PM','latitude':'11.150893','longitude':'-85.298004','tags':['quis','minim','deserunt','cillum','laboris'],'greeting':'Hello, Brennan! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed075c9c4f7439818d','index':68,'guid':'1ef76b18-6b8d-4c3c-aca3-9fa2b43f0242','isActive':false,'balance':'$2,091.17','picture':'http://placehold.it/32x32','age':26,'eyeColor':'brown','name':{'first':'Neal','last':'Stephenson'},'company':'OTHERSIDE','email':'neal.stephenson@otherside.ca','phone':'+1 (820) 496-3344','address':'867 Wilson Street, Kidder, Colorado, 4599','about':'Do laboris enim proident in qui velit adipisicing magna anim. Amet proident non exercitation ipsum aliqua excepteur nostrud. Enim esse non sit in nostrud deserunt id laborum cillum deserunt consequat. Anim velit exercitation qui sit voluptate. Irure duis non veniam velit mollit exercitation id exercitation.','registered':'Thursday, November 13, 2014 11:00 PM','latitude':'54.809693','longitude':'1.877241','tags':['anim','duis','in','officia','sint'],'greeting':'Hello, Neal! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0eda0a2dc24db64b638','index':69,'guid':'194744fd-089b-40b6-a290-98a6ec30a415','isActive':false,'balance':'$3,191.67','picture':'http://placehold.it/32x32','age':24,'eyeColor':'brown','name':{'first':'Shields','last':'Hubbard'},'company':'MIRACULA','email':'shields.hubbard@miracula.info','phone':'+1 (885) 582-2001','address':'529 Eagle Street, Guilford, Nevada, 1460','about':'Eiusmod exercitation ut incididunt veniam commodo culpa ullamco mollit id adipisicing exercitation ad sint. Nostrud excepteur amet aliqua mollit incididunt laborum voluptate id anim. Nulla sint laboris dolor esse cupidatat laborum ex sint. Ex non sunt sit nulla.','registered':'Monday, February 13, 2017 6:22 AM','latitude':'-69.145209','longitude':'-40.69755','tags':['tempor','enim','qui','velit','elit'],'greeting':'Hello, Shields! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edf939c130177e074d','index':70,'guid':'303b176c-7803-4ed2-a35f-3e3c831793ef','isActive':false,'balance':'$2,359.09','picture':'http://placehold.it/32x32','age':31,'eyeColor':'blue','name':{'first':'Coleen','last':'Knight'},'company':'BLEEKO','email':'coleen.knight@bleeko.tv','phone':'+1 (867) 423-3146','address':'527 Broadway , Bonanza, Marshall Islands, 4988','about':'Laboris nulla pariatur laborum ad aute excepteur sunt pariatur exercitation. Do nostrud qui ipsum ullamco et sint do Lorem cillum ullamco do. Exercitation labore excepteur commodo incididunt eiusmod proident consectetur adipisicing nostrud aute voluptate laboris. Commodo anim proident eiusmod pariatur est ea laborum incididunt qui tempor reprehenderit ullamco id. Eiusmod commodo nisi consectetur ut qui quis aliqua sit minim nostrud sunt laborum eiusmod adipisicing.','registered':'Sunday, May 6, 2018 8:03 AM','latitude':'70.729041','longitude':'113.052761','tags':['Lorem','ullamco','nulla','ullamco','commodo'],'greeting':'Hello, Coleen! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edae8b1ce688b61223','index':71,'guid':'7d6f3b1a-c367-4068-9e8e-1717d513ece3','isActive':false,'balance':'$2,911.07','picture':'http://placehold.it/32x32','age':21,'eyeColor':'brown','name':{'first':'Clark','last':'Ryan'},'company':'ECLIPSENT','email':'clark.ryan@eclipsent.co.uk','phone':'+1 (938) 562-2740','address':'500 Lewis Avenue, Rockbridge, North Dakota, 5133','about':'Adipisicing exercitation officia sit excepteur excepteur sunt sint amet. Aliqua ipsum sint laboris eiusmod esse culpa elit sunt. Dolore est consectetur est quis quis magna. Aliquip nostrud dolore ex pariatur. Anim nostrud duis exercitation ut magna magna culpa. Nisi irure id mollit labore non sit mollit occaecat Lorem est ipsum. Nulla est fugiat cillum nisi aliqua consectetur amet nulla nostrud esse.','registered':'Friday, July 24, 2015 9:28 AM','latitude':'-68.055815','longitude':'-50.926966','tags':['deserunt','ad','ad','ut','id'],'greeting':'Hello, Clark! You have 7 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed5d1e8df45d8ab4db','index':72,'guid':'ce85db37-7d04-4f4c-a4b0-78003533e5c6','isActive':false,'balance':'$1,127.43','picture':'http://placehold.it/32x32','age':21,'eyeColor':'green','name':{'first':'Dillon','last':'Hooper'},'company':'MEDESIGN','email':'dillon.hooper@medesign.io','phone':'+1 (929) 600-3797','address':'652 Mill Avenue, Elliston, Mississippi, 2958','about':'Dolore culpa qui exercitation nostrud do. Irure duis in ad ipsum aliqua aliquip nulla sit veniam officia quis occaecat est. Magna qui eiusmod pariatur aliquip minim commodo. Qui ex dolor excepteur consequat eiusmod occaecat. In officia ipsum do Lorem excepteur proident pariatur labore.','registered':'Monday, May 26, 2014 2:38 AM','latitude':'-36.032189','longitude':'86.865529','tags':['non','ut','ex','Lorem','quis'],'greeting':'Hello, Dillon! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edb84814579c3121b3','index':73,'guid':'d7303901-5186-4595-a759-22306f67d0a3','isActive':true,'balance':'$2,326.59','picture':'http://placehold.it/32x32','age':33,'eyeColor':'green','name':{'first':'Moreno','last':'Hull'},'company':'ZEAM','email':'moreno.hull@zeam.me','phone':'+1 (984) 586-3738','address':'265 Pine Street, Talpa, North Carolina, 6041','about':'Fugiat exercitation est ullamco anim. Exercitation proident id sunt culpa Lorem amet. Consectetur anim consectetur pariatur consequat consectetur amet excepteur voluptate ea velit duis eiusmod proident. In sint laborum cupidatat ea amet ex. Reprehenderit amet sunt dolor ullamco est ex deserunt.','registered':'Wednesday, January 24, 2018 8:52 PM','latitude':'84.956857','longitude':'113.210051','tags':['est','excepteur','anim','Lorem','dolor'],'greeting':'Hello, Moreno! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eda4eb9dcb92c82d06','index':74,'guid':'8ee28651-802e-4523-b676-c713f6e874b8','isActive':true,'balance':'$3,783.97','picture':'http://placehold.it/32x32','age':38,'eyeColor':'blue','name':{'first':'Tracie','last':'Price'},'company':'ICOLOGY','email':'tracie.price@icology.com','phone':'+1 (897) 403-3768','address':'487 Sheffield Avenue, Vallonia, Wyoming, 276','about':'Voluptate laboris laborum aute ex sint voluptate officia proident. Sit esse nostrud cupidatat in veniam sit duis est. Do mollit elit exercitation aliqua id irure ex. Lorem reprehenderit do ullamco sint ea ad nisi ad ut.','registered':'Saturday, December 10, 2016 9:44 AM','latitude':'77.770464','longitude':'151.392903','tags':['incididunt','labore','aliquip','anim','minim'],'greeting':'Hello, Tracie! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed68ab1a55d1c35e6c','index':75,'guid':'deedd26a-8928-4064-9666-5c59ea8144b4','isActive':true,'balance':'$2,848.08','picture':'http://placehold.it/32x32','age':32,'eyeColor':'brown','name':{'first':'Montgomery','last':'Bruce'},'company':'CYTREK','email':'montgomery.bruce@cytrek.org','phone':'+1 (824) 414-2731','address':'397 Beach Place, Ellerslie, South Carolina, 967','about':'Mollit minim excepteur magna velit cillum excepteur exercitation anim id labore deserunt do. Fugiat ex et id ad. Duis excepteur laboris est nulla do id irure quis eiusmod do esse ut culpa in.','registered':'Tuesday, August 25, 2015 6:42 AM','latitude':'79.722631','longitude':'-7.516885','tags':['Lorem','sint','voluptate','proident','incididunt'],'greeting':'Hello, Montgomery! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd90e0abb1cc2b0aa','index':76,'guid':'a072159d-12db-4747-9c2a-e2486a53d043','isActive':false,'balance':'$2,723.54','picture':'http://placehold.it/32x32','age':40,'eyeColor':'green','name':{'first':'Zelma','last':'Salinas'},'company':'IMAGEFLOW','email':'zelma.salinas@imageflow.net','phone':'+1 (964) 555-3856','address':'584 Reeve Place, Nord, Georgia, 7473','about':'Aliqua proident excepteur duis cupidatat cillum amet esse esse consectetur ea. Officia sunt consequat nostrud minim enim dolore dolor duis cillum. Esse labore veniam sint laborum excepteur sint tempor do ad cupidatat aliquip laboris elit id. Velit reprehenderit ullamco velit ullamco adipisicing velit esse irure velit et.','registered':'Thursday, February 25, 2016 8:18 PM','latitude':'-32.880524','longitude':'115.180489','tags':['id','nulla','reprehenderit','consequat','reprehenderit'],'greeting':'Hello, Zelma! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed98d836c8da283bb2','index':77,'guid':'838bebad-cc20-44e9-9eb7-902a8ca25efb','isActive':false,'balance':'$3,488.91','picture':'http://placehold.it/32x32','age':20,'eyeColor':'green','name':{'first':'Shaw','last':'Parsons'},'company':'PEARLESEX','email':'shaw.parsons@pearlesex.name','phone':'+1 (912) 567-3580','address':'606 Ocean Avenue, Tyro, Northern Mariana Islands, 3367','about':'Laborum labore occaecat culpa pariatur nisi non adipisicing esse consectetur officia officia. Deserunt velit eu enim consectetur ut cillum aliqua occaecat dolor qui esse. Incididunt ad est ex eu culpa anim aliquip laborum. Aliqua consectetur velit exercitation magna minim nulla do ut excepteur enim aliquip et. Nostrud enim sunt amet amet proident aliqua velit dolore. Consectetur ipsum fugiat proident id est reprehenderit tempor irure commodo. Sit excepteur fugiat occaecat nulla Lorem et cillum.','registered':'Thursday, April 19, 2018 1:41 AM','latitude':'69.715573','longitude':'-118.481237','tags':['laboris','adipisicing','magna','voluptate','id'],'greeting':'Hello, Shaw! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed1101734633c6ebba','index':78,'guid':'8fd0c52a-9d74-4984-a608-d612ecd8ddf0','isActive':true,'balance':'$3,820.02','picture':'http://placehold.it/32x32','age':39,'eyeColor':'brown','name':{'first':'Jaime','last':'Beard'},'company':'IZZBY','email':'jaime.beard@izzby.us','phone':'+1 (820) 412-3806','address':'362 Hudson Avenue, Delco, New Jersey, 5684','about':'Ut cupidatat veniam nulla magna commodo sit duis veniam consectetur cupidatat elit quis tempor. Duis officia ullamco proident sunt non mollit excepteur. Nisi ex amet laboris proident duis reprehenderit et est aliqua mollit amet ad. Enim eu elit excepteur eu exercitation duis consequat culpa. Adipisicing reprehenderit duis Lorem reprehenderit dolor aliqua incididunt eiusmod consequat ad occaecat fugiat do laborum. Qui ad aliquip ex do sunt. Fugiat non ut fugiat eu.','registered':'Sunday, March 9, 2014 3:41 PM','latitude':'17.926318','longitude':'108.985996','tags':['ut','voluptate','veniam','non','commodo'],'greeting':'Hello, Jaime! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edcd125a89dcf18e0d','index':79,'guid':'eccaa4ca-0fa7-4b00-a1e3-fe7953403894','isActive':true,'balance':'$1,521.33','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Terra','last':'Sullivan'},'company':'ZANITY','email':'terra.sullivan@zanity.biz','phone':'+1 (995) 498-2714','address':'346 Congress Street, Tuttle, Maryland, 3152','about':'Incididunt enim veniam ut veniam quis dolore pariatur culpa ex. Cillum laboris dolor exercitation officia. Officia irure magna aliqua veniam officia ullamco culpa. Cillum enim velit ea sint sint officia labore ea adipisicing culpa laboris. Anim aute sint commodo culpa ex quis minim ut laborum.','registered':'Sunday, June 1, 2014 5:38 AM','latitude':'-4.655435','longitude':'5.851803','tags':['anim','non','anim','laborum','pariatur'],'greeting':'Hello, Terra! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed9b9fc3041a674c87','index':80,'guid':'9f95fa36-4e45-4c3f-9362-3d4d809bf57f','isActive':true,'balance':'$3,403.16','picture':'http://placehold.it/32x32','age':39,'eyeColor':'brown','name':{'first':'Sharpe','last':'Berger'},'company':'ZILLAN','email':'sharpe.berger@zillan.ca','phone':'+1 (913) 498-3005','address':'277 Bragg Street, Faywood, Texas, 6487','about':'Dolor duis id aute ea veniam amet ullamco id. Culpa deserunt irure mollit tempor dolore veniam culpa officia culpa laborum eiusmod. Ullamco tempor qui aliqua cupidatat veniam cillum eu ut ex minim eu in. Quis exercitation anim eiusmod tempor esse mollit exercitation cillum ipsum reprehenderit. Sint voluptate ipsum officia sint magna nulla tempor eiusmod eiusmod veniam. Consectetur non ad veniam exercitation voluptate non nostrud.','registered':'Tuesday, June 27, 2017 12:58 AM','latitude':'-0.54085','longitude':'106.258693','tags':['proident','eiusmod','commodo','excepteur','pariatur'],'greeting':'Hello, Sharpe! You have 5 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed1a1866757bf675e0','index':81,'guid':'1b944a01-01d3-4846-94e3-630f4d0e51a3','isActive':true,'balance':'$2,038.61','picture':'http://placehold.it/32x32','age':28,'eyeColor':'brown','name':{'first':'Blanchard','last':'Ewing'},'company':'CONJURICA','email':'blanchard.ewing@conjurica.info','phone':'+1 (859) 593-3212','address':'252 Beaver Street, Kiskimere, Utah, 3255','about':'Labore magna aute adipisicing ut dolor sit ea. Officia culpa aute occaecat sit ex ullamco aliquip ad sit culpa. Ex in enim dolore ex est sit. Do irure nulla magna sint aliquip in duis aute. Magna ullamco sit labore ea tempor voluptate.','registered':'Monday, May 4, 2015 10:50 AM','latitude':'76.207595','longitude':'0.672563','tags':['proident','pariatur','officia','in','culpa'],'greeting':'Hello, Blanchard! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed987d82f4e22d939c','index':82,'guid':'97a90aee-3cee-4678-819e-24fb94279dc1','isActive':false,'balance':'$1,201.55','picture':'http://placehold.it/32x32','age':28,'eyeColor':'blue','name':{'first':'Wells','last':'Solomon'},'company':'CORPULSE','email':'wells.solomon@corpulse.tv','phone':'+1 (840) 539-3349','address':'159 Radde Place, Linganore, Idaho, 230','about':'Consequat dolore mollit sit irure cupidatat commodo. Incididunt cillum reprehenderit ullamco sit proident cupidatat occaecat reprehenderit officia. Ad anim Lorem elit in officia minim proident nisi commodo eiusmod ea Lorem dolore voluptate. Dolor aliquip est commodo Lorem dolor ut aliquip ut. Sit anim officia dolore excepteur aute enim cillum.','registered':'Friday, January 6, 2017 1:59 PM','latitude':'70.020883','longitude':'14.503588','tags':['mollit','aute','officia','nostrud','laboris'],'greeting':'Hello, Wells! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eddf7a904ea0d0bc2a','index':83,'guid':'fe639a0c-7517-43e6-b0da-cd9ca5b9e267','isActive':false,'balance':'$3,664.47','picture':'http://placehold.it/32x32','age':33,'eyeColor':'blue','name':{'first':'Natalia','last':'Brown'},'company':'SYNTAC','email':'natalia.brown@syntac.co.uk','phone':'+1 (952) 595-3513','address':'332 Lenox Road, Springville, Alabama, 8406','about':'Nulla consequat officia commodo ea sunt irure anim velit aliquip aliquip. Labore ullamco occaecat proident voluptate cillum labore minim nostrud excepteur. Qui fugiat nostrud cillum fugiat ullamco id commodo aliqua voluptate mollit id id laboris. Cillum qui duis duis sit adipisicing elit ut aliqua eu. Anim nisi aliqua sit mollit.','registered':'Sunday, July 30, 2017 1:02 PM','latitude':'31.937613','longitude':'-9.957927','tags':['magna','adipisicing','exercitation','tempor','consectetur'],'greeting':'Hello, Natalia! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed8823fa385cad4aa3','index':84,'guid':'5cf280da-f5f0-4cc6-9063-e9d5863c8c89','isActive':false,'balance':'$1,624.17','picture':'http://placehold.it/32x32','age':25,'eyeColor':'blue','name':{'first':'Greene','last':'Waller'},'company':'ISOTRACK','email':'greene.waller@isotrack.io','phone':'+1 (838) 406-3608','address':'362 Albemarle Road, Gardiner, Michigan, 2764','about':'Ut nisi sit sint nulla dolor magna. Culpa occaecat adipisicing veniam proident excepteur tempor quis ex. Fugiat tempor laborum dolor adipisicing irure anim cupidatat ut exercitation ex sit. Cupidatat exercitation commodo sunt ex irure fugiat eu esse do ullamco mollit dolore cupidatat. Cupidatat magna incididunt officia dolore esse voluptate deserunt in laborum dolor. Sit fugiat Lorem eu ullamco. Laboris veniam quis cillum tempor ex fugiat cillum cupidatat.','registered':'Sunday, June 10, 2018 10:32 PM','latitude':'0.256921','longitude':'-96.141941','tags':['magna','dolore','deserunt','aliquip','cillum'],'greeting':'Hello, Greene! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eda7c905c2d24c7d31','index':85,'guid':'aa30a9fb-8a16-48eb-8bb7-1307d1e1f191','isActive':false,'balance':'$1,974.04','picture':'http://placehold.it/32x32','age':36,'eyeColor':'green','name':{'first':'Carlene','last':'Hanson'},'company':'DIGIRANG','email':'carlene.hanson@digirang.me','phone':'+1 (981) 417-3209','address':'435 Clark Street, Choctaw, Oregon, 9888','about':'Amet labore esse cillum irure laborum consectetur occaecat non aliquip aliquip proident. Nisi magna nulla officia duis labore aute nulla laborum duis tempor minim. Velit elit reprehenderit nisi exercitation officia incididunt amet cupidatat excepteur proident consectetur.','registered':'Thursday, April 20, 2017 6:13 AM','latitude':'68.529086','longitude':'68.802409','tags':['pariatur','nulla','qui','amet','labore'],'greeting':'Hello, Carlene! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed6fbee12ce9e55dbf','index':86,'guid':'0fce89aa-3310-48df-862a-68bd3d776644','isActive':false,'balance':'$3,909.64','picture':'http://placehold.it/32x32','age':40,'eyeColor':'brown','name':{'first':'Doris','last':'Collins'},'company':'ZIORE','email':'doris.collins@ziore.com','phone':'+1 (914) 405-2360','address':'301 Lorraine Street, Stouchsburg, Minnesota, 7476','about':'Nisi deserunt aliquip et deserunt ipsum ad consectetur est non ullamco. Dolore do ut voluptate do eiusmod. Culpa ad in eiusmod nisi cillum do. Officia magna cillum sint aliqua reprehenderit amet est ipsum. Eiusmod deserunt commodo proident consequat. Amet minim dolor consequat aliquip aliquip culpa non exercitation non.','registered':'Wednesday, February 25, 2015 9:15 PM','latitude':'-57.364906','longitude':'130.766587','tags':['nulla','deserunt','cillum','eiusmod','adipisicing'],'greeting':'Hello, Doris! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edede9402476c398c0','index':87,'guid':'60cf0aa6-bc6d-4305-8842-d27e6af1306f','isActive':false,'balance':'$2,817.53','picture':'http://placehold.it/32x32','age':28,'eyeColor':'green','name':{'first':'Cline','last':'Hayden'},'company':'ECRAZE','email':'cline.hayden@ecraze.org','phone':'+1 (965) 507-2138','address':'352 Rutland Road, Ebro, Connecticut, 1196','about':'Dolor eiusmod enim anim sit enim ea tempor. Tempor amet consectetur aliquip culpa do ex excepteur deserunt. Dolor commodo veniam culpa sint. Commodo consectetur pariatur irure nisi deserunt cillum est dolor ipsum ea.','registered':'Thursday, September 29, 2016 5:58 AM','latitude':'62.50713','longitude':'86.247286','tags':['enim','tempor','anim','veniam','proident'],'greeting':'Hello, Cline! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0edeb72f151994a551b','index':88,'guid':'dbb49c62-86b1-409f-b8b8-f609c709d2a8','isActive':false,'balance':'$3,122.56','picture':'http://placehold.it/32x32','age':39,'eyeColor':'green','name':{'first':'Janelle','last':'Rutledge'},'company':'TERRAGEN','email':'janelle.rutledge@terragen.net','phone':'+1 (914) 581-3749','address':'170 Falmouth Street, Alderpoint, West Virginia, 642','about':'Laboris proident cillum sunt qui ea sunt. Officia adipisicing exercitation dolore magna reprehenderit amet anim id. Laboris commodo sit irure irure. Excepteur est mollit fugiat incididunt consectetur veniam irure ea mollit. Cillum enim consequat sunt sunt nisi incididunt tempor enim.','registered':'Monday, February 16, 2015 5:46 AM','latitude':'-46.392023','longitude':'32.054562','tags':['eu','eu','nisi','labore','deserunt'],'greeting':'Hello, Janelle! You have 9 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edc9c2604846ff9a0d','index':89,'guid':'c4d7a365-f1d3-4584-b78e-008394c219f7','isActive':true,'balance':'$1,807.19','picture':'http://placehold.it/32x32','age':24,'eyeColor':'green','name':{'first':'Abby','last':'Lopez'},'company':'GRAINSPOT','email':'abby.lopez@grainspot.name','phone':'+1 (917) 442-3955','address':'488 Kensington Walk, Winston, Hawaii, 9109','about':'Incididunt deserunt Lorem proident magna tempor enim quis duis eu ut adipisicing in. Ex mollit non irure aliqua officia. Fugiat id ipsum consequat irure id ullamco culpa quis nulla enim aliquip consequat et. Dolor ut anim velit irure consequat cillum eu. Aute occaecat laborum est aliqua.','registered':'Sunday, April 1, 2018 11:28 PM','latitude':'-10.177041','longitude':'-165.756718','tags':['est','laborum','culpa','non','quis'],'greeting':'Hello, Abby! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed03237438b158af9e','index':90,'guid':'36c4a19f-2d00-4e40-bd49-155fd2ce0a6c','isActive':false,'balance':'$2,757.86','picture':'http://placehold.it/32x32','age':31,'eyeColor':'blue','name':{'first':'Whitney','last':'Sheppard'},'company':'ANACHO','email':'whitney.sheppard@anacho.us','phone':'+1 (922) 437-2383','address':'951 Beekman Place, Homeworth, New York, 6088','about':'Sint minim nisi minim non minim aliqua pariatur ullamco do sint qui labore. Aute elit reprehenderit ad do fugiat est amet. In incididunt tempor commodo cillum tempor est labore anim.','registered':'Tuesday, September 13, 2016 6:43 PM','latitude':'-49.732527','longitude':'-171.846715','tags':['exercitation','veniam','sunt','est','proident'],'greeting':'Hello, Whitney! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0edb99dd3aa53d2cb7f','index':91,'guid':'17afd430-f37f-4d55-958c-72f35cdb5997','isActive':false,'balance':'$3,683.86','picture':'http://placehold.it/32x32','age':38,'eyeColor':'blue','name':{'first':'Ilene','last':'Blackwell'},'company':'ENQUILITY','email':'ilene.blackwell@enquility.biz','phone':'+1 (817) 555-2616','address':'950 Varanda Place, Belgreen, Virgin Islands, 1765','about':'Id eiusmod deserunt eiusmod adipisicing adipisicing est enim pariatur esse duis. Qui velit duis irure magna consectetur dolore reprehenderit. Cillum dolore minim consectetur irure non qui velit cillum veniam adipisicing incididunt. Deserunt veniam excepteur veniam velit aliquip labore quis exercitation magna do non dolor. Aliquip occaecat minim adipisicing deserunt fugiat nulla occaecat proident irure consectetur eiusmod irure. Enim Lorem deserunt amet Lorem commodo eiusmod reprehenderit occaecat adipisicing dolor voluptate cillum.','registered':'Thursday, February 1, 2018 8:39 AM','latitude':'57.393644','longitude':'-3.704258','tags':['adipisicing','dolor','commodo','Lorem','Lorem'],'greeting':'Hello, Ilene! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed353f4deb62c3342a','index':92,'guid':'9953e285-2095-4f1c-978b-9ece2a867e9d','isActive':false,'balance':'$1,202.44','picture':'http://placehold.it/32x32','age':38,'eyeColor':'blue','name':{'first':'Dawson','last':'Herman'},'company':'BITENDREX','email':'dawson.herman@bitendrex.ca','phone':'+1 (843) 522-2655','address':'471 Channel Avenue, Denio, Alaska, 5040','about':'Nisi occaecat mollit reprehenderit nisi minim Lorem mollit. Ea proident irure cillum quis. Deserunt consectetur consectetur consequat quis enim minim ea ipsum proident nisi ad non aliquip. Veniam aute minim consequat irure voluptate aute amet excepteur exercitation cillum duis quis adipisicing nostrud.','registered':'Tuesday, December 8, 2015 5:40 PM','latitude':'-55.602721','longitude':'-26.683234','tags':['qui','dolor','deserunt','eiusmod','labore'],'greeting':'Hello, Dawson! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd5464bc50a5310ad','index':93,'guid':'724b2434-4dbd-417d-aa07-6065715f434f','isActive':false,'balance':'$1,595.98','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Alice','last':'Christian'},'company':'ZENOLUX','email':'alice.christian@zenolux.info','phone':'+1 (954) 466-2650','address':'875 Gerritsen Avenue, Townsend, Kentucky, 6568','about':'Nulla labore occaecat ex culpa magna. Commodo occaecat et in consequat cillum laborum magna adipisicing excepteur. Do ut Lorem esse voluptate officia ea aliquip proident amet veniam minim nulla adipisicing. Enim consectetur incididunt laborum voluptate tempor deserunt non laboris. Aliquip deserunt aute irure dolore magna anim aliquip sint magna Lorem. Officia laboris nulla officia sint labore nisi. Do Lorem id in est esse adipisicing id fugiat enim esse laborum.','registered':'Wednesday, October 3, 2018 9:26 PM','latitude':'-88.790637','longitude':'138.817328','tags':['duis','ea','magna','ea','incididunt'],'greeting':'Hello, Alice! You have 8 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0eda01886247b6a4f3d','index':94,'guid':'17c9f4d3-7d72-44e3-8f7c-08d7de920f46','isActive':false,'balance':'$3,173.29','picture':'http://placehold.it/32x32','age':31,'eyeColor':'blue','name':{'first':'Schwartz','last':'Mccormick'},'company':'EVIDENDS','email':'schwartz.mccormick@evidends.tv','phone':'+1 (924) 531-2802','address':'160 Midwood Street, Indio, Palau, 4241','about':'Anim reprehenderit et et adipisicing voluptate consequat elit. Sint Lorem laboris Lorem minim nostrud aute reprehenderit elit aute quis nulla. Officia aute eiusmod mollit cillum eu aliquip non enim ea occaecat quis fugiat occaecat officia. Eiusmod culpa exercitation dolor aliqua enim occaecat nisi cupidatat duis ex dolore id. Id consequat aliqua cupidatat ut. Sit nisi est sunt culpa ullamco excepteur sunt pariatur incididunt amet. Ut tempor duis velit eu ut id culpa aute anim occaecat labore.','registered':'Thursday, March 2, 2017 5:57 PM','latitude':'38.618587','longitude':'-165.142529','tags':['ad','reprehenderit','magna','elit','mollit'],'greeting':'Hello, Schwartz! You have 10 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed51be4df456ec2bc9','index':95,'guid':'44f68f65-959b-4ec2-bd2a-1f30035f76fc','isActive':false,'balance':'$3,242.24','picture':'http://placehold.it/32x32','age':39,'eyeColor':'blue','name':{'first':'Bonita','last':'Stevens'},'company':'SLOFAST','email':'bonita.stevens@slofast.co.uk','phone':'+1 (886) 473-2105','address':'459 Bushwick Court, Kilbourne, Rhode Island, 9450','about':'Consequat reprehenderit qui reprehenderit nisi sit est in qui aliquip amet. Ex deserunt cupidatat amet cillum eiusmod irure anim in amet proident voluptate. Ad officia culpa in non incididunt do.','registered':'Saturday, August 22, 2015 5:23 AM','latitude':'60.013542','longitude':'58.242132','tags':['aute','adipisicing','in','cillum','officia'],'greeting':'Hello, Bonita! You have 5 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed50a55e3587993f68','index':96,'guid':'652e434f-221e-4899-af12-38dca5c9621d','isActive':false,'balance':'$2,720.06','picture':'http://placehold.it/32x32','age':28,'eyeColor':'green','name':{'first':'Charmaine','last':'Jackson'},'company':'FLUM','email':'charmaine.jackson@flum.io','phone':'+1 (947) 573-2692','address':'788 Windsor Place, Highland, Arkansas, 8869','about':'Dolore reprehenderit irure excepteur eu reprehenderit sint Lorem ut amet in. Consequat anim elit sunt aliquip incididunt. Culpa consequat do exercitation dolor enim dolor sunt sit excepteur ad anim. Dolor aute elit velit mollit minim eu.','registered':'Wednesday, April 6, 2016 7:54 PM','latitude':'25.756553','longitude':'-5.482531','tags':['amet','sint','consequat','est','ex'],'greeting':'Hello, Charmaine! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed213621949bbdd5d3','index':97,'guid':'7d7d93d8-3e37-4b4a-9fa2-591fb7d153ce','isActive':true,'balance':'$1,370.63','picture':'http://placehold.it/32x32','age':36,'eyeColor':'brown','name':{'first':'Petersen','last':'Cooley'},'company':'ROTODYNE','email':'petersen.cooley@rotodyne.me','phone':'+1 (929) 563-3339','address':'338 Pioneer Street, Carbonville, Missouri, 3030','about':'Cillum elit dolore labore aute. Cillum ea incididunt cupidatat consequat sint eu mollit. Excepteur commodo eiusmod ex Lorem enim velit minim.','registered':'Friday, December 8, 2017 5:53 AM','latitude':'-10.576254','longitude':'-111.176861','tags':['veniam','eu','eiusmod','dolore','voluptate'],'greeting':'Hello, Petersen! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed3e938138d58ed453','index':98,'guid':'d6fea4a3-03f6-46ee-90b9-8ec51a585e29','isActive':true,'balance':'$1,216.54','picture':'http://placehold.it/32x32','age':39,'eyeColor':'blue','name':{'first':'Rosanne','last':'Terry'},'company':'EXTREMO','email':'rosanne.terry@extremo.com','phone':'+1 (812) 496-2691','address':'368 Rockaway Avenue, Gloucester, Illinois, 7913','about':'Duis et nostrud duis quis minim eiusmod culpa do ea ad pariatur tempor. Velit veniam aliqua aliquip est enim ex et culpa dolor ullamco culpa officia. Eu id occaecat aute cillum aute sit aute laboris ipsum voluptate ex. Amet tempor minim tempor Lorem quis dolore. Pariatur consequat dolore nulla veniam dolor exercitation consequat nulla laboris incididunt do. Dolore do tempor deserunt exercitation incididunt officia incididunt ut do reprehenderit do eiusmod nulla.','registered':'Sunday, August 6, 2017 12:46 PM','latitude':'-43.257964','longitude':'-45.147686','tags':['et','incididunt','esse','commodo','ipsum'],'greeting':'Hello, Rosanne! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed632b1a1d65501d6b','index':99,'guid':'bf8c6ac1-ee18-48ee-ae94-ea515a53c951','isActive':true,'balance':'$2,905.58','picture':'http://placehold.it/32x32','age':21,'eyeColor':'blue','name':{'first':'Irene','last':'Castro'},'company':'POLARIA','email':'irene.castro@polaria.org','phone':'+1 (818) 417-3761','address':'901 Dupont Street, Sperryville, Oklahoma, 953','about':'Pariatur minim laboris aliqua dolor aliquip consequat ea do duis voluptate id Lorem. In reprehenderit et adipisicing anim elit incididunt velit in laborum laborum. Qui minim magna et amet sit do voluptate reprehenderit ea sit sint velit.','registered':'Tuesday, August 18, 2015 10:48 AM','latitude':'-7.004055','longitude':'116.052433','tags':['sit','proident','enim','ullamco','non'],'greeting':'Hello, Irene! You have 10 unread messages.','favoriteFruit':'apple'}]" + } +} diff --git a/internal/cloud/testdata/plan-no-changes/main.tf b/internal/cloud/testdata/plan-no-changes/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/plan-no-changes/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/plan-policy-hard-failed/main.tf b/internal/cloud/testdata/plan-policy-hard-failed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/plan-policy-hard-failed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/plan-policy-passed/main.tf b/internal/cloud/testdata/plan-policy-passed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/plan-policy-passed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/plan-policy-soft-failed/main.tf b/internal/cloud/testdata/plan-policy-soft-failed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/plan-policy-soft-failed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/plan-variables/main.tf b/internal/cloud/testdata/plan-variables/main.tf new file mode 100644 index 000000000..955e8b4c0 --- /dev/null +++ b/internal/cloud/testdata/plan-variables/main.tf @@ -0,0 +1,4 @@ +variable "foo" {} +variable "bar" {} + +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/plan-with-error/main.tf b/internal/cloud/testdata/plan-with-error/main.tf new file mode 100644 index 000000000..bc45f28f5 --- /dev/null +++ b/internal/cloud/testdata/plan-with-error/main.tf @@ -0,0 +1,5 @@ +resource "null_resource" "foo" { + triggers { + random = "${guid()}" + } +} diff --git a/internal/cloud/testdata/plan-with-working-directory/terraform/main.tf b/internal/cloud/testdata/plan-with-working-directory/terraform/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/plan-with-working-directory/terraform/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testdata/plan/main.tf b/internal/cloud/testdata/plan/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/internal/cloud/testdata/plan/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/internal/cloud/testing.go b/internal/cloud/testing.go new file mode 100644 index 000000000..73668b2b0 --- /dev/null +++ b/internal/cloud/testing.go @@ -0,0 +1,299 @@ +package cloud + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path" + "testing" + + tfe "github.com/hashicorp/go-tfe" + svchost "github.com/hashicorp/terraform-svchost" + "github.com/hashicorp/terraform-svchost/auth" + "github.com/hashicorp/terraform-svchost/disco" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/httpclient" + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/states/remote" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/hashicorp/terraform/version" + "github.com/mitchellh/cli" + "github.com/zclconf/go-cty/cty" + + backendLocal "github.com/hashicorp/terraform/internal/backend/local" +) + +const ( + testCred = "test-auth-token" +) + +var ( + tfeHost = svchost.Hostname(defaultHostname) + credsSrc = auth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{ + tfeHost: {"token": testCred}, + }) +) + +func testInput(t *testing.T, answers map[string]string) *mockInput { + return &mockInput{answers: answers} +} + +func testBackendDefault(t *testing.T) (*Cloud, func()) { + obj := cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + }), + }) + return testBackend(t, obj) +} + +func testBackendNoDefault(t *testing.T) (*Cloud, func()) { + obj := cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "prefix": cty.StringVal("my-app-"), + }), + }) + return testBackend(t, obj) +} + +func testBackendNoOperations(t *testing.T) (*Cloud, func()) { + obj := cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("no-operations"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + }), + }) + return testBackend(t, obj) +} + +func testRemoteClient(t *testing.T) remote.Client { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + raw, err := b.StateMgr(backend.DefaultStateName) + if err != nil { + t.Fatalf("error: %v", err) + } + + return raw.(*remote.State).Client +} + +func testBackend(t *testing.T, obj cty.Value) (*Cloud, func()) { + s := testServer(t) + b := New(testDisco(s)) + + // Configure the backend so the client is created. + newObj, valDiags := b.PrepareConfig(obj) + if len(valDiags) != 0 { + t.Fatal(valDiags.ErrWithWarnings()) + } + obj = newObj + + confDiags := b.Configure(obj) + if len(confDiags) != 0 { + t.Fatal(confDiags.ErrWithWarnings()) + } + + // Get a new mock client. + mc := newMockClient() + + // Replace the services we use with our mock services. + b.CLI = cli.NewMockUi() + b.client.Applies = mc.Applies + b.client.ConfigurationVersions = mc.ConfigurationVersions + b.client.CostEstimates = mc.CostEstimates + b.client.Organizations = mc.Organizations + b.client.Plans = mc.Plans + b.client.PolicyChecks = mc.PolicyChecks + b.client.Runs = mc.Runs + b.client.StateVersions = mc.StateVersions + b.client.Variables = mc.Variables + b.client.Workspaces = mc.Workspaces + + // Set local to a local test backend. + b.local = testLocalBackend(t, b) + + ctx := context.Background() + + // Create the organization. + _, err := b.client.Organizations.Create(ctx, tfe.OrganizationCreateOptions{ + Name: tfe.String(b.organization), + }) + if err != nil { + t.Fatalf("error: %v", err) + } + + // Create the default workspace if required. + if b.workspace != "" { + _, err = b.client.Workspaces.Create(ctx, b.organization, tfe.WorkspaceCreateOptions{ + Name: tfe.String(b.workspace), + }) + if err != nil { + t.Fatalf("error: %v", err) + } + } + + return b, s.Close +} + +func testLocalBackend(t *testing.T, cloud *Cloud) backend.Enhanced { + b := backendLocal.NewWithBackend(cloud) + + // Add a test provider to the local backend. + p := backendLocal.TestLocalProvider(t, b, "null", &terraform.ProviderSchema{ + ResourceTypes: map[string]*configschema.Block{ + "null_resource": { + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Computed: true}, + }, + }, + }, + }) + p.ApplyResourceChangeResponse = &providers.ApplyResourceChangeResponse{NewState: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("yes"), + })} + + return b +} + +// testServer returns a *httptest.Server used for local testing. +func testServer(t *testing.T) *httptest.Server { + mux := http.NewServeMux() + + // Respond to service discovery calls. + mux.HandleFunc("/well-known/terraform.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{ + "state.v2": "/api/v2/", + "tfe.v2.1": "/api/v2/", + "versions.v1": "/v1/versions/" +}`) + }) + + // Respond to service version constraints calls. + mux.HandleFunc("/v1/versions/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, fmt.Sprintf(`{ + "service": "%s", + "product": "terraform", + "minimum": "0.1.0", + "maximum": "10.0.0" +}`, path.Base(r.URL.Path))) + }) + + // Respond to pings to get the API version header. + mux.HandleFunc("/api/v2/ping", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("TFP-API-Version", "2.4") + }) + + // Respond to the initial query to read the hashicorp org entitlements. + mux.HandleFunc("/api/v2/organizations/hashicorp/entitlement-set", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.api+json") + io.WriteString(w, `{ + "data": { + "id": "org-GExadygjSbKP8hsY", + "type": "entitlement-sets", + "attributes": { + "operations": true, + "private-module-registry": true, + "sentinel": true, + "state-storage": true, + "teams": true, + "vcs-integrations": true + } + } +}`) + }) + + // Respond to the initial query to read the no-operations org entitlements. + mux.HandleFunc("/api/v2/organizations/no-operations/entitlement-set", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.api+json") + io.WriteString(w, `{ + "data": { + "id": "org-ufxa3y8jSbKP8hsT", + "type": "entitlement-sets", + "attributes": { + "operations": false, + "private-module-registry": true, + "sentinel": true, + "state-storage": true, + "teams": true, + "vcs-integrations": true + } + } +}`) + }) + + // All tests that are assumed to pass will use the hashicorp organization, + // so for all other organization requests we will return a 404. + mux.HandleFunc("/api/v2/organizations/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + io.WriteString(w, `{ + "errors": [ + { + "status": "404", + "title": "not found" + } + ] +}`) + }) + + return httptest.NewServer(mux) +} + +// testDisco returns a *disco.Disco mapping app.terraform.io and +// localhost to a local test server. +func testDisco(s *httptest.Server) *disco.Disco { + services := map[string]interface{}{ + "state.v2": fmt.Sprintf("%s/api/v2/", s.URL), + "tfe.v2.1": fmt.Sprintf("%s/api/v2/", s.URL), + "versions.v1": fmt.Sprintf("%s/v1/versions/", s.URL), + } + d := disco.NewWithCredentialsSource(credsSrc) + d.SetUserAgent(httpclient.TerraformUserAgent(version.String())) + + d.ForceHostServices(svchost.Hostname(defaultHostname), services) + d.ForceHostServices(svchost.Hostname("localhost"), services) + return d +} + +type unparsedVariableValue struct { + value string + source terraform.ValueSourceType +} + +func (v *unparsedVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) { + return &terraform.InputValue{ + Value: cty.StringVal(v.value), + SourceType: v.source, + }, tfdiags.Diagnostics{} +} + +// testVariable returns a backend.UnparsedVariableValue used for testing. +func testVariables(s terraform.ValueSourceType, vs ...string) map[string]backend.UnparsedVariableValue { + vars := make(map[string]backend.UnparsedVariableValue, len(vs)) + for _, v := range vs { + vars[v] = &unparsedVariableValue{ + value: v, + source: s, + } + } + return vars +} From a5f063625a189bd8e3e1a40e57bc63ff68349c49 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Fri, 20 Aug 2021 22:12:29 -0500 Subject: [PATCH 02/78] Replace generic host error messages for feature support --- internal/cloud/backend_apply.go | 8 ++++---- internal/cloud/backend_plan.go | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/cloud/backend_apply.go b/internal/cloud/backend_apply.go index e50f1ee60..b52719593 100644 --- a/internal/cloud/backend_apply.go +++ b/internal/cloud/backend_apply.go @@ -100,7 +100,7 @@ func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operatio tfdiags.Error, "Planning without refresh is not supported", fmt.Sprintf( - `The host %s does not support the -refresh=false option for `+ + `The Terraform Enterprise installation at %s does not support the -refresh=false option for `+ `remote plans.`, b.hostname, ), @@ -116,7 +116,7 @@ func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operatio tfdiags.Error, "Refresh-only mode is not supported", fmt.Sprintf( - `The host %s does not support -refresh-only mode for `+ + `The Terraform Enterprise installation at %s does not support -refresh-only mode for `+ `remote plans.`, b.hostname, ), @@ -132,7 +132,7 @@ func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operatio tfdiags.Error, "Planning resource replacements is not supported", fmt.Sprintf( - `The host %s does not support the -replace option for `+ + `The Terraform Enterprise installation at %s does not support the -replace option for `+ `remote plans.`, b.hostname, ), @@ -148,7 +148,7 @@ func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operatio tfdiags.Error, "Resource targeting is not supported", fmt.Sprintf( - `The host %s does not support the -target option for `+ + `The Terraform Enterprise installation at %s does not support the -target option for `+ `remote plans.`, b.hostname, ), diff --git a/internal/cloud/backend_plan.go b/internal/cloud/backend_plan.go index 025b36776..3e3916b36 100644 --- a/internal/cloud/backend_plan.go +++ b/internal/cloud/backend_plan.go @@ -106,7 +106,7 @@ func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation tfdiags.Error, "Resource targeting is not supported", fmt.Sprintf( - `The host %s does not support the -target option for `+ + `The Terraform Enterprise installation at %s does not support the -target option for `+ `remote plans.`, b.hostname, ), @@ -122,7 +122,7 @@ func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation tfdiags.Error, "Planning without refresh is not supported", fmt.Sprintf( - `The host %s does not support the -refresh=false option for `+ + `The Terraform Enterprise installation at %s does not support the -refresh=false option for `+ `remote plans.`, b.hostname, ), @@ -138,7 +138,7 @@ func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation tfdiags.Error, "Planning resource replacements is not supported", fmt.Sprintf( - `The host %s does not support the -replace option for `+ + `The Terraform Enterprise installation at %s does not support the -replace option for `+ `remote plans.`, b.hostname, ), @@ -154,7 +154,7 @@ func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation tfdiags.Error, "Refresh-only mode is not supported", fmt.Sprintf( - `The host %s does not support -refresh-only mode for `+ + `The Terraform Enterprise installation at %s does not support -refresh-only mode for `+ `remote plans.`, b.hostname, ), From a4c24e314719a08f859d309f37ea35c7f95c075f Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Tue, 24 Aug 2021 14:28:12 -0500 Subject: [PATCH 03/78] Add cloud {} configuration block for Terraform Cloud This is a replacement declaration for using Terraform Cloud as a remote backend, leaving the literal backend as an implementation detail and not a user-level concept. --- internal/command/console.go | 2 +- internal/command/graph.go | 2 +- internal/command/import.go | 2 +- internal/command/init.go | 33 +++++++++++++++++-- internal/command/meta_backend.go | 26 +++++++++------ internal/command/meta_backend_migrate.go | 8 ++--- internal/command/meta_config.go | 6 ++++ internal/command/output.go | 2 +- internal/command/providers.go | 2 +- internal/command/providers_schema.go | 2 +- internal/command/show.go | 2 +- internal/command/state_list.go | 2 +- internal/command/state_meta.go | 2 +- internal/command/state_pull.go | 2 +- internal/command/state_push.go | 2 +- internal/command/state_show.go | 2 +- internal/command/taint.go | 2 +- internal/command/untaint.go | 2 +- internal/command/workspace_delete.go | 2 +- internal/command/workspace_list.go | 2 +- internal/command/workspace_new.go | 2 +- internal/command/workspace_select.go | 2 +- internal/configs/cloud.go | 27 +++++++++++++++ internal/configs/module.go | 25 ++++++++++++++ internal/configs/parser_config.go | 10 ++++++ .../nested-cloud-warning/child/child.tf | 6 ++++ .../testdata/nested-cloud-warning/root.tf | 3 ++ .../configs/testdata/valid-files/cloud.tf | 10 ++++++ 28 files changed, 155 insertions(+), 35 deletions(-) create mode 100644 internal/configs/cloud.go create mode 100644 internal/configs/testdata/nested-cloud-warning/child/child.tf create mode 100644 internal/configs/testdata/nested-cloud-warning/root.tf create mode 100644 internal/configs/testdata/valid-files/cloud.tf diff --git a/internal/command/console.go b/internal/command/console.go index 319598868..a29007128 100644 --- a/internal/command/console.go +++ b/internal/command/console.go @@ -72,7 +72,7 @@ func (c *ConsoleCommand) Run(args []string) int { } // This is a read-only command - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) // Build the operation opReq := c.Operation(b) diff --git a/internal/command/graph.go b/internal/command/graph.go index 87880a855..4fe742804 100644 --- a/internal/command/graph.go +++ b/internal/command/graph.go @@ -88,7 +88,7 @@ func (c *GraphCommand) Run(args []string) int { } // This is a read-only command - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) // Build the operation opReq := c.Operation(b) diff --git a/internal/command/import.go b/internal/command/import.go index 7fc61a2f0..a576a29e4 100644 --- a/internal/command/import.go +++ b/internal/command/import.go @@ -204,7 +204,7 @@ func (c *ImportCommand) Run(args []string) int { opReq.View = views.NewOperation(arguments.ViewHuman, c.RunningInAutomation, c.View) // Check remote Terraform version is compatible - remoteVersionDiags := c.remoteBackendVersionCheck(b, opReq.Workspace) + remoteVersionDiags := c.remoteVersionCheck(b, opReq.Workspace) diags = diags.Append(remoteVersionDiags) c.showDiagnostics(diags) if diags.HasErrors() { diff --git a/internal/command/init.go b/internal/command/init.go index 8f8c5b829..8591f0c53 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -209,8 +209,20 @@ func (c *InitCommand) Run(args []string) int { } var back backend.Backend - if flagBackend { + switch { + case config.Module.CloudConfig != nil: + be, backendOutput, backendDiags := c.initCloud(config.Module) + diags = diags.Append(backendDiags) + if backendDiags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + if backendOutput { + header = true + } + back = be + case flagBackend: be, backendOutput, backendDiags := c.initBackend(config.Module, flagConfigExtra) diags = diags.Append(backendDiags) if backendDiags.HasErrors() { @@ -221,7 +233,7 @@ func (c *InitCommand) Run(args []string) int { header = true } back = be - } else { + default: // load the previously-stored backend config be, backendDiags := c.Meta.backendFromState() diags = diags.Append(backendDiags) @@ -251,7 +263,7 @@ func (c *InitCommand) Run(args []string) int { // on a previous run) we'll use the current state as a potential source // of provider dependencies. if back != nil { - c.ignoreRemoteBackendVersionConflict(back) + c.ignoreRemoteVersionConflict(back) workspace, err := c.Workspace() if err != nil { c.Ui.Error(fmt.Sprintf("Error selecting workspace: %s", err)) @@ -337,6 +349,21 @@ func (c *InitCommand) getModules(path string, earlyRoot *tfconfig.Module, upgrad return true, diags } +func (c *InitCommand) initCloud(root *configs.Module) (be backend.Backend, output bool, diags tfdiags.Diagnostics) { + c.Ui.Output(c.Colorize().Color("\n[reset][bold]Initializing Terraform Cloud...")) + + backendConfig := root.CloudConfig.ToBackendConfig() + + opts := &BackendOpts{ + Config: &backendConfig, + Init: true, + } + + back, backDiags := c.Backend(opts) + diags = diags.Append(backDiags) + return back, true, diags +} + func (c *InitCommand) initBackend(root *configs.Module, extraConfig rawFlags) (be backend.Backend, output bool, diags tfdiags.Diagnostics) { c.Ui.Output(c.Colorize().Color("\n[reset][bold]Initializing the backend...")) diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index 042e7cfff..58cafdd4f 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -17,7 +17,6 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hcldec" "github.com/hashicorp/terraform/internal/backend" - remoteBackend "github.com/hashicorp/terraform/internal/backend/remote" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" @@ -55,6 +54,13 @@ type BackendOpts struct { ForceLocal bool } +// BackendWithRemoteTerraformVersion is a shared interface between the 'remote' and 'cloud' backends +// for simplified type checking when calling functions common to those particular backends. +type BackendWithRemoteTerraformVersion interface { + IgnoreVersionConflict() + VerifyWorkspaceTerraformVersion(workspace string) tfdiags.Diagnostics +} + // Backend initializes and returns the backend for this CLI session. // // The backend is used to perform the actual Terraform operations. This @@ -1168,32 +1174,32 @@ func (m *Meta) backendInitFromConfig(c *configs.Backend) (backend.Backend, cty.V return b, configVal, diags } -// Helper method to ignore remote backend version conflicts. Only call this +// Helper method to ignore remote/cloud backend version conflicts. Only call this // for commands which cannot accidentally upgrade remote state files. -func (m *Meta) ignoreRemoteBackendVersionConflict(b backend.Backend) { - if rb, ok := b.(*remoteBackend.Remote); ok { - rb.IgnoreVersionConflict() +func (m *Meta) ignoreRemoteVersionConflict(b backend.Backend) { + if back, ok := b.(BackendWithRemoteTerraformVersion); ok { + back.IgnoreVersionConflict() } } // Helper method to check the local Terraform version against the configured // version in the remote workspace, returning diagnostics if they conflict. -func (m *Meta) remoteBackendVersionCheck(b backend.Backend, workspace string) tfdiags.Diagnostics { +func (m *Meta) remoteVersionCheck(b backend.Backend, workspace string) tfdiags.Diagnostics { var diags tfdiags.Diagnostics - if rb, ok := b.(*remoteBackend.Remote); ok { + if back, ok := b.(BackendWithRemoteTerraformVersion); ok { // Allow user override based on command-line flag if m.ignoreRemoteVersion { - rb.IgnoreVersionConflict() + back.IgnoreVersionConflict() } // If the override is set, this check will return a warning instead of // an error - versionDiags := rb.VerifyWorkspaceTerraformVersion(workspace) + versionDiags := back.VerifyWorkspaceTerraformVersion(workspace) diags = diags.Append(versionDiags) // If there are no errors resulting from this check, we do not need to // check again if !diags.HasErrors() { - rb.IgnoreVersionConflict() + back.IgnoreVersionConflict() } } diff --git a/internal/command/meta_backend_migrate.go b/internal/command/meta_backend_migrate.go index 15206bf8a..e998b0ffd 100644 --- a/internal/command/meta_backend_migrate.go +++ b/internal/command/meta_backend_migrate.go @@ -75,17 +75,17 @@ func (m *Meta) backendMigrateState(opts *backendMigrateOpts) error { // Disregard remote Terraform version for the state source backend. If it's a // Terraform Cloud remote backend, we don't care about the remote version, // as we are migrating away and will not break a remote workspace. - m.ignoreRemoteBackendVersionConflict(opts.Source) + m.ignoreRemoteVersionConflict(opts.Source) // Disregard remote Terraform version if instructed to do so via CLI flag. if m.ignoreRemoteVersion { - m.ignoreRemoteBackendVersionConflict(opts.Destination) + m.ignoreRemoteVersionConflict(opts.Destination) } else { // Check the remote Terraform version for the state destination backend. If // it's a Terraform Cloud remote backend, we want to ensure that we don't // break the workspace by uploading an incompatible state file. for _, workspace := range destinationWorkspaces { - diags := m.remoteBackendVersionCheck(opts.Destination, workspace) + diags := m.remoteVersionCheck(opts.Destination, workspace) if diags.HasErrors() { return diags.Err() } @@ -93,7 +93,7 @@ func (m *Meta) backendMigrateState(opts *backendMigrateOpts) error { // If there are no specified destination workspaces, perform a remote // backend version check with the default workspace. if len(destinationWorkspaces) == 0 { - diags := m.remoteBackendVersionCheck(opts.Destination, backend.DefaultStateName) + diags := m.remoteVersionCheck(opts.Destination, backend.DefaultStateName) if diags.HasErrors() { return diags.Err() } diff --git a/internal/command/meta_config.go b/internal/command/meta_config.go index 439df6b91..6913db594 100644 --- a/internal/command/meta_config.go +++ b/internal/command/meta_config.go @@ -136,6 +136,12 @@ func (m *Meta) loadBackendConfig(rootDir string) (*configs.Backend, tfdiags.Diag if diags.HasErrors() { return nil, diags } + + if mod.CloudConfig != nil { + backendConfig := mod.CloudConfig.ToBackendConfig() + return &backendConfig, nil + } + return mod.Backend, nil } diff --git a/internal/command/output.go b/internal/command/output.go index 3594fe33d..0f23a6109 100644 --- a/internal/command/output.go +++ b/internal/command/output.go @@ -67,7 +67,7 @@ func (c *OutputCommand) Outputs(statePath string) (map[string]*states.OutputValu } // This is a read-only command - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) env, err := c.Workspace() if err != nil { diff --git a/internal/command/providers.go b/internal/command/providers.go index 31fd79594..5bc0d4e6c 100644 --- a/internal/command/providers.go +++ b/internal/command/providers.go @@ -83,7 +83,7 @@ func (c *ProvidersCommand) Run(args []string) int { } // This is a read-only command - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) // Get the state env, err := c.Workspace() diff --git a/internal/command/providers_schema.go b/internal/command/providers_schema.go index 372564f12..b4d61ec76 100644 --- a/internal/command/providers_schema.go +++ b/internal/command/providers_schema.go @@ -68,7 +68,7 @@ func (c *ProvidersSchemaCommand) Run(args []string) int { } // This is a read-only command - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) // we expect that the config dir is the cwd cwd, err := os.Getwd() diff --git a/internal/command/show.go b/internal/command/show.go index 9886768ca..6ae66beeb 100644 --- a/internal/command/show.go +++ b/internal/command/show.go @@ -70,7 +70,7 @@ func (c *ShowCommand) Run(args []string) int { } // This is a read-only command - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) // the show command expects the config dir to always be the cwd cwd, err := os.Getwd() diff --git a/internal/command/state_list.go b/internal/command/state_list.go index ebd318bc8..54358b28d 100644 --- a/internal/command/state_list.go +++ b/internal/command/state_list.go @@ -41,7 +41,7 @@ func (c *StateListCommand) Run(args []string) int { } // This is a read-only command - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) // Get the state env, err := c.Workspace() diff --git a/internal/command/state_meta.go b/internal/command/state_meta.go index fa04245a6..17959f5ff 100644 --- a/internal/command/state_meta.go +++ b/internal/command/state_meta.go @@ -43,7 +43,7 @@ func (c *StateMeta) State() (statemgr.Full, error) { } // Check remote Terraform version is compatible - remoteVersionDiags := c.remoteBackendVersionCheck(b, workspace) + remoteVersionDiags := c.remoteVersionCheck(b, workspace) c.showDiagnostics(remoteVersionDiags) if remoteVersionDiags.HasErrors() { return nil, fmt.Errorf("Error checking remote Terraform version") diff --git a/internal/command/state_pull.go b/internal/command/state_pull.go index 0616df2d4..8ce16ff57 100644 --- a/internal/command/state_pull.go +++ b/internal/command/state_pull.go @@ -31,7 +31,7 @@ func (c *StatePullCommand) Run(args []string) int { } // This is a read-only command - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) // Get the state manager for the current workspace env, err := c.Workspace() diff --git a/internal/command/state_push.go b/internal/command/state_push.go index 117611e94..eb0ea1679 100644 --- a/internal/command/state_push.go +++ b/internal/command/state_push.go @@ -80,7 +80,7 @@ func (c *StatePushCommand) Run(args []string) int { } // Check remote Terraform version is compatible - remoteVersionDiags := c.remoteBackendVersionCheck(b, workspace) + remoteVersionDiags := c.remoteVersionCheck(b, workspace) c.showDiagnostics(remoteVersionDiags) if remoteVersionDiags.HasErrors() { return 1 diff --git a/internal/command/state_show.go b/internal/command/state_show.go index e95eca70f..7ee86624d 100644 --- a/internal/command/state_show.go +++ b/internal/command/state_show.go @@ -54,7 +54,7 @@ func (c *StateShowCommand) Run(args []string) int { } // This is a read-only command - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) // Check if the address can be parsed addr, addrDiags := addrs.ParseAbsResourceInstanceStr(args[0]) diff --git a/internal/command/taint.go b/internal/command/taint.go index 46e92d6d6..f2fbbb1ef 100644 --- a/internal/command/taint.go +++ b/internal/command/taint.go @@ -102,7 +102,7 @@ func (c *TaintCommand) Run(args []string) int { } // Check remote Terraform version is compatible - remoteVersionDiags := c.remoteBackendVersionCheck(b, workspace) + remoteVersionDiags := c.remoteVersionCheck(b, workspace) diags = diags.Append(remoteVersionDiags) c.showDiagnostics(diags) if diags.HasErrors() { diff --git a/internal/command/untaint.go b/internal/command/untaint.go index 98f203560..ba290a8a4 100644 --- a/internal/command/untaint.go +++ b/internal/command/untaint.go @@ -67,7 +67,7 @@ func (c *UntaintCommand) Run(args []string) int { } // Check remote Terraform version is compatible - remoteVersionDiags := c.remoteBackendVersionCheck(b, workspace) + remoteVersionDiags := c.remoteVersionCheck(b, workspace) diags = diags.Append(remoteVersionDiags) c.showDiagnostics(diags) if diags.HasErrors() { diff --git a/internal/command/workspace_delete.go b/internal/command/workspace_delete.go index 5c826d908..654aac581 100644 --- a/internal/command/workspace_delete.go +++ b/internal/command/workspace_delete.go @@ -68,7 +68,7 @@ func (c *WorkspaceDeleteCommand) Run(args []string) int { } // This command will not write state - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) workspaces, err := b.Workspaces() if err != nil { diff --git a/internal/command/workspace_list.go b/internal/command/workspace_list.go index aac6bc97d..7b43bc346 100644 --- a/internal/command/workspace_list.go +++ b/internal/command/workspace_list.go @@ -52,7 +52,7 @@ func (c *WorkspaceListCommand) Run(args []string) int { } // This command will not write state - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) states, err := b.Workspaces() if err != nil { diff --git a/internal/command/workspace_new.go b/internal/command/workspace_new.go index 1d7b2898c..41e657bef 100644 --- a/internal/command/workspace_new.go +++ b/internal/command/workspace_new.go @@ -83,7 +83,7 @@ func (c *WorkspaceNewCommand) Run(args []string) int { } // This command will not write state - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) workspaces, err := b.Workspaces() if err != nil { diff --git a/internal/command/workspace_select.go b/internal/command/workspace_select.go index 645a9c2bc..1f98ec55e 100644 --- a/internal/command/workspace_select.go +++ b/internal/command/workspace_select.go @@ -68,7 +68,7 @@ func (c *WorkspaceSelectCommand) Run(args []string) int { } // This command will not write state - c.ignoreRemoteBackendVersionConflict(b) + c.ignoreRemoteVersionConflict(b) name := args[0] if !validWorkspaceName(name) { diff --git a/internal/configs/cloud.go b/internal/configs/cloud.go new file mode 100644 index 000000000..1ed6482e1 --- /dev/null +++ b/internal/configs/cloud.go @@ -0,0 +1,27 @@ +package configs + +import ( + "github.com/hashicorp/hcl/v2" +) + +// Cloud represents a "cloud" block inside a "terraform" block in a module +// or file. +type CloudConfig struct { + Config hcl.Body + + DeclRange hcl.Range +} + +func decodeCloudBlock(block *hcl.Block) (*CloudConfig, hcl.Diagnostics) { + return &CloudConfig{ + Config: block.Body, + DeclRange: block.DefRange, + }, nil +} + +func (c *CloudConfig) ToBackendConfig() Backend { + return Backend{ + Type: "cloud", + Config: c.Config, + } +} diff --git a/internal/configs/module.go b/internal/configs/module.go index 18676de9d..ff9867630 100644 --- a/internal/configs/module.go +++ b/internal/configs/module.go @@ -29,6 +29,7 @@ type Module struct { ActiveExperiments experiments.Set Backend *Backend + CloudConfig *CloudConfig ProviderConfigs map[string]*Provider ProviderRequirements *RequiredProviders ProviderLocalNames map[addrs.Provider]string @@ -63,6 +64,7 @@ type File struct { ActiveExperiments experiments.Set Backends []*Backend + CloudConfigs []*CloudConfig ProviderConfigs []*Provider ProviderMetas []*ProviderMeta RequiredProviders []*RequiredProviders @@ -190,6 +192,29 @@ func (m *Module) appendFile(file *File) hcl.Diagnostics { m.Backend = b } + for _, c := range file.CloudConfigs { + if m.CloudConfig != nil { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate Terraform Cloud configurations", + Detail: fmt.Sprintf("A module may have only one 'cloud' block configuring Terraform Cloud. Terraform Cloud was previously configured at %s.", m.CloudConfig.DeclRange), + Subject: &c.DeclRange, + }) + continue + } + + m.CloudConfig = c + } + + if m.Backend != nil && m.CloudConfig != nil { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Both a backend and Terraform Cloud configuration are present", + Detail: fmt.Sprintf("A module may declare either one 'cloud' block configuring Terraform Cloud OR one 'backend' block configuring a state backend. Terraform Cloud is configured at %s; a backend is configured at %s. Remove the backend block to configure Terraform Cloud.", m.CloudConfig.DeclRange, m.Backend.DeclRange), + Subject: &m.Backend.DeclRange, + }) + } + for _, pc := range file.ProviderConfigs { key := pc.moduleUniqueKey() if existing, exists := m.ProviderConfigs[key]; exists { diff --git a/internal/configs/parser_config.go b/internal/configs/parser_config.go index 4be14501d..caebb8911 100644 --- a/internal/configs/parser_config.go +++ b/internal/configs/parser_config.go @@ -72,6 +72,13 @@ func (p *Parser) loadConfigFile(path string, override bool) (*File, hcl.Diagnost file.Backends = append(file.Backends, backendCfg) } + case "cloud": + cloudCfg, cfgDiags := decodeCloudBlock(innerBlock) + diags = append(diags, cfgDiags...) + if cloudCfg != nil { + file.CloudConfigs = append(file.CloudConfigs, cloudCfg) + } + case "required_providers": reqs, reqsDiags := decodeRequiredProvidersBlock(innerBlock) diags = append(diags, reqsDiags...) @@ -261,6 +268,9 @@ var terraformBlockSchema = &hcl.BodySchema{ Type: "backend", LabelNames: []string{"type"}, }, + { + Type: "cloud", + }, { Type: "required_providers", }, diff --git a/internal/configs/testdata/nested-cloud-warning/child/child.tf b/internal/configs/testdata/nested-cloud-warning/child/child.tf new file mode 100644 index 000000000..540b92170 --- /dev/null +++ b/internal/configs/testdata/nested-cloud-warning/child/child.tf @@ -0,0 +1,6 @@ +terraform { + # Only the root module can declare a Cloud configuration. Terraform should emit a warning + # about this child module Cloud declaration. + cloud { + } +} diff --git a/internal/configs/testdata/nested-cloud-warning/root.tf b/internal/configs/testdata/nested-cloud-warning/root.tf new file mode 100644 index 000000000..1f95749fa --- /dev/null +++ b/internal/configs/testdata/nested-cloud-warning/root.tf @@ -0,0 +1,3 @@ +module "child" { + source = "./child" +} diff --git a/internal/configs/testdata/valid-files/cloud.tf b/internal/configs/testdata/valid-files/cloud.tf new file mode 100644 index 000000000..91985fcad --- /dev/null +++ b/internal/configs/testdata/valid-files/cloud.tf @@ -0,0 +1,10 @@ + +terraform { + cloud { + foo = "bar" + + baz { + bar = "foo" + } + } +} From fccc873a3d714cfc1a1ec7e1e4f2392d645b57b6 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Tue, 24 Aug 2021 14:34:34 -0500 Subject: [PATCH 04/78] Remove ability to declare a 'cloud' backend With the alternative block introduced in 7bf9b2c7b, this removes the ability to explicitly declare the 'cloud' backend. The literal backend interface is an implementation detail and no longer a user-level concept when using Terraform Cloud. --- internal/command/init.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/command/init.go b/internal/command/init.go index 8591f0c53..d88ec181e 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -371,6 +371,16 @@ func (c *InitCommand) initBackend(root *configs.Module, extraConfig rawFlags) (b var backendConfigOverride hcl.Body if root.Backend != nil { backendType := root.Backend.Type + if backendType == "cloud" { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Unsupported backend type", + Detail: fmt.Sprintf("There is no explicit backend type named %q. To configure Terraform Cloud, declare a 'cloud' block instead.", backendType), + Subject: &root.Backend.TypeRange, + }) + return nil, true, diags + } + bf := backendInit.Backend(backendType) if bf == nil { diags = diags.Append(&hcl.Diagnostic{ From 07b3d015d1c5952c999a89b802cd54f50cf6bdbc Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Wed, 25 Aug 2021 22:16:57 -0500 Subject: [PATCH 05/78] Defer cloud block overrides This restriction is temporary. Overrides should be allowed, but have the added complexity of needing to also override a 'backend' block, so this work is being deferred for now. --- internal/configs/module.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/configs/module.go b/internal/configs/module.go index ff9867630..b75cc7fd5 100644 --- a/internal/configs/module.go +++ b/internal/configs/module.go @@ -392,6 +392,18 @@ func (m *Module) mergeFile(file *File) hcl.Diagnostics { } } + // TODO: This restriction is temporary. Overrides should be allowed, but have the added + // complexity of needing to also override a 'backend' block, so this work is being deferred + // for now. + for _, m := range file.CloudConfigs { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Cannot override 'cloud' configuration", + Detail: "Terraform Cloud configuration blocks can appear only in normal files, not in override files.", + Subject: m.DeclRange.Ptr(), + }) + } + for _, pc := range file.ProviderConfigs { key := pc.moduleUniqueKey() existing, exists := m.ProviderConfigs[key] From 18d54c11290fc1300fe3419fd577c14068e922e6 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Fri, 27 Aug 2021 12:13:41 -0500 Subject: [PATCH 06/78] Allow cloud block overrides These changes allow cloud blocks to be overridden by backend blocks and vice versa; the logic follows the current backend behavior of a block overriding a preceding block in full, with no merges. --- internal/configs/module.go | 26 +++-- internal/configs/module_test.go | 103 ++++++++++++++++++ .../override-cloud-duplicates/main.tf | 14 +++ .../override-cloud-duplicates/override.tf | 11 ++ .../override-backend-no-base/main.tf | 7 ++ .../override-backend-no-base/override.tf | 5 + .../override-backend-with-cloud/main.tf | 13 +++ .../override-backend-with-cloud/override.tf | 5 + .../valid-modules/override-backend/main.tf | 13 +++ .../override-backend/override.tf | 5 + .../override-cloud-no-base/main.tf | 7 ++ .../override-cloud-no-base/override.tf | 5 + .../valid-modules/override-cloud/main.tf | 14 +++ .../valid-modules/override-cloud/override.tf | 5 + 14 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 internal/configs/testdata/invalid-modules/override-cloud-duplicates/main.tf create mode 100644 internal/configs/testdata/invalid-modules/override-cloud-duplicates/override.tf create mode 100644 internal/configs/testdata/valid-modules/override-backend-no-base/main.tf create mode 100644 internal/configs/testdata/valid-modules/override-backend-no-base/override.tf create mode 100644 internal/configs/testdata/valid-modules/override-backend-with-cloud/main.tf create mode 100644 internal/configs/testdata/valid-modules/override-backend-with-cloud/override.tf create mode 100644 internal/configs/testdata/valid-modules/override-backend/main.tf create mode 100644 internal/configs/testdata/valid-modules/override-backend/override.tf create mode 100644 internal/configs/testdata/valid-modules/override-cloud-no-base/main.tf create mode 100644 internal/configs/testdata/valid-modules/override-cloud-no-base/override.tf create mode 100644 internal/configs/testdata/valid-modules/override-cloud/main.tf create mode 100644 internal/configs/testdata/valid-modules/override-cloud/override.tf diff --git a/internal/configs/module.go b/internal/configs/module.go index b75cc7fd5..c2088b9fd 100644 --- a/internal/configs/module.go +++ b/internal/configs/module.go @@ -379,6 +379,7 @@ func (m *Module) mergeFile(file *File) hcl.Diagnostics { if len(file.Backends) != 0 { switch len(file.Backends) { case 1: + m.CloudConfig = nil // A backend block is mutually exclusive with a cloud one, and overwrites any cloud config m.Backend = file.Backends[0] default: // An override file with multiple backends is still invalid, even @@ -392,16 +393,21 @@ func (m *Module) mergeFile(file *File) hcl.Diagnostics { } } - // TODO: This restriction is temporary. Overrides should be allowed, but have the added - // complexity of needing to also override a 'backend' block, so this work is being deferred - // for now. - for _, m := range file.CloudConfigs { - diags = append(diags, &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Cannot override 'cloud' configuration", - Detail: "Terraform Cloud configuration blocks can appear only in normal files, not in override files.", - Subject: m.DeclRange.Ptr(), - }) + if len(file.CloudConfigs) != 0 { + switch len(file.CloudConfigs) { + case 1: + m.Backend = nil // A cloud block is mutually exclusive with a backend one, and overwrites any backend + m.CloudConfig = file.CloudConfigs[0] + default: + // An override file with multiple cloud blocks is still invalid, even + // though it can override cloud/backend blocks from _other_ files. + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate Terraform Cloud configurations", + Detail: fmt.Sprintf("A module may have only one 'cloud' block configuring Terraform Cloud. Terraform Cloud was previously configured at %s.", file.CloudConfigs[0].DeclRange), + Subject: &file.CloudConfigs[1].DeclRange, + }) + } } for _, pc := range file.ProviderConfigs { diff --git a/internal/configs/module_test.go b/internal/configs/module_test.go index 394eaff1c..3eea93d37 100644 --- a/internal/configs/module_test.go +++ b/internal/configs/module_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/hashicorp/terraform/internal/addrs" + "github.com/zclconf/go-cty/cty" ) // TestNewModule_provider_fqns exercises module.gatherProviderLocalNames() @@ -309,3 +310,105 @@ func TestImpliedProviderForUnqualifiedType(t *testing.T) { } } } + +func TestModule_backend_override(t *testing.T) { + mod, diags := testModuleFromDir("testdata/valid-modules/override-backend") + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + gotType := mod.Backend.Type + wantType := "bar" + + if gotType != wantType { + t.Errorf("wrong result for backend type: got %#v, want %#v\n", gotType, wantType) + } + + attrs, _ := mod.Backend.Config.JustAttributes() + + gotAttr, diags := attrs["path"].Expr.Value(nil) + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + wantAttr := cty.StringVal("CHANGED/relative/path/to/terraform.tfstate") + + if !gotAttr.RawEquals(wantAttr) { + t.Errorf("wrong result for backend 'path': got %#v, want %#v\n", gotAttr, wantAttr) + } +} + +// Unlike most other overrides, backend blocks do not require a base configuration in a primary +// configuration file, as an omitted backend there implies the local backend. +func TestModule_backend_override_no_base(t *testing.T) { + mod, diags := testModuleFromDir("testdata/valid-modules/override-backend-no-base") + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + if mod.Backend == nil { + t.Errorf("expected module Backend not to be nil") + } +} + +func TestModule_cloud_override_backend(t *testing.T) { + mod, diags := testModuleFromDir("testdata/valid-modules/override-backend-with-cloud") + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + if mod.Backend != nil { + t.Errorf("expected module Backend to be nil") + } + + if mod.CloudConfig == nil { + t.Errorf("expected module CloudConfig not to be nil") + } +} + +// Unlike most other overrides, cloud blocks do not require a base configuration in a primary +// configuration file, as an omitted backend there implies the local backend and cloud blocks +// override backends. +func TestModule_cloud_override_no_base(t *testing.T) { + mod, diags := testModuleFromDir("testdata/valid-modules/override-cloud-no-base") + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + if mod.CloudConfig == nil { + t.Errorf("expected module CloudConfig not to be nil") + } +} + +func TestModule_cloud_override(t *testing.T) { + mod, diags := testModuleFromDir("testdata/valid-modules/override-cloud") + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + attrs, _ := mod.CloudConfig.Config.JustAttributes() + + gotAttr, diags := attrs["organization"].Expr.Value(nil) + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + wantAttr := cty.StringVal("CHANGED") + + if !gotAttr.RawEquals(wantAttr) { + t.Errorf("wrong result for Cloud 'organization': got %#v, want %#v\n", gotAttr, wantAttr) + } + + // The override should have completely replaced the cloud block in the primary file, no merging + if attrs["should_not_be_present_with_override"] != nil { + t.Errorf("expected 'should_not_be_present_with_override' attribute to be nil") + } +} + +func TestModule_cloud_duplicate_overrides(t *testing.T) { + _, diags := testModuleFromDir("testdata/invalid-modules/override-cloud-duplicates") + want := `Duplicate Terraform Cloud configurations` + if got := diags.Error(); !strings.Contains(got, want) { + t.Fatalf("expected module error to contain %q\nerror was:\n%s", want, got) + } +} diff --git a/internal/configs/testdata/invalid-modules/override-cloud-duplicates/main.tf b/internal/configs/testdata/invalid-modules/override-cloud-duplicates/main.tf new file mode 100644 index 000000000..2de9a58dd --- /dev/null +++ b/internal/configs/testdata/invalid-modules/override-cloud-duplicates/main.tf @@ -0,0 +1,14 @@ +terraform { + cloud { + organization = "foo" + should_not_be_present_with_override = true + } +} + +resource "aws_instance" "web" { + ami = "ami-1234" + security_groups = [ + "foo", + "bar", + ] +} diff --git a/internal/configs/testdata/invalid-modules/override-cloud-duplicates/override.tf b/internal/configs/testdata/invalid-modules/override-cloud-duplicates/override.tf new file mode 100644 index 000000000..17ef01150 --- /dev/null +++ b/internal/configs/testdata/invalid-modules/override-cloud-duplicates/override.tf @@ -0,0 +1,11 @@ +terraform { + cloud { + organization = "foo" + } +} + +terraform { + cloud { + organization = "bar" + } +} diff --git a/internal/configs/testdata/valid-modules/override-backend-no-base/main.tf b/internal/configs/testdata/valid-modules/override-backend-no-base/main.tf new file mode 100644 index 000000000..7bb1380e6 --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-backend-no-base/main.tf @@ -0,0 +1,7 @@ +resource "aws_instance" "web" { + ami = "ami-1234" + security_groups = [ + "foo", + "bar", + ] +} diff --git a/internal/configs/testdata/valid-modules/override-backend-no-base/override.tf b/internal/configs/testdata/valid-modules/override-backend-no-base/override.tf new file mode 100644 index 000000000..d57fade63 --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-backend-no-base/override.tf @@ -0,0 +1,5 @@ +terraform { + backend "bar" { + path = "CHANGED/relative/path/to/terraform.tfstate" + } +} diff --git a/internal/configs/testdata/valid-modules/override-backend-with-cloud/main.tf b/internal/configs/testdata/valid-modules/override-backend-with-cloud/main.tf new file mode 100644 index 000000000..56fb72f32 --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-backend-with-cloud/main.tf @@ -0,0 +1,13 @@ +terraform { + backend "foo" { + path = "relative/path/to/terraform.tfstate" + } +} + +resource "aws_instance" "web" { + ami = "ami-1234" + security_groups = [ + "foo", + "bar", + ] +} diff --git a/internal/configs/testdata/valid-modules/override-backend-with-cloud/override.tf b/internal/configs/testdata/valid-modules/override-backend-with-cloud/override.tf new file mode 100644 index 000000000..51ae925fb --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-backend-with-cloud/override.tf @@ -0,0 +1,5 @@ +terraform { + cloud { + organization = "foo" + } +} diff --git a/internal/configs/testdata/valid-modules/override-backend/main.tf b/internal/configs/testdata/valid-modules/override-backend/main.tf new file mode 100644 index 000000000..56fb72f32 --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-backend/main.tf @@ -0,0 +1,13 @@ +terraform { + backend "foo" { + path = "relative/path/to/terraform.tfstate" + } +} + +resource "aws_instance" "web" { + ami = "ami-1234" + security_groups = [ + "foo", + "bar", + ] +} diff --git a/internal/configs/testdata/valid-modules/override-backend/override.tf b/internal/configs/testdata/valid-modules/override-backend/override.tf new file mode 100644 index 000000000..d57fade63 --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-backend/override.tf @@ -0,0 +1,5 @@ +terraform { + backend "bar" { + path = "CHANGED/relative/path/to/terraform.tfstate" + } +} diff --git a/internal/configs/testdata/valid-modules/override-cloud-no-base/main.tf b/internal/configs/testdata/valid-modules/override-cloud-no-base/main.tf new file mode 100644 index 000000000..7bb1380e6 --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-cloud-no-base/main.tf @@ -0,0 +1,7 @@ +resource "aws_instance" "web" { + ami = "ami-1234" + security_groups = [ + "foo", + "bar", + ] +} diff --git a/internal/configs/testdata/valid-modules/override-cloud-no-base/override.tf b/internal/configs/testdata/valid-modules/override-cloud-no-base/override.tf new file mode 100644 index 000000000..51ae925fb --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-cloud-no-base/override.tf @@ -0,0 +1,5 @@ +terraform { + cloud { + organization = "foo" + } +} diff --git a/internal/configs/testdata/valid-modules/override-cloud/main.tf b/internal/configs/testdata/valid-modules/override-cloud/main.tf new file mode 100644 index 000000000..2de9a58dd --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-cloud/main.tf @@ -0,0 +1,14 @@ +terraform { + cloud { + organization = "foo" + should_not_be_present_with_override = true + } +} + +resource "aws_instance" "web" { + ami = "ami-1234" + security_groups = [ + "foo", + "bar", + ] +} diff --git a/internal/configs/testdata/valid-modules/override-cloud/override.tf b/internal/configs/testdata/valid-modules/override-cloud/override.tf new file mode 100644 index 000000000..a4a7752ca --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-cloud/override.tf @@ -0,0 +1,5 @@ +terraform { + cloud { + organization = "CHANGED" + } +} From f4758803fe572c428796fb9ae9c6fcaf8512ad2b Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Mon, 30 Aug 2021 15:27:58 -0700 Subject: [PATCH 07/78] cloud: Backend implements new version of the backend.Local interface This makes the new backend compatible with the new terraform.Context API, which has changed in main. --- internal/cloud/backend.go | 2 ++ internal/cloud/backend_context.go | 32 +++++++++++++++----------- internal/cloud/backend_context_test.go | 2 +- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index b6ae5e154..2a0549689 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -90,6 +90,8 @@ type Cloud struct { } var _ backend.Backend = (*Cloud)(nil) +var _ backend.Enhanced = (*Cloud)(nil) +var _ backend.Local = (*Cloud)(nil) // New creates a new initialized cloud backend. func New(services *disco.Disco) *Cloud { diff --git a/internal/cloud/backend_context.go b/internal/cloud/backend_context.go index 55f9aba54..a564a6d3a 100644 --- a/internal/cloud/backend_context.go +++ b/internal/cloud/backend_context.go @@ -6,7 +6,6 @@ import ( "log" "strings" - "github.com/hashicorp/errwrap" tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" @@ -18,9 +17,15 @@ import ( "github.com/zclconf/go-cty/cty" ) -// Context implements backend.Enhanced. -func (b *Cloud) Context(op *backend.Operation) (*terraform.Context, statemgr.Full, tfdiags.Diagnostics) { +// LocalRun implements backend.Local +func (b *Cloud) LocalRun(op *backend.Operation) (*backend.LocalRun, statemgr.Full, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics + ret := &backend.LocalRun{ + PlanOpts: &terraform.PlanOpts{ + Mode: op.PlanMode, + Targets: op.Targets, + }, + } op.StateLocker = op.StateLocker.WithContext(context.Background()) @@ -31,7 +36,7 @@ func (b *Cloud) Context(op *backend.Operation) (*terraform.Context, statemgr.Ful log.Printf("[TRACE] cloud: requesting state manager for workspace %q", remoteWorkspaceName) stateMgr, err := b.StateMgr(op.Workspace) if err != nil { - diags = diags.Append(errwrap.Wrapf("Error loading state: {{err}}", err)) + diags = diags.Append(fmt.Errorf("error loading state: %w", err)) return nil, nil, diags } @@ -50,7 +55,7 @@ func (b *Cloud) Context(op *backend.Operation) (*terraform.Context, statemgr.Ful log.Printf("[TRACE] cloud: reading remote state for workspace %q", remoteWorkspaceName) if err := stateMgr.RefreshState(); err != nil { - diags = diags.Append(errwrap.Wrapf("Error loading state: {{err}}", err)) + diags = diags.Append(fmt.Errorf("error loading state: %w", err)) return nil, nil, diags } @@ -61,15 +66,13 @@ func (b *Cloud) Context(op *backend.Operation) (*terraform.Context, statemgr.Ful } // Copy set options from the operation - opts.PlanMode = op.PlanMode - opts.Targets = op.Targets opts.UIInput = op.UIIn // Load the latest state. If we enter contextFromPlanFile below then the // state snapshot in the plan file must match this, or else it'll return // error diagnostics. log.Printf("[TRACE] cloud: retrieving remote state snapshot for workspace %q", remoteWorkspaceName) - opts.State = stateMgr.State() + ret.InputState = stateMgr.State() log.Printf("[TRACE] cloud: loading configuration for the current working directory") config, configDiags := op.ConfigLoader.LoadConfig(op.ConfigDir) @@ -77,21 +80,21 @@ func (b *Cloud) Context(op *backend.Operation) (*terraform.Context, statemgr.Ful if configDiags.HasErrors() { return nil, nil, diags } - opts.Config = config + ret.Config = config // The underlying API expects us to use the opaque workspace id to request // variables, so we'll need to look that up using our organization name // and workspace name. remoteWorkspaceID, err := b.getRemoteWorkspaceID(context.Background(), op.Workspace) if err != nil { - diags = diags.Append(errwrap.Wrapf("Error finding remote workspace: {{err}}", err)) + diags = diags.Append(fmt.Errorf("error finding remote workspace: %w", err)) return nil, nil, diags } log.Printf("[TRACE] cloud: retrieving variables from workspace %s/%s (%s)", remoteWorkspaceName, b.organization, remoteWorkspaceID) tfeVariables, err := b.client.Variables.List(context.Background(), remoteWorkspaceID, tfe.VariableListOptions{}) if err != nil && err != tfe.ErrResourceNotFound { - diags = diags.Append(errwrap.Wrapf("Error loading variables: {{err}}", err)) + diags = diags.Append(fmt.Errorf("error loading variables: %w", err)) return nil, nil, diags } @@ -100,7 +103,7 @@ func (b *Cloud) Context(op *backend.Operation) (*terraform.Context, statemgr.Ful // more lax about them, stubbing out any unset ones as unknown. // This gives us enough information to produce a consistent context, // but not enough information to run a real operation (plan, apply, etc) - opts.Variables = stubAllVariables(op.Variables, config.Module.Variables) + ret.PlanOpts.SetVariables = stubAllVariables(op.Variables, config.Module.Variables) } else { if tfeVariables != nil { if op.Variables == nil { @@ -121,16 +124,17 @@ func (b *Cloud) Context(op *backend.Operation) (*terraform.Context, statemgr.Ful if diags.HasErrors() { return nil, nil, diags } - opts.Variables = variables + ret.PlanOpts.SetVariables = variables } } tfCtx, ctxDiags := terraform.NewContext(&opts) diags = diags.Append(ctxDiags) + ret.Core = tfCtx log.Printf("[TRACE] cloud: finished building terraform.Context") - return tfCtx, stateMgr, diags + return ret, stateMgr, diags } func (b *Cloud) getRemoteWorkspaceName(localWorkspaceName string) string { diff --git a/internal/cloud/backend_context_test.go b/internal/cloud/backend_context_test.go index 264d10a71..683ad98eb 100644 --- a/internal/cloud/backend_context_test.go +++ b/internal/cloud/backend_context_test.go @@ -204,7 +204,7 @@ func TestRemoteContextWithVars(t *testing.T) { } b.client.Variables.Create(context.TODO(), workspaceID, *v) - _, _, diags := b.Context(op) + _, _, diags := b.LocalRun(op) if test.WantError != "" { if !diags.HasErrors() { From 85f37f1aa54d82abb58cf53cb714534894d0a87c Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Mon, 30 Aug 2021 22:03:51 -0500 Subject: [PATCH 08/78] Add missing log mocks I missed adding these in the original porting commit from the remote backend, because *.log is added to gitignore. --- .../cloud/testdata/apply-destroy/apply.log | 7 ++++++ .../cloud/testdata/apply-destroy/plan.log | 22 ++++++++++++++++++ .../cloud/testdata/apply-no-changes/plan.log | 17 ++++++++++++++ .../testdata/apply-no-changes/policy.log | 12 ++++++++++ .../apply-policy-hard-failed/plan.log | 21 +++++++++++++++++ .../apply-policy-hard-failed/policy.log | 12 ++++++++++ .../testdata/apply-policy-passed/apply.log | 7 ++++++ .../testdata/apply-policy-passed/plan.log | 21 +++++++++++++++++ .../testdata/apply-policy-passed/policy.log | 12 ++++++++++ .../apply-policy-soft-failed/apply.log | 7 ++++++ .../apply-policy-soft-failed/plan.log | 21 +++++++++++++++++ .../apply-policy-soft-failed/policy.log | 12 ++++++++++ .../cloud/testdata/apply-variables/apply.log | 7 ++++++ .../cloud/testdata/apply-variables/plan.log | 21 +++++++++++++++++ .../cloud/testdata/apply-with-error/plan.log | 10 ++++++++ internal/cloud/testdata/apply/apply.log | 7 ++++++ internal/cloud/testdata/apply/plan.log | 21 +++++++++++++++++ .../testdata/plan-cost-estimation/ce.log | 6 +++++ .../plan-cost-estimation/cost-estimate.log | 5 ++++ .../testdata/plan-cost-estimation/plan.log | 20 ++++++++++++++++ .../cloud/testdata/plan-long-line/plan.log | 23 +++++++++++++++++++ .../cloud/testdata/plan-no-changes/plan.log | 17 ++++++++++++++ .../cloud/testdata/plan-no-changes/policy.log | 12 ++++++++++ .../testdata/plan-policy-hard-failed/plan.log | 21 +++++++++++++++++ .../plan-policy-hard-failed/policy.log | 12 ++++++++++ .../testdata/plan-policy-passed/plan.log | 21 +++++++++++++++++ .../testdata/plan-policy-passed/policy.log | 12 ++++++++++ .../testdata/plan-policy-soft-failed/plan.log | 21 +++++++++++++++++ .../plan-policy-soft-failed/policy.log | 12 ++++++++++ .../cloud/testdata/plan-variables/plan.log | 21 +++++++++++++++++ .../cloud/testdata/plan-with-error/plan.log | 10 ++++++++ .../terraform/plan.log | 21 +++++++++++++++++ internal/cloud/testdata/plan/plan.log | 21 +++++++++++++++++ 33 files changed, 492 insertions(+) create mode 100644 internal/cloud/testdata/apply-destroy/apply.log create mode 100644 internal/cloud/testdata/apply-destroy/plan.log create mode 100644 internal/cloud/testdata/apply-no-changes/plan.log create mode 100644 internal/cloud/testdata/apply-no-changes/policy.log create mode 100644 internal/cloud/testdata/apply-policy-hard-failed/plan.log create mode 100644 internal/cloud/testdata/apply-policy-hard-failed/policy.log create mode 100644 internal/cloud/testdata/apply-policy-passed/apply.log create mode 100644 internal/cloud/testdata/apply-policy-passed/plan.log create mode 100644 internal/cloud/testdata/apply-policy-passed/policy.log create mode 100644 internal/cloud/testdata/apply-policy-soft-failed/apply.log create mode 100644 internal/cloud/testdata/apply-policy-soft-failed/plan.log create mode 100644 internal/cloud/testdata/apply-policy-soft-failed/policy.log create mode 100644 internal/cloud/testdata/apply-variables/apply.log create mode 100644 internal/cloud/testdata/apply-variables/plan.log create mode 100644 internal/cloud/testdata/apply-with-error/plan.log create mode 100644 internal/cloud/testdata/apply/apply.log create mode 100644 internal/cloud/testdata/apply/plan.log create mode 100644 internal/cloud/testdata/plan-cost-estimation/ce.log create mode 100644 internal/cloud/testdata/plan-cost-estimation/cost-estimate.log create mode 100644 internal/cloud/testdata/plan-cost-estimation/plan.log create mode 100644 internal/cloud/testdata/plan-long-line/plan.log create mode 100644 internal/cloud/testdata/plan-no-changes/plan.log create mode 100644 internal/cloud/testdata/plan-no-changes/policy.log create mode 100644 internal/cloud/testdata/plan-policy-hard-failed/plan.log create mode 100644 internal/cloud/testdata/plan-policy-hard-failed/policy.log create mode 100644 internal/cloud/testdata/plan-policy-passed/plan.log create mode 100644 internal/cloud/testdata/plan-policy-passed/policy.log create mode 100644 internal/cloud/testdata/plan-policy-soft-failed/plan.log create mode 100644 internal/cloud/testdata/plan-policy-soft-failed/policy.log create mode 100644 internal/cloud/testdata/plan-variables/plan.log create mode 100644 internal/cloud/testdata/plan-with-error/plan.log create mode 100644 internal/cloud/testdata/plan-with-working-directory/terraform/plan.log create mode 100644 internal/cloud/testdata/plan/plan.log diff --git a/internal/cloud/testdata/apply-destroy/apply.log b/internal/cloud/testdata/apply-destroy/apply.log new file mode 100644 index 000000000..d126547d9 --- /dev/null +++ b/internal/cloud/testdata/apply-destroy/apply.log @@ -0,0 +1,7 @@ +Terraform v0.11.10 + +Initializing plugins and modules... +null_resource.hello: Destroying... (ID: 8657651096157629581) +null_resource.hello: Destruction complete after 0s + +Apply complete! Resources: 0 added, 0 changed, 1 destroyed. diff --git a/internal/cloud/testdata/apply-destroy/plan.log b/internal/cloud/testdata/apply-destroy/plan.log new file mode 100644 index 000000000..1d38d4168 --- /dev/null +++ b/internal/cloud/testdata/apply-destroy/plan.log @@ -0,0 +1,22 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +null_resource.hello: Refreshing state... (ID: 8657651096157629581) + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + - destroy + +Terraform will perform the following actions: + + - null_resource.hello + + +Plan: 0 to add, 0 to change, 1 to destroy. diff --git a/internal/cloud/testdata/apply-no-changes/plan.log b/internal/cloud/testdata/apply-no-changes/plan.log new file mode 100644 index 000000000..704168151 --- /dev/null +++ b/internal/cloud/testdata/apply-no-changes/plan.log @@ -0,0 +1,17 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +null_resource.hello: Refreshing state... (ID: 8657651096157629581) + +------------------------------------------------------------------------ + +No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your +configuration and real physical resources that exist. As a result, no +actions need to be performed. diff --git a/internal/cloud/testdata/apply-no-changes/policy.log b/internal/cloud/testdata/apply-no-changes/policy.log new file mode 100644 index 000000000..b0cb1e598 --- /dev/null +++ b/internal/cloud/testdata/apply-no-changes/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: true + +This result means that Sentinel policies returned true and the protected +behavior is allowed by Sentinel policies. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: true + +TRUE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/internal/cloud/testdata/apply-policy-hard-failed/plan.log b/internal/cloud/testdata/apply-policy-hard-failed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/apply-policy-hard-failed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/apply-policy-hard-failed/policy.log b/internal/cloud/testdata/apply-policy-hard-failed/policy.log new file mode 100644 index 000000000..5d6e6935b --- /dev/null +++ b/internal/cloud/testdata/apply-policy-hard-failed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: false + +Sentinel evaluated to false because one or more Sentinel policies evaluated +to false. This false was not due to an undefined value or runtime error. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (hard-mandatory) + +Result: false + +FALSE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/internal/cloud/testdata/apply-policy-passed/apply.log b/internal/cloud/testdata/apply-policy-passed/apply.log new file mode 100644 index 000000000..901994838 --- /dev/null +++ b/internal/cloud/testdata/apply-policy-passed/apply.log @@ -0,0 +1,7 @@ +Terraform v0.11.10 + +Initializing plugins and modules... +null_resource.hello: Creating... +null_resource.hello: Creation complete after 0s (ID: 8657651096157629581) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. diff --git a/internal/cloud/testdata/apply-policy-passed/plan.log b/internal/cloud/testdata/apply-policy-passed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/apply-policy-passed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/apply-policy-passed/policy.log b/internal/cloud/testdata/apply-policy-passed/policy.log new file mode 100644 index 000000000..b0cb1e598 --- /dev/null +++ b/internal/cloud/testdata/apply-policy-passed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: true + +This result means that Sentinel policies returned true and the protected +behavior is allowed by Sentinel policies. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: true + +TRUE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/internal/cloud/testdata/apply-policy-soft-failed/apply.log b/internal/cloud/testdata/apply-policy-soft-failed/apply.log new file mode 100644 index 000000000..901994838 --- /dev/null +++ b/internal/cloud/testdata/apply-policy-soft-failed/apply.log @@ -0,0 +1,7 @@ +Terraform v0.11.10 + +Initializing plugins and modules... +null_resource.hello: Creating... +null_resource.hello: Creation complete after 0s (ID: 8657651096157629581) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. diff --git a/internal/cloud/testdata/apply-policy-soft-failed/plan.log b/internal/cloud/testdata/apply-policy-soft-failed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/apply-policy-soft-failed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/apply-policy-soft-failed/policy.log b/internal/cloud/testdata/apply-policy-soft-failed/policy.log new file mode 100644 index 000000000..3e4ebedf6 --- /dev/null +++ b/internal/cloud/testdata/apply-policy-soft-failed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: false + +Sentinel evaluated to false because one or more Sentinel policies evaluated +to false. This false was not due to an undefined value or runtime error. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: false + +FALSE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/internal/cloud/testdata/apply-variables/apply.log b/internal/cloud/testdata/apply-variables/apply.log new file mode 100644 index 000000000..901994838 --- /dev/null +++ b/internal/cloud/testdata/apply-variables/apply.log @@ -0,0 +1,7 @@ +Terraform v0.11.10 + +Initializing plugins and modules... +null_resource.hello: Creating... +null_resource.hello: Creation complete after 0s (ID: 8657651096157629581) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. diff --git a/internal/cloud/testdata/apply-variables/plan.log b/internal/cloud/testdata/apply-variables/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/apply-variables/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/apply-with-error/plan.log b/internal/cloud/testdata/apply-with-error/plan.log new file mode 100644 index 000000000..4344a3722 --- /dev/null +++ b/internal/cloud/testdata/apply-with-error/plan.log @@ -0,0 +1,10 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... + +Error: null_resource.foo: 1 error(s) occurred: + +* null_resource.foo: 1:3: unknown function called: guid in: + +${guid()} diff --git a/internal/cloud/testdata/apply/apply.log b/internal/cloud/testdata/apply/apply.log new file mode 100644 index 000000000..901994838 --- /dev/null +++ b/internal/cloud/testdata/apply/apply.log @@ -0,0 +1,7 @@ +Terraform v0.11.10 + +Initializing plugins and modules... +null_resource.hello: Creating... +null_resource.hello: Creation complete after 0s (ID: 8657651096157629581) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. diff --git a/internal/cloud/testdata/apply/plan.log b/internal/cloud/testdata/apply/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/apply/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/plan-cost-estimation/ce.log b/internal/cloud/testdata/plan-cost-estimation/ce.log new file mode 100644 index 000000000..e51fef1ed --- /dev/null +++ b/internal/cloud/testdata/plan-cost-estimation/ce.log @@ -0,0 +1,6 @@ ++---------+------+-----+-------------+----------------------+ +| PRODUCT | NAME | SKU | DESCRIPTION | DELTA | ++---------+------+-----+-------------+----------------------+ ++---------+------+-----+-------------+----------------------+ +| TOTAL | $0.000 USD / 720 HRS | ++---------+------+-----+-------------+----------------------+ diff --git a/internal/cloud/testdata/plan-cost-estimation/cost-estimate.log b/internal/cloud/testdata/plan-cost-estimation/cost-estimate.log new file mode 100644 index 000000000..67a50928c --- /dev/null +++ b/internal/cloud/testdata/plan-cost-estimation/cost-estimate.log @@ -0,0 +1,5 @@ +Cost estimation: + +Waiting for cost estimation to complete... +Resources: 1 of 1 estimated + $25.488/mo +$25.488 \ No newline at end of file diff --git a/internal/cloud/testdata/plan-cost-estimation/plan.log b/internal/cloud/testdata/plan-cost-estimation/plan.log new file mode 100644 index 000000000..fae287f45 --- /dev/null +++ b/internal/cloud/testdata/plan-cost-estimation/plan.log @@ -0,0 +1,20 @@ +Terraform v0.12.9 +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/plan-long-line/plan.log b/internal/cloud/testdata/plan-long-line/plan.log new file mode 100644 index 000000000..f34ed170c --- /dev/null +++ b/internal/cloud/testdata/plan-long-line/plan.log @@ -0,0 +1,23 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + triggers.%: "1" + triggers.long_line: "[{'_id':'5c5ab0ed7de45e993ffb9eeb','index':0,'guid':'e734d772-6b5a-4cb0-805c-91cd5e560e20','isActive':false,'balance':'$1,472.03','picture':'http://placehold.it/32x32','age':30,'eyeColor':'blue','name':{'first':'Darlene','last':'Garza'},'company':'GEEKOSIS','email':'darlene.garza@geekosis.io','phone':'+1 (850) 506-3347','address':'165 Kiely Place, Como, New Mexico, 4335','about':'Officia ullamco et sunt magna voluptate culpa cupidatat ea tempor laboris cupidatat ea anim laboris. Minim enim quis enim esse laborum est veniam. Lorem excepteur elit Lorem cupidatat elit ea anim irure fugiat fugiat sunt mollit. Consectetur ad nulla dolor amet esse occaecat aliquip sit. Magna sit elit adipisicing ut reprehenderit anim exercitation sit quis ea pariatur Lorem magna dolore.','registered':'Wednesday, March 11, 2015 12:58 PM','latitude':'20.729127','longitude':'-127.343593','tags':['minim','in','deserunt','occaecat','fugiat'],'greeting':'Hello, Darlene! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eda9117d15f1c1f112','index':1,'guid':'f0d1eed2-c6a9-4535-8800-d4bd53fe7eee','isActive':true,'balance':'$2,901.90','picture':'http://placehold.it/32x32','age':28,'eyeColor':'brown','name':{'first':'Flora','last':'Short'},'company':'SIGNITY','email':'flora.short@signity.me','phone':'+1 (840) 520-2666','address':'636 Johnson Avenue, Gerber, Wisconsin, 9139','about':'Veniam dolore deserunt Lorem aliqua qui eiusmod. Amet tempor fugiat duis incididunt amet adipisicing. Id ea nisi veniam eiusmod.','registered':'Wednesday, May 2, 2018 5:59 AM','latitude':'-63.267612','longitude':'4.224102','tags':['veniam','incididunt','id','aliqua','reprehenderit'],'greeting':'Hello, Flora! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed83fd574d8041fa16','index':2,'guid':'29499a07-414a-436f-ba62-6634ca16bdcc','isActive':true,'balance':'$2,781.28','picture':'http://placehold.it/32x32','age':22,'eyeColor':'green','name':{'first':'Trevino','last':'Marks'},'company':'KEGULAR','email':'trevino.marks@kegular.com','phone':'+1 (843) 571-2269','address':'200 Alabama Avenue, Grenelefe, Florida, 7963','about':'Occaecat nisi exercitation Lorem mollit laborum magna adipisicing culpa dolor proident dolore. Non consequat ea amet et id mollit incididunt minim anim amet nostrud labore tempor. Proident eu sint commodo nisi consequat voluptate do fugiat proident. Laboris eiusmod veniam non et elit nulla nisi labore incididunt Lorem consequat consectetur voluptate.','registered':'Saturday, January 25, 2014 5:56 AM','latitude':'65.044005','longitude':'-127.454864','tags':['anim','duis','velit','pariatur','enim'],'greeting':'Hello, Trevino! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed784eb6e350ff0a07','index':3,'guid':'40ed47e2-1747-4665-ab59-cdb3630a7642','isActive':true,'balance':'$2,000.78','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Solis','last':'Mckinney'},'company':'QABOOS','email':'solis.mckinney@qaboos.org','phone':'+1 (924) 405-2560','address':'712 Herkimer Court, Klondike, Ohio, 8133','about':'Minim ad anim minim tempor mollit magna tempor et non commodo amet. Nisi cupidatat labore culpa consectetur exercitation laborum adipisicing fugiat officia adipisicing consequat non. Qui voluptate tempor laboris exercitation qui non adipisicing occaecat voluptate sunt do nostrud velit. Consequat tempor officia laboris tempor irure cupidatat aliquip voluptate nostrud velit ex nulla tempor laboris. Qui pariatur pariatur enim aliquip velit. Officia mollit ullamco laboris velit velit eiusmod enim amet incididunt consectetur sunt.','registered':'Wednesday, April 12, 2017 6:59 AM','latitude':'-25.055596','longitude':'-140.126525','tags':['ipsum','adipisicing','amet','nulla','dolore'],'greeting':'Hello, Solis! You have 5 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed02ce1ea9a2155d51','index':4,'guid':'1b5fb7d3-3b9a-4382-81b5-9ab01a27e74b','isActive':true,'balance':'$1,373.67','picture':'http://placehold.it/32x32','age':28,'eyeColor':'green','name':{'first':'Janell','last':'Battle'},'company':'GEEKMOSIS','email':'janell.battle@geekmosis.net','phone':'+1 (810) 591-3014','address':'517 Onderdonk Avenue, Shrewsbury, District Of Columbia, 2335','about':'Reprehenderit ad proident do anim qui officia magna magna duis cillum esse minim est. Excepteur ipsum anim ad laboris. In occaecat dolore nulla ea Lorem tempor et culpa in sint. Officia eu eu incididunt sit amet. Culpa duis id reprehenderit ut anim sit sunt. Duis dolore proident velit incididunt adipisicing pariatur fugiat incididunt eiusmod eu veniam irure.','registered':'Thursday, February 8, 2018 1:44 AM','latitude':'-33.254864','longitude':'-154.145885','tags':['aute','deserunt','ipsum','eiusmod','laborum'],'greeting':'Hello, Janell! You have 5 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edab58604bd7d3dd1c','index':5,'guid':'6354c035-af22-44c9-8be9-b2ea9decc24d','isActive':true,'balance':'$3,535.68','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Combs','last':'Kirby'},'company':'LUXURIA','email':'combs.kirby@luxuria.name','phone':'+1 (900) 498-3266','address':'377 Kingsland Avenue, Ruckersville, Maine, 9916','about':'Lorem duis ipsum pariatur aliquip sunt. Commodo esse laborum incididunt mollit quis est laboris ea ea quis fugiat. Enim elit ullamco velit et fugiat veniam irure deserunt aliqua ad irure veniam.','registered':'Tuesday, February 21, 2017 4:04 PM','latitude':'-70.20591','longitude':'162.546871','tags':['reprehenderit','est','enim','aute','ad'],'greeting':'Hello, Combs! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edf7fafeffc6357c51','index':6,'guid':'02523e0b-cc90-4309-b6b2-f493dc6076f6','isActive':false,'balance':'$3,754.30','picture':'http://placehold.it/32x32','age':29,'eyeColor':'green','name':{'first':'Macias','last':'Calderon'},'company':'AMTAP','email':'macias.calderon@amtap.us','phone':'+1 (996) 569-3667','address':'305 Royce Street, Glidden, Iowa, 9248','about':'Exercitation nulla deserunt pariatur adipisicing. In commodo deserunt incididunt ut velit minim qui ut quis. Labore elit ullamco eiusmod voluptate in eu do est fugiat aute mollit deserunt. Eu duis proident velit fugiat velit ut. Ut non esse amet laborum nisi tempor in nulla.','registered':'Thursday, October 23, 2014 10:28 PM','latitude':'32.371629','longitude':'60.155135','tags':['commodo','elit','velit','excepteur','aliqua'],'greeting':'Hello, Macias! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed0e8a6109e7fabf17','index':7,'guid':'675ff6b6-197b-4154-9775-813d661df822','isActive':false,'balance':'$2,850.62','picture':'http://placehold.it/32x32','age':37,'eyeColor':'green','name':{'first':'Stefanie','last':'Rivers'},'company':'RECRITUBE','email':'stefanie.rivers@recritube.biz','phone':'+1 (994) 591-3551','address':'995 Campus Road, Abrams, Virginia, 3251','about':'Esse aute non laborum Lorem nulla irure. Veniam elit aute ut et dolor non deserunt laboris tempor. Ipsum quis cupidatat laborum laboris voluptate esse duis eiusmod excepteur consectetur commodo ullamco qui occaecat. Culpa velit cillum occaecat minim nisi.','registered':'Thursday, June 9, 2016 3:40 PM','latitude':'-18.526825','longitude':'149.670782','tags':['occaecat','sunt','reprehenderit','ipsum','magna'],'greeting':'Hello, Stefanie! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edf7d9bc2db4e476e3','index':8,'guid':'adaefc55-f6ea-4bd1-a147-0e31c3ce7a21','isActive':true,'balance':'$2,555.13','picture':'http://placehold.it/32x32','age':20,'eyeColor':'blue','name':{'first':'Hillary','last':'Lancaster'},'company':'OLUCORE','email':'hillary.lancaster@olucore.ca','phone':'+1 (964) 474-3018','address':'232 Berriman Street, Kaka, Massachusetts, 6792','about':'Veniam ad laboris quis reprehenderit aliquip nisi sunt excepteur ea aute laborum excepteur incididunt. Nisi exercitation aliquip do culpa commodo ex officia ut enim mollit in deserunt in amet. Anim eu deserunt dolore non cupidatat ut enim incididunt aute dolore voluptate. Do cillum mollit laborum non incididunt occaecat aute voluptate nisi irure.','registered':'Thursday, June 4, 2015 9:45 PM','latitude':'88.075919','longitude':'-148.951368','tags':['reprehenderit','veniam','ad','aute','anim'],'greeting':'Hello, Hillary! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed7b7192ad6a0f267c','index':9,'guid':'0ca9b8ea-f671-474e-be26-4a49cae4838a','isActive':true,'balance':'$3,684.51','picture':'http://placehold.it/32x32','age':40,'eyeColor':'brown','name':{'first':'Jill','last':'Conner'},'company':'EXOZENT','email':'jill.conner@exozent.info','phone':'+1 (887) 467-2168','address':'751 Thames Street, Juarez, American Samoa, 8386','about':'Enim voluptate et non est in magna laborum aliqua enim aliqua est non nostrud. Tempor est nulla ipsum consectetur esse nostrud est id. Consequat do voluptate cupidatat eu fugiat et fugiat velit id. Sint dolore ad qui tempor anim eu amet consectetur do elit aute adipisicing consequat ex.','registered':'Sunday, October 22, 2017 7:35 AM','latitude':'84.384911','longitude':'40.305648','tags':['tempor','sint','irure','et','ex'],'greeting':'Hello, Jill! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed713fe676575aa72b','index':10,'guid':'c28023cf-cc57-4c2e-8d91-dfbe6bafadcd','isActive':false,'balance':'$2,792.45','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Hurley','last':'George'},'company':'ZAJ','email':'hurley.george@zaj.tv','phone':'+1 (984) 547-3284','address':'727 Minna Street, Lacomb, Colorado, 2557','about':'Ex velit cupidatat veniam culpa. Eiusmod ut fugiat adipisicing incididunt consectetur exercitation Lorem exercitation ex. Incididunt anim aute incididunt fugiat cupidatat qui eu non reprehenderit. Eiusmod dolor nisi culpa excepteur ut velit minim dolor voluptate amet commodo culpa in.','registered':'Thursday, February 16, 2017 6:41 AM','latitude':'25.989949','longitude':'10.200053','tags':['minim','ut','sunt','consequat','ullamco'],'greeting':'Hello, Hurley! You have 8 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed1e56732746c70d8b','index':11,'guid':'e9766f13-766c-4450-b4d2-8b04580f60b7','isActive':true,'balance':'$3,874.26','picture':'http://placehold.it/32x32','age':35,'eyeColor':'green','name':{'first':'Leticia','last':'Pace'},'company':'HONOTRON','email':'leticia.pace@honotron.co.uk','phone':'+1 (974) 536-3322','address':'365 Goodwin Place, Savage, Nevada, 9191','about':'Nisi Lorem aliqua esse eiusmod magna. Ad minim incididunt proident ut Lorem cupidatat qui velit aliqua ullamco et ipsum in. Aliquip elit consectetur pariatur esse exercitation et officia quis. Occaecat tempor proident cillum anim ad commodo velit ut voluptate. Tempor et occaecat sit sint aliquip tempor nulla velit magna nisi proident exercitation Lorem id.','registered':'Saturday, August 4, 2018 5:05 AM','latitude':'70.620386','longitude':'-86.335813','tags':['occaecat','velit','labore','laboris','esse'],'greeting':'Hello, Leticia! You have 8 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed941337fe42f47426','index':12,'guid':'6d390762-17ea-4b58-9a36-b0c9a8748a42','isActive':true,'balance':'$1,049.61','picture':'http://placehold.it/32x32','age':38,'eyeColor':'green','name':{'first':'Rose','last':'Humphrey'},'company':'MYOPIUM','email':'rose.humphrey@myopium.io','phone':'+1 (828) 426-3086','address':'389 Sapphire Street, Saticoy, Marshall Islands, 1423','about':'Aliquip enim excepteur adipisicing ex. Consequat aliqua consequat nostrud do occaecat deserunt excepteur sit et ipsum sunt dolor eu. Dolore laborum commodo excepteur tempor ad adipisicing proident excepteur magna non Lorem proident consequat aute. Fugiat minim consequat occaecat voluptate esse velit officia laboris nostrud nisi ut voluptate.','registered':'Monday, April 16, 2018 12:38 PM','latitude':'-47.083742','longitude':'109.022423','tags':['aute','non','sit','adipisicing','mollit'],'greeting':'Hello, Rose! You have 9 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd0c02fc3fdc01a40','index':13,'guid':'07755618-6fdf-4b33-af50-364c18909227','isActive':true,'balance':'$1,823.61','picture':'http://placehold.it/32x32','age':36,'eyeColor':'green','name':{'first':'Judith','last':'Hale'},'company':'COLLAIRE','email':'judith.hale@collaire.me','phone':'+1 (922) 508-2843','address':'193 Coffey Street, Castleton, North Dakota, 3638','about':'Minim non ullamco ad anim nostrud dolore nostrud veniam consequat id eiusmod veniam laboris. Lorem irure esse mollit non velit aute id cupidatat est mollit occaecat magna excepteur. Adipisicing tempor nisi sit aliquip tempor pariatur tempor eu consectetur nulla amet nulla. Quis nisi nisi ea incididunt culpa et do. Esse officia eu pariatur velit sunt quis proident amet consectetur consequat. Nisi excepteur culpa nulla sit dolor deserunt excepteur dolor consequat elit cillum tempor Lorem.','registered':'Wednesday, August 24, 2016 12:29 AM','latitude':'-80.15514','longitude':'39.91007','tags':['consectetur','incididunt','aliquip','dolor','consequat'],'greeting':'Hello, Judith! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edb3e1e29caa4f728b','index':14,'guid':'2c6617a2-e7a9-4ff7-a8b9-e99554fe70fe','isActive':true,'balance':'$1,971.00','picture':'http://placehold.it/32x32','age':39,'eyeColor':'blue','name':{'first':'Estes','last':'Sweet'},'company':'GEEKKO','email':'estes.sweet@geekko.com','phone':'+1 (866) 448-3032','address':'847 Cove Lane, Kula, Mississippi, 9178','about':'Veniam consectetur occaecat est excepteur consequat ipsum cillum sit consectetur. Ut cupidatat et reprehenderit dolore enim do cillum qui pariatur ad laborum incididunt esse. Fugiat sunt dolor veniam laboris ipsum deserunt proident reprehenderit laboris non nostrud. Magna excepteur sint magna laborum tempor sit exercitation ipsum labore est ullamco ullamco. Cillum voluptate cillum ea laborum Lorem. Excepteur sint ut nisi est esse non. Minim excepteur ullamco velit nisi ut in elit exercitation ut dolore.','registered':'Sunday, August 12, 2018 5:06 PM','latitude':'-9.57771','longitude':'-159.94577','tags':['culpa','dolor','velit','anim','pariatur'],'greeting':'Hello, Estes! You have 7 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0edbcf088c6fd593091','index':15,'guid':'2cc79958-1b40-4e2c-907a-433903fd3da9','isActive':false,'balance':'$3,751.53','picture':'http://placehold.it/32x32','age':34,'eyeColor':'brown','name':{'first':'Kemp','last':'Spence'},'company':'EXOBLUE','email':'kemp.spence@exoblue.org','phone':'+1 (864) 487-2992','address':'217 Clay Street, Monument, North Carolina, 1460','about':'Nostrud duis cillum sint non commodo dolor aute aliqua adipisicing ad nulla non excepteur proident. Fugiat labore elit tempor cillum veniam reprehenderit laboris consectetur dolore amet qui cupidatat. Amet aliqua elit anim et consequat commodo excepteur officia anim aliqua ea eu labore cillum. Et ex dolor duis dolore commodo veniam et nisi.','registered':'Monday, October 29, 2018 5:23 AM','latitude':'-70.304222','longitude':'83.582371','tags':['velit','duis','consequat','incididunt','duis'],'greeting':'Hello, Kemp! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed6400479feb3de505','index':16,'guid':'91ccae6d-a3ea-43cf-bb00-3f2729256cc9','isActive':false,'balance':'$2,477.79','picture':'http://placehold.it/32x32','age':40,'eyeColor':'blue','name':{'first':'Ronda','last':'Burris'},'company':'EQUITOX','email':'ronda.burris@equitox.net','phone':'+1 (817) 553-3228','address':'708 Lawton Street, Deputy, Wyoming, 8598','about':'Excepteur voluptate aliquip consequat cillum est duis sit cillum eu eiusmod et laborum ullamco. Et minim reprehenderit aute voluptate amet ullamco. Amet sit enim ad irure deserunt nostrud anim veniam consequat dolor commodo. Consequat do occaecat do exercitation ullamco dolor ut. Id laboris consequat est dolor dolore tempor ullamco anim do ut nulla deserunt labore. Mollit ex Lorem ullamco mollit.','registered':'Monday, April 23, 2018 5:27 PM','latitude':'-31.227208','longitude':'0.63785','tags':['ipsum','magna','consectetur','sit','irure'],'greeting':'Hello, Ronda! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eddbeab2e53e04d563','index':17,'guid':'a86d4eb6-6bd8-48c2-a8fc-1c933c835852','isActive':false,'balance':'$3,709.03','picture':'http://placehold.it/32x32','age':37,'eyeColor':'blue','name':{'first':'Rosario','last':'Dillard'},'company':'BARKARAMA','email':'rosario.dillard@barkarama.name','phone':'+1 (933) 525-3898','address':'730 Chauncey Street, Forbestown, South Carolina, 6894','about':'Est eu fugiat aliquip ea ad qui ad mollit ad tempor voluptate et incididunt reprehenderit. Incididunt fugiat commodo minim adipisicing culpa consectetur duis eu ut commodo consequat voluptate labore. Nostrud irure labore adipisicing irure quis magna consequat dolor Lorem sint enim. Sint excepteur eu dolore elit ut do mollit sunt enim est. Labore id nostrud sint Lorem esse nostrud.','registered':'Friday, December 25, 2015 8:59 PM','latitude':'37.440827','longitude':'44.580474','tags':['Lorem','sit','ipsum','ea','ut'],'greeting':'Hello, Rosario! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eddf8e9b9c031d04e8','index':18,'guid':'a96f997c-daf8-40d4-92e1-be07e2cf0f60','isActive':false,'balance':'$1,878.37','picture':'http://placehold.it/32x32','age':37,'eyeColor':'brown','name':{'first':'Sondra','last':'Gonzales'},'company':'XUMONK','email':'sondra.gonzales@xumonk.us','phone':'+1 (838) 560-2255','address':'230 Cox Place, Geyserville, Georgia, 6805','about':'Laborum sunt voluptate ea laboris nostrud. Amet deserunt aliqua Lorem voluptate velit deserunt occaecat minim ullamco. Lorem occaecat sit labore adipisicing ad magna mollit labore ullamco proident. Ea velit do proident fugiat esse commodo ex nostrud eu mollit pariatur. Labore laborum qui voluptate quis proident reprehenderit tempor dolore duis deserunt esse aliqua aliquip. Non veniam enim pariatur cupidatat ipsum dolore est reprehenderit. Non exercitation adipisicing proident magna elit occaecat non magna.','registered':'Sunday, June 26, 2016 4:02 AM','latitude':'62.247742','longitude':'-44.90666','tags':['ea','aute','in','voluptate','magna'],'greeting':'Hello, Sondra! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed2c1bcd06781f677e','index':19,'guid':'6ac47a16-eed4-4460-92ee-e0dd33c1fbb5','isActive':false,'balance':'$3,730.64','picture':'http://placehold.it/32x32','age':20,'eyeColor':'brown','name':{'first':'Anastasia','last':'Vega'},'company':'FIREWAX','email':'anastasia.vega@firewax.biz','phone':'+1 (867) 493-3698','address':'803 Arlington Avenue, Rosburg, Northern Mariana Islands, 8769','about':'Sint ex nisi tempor sunt voluptate non et eiusmod irure. Aute reprehenderit dolor mollit aliqua Lorem voluptate occaecat. Sint laboris deserunt Lorem incididunt nulla cupidatat do.','registered':'Friday, March 18, 2016 12:02 PM','latitude':'-32.010216','longitude':'-87.874753','tags':['aliquip','mollit','mollit','ad','laborum'],'greeting':'Hello, Anastasia! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed727fd645854bbf43','index':20,'guid':'67bd8cdb-ce6b-455c-944c-a80e17c6fa75','isActive':true,'balance':'$2,868.06','picture':'http://placehold.it/32x32','age':29,'eyeColor':'green','name':{'first':'Lucinda','last':'Cox'},'company':'ENDIPINE','email':'lucinda.cox@endipine.ca','phone':'+1 (990) 428-3002','address':'412 Thatford Avenue, Lafferty, New Jersey, 5271','about':'Esse nulla sunt ut consequat aute mollit. Est occaecat sunt nisi irure id anim est commodo. Elit mollit amet dolore sunt adipisicing ea laborum quis ea reprehenderit non consequat dolore. Minim sunt occaecat quis aute commodo dolore quis commodo proident. Sunt sint duis ullamco sit ea esse Lorem. Consequat pariatur eiusmod laboris adipisicing labore in laboris adipisicing adipisicing consequat aute ea et.','registered':'Friday, May 1, 2015 10:16 PM','latitude':'-14.200957','longitude':'-82.211386','tags':['do','sit','qui','officia','aliquip'],'greeting':'Hello, Lucinda! You have 9 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed5a97284eb2cbd3a8','index':21,'guid':'f9fc999d-515c-4fc4-b339-76300e1b4bf2','isActive':true,'balance':'$1,172.57','picture':'http://placehold.it/32x32','age':35,'eyeColor':'brown','name':{'first':'Conrad','last':'Bradley'},'company':'FUELWORKS','email':'conrad.bradley@fuelworks.info','phone':'+1 (956) 561-3226','address':'685 Fenimore Street, Esmont, Maryland, 7523','about':'Labore reprehenderit anim nisi sunt do nisi in. Est anim cillum id minim exercitation ullamco voluptate ipsum eu. Elit culpa consequat reprehenderit laborum in eu. Laboris amet voluptate laboris qui voluptate duis minim reprehenderit. Commodo sunt irure dolore sunt occaecat velit nisi eu minim minim.','registered':'Wednesday, January 18, 2017 11:13 PM','latitude':'31.665993','longitude':'38.868968','tags':['excepteur','exercitation','est','nisi','mollit'],'greeting':'Hello, Conrad! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edc4eaf6f760c38218','index':22,'guid':'8794ef5f-da2f-46f0-a755-c18a16409fd5','isActive':false,'balance':'$3,594.73','picture':'http://placehold.it/32x32','age':27,'eyeColor':'blue','name':{'first':'Marquez','last':'Vargas'},'company':'MALATHION','email':'marquez.vargas@malathion.tv','phone':'+1 (976) 438-3126','address':'296 Hall Street, National, Texas, 2067','about':'Proident cillum aute minim fugiat sunt aliqua non occaecat est duis id id tempor. Qui deserunt nisi amet pariatur proident eu laboris esse adipisicing magna. Anim anim mollit aute non magna nisi aute magna labore ullamco reprehenderit voluptate et ad. Proident adipisicing aute eiusmod nostrud nostrud deserunt culpa. Elit eu ullamco nisi aliqua dolor sint pariatur excepteur sit consectetur tempor. Consequat Lorem ullamco commodo veniam qui sint magna. Sit mollit ad aliquip est id eu officia id adipisicing duis ad.','registered':'Tuesday, November 17, 2015 6:16 PM','latitude':'-36.443667','longitude':'22.336776','tags':['aliquip','veniam','ipsum','Lorem','ex'],'greeting':'Hello, Marquez! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0edd7c718518ee0466a','index':23,'guid':'ad8781a2-059e-4288-9879-309d53a99bf5','isActive':true,'balance':'$3,570.68','picture':'http://placehold.it/32x32','age':21,'eyeColor':'brown','name':{'first':'Snider','last':'Frost'},'company':'ZILODYNE','email':'snider.frost@zilodyne.co.uk','phone':'+1 (913) 485-3275','address':'721 Lincoln Road, Richmond, Utah, 672','about':'Minim enim Lorem esse incididunt do reprehenderit velit laborum ullamco. In aute eiusmod esse aliqua et labore tempor sunt ex mollit veniam tempor. Nulla elit cillum qui ullamco dolore amet deserunt magna amet laborum.','registered':'Saturday, August 23, 2014 12:58 AM','latitude':'-88.682554','longitude':'74.063179','tags':['nulla','ea','sint','aliquip','duis'],'greeting':'Hello, Snider! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edf026fece8e2c0970','index':24,'guid':'1b7d81e1-1dba-4322-bb1a-eaa6a24cccea','isActive':false,'balance':'$2,037.91','picture':'http://placehold.it/32x32','age':28,'eyeColor':'green','name':{'first':'Snyder','last':'Fletcher'},'company':'COMTEST','email':'snyder.fletcher@comtest.io','phone':'+1 (830) 538-3860','address':'221 Lewis Place, Zortman, Idaho, 572','about':'Elit anim enim esse dolore exercitation. Laboris esse sint adipisicing fugiat sint do occaecat ut voluptate sint nulla. Ad sint ut reprehenderit nostrud irure id consectetur officia velit consequat.','registered':'Sunday, January 1, 2017 1:13 AM','latitude':'-54.742604','longitude':'69.534932','tags':['exercitation','commodo','in','id','aliqua'],'greeting':'Hello, Snyder! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed4b9a7f83da6d2dfd','index':25,'guid':'0b2cc6b6-0044-4b1c-aa31-bd72963457a0','isActive':false,'balance':'$1,152.76','picture':'http://placehold.it/32x32','age':27,'eyeColor':'blue','name':{'first':'Regina','last':'James'},'company':'TELPOD','email':'regina.james@telpod.me','phone':'+1 (989) 455-3228','address':'688 Essex Street, Clayville, Alabama, 2772','about':'Eiusmod elit culpa reprehenderit ea veniam. Officia irure culpa duis aute ut. Irure duis cillum officia ea pariatur velit ut dolor incididunt reprehenderit ex elit laborum. Est pariatur veniam ad irure. Labore velit sunt esse laboris aliqua velit deserunt deserunt sit. Elit eiusmod ad laboris aliquip minim irure excepteur enim quis. Quis incididunt adipisicing ut magna cupidatat sit amet culpa.','registered':'Tuesday, April 25, 2017 10:16 PM','latitude':'-75.088027','longitude':'47.209828','tags':['elit','nisi','est','voluptate','proident'],'greeting':'Hello, Regina! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed10884f32f779f2bf','index':26,'guid':'1f6fb522-0002-46ff-8dac-451247f28168','isActive':true,'balance':'$1,948.79','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Collins','last':'Mcpherson'},'company':'DIGIGEN','email':'collins.mcpherson@digigen.com','phone':'+1 (991) 519-2334','address':'317 Merit Court, Sanford, Michigan, 6468','about':'Magna qui culpa dolor officia labore mollit ex excepteur duis eiusmod. Ea cupidatat ex ipsum mollit do minim duis. Nisi eiusmod minim tempor id esse commodo sunt sunt ullamco ut do laborum ullamco magna. Aliquip laborum dolor officia officia eu nostrud velit minim est anim. Ex elit laborum sunt magna exercitation nisi cillum sunt aute qui ea ullamco. Cupidatat ea sunt aute dolor duis nisi Lorem ullamco eiusmod. Sit ea velit ad veniam aliqua ad elit cupidatat ut magna in.','registered':'Friday, June 10, 2016 4:38 PM','latitude':'25.513996','longitude':'14.911124','tags':['exercitation','non','sit','velit','officia'],'greeting':'Hello, Collins! You have 5 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed8a575110efb15c6c','index':27,'guid':'2a904c82-068b-4ded-9ae6-cfeb6d7e62c9','isActive':true,'balance':'$3,427.91','picture':'http://placehold.it/32x32','age':24,'eyeColor':'green','name':{'first':'Mckay','last':'Barrera'},'company':'COMVEYER','email':'mckay.barrera@comveyer.org','phone':'+1 (853) 470-2560','address':'907 Glenwood Road, Churchill, Oregon, 8583','about':'In voluptate esse dolore enim sint quis dolor do exercitation sint et labore nisi. Eiusmod tempor exercitation dolore elit sit velit sint et. Sit magna adipisicing eiusmod do anim velit deserunt laboris ad ea pariatur. Irure nisi anim mollit elit commodo nulla. Aute eiusmod sit nulla eiusmod. Eiusmod est officia commodo mollit laboris do deserunt eu do nisi amet. Proident ad duis eiusmod laboris Lorem ut culpa pariatur Lorem reprehenderit minim aliquip irure sunt.','registered':'Saturday, December 19, 2015 2:49 PM','latitude':'-55.243287','longitude':'138.035406','tags':['non','quis','laboris','enim','nisi'],'greeting':'Hello, Mckay! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edcd49ab6a73ff7f32','index':28,'guid':'5d3e0dae-3f58-437f-b12d-de24667a904d','isActive':true,'balance':'$3,270.52','picture':'http://placehold.it/32x32','age':35,'eyeColor':'blue','name':{'first':'Mabel','last':'Leonard'},'company':'QUADEEBO','email':'mabel.leonard@quadeebo.net','phone':'+1 (805) 432-2356','address':'965 Underhill Avenue, Falconaire, Minnesota, 4450','about':'Cupidatat amet sunt est ipsum occaecat sit fugiat excepteur Lorem Lorem ex ea ipsum. Ad incididunt est irure magna excepteur occaecat nostrud. Minim dolor id anim ipsum qui nostrud ullamco aute ex Lorem magna deserunt excepteur Lorem.','registered':'Saturday, March 28, 2015 5:55 AM','latitude':'27.388359','longitude':'156.408728','tags':['quis','velit','deserunt','dolore','sit'],'greeting':'Hello, Mabel! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edde16ac2dc2fbb6c1','index':29,'guid':'d50c2233-70fc-4748-8ebf-02d45ac2a446','isActive':false,'balance':'$3,100.70','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Pace','last':'Duke'},'company':'SEQUITUR','email':'pace.duke@sequitur.name','phone':'+1 (983) 568-3119','address':'895 Melrose Street, Reno, Connecticut, 6259','about':'Ex veniam aliquip exercitation mollit elit est minim veniam aliqua labore deserunt. Dolor sunt sint cillum Lorem nisi ea irure cupidatat. Velit ut culpa cupidatat consequat cillum. Sint voluptate quis laboris qui incididunt do elit Lorem qui ullamco ut eu pariatur occaecat.','registered':'Saturday, August 18, 2018 2:18 PM','latitude':'31.930443','longitude':'-129.494784','tags':['culpa','est','nostrud','quis','aliquip'],'greeting':'Hello, Pace! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edb908d85642ba77e8','index':30,'guid':'3edb6e42-367a-403d-a511-eb78bcc11f60','isActive':true,'balance':'$1,912.07','picture':'http://placehold.it/32x32','age':24,'eyeColor':'green','name':{'first':'Cohen','last':'Morrison'},'company':'POWERNET','email':'cohen.morrison@powernet.us','phone':'+1 (888) 597-2141','address':'565 Troutman Street, Idledale, West Virginia, 3196','about':'Ullamco voluptate duis commodo amet occaecat consequat et occaecat dolore nulla eu. Do aliqua sunt deserunt occaecat laboris labore voluptate cupidatat ullamco exercitation aliquip elit voluptate anim. Occaecat deserunt in labore cillum aute deserunt ea excepteur laboris sunt. Officia irure sint incididunt labore sint ipsum ullamco ea elit. Fugiat nostrud sunt ut officia mollit proident sunt dolor fugiat esse tempor do.','registered':'Friday, January 1, 2016 5:42 AM','latitude':'-20.01215','longitude':'26.361552','tags':['consectetur','sunt','nulla','reprehenderit','dolore'],'greeting':'Hello, Cohen! You have 10 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed91c77aa25a64a757','index':31,'guid':'8999a97b-0035-4f19-b555-91dd69aaa9b8','isActive':false,'balance':'$3,097.67','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Stout','last':'Valdez'},'company':'UPLINX','email':'stout.valdez@uplinx.biz','phone':'+1 (854) 480-3633','address':'880 Chestnut Avenue, Lowgap, Hawaii, 1537','about':'Cupidatat enim dolore non voluptate. Aliqua ut non Lorem in exercitation reprehenderit voluptate. Excepteur deserunt tempor laboris quis.','registered':'Wednesday, March 16, 2016 6:53 AM','latitude':'50.328393','longitude':'-25.990308','tags':['ea','fugiat','duis','consectetur','enim'],'greeting':'Hello, Stout! You have 5 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed0f52176c8c3e1bed','index':32,'guid':'743abcbd-1fab-4aed-8cb7-3c935eb64c74','isActive':false,'balance':'$1,118.54','picture':'http://placehold.it/32x32','age':30,'eyeColor':'blue','name':{'first':'Ortega','last':'Joseph'},'company':'APEXIA','email':'ortega.joseph@apexia.ca','phone':'+1 (872) 596-3024','address':'304 Canda Avenue, Mulino, New York, 8721','about':'Ipsum elit id cupidatat minim nisi minim. Ea ex amet ea ipsum Lorem deserunt. Occaecat cupidatat magna cillum aliquip sint id quis amet nostrud officia enim laborum. Aliqua deserunt amet commodo laboris labore mollit est. Officia voluptate Lorem esse mollit aliquip laboris cupidatat minim et. Labore esse incididunt officia nostrud pariatur reprehenderit.','registered':'Tuesday, January 31, 2017 6:06 AM','latitude':'43.861714','longitude':'33.771783','tags':['ut','Lorem','esse','quis','fugiat'],'greeting':'Hello, Ortega! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed2c00cdd101b6cd52','index':33,'guid':'4f6f99cf-f692-4d03-b23a-26f2b27273bd','isActive':true,'balance':'$1,682.91','picture':'http://placehold.it/32x32','age':20,'eyeColor':'blue','name':{'first':'Sampson','last':'Taylor'},'company':'GEOFORMA','email':'sampson.taylor@geoforma.info','phone':'+1 (911) 482-2993','address':'582 Kent Street, Umapine, Virgin Islands, 5300','about':'Voluptate laboris occaecat laboris tempor cillum quis cupidatat qui pariatur. Lorem minim commodo mollit adipisicing Lorem ut dolor consectetur ipsum. Sint sit voluptate labore aliqua ex labore velit. Ullamco tempor consectetur voluptate deserunt voluptate minim enim. Cillum commodo duis reprehenderit eu duis.','registered':'Thursday, November 9, 2017 11:24 PM','latitude':'24.949379','longitude':'155.034468','tags':['Lorem','cupidatat','elit','reprehenderit','commodo'],'greeting':'Hello, Sampson! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed4b7210ba0bc0d508','index':34,'guid':'73fd415f-f8cf-43e0-a86c-e725d000abd4','isActive':false,'balance':'$1,289.37','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Shari','last':'Melendez'},'company':'DIGIPRINT','email':'shari.melendez@digiprint.tv','phone':'+1 (914) 475-3995','address':'950 Wolf Place, Enetai, Alaska, 693','about':'Dolor incididunt et est commodo aliquip labore ad ullamco. Velit ex cillum nulla elit ex esse. Consectetur mollit fugiat cillum proident elit sunt non officia cillum ex laboris sint eu. Esse nulla eu officia in Lorem sint minim esse velit. Est Lorem ipsum enim aute. Elit minim eiusmod officia reprehenderit officia ut irure Lorem.','registered':'Wednesday, August 23, 2017 11:12 PM','latitude':'-70.347863','longitude':'94.812072','tags':['ea','ex','fugiat','duis','eu'],'greeting':'Hello, Shari! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed85ac364619d892ef','index':35,'guid':'c1905f34-14ff-4bd8-b683-02cac4d52623','isActive':false,'balance':'$2,538.50','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Santiago','last':'Joyner'},'company':'BRAINCLIP','email':'santiago.joyner@brainclip.co.uk','phone':'+1 (835) 405-2676','address':'554 Rose Street, Muir, Kentucky, 7752','about':'Quis culpa dolore fugiat magna culpa non deserunt consectetur elit. Id cupidatat occaecat duis irure ullamco elit in labore magna pariatur cillum est. Mollit dolore velit ipsum anim aliqua culpa sint. Occaecat aute anim ut sunt eu.','registered':'Thursday, January 18, 2018 4:49 PM','latitude':'57.057918','longitude':'-50.472596','tags':['ullamco','ullamco','sunt','voluptate','irure'],'greeting':'Hello, Santiago! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed1763f56b1121fa88','index':36,'guid':'a7f50659-4ae3-4f3e-a9d8-087e05334b51','isActive':false,'balance':'$1,435.16','picture':'http://placehold.it/32x32','age':37,'eyeColor':'blue','name':{'first':'Adeline','last':'Hoffman'},'company':'BITREX','email':'adeline.hoffman@bitrex.io','phone':'+1 (823) 488-3201','address':'221 Corbin Place, Edmund, Palau, 193','about':'Magna ullamco consectetur velit adipisicing cillum ea. Est qui incididunt est ullamco ex aute exercitation irure. Cupidatat consectetur proident qui fugiat do. Labore magna aliqua consectetur fugiat. Excepteur deserunt sit qui dolor fugiat aute sunt anim ipsum magna ea commodo qui. Minim eu adipisicing ut irure excepteur eiusmod aliqua. Voluptate nisi ad consequat qui.','registered':'Tuesday, June 14, 2016 9:26 AM','latitude':'-53.123355','longitude':'88.180776','tags':['non','est','commodo','ut','aliquip'],'greeting':'Hello, Adeline! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed945d079f63e3185e','index':37,'guid':'1f4619e0-9289-4bea-a9db-a75f4cba1138','isActive':true,'balance':'$2,019.54','picture':'http://placehold.it/32x32','age':36,'eyeColor':'blue','name':{'first':'Porter','last':'Morse'},'company':'COMVOY','email':'porter.morse@comvoy.me','phone':'+1 (933) 562-3220','address':'416 India Street, Bourg, Rhode Island, 2266','about':'Et sint anim et sunt. Non mollit sunt cillum veniam sunt sint amet non mollit. Fugiat ea ullamco pariatur deserunt ex do minim irure irure.','registered':'Saturday, July 16, 2016 10:03 PM','latitude':'-81.782545','longitude':'69.783509','tags':['irure','consequat','veniam','nulla','velit'],'greeting':'Hello, Porter! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed411dd0f06c66bba6','index':38,'guid':'93c900f0-54c0-4c4c-b21d-d59d8d7c6177','isActive':true,'balance':'$3,764.84','picture':'http://placehold.it/32x32','age':26,'eyeColor':'green','name':{'first':'Fitzgerald','last':'Logan'},'company':'UTARIAN','email':'fitzgerald.logan@utarian.com','phone':'+1 (815) 461-2709','address':'498 Logan Street, Tonopah, Arkansas, 6652','about':'Quis Lorem sit est et dolor est esse in veniam. Mollit anim nostrud laboris consequat voluptate qui ad ipsum sint laborum exercitation quis ipsum. Incididunt cupidatat esse ea amet deserunt consequat eu proident duis adipisicing pariatur. Amet deserunt mollit aliquip mollit consequat sunt quis labore laboris quis. Magna cillum fugiat anim velit Lorem duis. Lorem duis amet veniam occaecat est excepteur ut ea velit esse non pariatur. Do veniam quis eu consequat ad duis incididunt minim dolore sit non minim adipisicing et.','registered':'Wednesday, August 9, 2017 9:20 PM','latitude':'24.480657','longitude':'-108.693421','tags':['dolore','ad','occaecat','quis','labore'],'greeting':'Hello, Fitzgerald! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edbb6f14559d8a7b28','index':39,'guid':'9434f48b-70a0-4161-8d06-c53bf8b9df94','isActive':true,'balance':'$3,713.47','picture':'http://placehold.it/32x32','age':25,'eyeColor':'blue','name':{'first':'Mcconnell','last':'Nash'},'company':'TETAK','email':'mcconnell.nash@tetak.org','phone':'+1 (956) 477-3586','address':'853 Turnbull Avenue, Clarence, Missouri, 1599','about':'Culpa excepteur minim anim magna dolor dolore ad ex eu. In cupidatat cillum elit dolore in est minim dolore consectetur reprehenderit voluptate laborum. Deserunt id velit ad dolor mollit.','registered':'Saturday, November 10, 2018 9:27 AM','latitude':'1.691589','longitude':'143.704377','tags':['ut','deserunt','sit','cupidatat','ea'],'greeting':'Hello, Mcconnell! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed1a87ea0390733ffa','index':40,'guid':'ec8a55f7-7114-4787-b1ff-4e631731bc2c','isActive':true,'balance':'$2,200.71','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Kitty','last':'Meyers'},'company':'FIBEROX','email':'kitty.meyers@fiberox.net','phone':'+1 (864) 458-3826','address':'537 Georgia Avenue, Thermal, Illinois, 7930','about':'Non excepteur laboris Lorem magna adipisicing exercitation. Anim esse in pariatur minim ipsum qui voluptate irure. Pariatur Lorem pariatur esse commodo aute adipisicing anim commodo. Exercitation nostrud aliqua duis et amet amet tempor.','registered':'Tuesday, September 13, 2016 8:16 PM','latitude':'19.59506','longitude':'-57.814297','tags':['duis','ullamco','velit','sint','consequat'],'greeting':'Hello, Kitty! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed4dc76717bf1217b3','index':41,'guid':'40521cde-f835-4620-902b-af7abf185d8d','isActive':false,'balance':'$2,907.02','picture':'http://placehold.it/32x32','age':26,'eyeColor':'green','name':{'first':'Klein','last':'Goodwin'},'company':'PLASTO','email':'klein.goodwin@plasto.name','phone':'+1 (950) 563-3104','address':'764 Devoe Street, Lindcove, Oklahoma, 458','about':'Amet aliqua magna ea veniam non aliquip irure esse id ipsum cillum sint tempor dolor. Ullamco deserunt fugiat amet pariatur culpa nostrud commodo commodo. Ad occaecat magna adipisicing voluptate. Minim ad adipisicing cupidatat elit nostrud eu irure. Cupidatat occaecat aute magna consectetur dolore anim et. Ex voluptate velit exercitation laborum ad ullamco ad. Aliquip nulla ipsum dolore cillum qui nostrud eu adipisicing amet tempor do.','registered':'Tuesday, February 13, 2018 3:56 PM','latitude':'-27.168725','longitude':'-29.499285','tags':['minim','labore','do','deserunt','dolor'],'greeting':'Hello, Klein! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed1ac77396b29aee9e','index':42,'guid':'7cfc03e3-30e9-4ae1-a1f5-f6c3223ca770','isActive':true,'balance':'$2,986.47','picture':'http://placehold.it/32x32','age':22,'eyeColor':'brown','name':{'first':'Isabelle','last':'Bishop'},'company':'GEEKNET','email':'isabelle.bishop@geeknet.us','phone':'+1 (908) 418-2642','address':'729 Willmohr Street, Aguila, Montana, 7510','about':'In nulla commodo nostrud sint. Elit et occaecat et aliqua aliquip magna esse commodo duis Lorem dolor magna enim deserunt. Ipsum pariatur reprehenderit ipsum adipisicing mollit incididunt ut. Sunt in consequat ex ut minim non qui anim labore. Deserunt minim voluptate in nulla occaecat.','registered':'Monday, September 15, 2014 6:22 AM','latitude':'-81.686947','longitude':'38.409291','tags':['proident','est','aliqua','veniam','anim'],'greeting':'Hello, Isabelle! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edb3a070c9469a4893','index':43,'guid':'3dec76b4-0b55-4765-a2fd-b8dbd9c82f8f','isActive':true,'balance':'$2,501.24','picture':'http://placehold.it/32x32','age':31,'eyeColor':'blue','name':{'first':'Josefina','last':'Turner'},'company':'COMSTAR','email':'josefina.turner@comstar.biz','phone':'+1 (908) 566-3029','address':'606 Schenck Place, Brutus, Vermont, 8681','about':'Enim consectetur pariatur sint dolor nostrud est deserunt nulla quis pariatur sit. Ad aute incididunt nisi excepteur duis est velit voluptate ullamco occaecat magna reprehenderit aliquip. Proident deserunt consectetur non et exercitation elit dolore enim aliqua incididunt anim amet. Ex esse sint commodo minim aliqua ut irure. Proident ex culpa voluptate fugiat nisi. Sint commodo laboris excepteur minim ipsum labore tempor quis magna.','registered':'Saturday, December 31, 2016 6:38 AM','latitude':'35.275088','longitude':'24.30485','tags':['minim','ut','irure','Lorem','veniam'],'greeting':'Hello, Josefina! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed1aa7d74128ee3d0f','index':44,'guid':'10599279-c367-46c4-9f7a-744c2e4bf6c9','isActive':true,'balance':'$1,753.06','picture':'http://placehold.it/32x32','age':27,'eyeColor':'blue','name':{'first':'Lily','last':'Haynes'},'company':'KIOSK','email':'lily.haynes@kiosk.ca','phone':'+1 (872) 451-2301','address':'509 Balfour Place, Grazierville, New Hampshire, 2750','about':'Nisi aliquip occaecat nostrud do sint qui nisi officia Lorem. Ad et et laboris nisi dolore aliqua eu. Aliqua veniam quis eu pariatur incididunt mollit id deserunt officia eiusmod. Consequat adipisicing do nisi voluptate eiusmod minim pariatur minim nisi nostrud culpa cupidatat. Irure consectetur id consequat adipisicing ullamco occaecat do. Ex proident ea quis nulla incididunt sunt excepteur incididunt. Aliquip minim nostrud non anim Lorem.','registered':'Tuesday, November 20, 2018 9:28 AM','latitude':'-12.677798','longitude':'114.506787','tags':['culpa','amet','elit','officia','irure'],'greeting':'Hello, Lily! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed74c76f2e84e201ce','index':45,'guid':'ec0a68d4-629e-46c9-9af7-f6ea867f02ba','isActive':true,'balance':'$1,477.93','picture':'http://placehold.it/32x32','age':23,'eyeColor':'green','name':{'first':'Shauna','last':'Pitts'},'company':'SPACEWAX','email':'shauna.pitts@spacewax.info','phone':'+1 (841) 406-2360','address':'348 Tabor Court, Westwood, Puerto Rico, 8297','about':'Aliquip irure officia magna ea magna mollit ea non amet deserunt. Veniam mollit labore culpa magna aliqua quis consequat est consectetur ea reprehenderit nostrud consequat aliqua. Mollit do ipsum mollit eiusmod.','registered':'Thursday, October 2, 2014 2:48 AM','latitude':'-55.17388','longitude':'-13.370494','tags':['anim','consectetur','cillum','veniam','duis'],'greeting':'Hello, Shauna! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed419e718484b16722','index':46,'guid':'b2d6101d-5646-43f4-8207-284494e5a990','isActive':false,'balance':'$2,006.96','picture':'http://placehold.it/32x32','age':27,'eyeColor':'brown','name':{'first':'Lawrence','last':'Boyer'},'company':'SKYPLEX','email':'lawrence.boyer@skyplex.tv','phone':'+1 (953) 548-2618','address':'464 Pilling Street, Blandburg, Arizona, 5531','about':'Culpa sit minim pariatur mollit cupidatat sunt duis. Nisi ea proident veniam exercitation adipisicing Lorem aliquip amet dolor voluptate in nisi. Non commodo anim sunt est fugiat laborum nisi aliqua non Lorem exercitation dolor. Laboris dolore do minim ut eiusmod enim magna cillum laborum consectetur aliquip minim enim Lorem. Veniam ex veniam occaecat aliquip elit aliquip est eiusmod minim minim adipisicing.','registered':'Wednesday, July 30, 2014 2:17 AM','latitude':'-78.681255','longitude':'139.960626','tags':['consequat','Lorem','incididunt','dolor','esse'],'greeting':'Hello, Lawrence! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed08a9024998292c70','index':47,'guid':'277de142-ebeb-4828-906a-7fd8bc0a738a','isActive':true,'balance':'$1,273.19','picture':'http://placehold.it/32x32','age':27,'eyeColor':'brown','name':{'first':'Sonya','last':'Stafford'},'company':'AQUACINE','email':'sonya.stafford@aquacine.co.uk','phone':'+1 (824) 581-3927','address':'641 Bowery Street, Hillsboro, Delaware, 7893','about':'Culpa labore ex reprehenderit mollit cupidatat dolore et ut quis in. Sint esse culpa enim culpa tempor exercitation veniam minim consectetur. Sunt est laboris minim quis incididunt exercitation laboris cupidatat fugiat ad. Deserunt ipsum do dolor cillum excepteur incididunt.','registered':'Thursday, March 26, 2015 1:10 PM','latitude':'-84.750592','longitude':'165.493533','tags':['minim','officia','dolore','ipsum','est'],'greeting':'Hello, Sonya! You have 8 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd5037f2c79ecde68','index':48,'guid':'2dc6532f-9a26-49aa-b444-8923896db89c','isActive':false,'balance':'$3,168.93','picture':'http://placehold.it/32x32','age':36,'eyeColor':'brown','name':{'first':'Marguerite','last':'Stuart'},'company':'ACCUFARM','email':'marguerite.stuart@accufarm.io','phone':'+1 (848) 535-2253','address':'301 Menahan Street, Sunnyside, Nebraska, 4809','about':'Deserunt sint labore voluptate amet anim culpa nostrud adipisicing enim cupidatat ullamco exercitation fugiat est. Magna dolor aute incididunt ea ad adipisicing. Do cupidatat ut officia officia culpa sit do.','registered':'Thursday, May 8, 2014 1:25 PM','latitude':'21.82277','longitude':'-7.368347','tags':['labore','nulla','ullamco','irure','adipisicing'],'greeting':'Hello, Marguerite! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edb26d315635818dae','index':49,'guid':'083a5eda-0a70-4f89-87f7-2cd386c0f22a','isActive':false,'balance':'$2,576.25','picture':'http://placehold.it/32x32','age':38,'eyeColor':'blue','name':{'first':'Louella','last':'Holloway'},'company':'BEDDER','email':'louella.holloway@bedder.me','phone':'+1 (801) 425-3761','address':'545 Lafayette Avenue, Caledonia, Louisiana, 2816','about':'Qui exercitation occaecat dolore mollit. Fugiat cupidatat proident culpa fugiat quis. In cupidatat commodo elit ea enim occaecat esse exercitation nostrud occaecat veniam laboris fugiat. Nisi sunt reprehenderit aliqua reprehenderit tempor id dolore ullamco pariatur reprehenderit et eu ex pariatur.','registered':'Wednesday, November 5, 2014 1:10 AM','latitude':'36.385637','longitude':'77.949423','tags':['eu','irure','velit','non','aliquip'],'greeting':'Hello, Louella! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed77cd60a1abc1ecce','index':50,'guid':'2887c3c1-3eba-4237-a0db-1977eed94554','isActive':true,'balance':'$1,633.51','picture':'http://placehold.it/32x32','age':22,'eyeColor':'green','name':{'first':'Bates','last':'Carrillo'},'company':'ZOMBOID','email':'bates.carrillo@zomboid.com','phone':'+1 (934) 405-2006','address':'330 Howard Alley, Troy, Kansas, 4881','about':'Voluptate esse est ullamco anim tempor ea reprehenderit. Occaecat pariatur deserunt cillum laboris labore id exercitation esse ipsum ipsum ex aliquip. Sunt non elit est ea occaecat. Magna deserunt commodo aliqua ipsum est cillum dolor nisi. Ex duis est tempor tempor laboris do do quis id magna. Dolor do est elit eu laborum ullamco culpa consequat velit eiusmod tempor.','registered':'Saturday, May 28, 2016 3:56 AM','latitude':'83.310134','longitude':'-105.862836','tags':['est','commodo','ea','commodo','sunt'],'greeting':'Hello, Bates! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed5ec0ec299b471fb5','index':51,'guid':'512b5e67-f785-492e-9d94-e43ef8b399b8','isActive':false,'balance':'$3,032.22','picture':'http://placehold.it/32x32','age':30,'eyeColor':'blue','name':{'first':'Floyd','last':'Yang'},'company':'FRENEX','email':'floyd.yang@frenex.org','phone':'+1 (924) 566-3304','address':'418 Quay Street, Chumuckla, Guam, 7743','about':'Irure sit velit exercitation dolore est nisi incididunt ut quis consectetur incididunt est dolor. Aute nisi enim esse aliquip enim culpa commodo consectetur. Duis laborum magna ad duis ipsum aliqua eiusmod cillum. Consectetur et duis eiusmod irure ad est nisi incididunt eiusmod labore. Pariatur proident in Lorem adipisicing mollit proident excepteur nulla do nostrud mollit eiusmod. Duis ad dolore irure fugiat anim laboris ipsum et sit duis ipsum voluptate. Lorem non aute exercitation qui ullamco officia minim sint pariatur ut dolor.','registered':'Wednesday, January 18, 2017 2:01 AM','latitude':'45.888721','longitude':'-41.232793','tags':['elit','in','esse','ea','officia'],'greeting':'Hello, Floyd! You have 5 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed51e26ca89e5caf49','index':52,'guid':'4e0907f6-facc-46df-8952-73561a53fe33','isActive':true,'balance':'$3,767.41','picture':'http://placehold.it/32x32','age':25,'eyeColor':'blue','name':{'first':'Gardner','last':'Carey'},'company':'KLUGGER','email':'gardner.carey@klugger.net','phone':'+1 (876) 481-3502','address':'131 Utica Avenue, Cannondale, Federated States Of Micronesia, 610','about':'Amet ad pariatur excepteur anim ex officia commodo proident aliqua occaecat consequat Lorem officia sit. Id minim velit nisi laboris nisi nulla incididunt eiusmod velit. Deserunt labore quis et tempor. Et labore exercitation laborum officia ullamco nostrud adipisicing laboris esse laborum aute anim elit. Sunt ad officia tempor esse et quis aliquip irure pariatur laborum id quis ex. Eu consequat nisi deserunt id eu proident ex minim aute nulla tempor ex.','registered':'Friday, February 21, 2014 6:42 AM','latitude':'-54.740231','longitude':'15.01484','tags':['commodo','laboris','occaecat','aliquip','adipisicing'],'greeting':'Hello, Gardner! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed52e3c9407105093a','index':53,'guid':'1d3b9e7a-1bc3-40ea-b808-1c33f0d48c70','isActive':true,'balance':'$1,113.30','picture':'http://placehold.it/32x32','age':26,'eyeColor':'blue','name':{'first':'Herman','last':'Rogers'},'company':'TALENDULA','email':'herman.rogers@talendula.name','phone':'+1 (818) 521-2005','address':'541 Norman Avenue, Winfred, Tennessee, 447','about':'Culpa ex laborum non ad ullamco officia. Nisi mollit mollit voluptate sit sint ullamco. Lorem exercitation nulla anim eiusmod deserunt magna sint. Officia sunt eiusmod aliqua reprehenderit sunt mollit sit cupidatat sint.','registered':'Wednesday, July 11, 2018 1:05 AM','latitude':'-20.708105','longitude':'-151.294563','tags':['exercitation','minim','officia','qui','enim'],'greeting':'Hello, Herman! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edfcb123d545b6edb4','index':54,'guid':'c0e0c669-4eed-43ee-bdd0-78fe6e9ca4d5','isActive':true,'balance':'$3,309.64','picture':'http://placehold.it/32x32','age':22,'eyeColor':'green','name':{'first':'Whitley','last':'Stark'},'company':'MUSAPHICS','email':'whitley.stark@musaphics.us','phone':'+1 (803) 476-2151','address':'548 Cobek Court, Chamizal, Indiana, 204','about':'Adipisicing veniam dolor ex sint sit id eu voluptate. Excepteur veniam proident exercitation id eu et sunt pariatur. Qui occaecat culpa aliqua nisi excepteur minim veniam. Est duis nulla laborum excepteur cillum pariatur sint incididunt. Velit commodo eu incididunt voluptate. Amet laboris laboris id adipisicing labore eiusmod consequat minim cillum et.','registered':'Thursday, March 27, 2014 9:10 AM','latitude':'71.219596','longitude':'51.012855','tags':['reprehenderit','mollit','laborum','voluptate','aliquip'],'greeting':'Hello, Whitley! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed81510dfc61602fcf','index':55,'guid':'7ec5c24d-f169-4399-a2a3-300c0f45e52e','isActive':false,'balance':'$3,721.04','picture':'http://placehold.it/32x32','age':23,'eyeColor':'green','name':{'first':'Gretchen','last':'Wade'},'company':'EWEVILLE','email':'gretchen.wade@eweville.biz','phone':'+1 (977) 598-3700','address':'721 Colonial Road, Brookfield, South Dakota, 3888','about':'Fugiat consequat sint ut ut et ullamco eiusmod deserunt pariatur. Veniam eiusmod esse fugiat mollit. Proident laboris minim qui do ipsum excepteur exercitation irure anim. Aliqua labore quis eu fugiat dolore ullamco velit Lorem voluptate ipsum nostrud eiusmod laborum proident.','registered':'Friday, October 12, 2018 10:59 AM','latitude':'41.937653','longitude':'63.378531','tags':['aute','cillum','ea','ex','aute'],'greeting':'Hello, Gretchen! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edf78f77d4a7d557bb','index':56,'guid':'8718ada7-6fd0-49ef-a405-29850503948b','isActive':false,'balance':'$3,341.33','picture':'http://placehold.it/32x32','age':32,'eyeColor':'blue','name':{'first':'Naomi','last':'Frye'},'company':'MAZUDA','email':'naomi.frye@mazuda.ca','phone':'+1 (825) 427-2255','address':'741 Coyle Street, Comptche, Pennsylvania, 8441','about':'Aliqua fugiat laborum quis ullamco cupidatat sit dolor nulla dolore. Do Lorem et ipsum culpa irure sit do dolor qui sit laboris aliqua. Ex consectetur irure in veniam reprehenderit amet do elit eiusmod est magna.','registered':'Thursday, January 9, 2014 7:18 AM','latitude':'41.078645','longitude':'-50.241966','tags':['do','aliquip','eiusmod','velit','id'],'greeting':'Hello, Naomi! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edbf45db2e072a48b4','index':57,'guid':'c158ebf7-fb8b-4ea8-adbf-8c51c6486715','isActive':true,'balance':'$2,811.55','picture':'http://placehold.it/32x32','age':25,'eyeColor':'blue','name':{'first':'Lamb','last':'Johns'},'company':'DOGTOWN','email':'lamb.johns@dogtown.info','phone':'+1 (946) 530-3057','address':'559 Malbone Street, Kennedyville, California, 2052','about':'Eiusmod dolor labore cillum ad veniam elit voluptate voluptate pariatur est cupidatat. Laboris ut qui in cillum sunt dolore ut enim. Minim nostrud ex qui quis reprehenderit magna ipsum cupidatat irure minim laboris veniam irure. Fugiat velit deserunt aliquip in esse proident excepteur labore reprehenderit excepteur sunt in cupidatat exercitation. Ex pariatur irure mollit tempor non magna ex.','registered':'Friday, April 21, 2017 1:51 AM','latitude':'-61.403599','longitude':'-93.447102','tags':['aliquip','tempor','sint','enim','ipsum'],'greeting':'Hello, Lamb! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edbb9c88190cb59cf2','index':58,'guid':'f0de5ac5-eb28-491b-81c5-76d447c9055e','isActive':true,'balance':'$1,611.99','picture':'http://placehold.it/32x32','age':37,'eyeColor':'brown','name':{'first':'Lynette','last':'Cleveland'},'company':'ARTWORLDS','email':'lynette.cleveland@artworlds.tv','phone':'+1 (889) 596-3723','address':'439 Montauk Avenue, Felt, New Mexico, 9681','about':'Incididunt aliquip est aliquip est ullamco do consectetur dolor. Lorem mollit mollit dolor et ipsum ut qui veniam aute ea. Adipisicing reprehenderit culpa velit laborum adipisicing amet consectetur velit nisi. Ut qui proident ad cillum excepteur adipisicing quis labore. Duis velit culpa et excepteur eiusmod ex labore in nisi nostrud. Et ullamco minim excepteur ut enim reprehenderit consequat eiusmod laboris Lorem commodo exercitation qui laborum.','registered':'Wednesday, August 26, 2015 12:53 PM','latitude':'49.861336','longitude':'86.865926','tags':['reprehenderit','minim','in','minim','nostrud'],'greeting':'Hello, Lynette! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed5b760ddde7295fa8','index':59,'guid':'f8180d3f-c5c0-48b2-966e-a0b2a80f8e84','isActive':true,'balance':'$3,376.75','picture':'http://placehold.it/32x32','age':32,'eyeColor':'green','name':{'first':'Obrien','last':'Page'},'company':'GLASSTEP','email':'obrien.page@glasstep.co.uk','phone':'+1 (902) 583-3086','address':'183 Ridgewood Avenue, Vicksburg, Wisconsin, 7430','about':'Aute excepteur cillum exercitation duis Lorem irure labore elit. Labore magna cupidatat velit consectetur minim do Lorem in excepteur commodo ea consequat ullamco laborum. Ut in id occaecat eu quis duis id ea deserunt veniam.','registered':'Wednesday, March 29, 2017 12:13 AM','latitude':'-40.156154','longitude':'72.76301','tags':['excepteur','non','anim','nulla','anim'],'greeting':'Hello, Obrien! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed52985d3d8901d653','index':60,'guid':'d2e14fa1-8c54-4bcb-8a58-eb2e6f8d0e45','isActive':true,'balance':'$1,659.47','picture':'http://placehold.it/32x32','age':33,'eyeColor':'brown','name':{'first':'Knowles','last':'Goodman'},'company':'CENTREE','email':'knowles.goodman@centree.io','phone':'+1 (862) 563-3692','address':'504 Lott Street, Allensworth, Florida, 7148','about':'Do aliquip voluptate aliqua nostrud. Eu dolore ex occaecat pariatur aute laborum aute nulla aute amet. Excepteur sit laboris ad non anim ut officia ut ad exercitation officia dolore laboris. Esse voluptate minim deserunt nostrud exercitation laborum voluptate exercitation id laborum fugiat proident cupidatat proident. Nulla nostrud est sint adipisicing incididunt exercitation dolor sit et elit tempor occaecat sint culpa. Pariatur occaecat laboris pariatur laboris ad pariatur in cillum fugiat est fugiat. Proident eu id irure excepteur esse aute cillum adipisicing.','registered':'Wednesday, October 15, 2014 6:17 PM','latitude':'-15.73863','longitude':'87.422009','tags':['consequat','sint','tempor','veniam','culpa'],'greeting':'Hello, Knowles! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0eda00b73bdb7ea54e9','index':61,'guid':'c8a064db-0ec6-4832-9820-7280a0333709','isActive':true,'balance':'$3,701.14','picture':'http://placehold.it/32x32','age':35,'eyeColor':'brown','name':{'first':'Shepherd','last':'Todd'},'company':'ECRATIC','email':'shepherd.todd@ecratic.me','phone':'+1 (881) 444-3389','address':'450 Frank Court, Temperanceville, Ohio, 7006','about':'Voluptate cillum ad fugiat velit adipisicing sint consequat veniam Lorem reprehenderit. Cillum sit non deserunt consequat. Amet sunt pariatur non mollit ullamco proident sint dolore anim elit cupidatat anim do ullamco. Lorem Lorem incididunt ea elit consequat laboris enim duis quis Lorem id aute veniam consequat. Cillum veniam cillum sint qui Lorem fugiat culpa consequat. Est sint duis ut qui fugiat. Laborum pariatur velit et sunt mollit eiusmod excepteur culpa ex et officia.','registered':'Tuesday, October 10, 2017 2:01 AM','latitude':'82.951563','longitude':'-4.866954','tags':['eu','qui','proident','esse','ex'],'greeting':'Hello, Shepherd! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed0e51d1a7e2d9e559','index':62,'guid':'739c3d38-200d-4531-84d8-4e7c39ae5b8c','isActive':true,'balance':'$3,679.01','picture':'http://placehold.it/32x32','age':31,'eyeColor':'brown','name':{'first':'Rosalyn','last':'Heath'},'company':'ZAYA','email':'rosalyn.heath@zaya.com','phone':'+1 (865) 403-3520','address':'303 Henderson Walk, Hoehne, District Of Columbia, 4306','about':'Sint occaecat nulla mollit sint fugiat eu proident dolor labore consequat. Occaecat tempor excepteur do fugiat incididunt Lorem in ullamco dolore laborum. Cillum mollit aliquip excepteur aliquip sint sunt minim non irure irure. Cillum fugiat aliqua enim dolore. Nulla culpa culpa nostrud ad. Eiusmod culpa proident proident non est cupidatat eu sunt sit incididunt id nisi.','registered':'Wednesday, April 22, 2015 12:35 PM','latitude':'33.628504','longitude':'110.772802','tags':['consequat','ut','ex','labore','consectetur'],'greeting':'Hello, Rosalyn! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd5274c01d353d0c5','index':63,'guid':'8815fe55-8af1-4708-a62a-d554dbd74a4a','isActive':true,'balance':'$2,126.01','picture':'http://placehold.it/32x32','age':30,'eyeColor':'blue','name':{'first':'Queen','last':'Harper'},'company':'TRI@TRIBALOG','email':'queen.harper@tri@tribalog.org','phone':'+1 (903) 592-3145','address':'926 Heath Place, Wawona, Maine, 7340','about':'Laborum cupidatat commodo aliquip reprehenderit. Excepteur eu labore duis minim minim voluptate aute nostrud deserunt ut velit ullamco. Adipisicing nisi occaecat laborum proident. Id reprehenderit eiusmod cupidatat qui aute consequat amet enim commodo duis non ipsum. Amet ut aliqua magna qui proident mollit aute.','registered':'Saturday, April 9, 2016 5:12 AM','latitude':'51.814216','longitude':'177.348115','tags':['cillum','ut','dolor','do','nisi'],'greeting':'Hello, Queen! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed126298b6ce62ed56','index':64,'guid':'001c87fe-182f-450f-903b-2e29a9bb0322','isActive':true,'balance':'$3,578.29','picture':'http://placehold.it/32x32','age':20,'eyeColor':'green','name':{'first':'Pauline','last':'Mills'},'company':'CRUSTATIA','email':'pauline.mills@crustatia.net','phone':'+1 (984) 582-3899','address':'899 Revere Place, Welch, Iowa, 216','about':'Tempor eu exercitation ut id. Deserunt ex reprehenderit veniam nisi. Aute laborum veniam velit dolore ut deserunt Lorem sit esse quis dolor ex do nisi. In dolor tempor officia id. Velit nisi culpa nostrud laborum officia incididunt laborum velit non quis id exercitation exercitation. Anim elit ullamco in enim Lorem culpa aliqua Lorem.','registered':'Monday, June 2, 2014 2:03 PM','latitude':'56.427576','longitude':'172.183669','tags':['pariatur','pariatur','pariatur','fugiat','Lorem'],'greeting':'Hello, Pauline! You have 8 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed3e332ad9e8a178d8','index':65,'guid':'5ad7292b-feef-4a7e-b485-142cadfbe8ea','isActive':false,'balance':'$3,916.54','picture':'http://placehold.it/32x32','age':22,'eyeColor':'brown','name':{'first':'Garrett','last':'Richmond'},'company':'XYQAG','email':'garrett.richmond@xyqag.name','phone':'+1 (952) 584-3794','address':'233 Grove Street, Summerfield, Virginia, 4735','about':'Nostrud quis pariatur occaecat laborum laboris aliqua ut fugiat dolor. Commodo tempor excepteur enim nostrud Lorem. Aute elit nulla labore ad pariatur cupidatat Lorem qui cupidatat velit deserunt excepteur esse. Excepteur nulla et nostrud quis labore est veniam enim nisi laboris ut enim. Ea esse nulla anim excepteur reprehenderit deserunt voluptate minim qui labore adipisicing amet eu enim.','registered':'Wednesday, March 5, 2014 4:35 PM','latitude':'68.665041','longitude':'148.799524','tags':['irure','reprehenderit','minim','ea','do'],'greeting':'Hello, Garrett! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed541aa2ec47466ace','index':66,'guid':'9cda6f3c-c9ab-451c-bb19-2e4c8463d011','isActive':true,'balance':'$3,352.52','picture':'http://placehold.it/32x32','age':30,'eyeColor':'brown','name':{'first':'Cobb','last':'Whitley'},'company':'UNIA','email':'cobb.whitley@unia.us','phone':'+1 (888) 490-3342','address':'864 Belmont Avenue, Needmore, Massachusetts, 8286','about':'Nisi aliquip fugiat ipsum nisi ullamco minim pariatur labore. Sint labore anim do ad ad esse eu nostrud nulla commodo anim. Cillum anim enim duis cillum non do nisi aliquip veniam voluptate commodo aliqua laborum. Exercitation in do eu qui sint aliquip. Esse adipisicing deserunt deserunt qui anim aliqua occaecat et nostrud elit ea in anim cillum. Tempor mollit proident tempor sunt est sint laborum ullamco incididunt non. Velit aliqua sunt excepteur nisi qui eiusmod ipsum dolore aliquip velit ullamco ullamco.','registered':'Friday, May 23, 2014 7:11 PM','latitude':'-32.950581','longitude':'147.772494','tags':['mollit','adipisicing','irure','ad','minim'],'greeting':'Hello, Cobb! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed8186c3d6f34c2be3','index':67,'guid':'fee98f6d-d68a-4189-8180-b6cb337e537e','isActive':false,'balance':'$1,698.42','picture':'http://placehold.it/32x32','age':20,'eyeColor':'blue','name':{'first':'Brennan','last':'Tyler'},'company':'PODUNK','email':'brennan.tyler@podunk.biz','phone':'+1 (867) 498-2727','address':'599 Harkness Avenue, Gorst, American Samoa, 322','about':'Reprehenderit id sit qui id qui aute ea sit magna in qui proident. Excepteur ad nostrud do nostrud in incididunt voluptate adipisicing sint anim. Ullamco consequat minim nulla irure ex est irure reprehenderit deserunt voluptate dolore anim sunt. Occaecat dolore voluptate voluptate elit commodo nulla laborum ad do irure.','registered':'Friday, February 9, 2018 5:40 PM','latitude':'11.150893','longitude':'-85.298004','tags':['quis','minim','deserunt','cillum','laboris'],'greeting':'Hello, Brennan! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed075c9c4f7439818d','index':68,'guid':'1ef76b18-6b8d-4c3c-aca3-9fa2b43f0242','isActive':false,'balance':'$2,091.17','picture':'http://placehold.it/32x32','age':26,'eyeColor':'brown','name':{'first':'Neal','last':'Stephenson'},'company':'OTHERSIDE','email':'neal.stephenson@otherside.ca','phone':'+1 (820) 496-3344','address':'867 Wilson Street, Kidder, Colorado, 4599','about':'Do laboris enim proident in qui velit adipisicing magna anim. Amet proident non exercitation ipsum aliqua excepteur nostrud. Enim esse non sit in nostrud deserunt id laborum cillum deserunt consequat. Anim velit exercitation qui sit voluptate. Irure duis non veniam velit mollit exercitation id exercitation.','registered':'Thursday, November 13, 2014 11:00 PM','latitude':'54.809693','longitude':'1.877241','tags':['anim','duis','in','officia','sint'],'greeting':'Hello, Neal! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0eda0a2dc24db64b638','index':69,'guid':'194744fd-089b-40b6-a290-98a6ec30a415','isActive':false,'balance':'$3,191.67','picture':'http://placehold.it/32x32','age':24,'eyeColor':'brown','name':{'first':'Shields','last':'Hubbard'},'company':'MIRACULA','email':'shields.hubbard@miracula.info','phone':'+1 (885) 582-2001','address':'529 Eagle Street, Guilford, Nevada, 1460','about':'Eiusmod exercitation ut incididunt veniam commodo culpa ullamco mollit id adipisicing exercitation ad sint. Nostrud excepteur amet aliqua mollit incididunt laborum voluptate id anim. Nulla sint laboris dolor esse cupidatat laborum ex sint. Ex non sunt sit nulla.','registered':'Monday, February 13, 2017 6:22 AM','latitude':'-69.145209','longitude':'-40.69755','tags':['tempor','enim','qui','velit','elit'],'greeting':'Hello, Shields! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edf939c130177e074d','index':70,'guid':'303b176c-7803-4ed2-a35f-3e3c831793ef','isActive':false,'balance':'$2,359.09','picture':'http://placehold.it/32x32','age':31,'eyeColor':'blue','name':{'first':'Coleen','last':'Knight'},'company':'BLEEKO','email':'coleen.knight@bleeko.tv','phone':'+1 (867) 423-3146','address':'527 Broadway , Bonanza, Marshall Islands, 4988','about':'Laboris nulla pariatur laborum ad aute excepteur sunt pariatur exercitation. Do nostrud qui ipsum ullamco et sint do Lorem cillum ullamco do. Exercitation labore excepteur commodo incididunt eiusmod proident consectetur adipisicing nostrud aute voluptate laboris. Commodo anim proident eiusmod pariatur est ea laborum incididunt qui tempor reprehenderit ullamco id. Eiusmod commodo nisi consectetur ut qui quis aliqua sit minim nostrud sunt laborum eiusmod adipisicing.','registered':'Sunday, May 6, 2018 8:03 AM','latitude':'70.729041','longitude':'113.052761','tags':['Lorem','ullamco','nulla','ullamco','commodo'],'greeting':'Hello, Coleen! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edae8b1ce688b61223','index':71,'guid':'7d6f3b1a-c367-4068-9e8e-1717d513ece3','isActive':false,'balance':'$2,911.07','picture':'http://placehold.it/32x32','age':21,'eyeColor':'brown','name':{'first':'Clark','last':'Ryan'},'company':'ECLIPSENT','email':'clark.ryan@eclipsent.co.uk','phone':'+1 (938) 562-2740','address':'500 Lewis Avenue, Rockbridge, North Dakota, 5133','about':'Adipisicing exercitation officia sit excepteur excepteur sunt sint amet. Aliqua ipsum sint laboris eiusmod esse culpa elit sunt. Dolore est consectetur est quis quis magna. Aliquip nostrud dolore ex pariatur. Anim nostrud duis exercitation ut magna magna culpa. Nisi irure id mollit labore non sit mollit occaecat Lorem est ipsum. Nulla est fugiat cillum nisi aliqua consectetur amet nulla nostrud esse.','registered':'Friday, July 24, 2015 9:28 AM','latitude':'-68.055815','longitude':'-50.926966','tags':['deserunt','ad','ad','ut','id'],'greeting':'Hello, Clark! You have 7 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed5d1e8df45d8ab4db','index':72,'guid':'ce85db37-7d04-4f4c-a4b0-78003533e5c6','isActive':false,'balance':'$1,127.43','picture':'http://placehold.it/32x32','age':21,'eyeColor':'green','name':{'first':'Dillon','last':'Hooper'},'company':'MEDESIGN','email':'dillon.hooper@medesign.io','phone':'+1 (929) 600-3797','address':'652 Mill Avenue, Elliston, Mississippi, 2958','about':'Dolore culpa qui exercitation nostrud do. Irure duis in ad ipsum aliqua aliquip nulla sit veniam officia quis occaecat est. Magna qui eiusmod pariatur aliquip minim commodo. Qui ex dolor excepteur consequat eiusmod occaecat. In officia ipsum do Lorem excepteur proident pariatur labore.','registered':'Monday, May 26, 2014 2:38 AM','latitude':'-36.032189','longitude':'86.865529','tags':['non','ut','ex','Lorem','quis'],'greeting':'Hello, Dillon! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edb84814579c3121b3','index':73,'guid':'d7303901-5186-4595-a759-22306f67d0a3','isActive':true,'balance':'$2,326.59','picture':'http://placehold.it/32x32','age':33,'eyeColor':'green','name':{'first':'Moreno','last':'Hull'},'company':'ZEAM','email':'moreno.hull@zeam.me','phone':'+1 (984) 586-3738','address':'265 Pine Street, Talpa, North Carolina, 6041','about':'Fugiat exercitation est ullamco anim. Exercitation proident id sunt culpa Lorem amet. Consectetur anim consectetur pariatur consequat consectetur amet excepteur voluptate ea velit duis eiusmod proident. In sint laborum cupidatat ea amet ex. Reprehenderit amet sunt dolor ullamco est ex deserunt.','registered':'Wednesday, January 24, 2018 8:52 PM','latitude':'84.956857','longitude':'113.210051','tags':['est','excepteur','anim','Lorem','dolor'],'greeting':'Hello, Moreno! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eda4eb9dcb92c82d06','index':74,'guid':'8ee28651-802e-4523-b676-c713f6e874b8','isActive':true,'balance':'$3,783.97','picture':'http://placehold.it/32x32','age':38,'eyeColor':'blue','name':{'first':'Tracie','last':'Price'},'company':'ICOLOGY','email':'tracie.price@icology.com','phone':'+1 (897) 403-3768','address':'487 Sheffield Avenue, Vallonia, Wyoming, 276','about':'Voluptate laboris laborum aute ex sint voluptate officia proident. Sit esse nostrud cupidatat in veniam sit duis est. Do mollit elit exercitation aliqua id irure ex. Lorem reprehenderit do ullamco sint ea ad nisi ad ut.','registered':'Saturday, December 10, 2016 9:44 AM','latitude':'77.770464','longitude':'151.392903','tags':['incididunt','labore','aliquip','anim','minim'],'greeting':'Hello, Tracie! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed68ab1a55d1c35e6c','index':75,'guid':'deedd26a-8928-4064-9666-5c59ea8144b4','isActive':true,'balance':'$2,848.08','picture':'http://placehold.it/32x32','age':32,'eyeColor':'brown','name':{'first':'Montgomery','last':'Bruce'},'company':'CYTREK','email':'montgomery.bruce@cytrek.org','phone':'+1 (824) 414-2731','address':'397 Beach Place, Ellerslie, South Carolina, 967','about':'Mollit minim excepteur magna velit cillum excepteur exercitation anim id labore deserunt do. Fugiat ex et id ad. Duis excepteur laboris est nulla do id irure quis eiusmod do esse ut culpa in.','registered':'Tuesday, August 25, 2015 6:42 AM','latitude':'79.722631','longitude':'-7.516885','tags':['Lorem','sint','voluptate','proident','incididunt'],'greeting':'Hello, Montgomery! You have 6 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd90e0abb1cc2b0aa','index':76,'guid':'a072159d-12db-4747-9c2a-e2486a53d043','isActive':false,'balance':'$2,723.54','picture':'http://placehold.it/32x32','age':40,'eyeColor':'green','name':{'first':'Zelma','last':'Salinas'},'company':'IMAGEFLOW','email':'zelma.salinas@imageflow.net','phone':'+1 (964) 555-3856','address':'584 Reeve Place, Nord, Georgia, 7473','about':'Aliqua proident excepteur duis cupidatat cillum amet esse esse consectetur ea. Officia sunt consequat nostrud minim enim dolore dolor duis cillum. Esse labore veniam sint laborum excepteur sint tempor do ad cupidatat aliquip laboris elit id. Velit reprehenderit ullamco velit ullamco adipisicing velit esse irure velit et.','registered':'Thursday, February 25, 2016 8:18 PM','latitude':'-32.880524','longitude':'115.180489','tags':['id','nulla','reprehenderit','consequat','reprehenderit'],'greeting':'Hello, Zelma! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed98d836c8da283bb2','index':77,'guid':'838bebad-cc20-44e9-9eb7-902a8ca25efb','isActive':false,'balance':'$3,488.91','picture':'http://placehold.it/32x32','age':20,'eyeColor':'green','name':{'first':'Shaw','last':'Parsons'},'company':'PEARLESEX','email':'shaw.parsons@pearlesex.name','phone':'+1 (912) 567-3580','address':'606 Ocean Avenue, Tyro, Northern Mariana Islands, 3367','about':'Laborum labore occaecat culpa pariatur nisi non adipisicing esse consectetur officia officia. Deserunt velit eu enim consectetur ut cillum aliqua occaecat dolor qui esse. Incididunt ad est ex eu culpa anim aliquip laborum. Aliqua consectetur velit exercitation magna minim nulla do ut excepteur enim aliquip et. Nostrud enim sunt amet amet proident aliqua velit dolore. Consectetur ipsum fugiat proident id est reprehenderit tempor irure commodo. Sit excepteur fugiat occaecat nulla Lorem et cillum.','registered':'Thursday, April 19, 2018 1:41 AM','latitude':'69.715573','longitude':'-118.481237','tags':['laboris','adipisicing','magna','voluptate','id'],'greeting':'Hello, Shaw! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed1101734633c6ebba','index':78,'guid':'8fd0c52a-9d74-4984-a608-d612ecd8ddf0','isActive':true,'balance':'$3,820.02','picture':'http://placehold.it/32x32','age':39,'eyeColor':'brown','name':{'first':'Jaime','last':'Beard'},'company':'IZZBY','email':'jaime.beard@izzby.us','phone':'+1 (820) 412-3806','address':'362 Hudson Avenue, Delco, New Jersey, 5684','about':'Ut cupidatat veniam nulla magna commodo sit duis veniam consectetur cupidatat elit quis tempor. Duis officia ullamco proident sunt non mollit excepteur. Nisi ex amet laboris proident duis reprehenderit et est aliqua mollit amet ad. Enim eu elit excepteur eu exercitation duis consequat culpa. Adipisicing reprehenderit duis Lorem reprehenderit dolor aliqua incididunt eiusmod consequat ad occaecat fugiat do laborum. Qui ad aliquip ex do sunt. Fugiat non ut fugiat eu.','registered':'Sunday, March 9, 2014 3:41 PM','latitude':'17.926318','longitude':'108.985996','tags':['ut','voluptate','veniam','non','commodo'],'greeting':'Hello, Jaime! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edcd125a89dcf18e0d','index':79,'guid':'eccaa4ca-0fa7-4b00-a1e3-fe7953403894','isActive':true,'balance':'$1,521.33','picture':'http://placehold.it/32x32','age':30,'eyeColor':'green','name':{'first':'Terra','last':'Sullivan'},'company':'ZANITY','email':'terra.sullivan@zanity.biz','phone':'+1 (995) 498-2714','address':'346 Congress Street, Tuttle, Maryland, 3152','about':'Incididunt enim veniam ut veniam quis dolore pariatur culpa ex. Cillum laboris dolor exercitation officia. Officia irure magna aliqua veniam officia ullamco culpa. Cillum enim velit ea sint sint officia labore ea adipisicing culpa laboris. Anim aute sint commodo culpa ex quis minim ut laborum.','registered':'Sunday, June 1, 2014 5:38 AM','latitude':'-4.655435','longitude':'5.851803','tags':['anim','non','anim','laborum','pariatur'],'greeting':'Hello, Terra! You have 5 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed9b9fc3041a674c87','index':80,'guid':'9f95fa36-4e45-4c3f-9362-3d4d809bf57f','isActive':true,'balance':'$3,403.16','picture':'http://placehold.it/32x32','age':39,'eyeColor':'brown','name':{'first':'Sharpe','last':'Berger'},'company':'ZILLAN','email':'sharpe.berger@zillan.ca','phone':'+1 (913) 498-3005','address':'277 Bragg Street, Faywood, Texas, 6487','about':'Dolor duis id aute ea veniam amet ullamco id. Culpa deserunt irure mollit tempor dolore veniam culpa officia culpa laborum eiusmod. Ullamco tempor qui aliqua cupidatat veniam cillum eu ut ex minim eu in. Quis exercitation anim eiusmod tempor esse mollit exercitation cillum ipsum reprehenderit. Sint voluptate ipsum officia sint magna nulla tempor eiusmod eiusmod veniam. Consectetur non ad veniam exercitation voluptate non nostrud.','registered':'Tuesday, June 27, 2017 12:58 AM','latitude':'-0.54085','longitude':'106.258693','tags':['proident','eiusmod','commodo','excepteur','pariatur'],'greeting':'Hello, Sharpe! You have 5 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed1a1866757bf675e0','index':81,'guid':'1b944a01-01d3-4846-94e3-630f4d0e51a3','isActive':true,'balance':'$2,038.61','picture':'http://placehold.it/32x32','age':28,'eyeColor':'brown','name':{'first':'Blanchard','last':'Ewing'},'company':'CONJURICA','email':'blanchard.ewing@conjurica.info','phone':'+1 (859) 593-3212','address':'252 Beaver Street, Kiskimere, Utah, 3255','about':'Labore magna aute adipisicing ut dolor sit ea. Officia culpa aute occaecat sit ex ullamco aliquip ad sit culpa. Ex in enim dolore ex est sit. Do irure nulla magna sint aliquip in duis aute. Magna ullamco sit labore ea tempor voluptate.','registered':'Monday, May 4, 2015 10:50 AM','latitude':'76.207595','longitude':'0.672563','tags':['proident','pariatur','officia','in','culpa'],'greeting':'Hello, Blanchard! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed987d82f4e22d939c','index':82,'guid':'97a90aee-3cee-4678-819e-24fb94279dc1','isActive':false,'balance':'$1,201.55','picture':'http://placehold.it/32x32','age':28,'eyeColor':'blue','name':{'first':'Wells','last':'Solomon'},'company':'CORPULSE','email':'wells.solomon@corpulse.tv','phone':'+1 (840) 539-3349','address':'159 Radde Place, Linganore, Idaho, 230','about':'Consequat dolore mollit sit irure cupidatat commodo. Incididunt cillum reprehenderit ullamco sit proident cupidatat occaecat reprehenderit officia. Ad anim Lorem elit in officia minim proident nisi commodo eiusmod ea Lorem dolore voluptate. Dolor aliquip est commodo Lorem dolor ut aliquip ut. Sit anim officia dolore excepteur aute enim cillum.','registered':'Friday, January 6, 2017 1:59 PM','latitude':'70.020883','longitude':'14.503588','tags':['mollit','aute','officia','nostrud','laboris'],'greeting':'Hello, Wells! You have 7 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eddf7a904ea0d0bc2a','index':83,'guid':'fe639a0c-7517-43e6-b0da-cd9ca5b9e267','isActive':false,'balance':'$3,664.47','picture':'http://placehold.it/32x32','age':33,'eyeColor':'blue','name':{'first':'Natalia','last':'Brown'},'company':'SYNTAC','email':'natalia.brown@syntac.co.uk','phone':'+1 (952) 595-3513','address':'332 Lenox Road, Springville, Alabama, 8406','about':'Nulla consequat officia commodo ea sunt irure anim velit aliquip aliquip. Labore ullamco occaecat proident voluptate cillum labore minim nostrud excepteur. Qui fugiat nostrud cillum fugiat ullamco id commodo aliqua voluptate mollit id id laboris. Cillum qui duis duis sit adipisicing elit ut aliqua eu. Anim nisi aliqua sit mollit.','registered':'Sunday, July 30, 2017 1:02 PM','latitude':'31.937613','longitude':'-9.957927','tags':['magna','adipisicing','exercitation','tempor','consectetur'],'greeting':'Hello, Natalia! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed8823fa385cad4aa3','index':84,'guid':'5cf280da-f5f0-4cc6-9063-e9d5863c8c89','isActive':false,'balance':'$1,624.17','picture':'http://placehold.it/32x32','age':25,'eyeColor':'blue','name':{'first':'Greene','last':'Waller'},'company':'ISOTRACK','email':'greene.waller@isotrack.io','phone':'+1 (838) 406-3608','address':'362 Albemarle Road, Gardiner, Michigan, 2764','about':'Ut nisi sit sint nulla dolor magna. Culpa occaecat adipisicing veniam proident excepteur tempor quis ex. Fugiat tempor laborum dolor adipisicing irure anim cupidatat ut exercitation ex sit. Cupidatat exercitation commodo sunt ex irure fugiat eu esse do ullamco mollit dolore cupidatat. Cupidatat magna incididunt officia dolore esse voluptate deserunt in laborum dolor. Sit fugiat Lorem eu ullamco. Laboris veniam quis cillum tempor ex fugiat cillum cupidatat.','registered':'Sunday, June 10, 2018 10:32 PM','latitude':'0.256921','longitude':'-96.141941','tags':['magna','dolore','deserunt','aliquip','cillum'],'greeting':'Hello, Greene! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0eda7c905c2d24c7d31','index':85,'guid':'aa30a9fb-8a16-48eb-8bb7-1307d1e1f191','isActive':false,'balance':'$1,974.04','picture':'http://placehold.it/32x32','age':36,'eyeColor':'green','name':{'first':'Carlene','last':'Hanson'},'company':'DIGIRANG','email':'carlene.hanson@digirang.me','phone':'+1 (981) 417-3209','address':'435 Clark Street, Choctaw, Oregon, 9888','about':'Amet labore esse cillum irure laborum consectetur occaecat non aliquip aliquip proident. Nisi magna nulla officia duis labore aute nulla laborum duis tempor minim. Velit elit reprehenderit nisi exercitation officia incididunt amet cupidatat excepteur proident consectetur.','registered':'Thursday, April 20, 2017 6:13 AM','latitude':'68.529086','longitude':'68.802409','tags':['pariatur','nulla','qui','amet','labore'],'greeting':'Hello, Carlene! You have 10 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed6fbee12ce9e55dbf','index':86,'guid':'0fce89aa-3310-48df-862a-68bd3d776644','isActive':false,'balance':'$3,909.64','picture':'http://placehold.it/32x32','age':40,'eyeColor':'brown','name':{'first':'Doris','last':'Collins'},'company':'ZIORE','email':'doris.collins@ziore.com','phone':'+1 (914) 405-2360','address':'301 Lorraine Street, Stouchsburg, Minnesota, 7476','about':'Nisi deserunt aliquip et deserunt ipsum ad consectetur est non ullamco. Dolore do ut voluptate do eiusmod. Culpa ad in eiusmod nisi cillum do. Officia magna cillum sint aliqua reprehenderit amet est ipsum. Eiusmod deserunt commodo proident consequat. Amet minim dolor consequat aliquip aliquip culpa non exercitation non.','registered':'Wednesday, February 25, 2015 9:15 PM','latitude':'-57.364906','longitude':'130.766587','tags':['nulla','deserunt','cillum','eiusmod','adipisicing'],'greeting':'Hello, Doris! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0edede9402476c398c0','index':87,'guid':'60cf0aa6-bc6d-4305-8842-d27e6af1306f','isActive':false,'balance':'$2,817.53','picture':'http://placehold.it/32x32','age':28,'eyeColor':'green','name':{'first':'Cline','last':'Hayden'},'company':'ECRAZE','email':'cline.hayden@ecraze.org','phone':'+1 (965) 507-2138','address':'352 Rutland Road, Ebro, Connecticut, 1196','about':'Dolor eiusmod enim anim sit enim ea tempor. Tempor amet consectetur aliquip culpa do ex excepteur deserunt. Dolor commodo veniam culpa sint. Commodo consectetur pariatur irure nisi deserunt cillum est dolor ipsum ea.','registered':'Thursday, September 29, 2016 5:58 AM','latitude':'62.50713','longitude':'86.247286','tags':['enim','tempor','anim','veniam','proident'],'greeting':'Hello, Cline! You have 9 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0edeb72f151994a551b','index':88,'guid':'dbb49c62-86b1-409f-b8b8-f609c709d2a8','isActive':false,'balance':'$3,122.56','picture':'http://placehold.it/32x32','age':39,'eyeColor':'green','name':{'first':'Janelle','last':'Rutledge'},'company':'TERRAGEN','email':'janelle.rutledge@terragen.net','phone':'+1 (914) 581-3749','address':'170 Falmouth Street, Alderpoint, West Virginia, 642','about':'Laboris proident cillum sunt qui ea sunt. Officia adipisicing exercitation dolore magna reprehenderit amet anim id. Laboris commodo sit irure irure. Excepteur est mollit fugiat incididunt consectetur veniam irure ea mollit. Cillum enim consequat sunt sunt nisi incididunt tempor enim.','registered':'Monday, February 16, 2015 5:46 AM','latitude':'-46.392023','longitude':'32.054562','tags':['eu','eu','nisi','labore','deserunt'],'greeting':'Hello, Janelle! You have 9 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edc9c2604846ff9a0d','index':89,'guid':'c4d7a365-f1d3-4584-b78e-008394c219f7','isActive':true,'balance':'$1,807.19','picture':'http://placehold.it/32x32','age':24,'eyeColor':'green','name':{'first':'Abby','last':'Lopez'},'company':'GRAINSPOT','email':'abby.lopez@grainspot.name','phone':'+1 (917) 442-3955','address':'488 Kensington Walk, Winston, Hawaii, 9109','about':'Incididunt deserunt Lorem proident magna tempor enim quis duis eu ut adipisicing in. Ex mollit non irure aliqua officia. Fugiat id ipsum consequat irure id ullamco culpa quis nulla enim aliquip consequat et. Dolor ut anim velit irure consequat cillum eu. Aute occaecat laborum est aliqua.','registered':'Sunday, April 1, 2018 11:28 PM','latitude':'-10.177041','longitude':'-165.756718','tags':['est','laborum','culpa','non','quis'],'greeting':'Hello, Abby! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed03237438b158af9e','index':90,'guid':'36c4a19f-2d00-4e40-bd49-155fd2ce0a6c','isActive':false,'balance':'$2,757.86','picture':'http://placehold.it/32x32','age':31,'eyeColor':'blue','name':{'first':'Whitney','last':'Sheppard'},'company':'ANACHO','email':'whitney.sheppard@anacho.us','phone':'+1 (922) 437-2383','address':'951 Beekman Place, Homeworth, New York, 6088','about':'Sint minim nisi minim non minim aliqua pariatur ullamco do sint qui labore. Aute elit reprehenderit ad do fugiat est amet. In incididunt tempor commodo cillum tempor est labore anim.','registered':'Tuesday, September 13, 2016 6:43 PM','latitude':'-49.732527','longitude':'-171.846715','tags':['exercitation','veniam','sunt','est','proident'],'greeting':'Hello, Whitney! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0edb99dd3aa53d2cb7f','index':91,'guid':'17afd430-f37f-4d55-958c-72f35cdb5997','isActive':false,'balance':'$3,683.86','picture':'http://placehold.it/32x32','age':38,'eyeColor':'blue','name':{'first':'Ilene','last':'Blackwell'},'company':'ENQUILITY','email':'ilene.blackwell@enquility.biz','phone':'+1 (817) 555-2616','address':'950 Varanda Place, Belgreen, Virgin Islands, 1765','about':'Id eiusmod deserunt eiusmod adipisicing adipisicing est enim pariatur esse duis. Qui velit duis irure magna consectetur dolore reprehenderit. Cillum dolore minim consectetur irure non qui velit cillum veniam adipisicing incididunt. Deserunt veniam excepteur veniam velit aliquip labore quis exercitation magna do non dolor. Aliquip occaecat minim adipisicing deserunt fugiat nulla occaecat proident irure consectetur eiusmod irure. Enim Lorem deserunt amet Lorem commodo eiusmod reprehenderit occaecat adipisicing dolor voluptate cillum.','registered':'Thursday, February 1, 2018 8:39 AM','latitude':'57.393644','longitude':'-3.704258','tags':['adipisicing','dolor','commodo','Lorem','Lorem'],'greeting':'Hello, Ilene! You have 6 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed353f4deb62c3342a','index':92,'guid':'9953e285-2095-4f1c-978b-9ece2a867e9d','isActive':false,'balance':'$1,202.44','picture':'http://placehold.it/32x32','age':38,'eyeColor':'blue','name':{'first':'Dawson','last':'Herman'},'company':'BITENDREX','email':'dawson.herman@bitendrex.ca','phone':'+1 (843) 522-2655','address':'471 Channel Avenue, Denio, Alaska, 5040','about':'Nisi occaecat mollit reprehenderit nisi minim Lorem mollit. Ea proident irure cillum quis. Deserunt consectetur consectetur consequat quis enim minim ea ipsum proident nisi ad non aliquip. Veniam aute minim consequat irure voluptate aute amet excepteur exercitation cillum duis quis adipisicing nostrud.','registered':'Tuesday, December 8, 2015 5:40 PM','latitude':'-55.602721','longitude':'-26.683234','tags':['qui','dolor','deserunt','eiusmod','labore'],'greeting':'Hello, Dawson! You have 7 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0edd5464bc50a5310ad','index':93,'guid':'724b2434-4dbd-417d-aa07-6065715f434f','isActive':false,'balance':'$1,595.98','picture':'http://placehold.it/32x32','age':25,'eyeColor':'brown','name':{'first':'Alice','last':'Christian'},'company':'ZENOLUX','email':'alice.christian@zenolux.info','phone':'+1 (954) 466-2650','address':'875 Gerritsen Avenue, Townsend, Kentucky, 6568','about':'Nulla labore occaecat ex culpa magna. Commodo occaecat et in consequat cillum laborum magna adipisicing excepteur. Do ut Lorem esse voluptate officia ea aliquip proident amet veniam minim nulla adipisicing. Enim consectetur incididunt laborum voluptate tempor deserunt non laboris. Aliquip deserunt aute irure dolore magna anim aliquip sint magna Lorem. Officia laboris nulla officia sint labore nisi. Do Lorem id in est esse adipisicing id fugiat enim esse laborum.','registered':'Wednesday, October 3, 2018 9:26 PM','latitude':'-88.790637','longitude':'138.817328','tags':['duis','ea','magna','ea','incididunt'],'greeting':'Hello, Alice! You have 8 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0eda01886247b6a4f3d','index':94,'guid':'17c9f4d3-7d72-44e3-8f7c-08d7de920f46','isActive':false,'balance':'$3,173.29','picture':'http://placehold.it/32x32','age':31,'eyeColor':'blue','name':{'first':'Schwartz','last':'Mccormick'},'company':'EVIDENDS','email':'schwartz.mccormick@evidends.tv','phone':'+1 (924) 531-2802','address':'160 Midwood Street, Indio, Palau, 4241','about':'Anim reprehenderit et et adipisicing voluptate consequat elit. Sint Lorem laboris Lorem minim nostrud aute reprehenderit elit aute quis nulla. Officia aute eiusmod mollit cillum eu aliquip non enim ea occaecat quis fugiat occaecat officia. Eiusmod culpa exercitation dolor aliqua enim occaecat nisi cupidatat duis ex dolore id. Id consequat aliqua cupidatat ut. Sit nisi est sunt culpa ullamco excepteur sunt pariatur incididunt amet. Ut tempor duis velit eu ut id culpa aute anim occaecat labore.','registered':'Thursday, March 2, 2017 5:57 PM','latitude':'38.618587','longitude':'-165.142529','tags':['ad','reprehenderit','magna','elit','mollit'],'greeting':'Hello, Schwartz! You have 10 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed51be4df456ec2bc9','index':95,'guid':'44f68f65-959b-4ec2-bd2a-1f30035f76fc','isActive':false,'balance':'$3,242.24','picture':'http://placehold.it/32x32','age':39,'eyeColor':'blue','name':{'first':'Bonita','last':'Stevens'},'company':'SLOFAST','email':'bonita.stevens@slofast.co.uk','phone':'+1 (886) 473-2105','address':'459 Bushwick Court, Kilbourne, Rhode Island, 9450','about':'Consequat reprehenderit qui reprehenderit nisi sit est in qui aliquip amet. Ex deserunt cupidatat amet cillum eiusmod irure anim in amet proident voluptate. Ad officia culpa in non incididunt do.','registered':'Saturday, August 22, 2015 5:23 AM','latitude':'60.013542','longitude':'58.242132','tags':['aute','adipisicing','in','cillum','officia'],'greeting':'Hello, Bonita! You have 5 unread messages.','favoriteFruit':'banana'},{'_id':'5c5ab0ed50a55e3587993f68','index':96,'guid':'652e434f-221e-4899-af12-38dca5c9621d','isActive':false,'balance':'$2,720.06','picture':'http://placehold.it/32x32','age':28,'eyeColor':'green','name':{'first':'Charmaine','last':'Jackson'},'company':'FLUM','email':'charmaine.jackson@flum.io','phone':'+1 (947) 573-2692','address':'788 Windsor Place, Highland, Arkansas, 8869','about':'Dolore reprehenderit irure excepteur eu reprehenderit sint Lorem ut amet in. Consequat anim elit sunt aliquip incididunt. Culpa consequat do exercitation dolor enim dolor sunt sit excepteur ad anim. Dolor aute elit velit mollit minim eu.','registered':'Wednesday, April 6, 2016 7:54 PM','latitude':'25.756553','longitude':'-5.482531','tags':['amet','sint','consequat','est','ex'],'greeting':'Hello, Charmaine! You have 10 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed213621949bbdd5d3','index':97,'guid':'7d7d93d8-3e37-4b4a-9fa2-591fb7d153ce','isActive':true,'balance':'$1,370.63','picture':'http://placehold.it/32x32','age':36,'eyeColor':'brown','name':{'first':'Petersen','last':'Cooley'},'company':'ROTODYNE','email':'petersen.cooley@rotodyne.me','phone':'+1 (929) 563-3339','address':'338 Pioneer Street, Carbonville, Missouri, 3030','about':'Cillum elit dolore labore aute. Cillum ea incididunt cupidatat consequat sint eu mollit. Excepteur commodo eiusmod ex Lorem enim velit minim.','registered':'Friday, December 8, 2017 5:53 AM','latitude':'-10.576254','longitude':'-111.176861','tags':['veniam','eu','eiusmod','dolore','voluptate'],'greeting':'Hello, Petersen! You have 9 unread messages.','favoriteFruit':'apple'},{'_id':'5c5ab0ed3e938138d58ed453','index':98,'guid':'d6fea4a3-03f6-46ee-90b9-8ec51a585e29','isActive':true,'balance':'$1,216.54','picture':'http://placehold.it/32x32','age':39,'eyeColor':'blue','name':{'first':'Rosanne','last':'Terry'},'company':'EXTREMO','email':'rosanne.terry@extremo.com','phone':'+1 (812) 496-2691','address':'368 Rockaway Avenue, Gloucester, Illinois, 7913','about':'Duis et nostrud duis quis minim eiusmod culpa do ea ad pariatur tempor. Velit veniam aliqua aliquip est enim ex et culpa dolor ullamco culpa officia. Eu id occaecat aute cillum aute sit aute laboris ipsum voluptate ex. Amet tempor minim tempor Lorem quis dolore. Pariatur consequat dolore nulla veniam dolor exercitation consequat nulla laboris incididunt do. Dolore do tempor deserunt exercitation incididunt officia incididunt ut do reprehenderit do eiusmod nulla.','registered':'Sunday, August 6, 2017 12:46 PM','latitude':'-43.257964','longitude':'-45.147686','tags':['et','incididunt','esse','commodo','ipsum'],'greeting':'Hello, Rosanne! You have 6 unread messages.','favoriteFruit':'strawberry'},{'_id':'5c5ab0ed632b1a1d65501d6b','index':99,'guid':'bf8c6ac1-ee18-48ee-ae94-ea515a53c951','isActive':true,'balance':'$2,905.58','picture':'http://placehold.it/32x32','age':21,'eyeColor':'blue','name':{'first':'Irene','last':'Castro'},'company':'POLARIA','email':'irene.castro@polaria.org','phone':'+1 (818) 417-3761','address':'901 Dupont Street, Sperryville, Oklahoma, 953','about':'Pariatur minim laboris aliqua dolor aliquip consequat ea do duis voluptate id Lorem. In reprehenderit et adipisicing anim elit incididunt velit in laborum laborum. Qui minim magna et amet sit do voluptate reprehenderit ea sit sint velit.','registered':'Tuesday, August 18, 2015 10:48 AM','latitude':'-7.004055','longitude':'116.052433','tags':['sit','proident','enim','ullamco','non'],'greeting':'Hello, Irene! You have 10 unread messages.','favoriteFruit':'apple'}]" + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/plan-no-changes/plan.log b/internal/cloud/testdata/plan-no-changes/plan.log new file mode 100644 index 000000000..704168151 --- /dev/null +++ b/internal/cloud/testdata/plan-no-changes/plan.log @@ -0,0 +1,17 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +null_resource.hello: Refreshing state... (ID: 8657651096157629581) + +------------------------------------------------------------------------ + +No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your +configuration and real physical resources that exist. As a result, no +actions need to be performed. diff --git a/internal/cloud/testdata/plan-no-changes/policy.log b/internal/cloud/testdata/plan-no-changes/policy.log new file mode 100644 index 000000000..b0cb1e598 --- /dev/null +++ b/internal/cloud/testdata/plan-no-changes/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: true + +This result means that Sentinel policies returned true and the protected +behavior is allowed by Sentinel policies. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: true + +TRUE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/internal/cloud/testdata/plan-policy-hard-failed/plan.log b/internal/cloud/testdata/plan-policy-hard-failed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/plan-policy-hard-failed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/plan-policy-hard-failed/policy.log b/internal/cloud/testdata/plan-policy-hard-failed/policy.log new file mode 100644 index 000000000..5d6e6935b --- /dev/null +++ b/internal/cloud/testdata/plan-policy-hard-failed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: false + +Sentinel evaluated to false because one or more Sentinel policies evaluated +to false. This false was not due to an undefined value or runtime error. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (hard-mandatory) + +Result: false + +FALSE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/internal/cloud/testdata/plan-policy-passed/plan.log b/internal/cloud/testdata/plan-policy-passed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/plan-policy-passed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/plan-policy-passed/policy.log b/internal/cloud/testdata/plan-policy-passed/policy.log new file mode 100644 index 000000000..b0cb1e598 --- /dev/null +++ b/internal/cloud/testdata/plan-policy-passed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: true + +This result means that Sentinel policies returned true and the protected +behavior is allowed by Sentinel policies. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: true + +TRUE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/internal/cloud/testdata/plan-policy-soft-failed/plan.log b/internal/cloud/testdata/plan-policy-soft-failed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/plan-policy-soft-failed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/plan-policy-soft-failed/policy.log b/internal/cloud/testdata/plan-policy-soft-failed/policy.log new file mode 100644 index 000000000..3e4ebedf6 --- /dev/null +++ b/internal/cloud/testdata/plan-policy-soft-failed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: false + +Sentinel evaluated to false because one or more Sentinel policies evaluated +to false. This false was not due to an undefined value or runtime error. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: false + +FALSE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/internal/cloud/testdata/plan-variables/plan.log b/internal/cloud/testdata/plan-variables/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/plan-variables/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/plan-with-error/plan.log b/internal/cloud/testdata/plan-with-error/plan.log new file mode 100644 index 000000000..4344a3722 --- /dev/null +++ b/internal/cloud/testdata/plan-with-error/plan.log @@ -0,0 +1,10 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... + +Error: null_resource.foo: 1 error(s) occurred: + +* null_resource.foo: 1:3: unknown function called: guid in: + +${guid()} diff --git a/internal/cloud/testdata/plan-with-working-directory/terraform/plan.log b/internal/cloud/testdata/plan-with-working-directory/terraform/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/plan-with-working-directory/terraform/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/internal/cloud/testdata/plan/plan.log b/internal/cloud/testdata/plan/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/internal/cloud/testdata/plan/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. From 34597c237f6dec569a51a40bec6b412864ef942e Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Tue, 31 Aug 2021 11:47:26 -0500 Subject: [PATCH 09/78] cloud: Don't ignore .log golden files --- internal/cloud/testdata/.gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 internal/cloud/testdata/.gitignore diff --git a/internal/cloud/testdata/.gitignore b/internal/cloud/testdata/.gitignore new file mode 100644 index 000000000..15498bbfb --- /dev/null +++ b/internal/cloud/testdata/.gitignore @@ -0,0 +1 @@ +!*.log From 471dd479e8296878378750991c865e2503aa490c Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Tue, 7 Sep 2021 15:35:32 -0500 Subject: [PATCH 10/78] Update go-tfe to 26689e These changes include additions to fulfill the interface for the client mock, plus moving all that logic (which needn't be duplicated across both the remote and cloud packages) over to the cloud package under a dedicated mock client file. --- go.mod | 6 +- go.sum | 15 +- internal/backend/remote/backend_apply_test.go | 25 +- internal/backend/remote/backend_mock.go | 1364 ----------------- internal/backend/remote/backend_plan_test.go | 29 +- internal/backend/remote/backend_state_test.go | 3 +- internal/backend/remote/testing.go | 24 +- internal/cloud/backend_apply_test.go | 24 +- internal/cloud/backend_plan_test.go | 28 +- internal/cloud/backend_state_test.go | 2 +- internal/cloud/testing.go | 23 +- .../{backend_mock.go => tfe_client_mock.go} | 359 ++--- 12 files changed, 300 insertions(+), 1602 deletions(-) delete mode 100644 internal/backend/remote/backend_mock.go rename internal/cloud/{backend_mock.go => tfe_client_mock.go} (75%) diff --git a/go.mod b/go.mod index 646309e2a..9074b31d8 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-plugin v1.4.3 github.com/hashicorp/go-retryablehttp v0.5.2 - github.com/hashicorp/go-tfe v0.15.0 + github.com/hashicorp/go-tfe v0.18.1-0.20210902165242-26689edbfddf github.com/hashicorp/go-uuid v1.0.1 github.com/hashicorp/go-version v1.2.1 github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f @@ -143,9 +143,9 @@ require ( github.com/hashicorp/go-msgpack v0.5.4 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect github.com/hashicorp/go-safetemp v1.0.0 // indirect - github.com/hashicorp/go-slug v0.4.1 // indirect + github.com/hashicorp/go-slug v0.7.0 // indirect github.com/hashicorp/golang-lru v0.5.1 // indirect - github.com/hashicorp/jsonapi v0.0.0-20210518035559-1e50d74c8db3 // indirect + github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d // indirect github.com/hashicorp/serf v0.9.5 // indirect github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d // indirect github.com/huandu/xstrings v1.3.2 // indirect diff --git a/go.sum b/go.sum index bd8b487fb..4a2064597 100644 --- a/go.sum +++ b/go.sum @@ -370,13 +370,13 @@ github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5O github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= -github.com/hashicorp/go-slug v0.4.1 h1:/jAo8dNuLgSImoLXaX7Od7QB4TfYCVPam+OpAt5bZqc= -github.com/hashicorp/go-slug v0.4.1/go.mod h1:I5tq5Lv0E2xcNXNkmx7BSfzi1PsJ2cNjs3cC3LwyhK8= +github.com/hashicorp/go-slug v0.7.0 h1:8HIi6oreWPtnhpYd8lIGQBgp4rXzDWQTOhfILZm+nok= +github.com/hashicorp/go-slug v0.7.0/go.mod h1:Ib+IWBYfEfJGI1ZyXMGNbu2BU+aa3Dzu41RKLH301v4= github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-tfe v0.15.0 h1:vdnz1NjOhvmap+cj8iPsL8SbS4iFFVuNYFkGpF5SdoA= -github.com/hashicorp/go-tfe v0.15.0/go.mod h1:c8glB5p6XzocEWLNkuy5RxcjqN5X2PpY6NF3f2W6nIo= +github.com/hashicorp/go-tfe v0.18.1-0.20210902165242-26689edbfddf h1:Tn5cI9kacNyO40ztxmwfAaHrOGd7dELLSAueV2Xfv38= +github.com/hashicorp/go-tfe v0.18.1-0.20210902165242-26689edbfddf/go.mod h1:7lChm1Mjsh0ofrUNkP8MHljUFrnKNZNTw36S6qSbJZU= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -393,8 +393,8 @@ github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f/go.mod h1:oZtUIOe8dh github.com/hashicorp/hcl/v2 v2.0.0/go.mod h1:oVVDG71tEinNGYCxinCYadcmKU9bglqW9pV3txagJ90= github.com/hashicorp/hcl/v2 v2.10.1 h1:h4Xx4fsrRE26ohAk/1iGF/JBqRQbyUqu5Lvj60U54ys= github.com/hashicorp/hcl/v2 v2.10.1/go.mod h1:FwWsfWEjyV/CMj8s/gqAuiviY72rJ1/oayI9WftqcKg= -github.com/hashicorp/jsonapi v0.0.0-20210518035559-1e50d74c8db3 h1:mzwkutymYIXR5oQT9YnfbLuuw7LZmksiHKRPUTN5ijo= -github.com/hashicorp/jsonapi v0.0.0-20210518035559-1e50d74c8db3/go.mod h1:Yog5+CPEM3c99L1CL2CFCYoSzgWm5vTU58idbRUaLik= +github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d h1:9ARUJJ1VVynB176G1HCwleORqCaXm/Vx0uUi0dL26I0= +github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d/go.mod h1:Yog5+CPEM3c99L1CL2CFCYoSzgWm5vTU58idbRUaLik= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= github.com/hashicorp/memberlist v0.2.2 h1:5+RffWKwqJ71YPu9mWsF7ZOscZmwfasdA8kbdC7AO2g= @@ -610,8 +610,9 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.194/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.232 h1:kwsWbh4rEw42ZDe9/812ebhbwNZxlQyZ2sTmxBOKhN4= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.232/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y= diff --git a/internal/backend/remote/backend_apply_test.go b/internal/backend/remote/backend_apply_test.go index e15fe0bb3..9b4286010 100644 --- a/internal/backend/remote/backend_apply_test.go +++ b/internal/backend/remote/backend_apply_test.go @@ -14,6 +14,7 @@ import ( version "github.com/hashicorp/go-version" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/cloud" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" @@ -308,11 +309,11 @@ func TestRemote_applyWithoutRefresh(t *testing.T) { // We should find a run inside the mock client that has refresh set // to false. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*cloud.MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff(false, run.Refresh); diff != "" { t.Errorf("wrong Refresh setting in the created run\n%s", diff) } @@ -377,11 +378,11 @@ func TestRemote_applyWithRefreshOnly(t *testing.T) { // We should find a run inside the mock client that has refresh-only set // to true. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*cloud.MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff(true, run.RefreshOnly); diff != "" { t.Errorf("wrong RefreshOnly setting in the created run\n%s", diff) } @@ -448,11 +449,11 @@ func TestRemote_applyWithTarget(t *testing.T) { // We should find a run inside the mock client that has the same // target address we requested above. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*cloud.MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff([]string{"null_resource.foo"}, run.TargetAddrs); diff != "" { t.Errorf("wrong TargetAddrs in the created run\n%s", diff) } @@ -523,11 +524,11 @@ func TestRemote_applyWithReplace(t *testing.T) { // We should find a run inside the mock client that has the same // refresh address we requested above. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*cloud.MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff([]string{"null_resource.foo"}, run.ReplaceAddrs); diff != "" { t.Errorf("wrong ReplaceAddrs in the created run\n%s", diff) } diff --git a/internal/backend/remote/backend_mock.go b/internal/backend/remote/backend_mock.go deleted file mode 100644 index abf150f7c..000000000 --- a/internal/backend/remote/backend_mock.go +++ /dev/null @@ -1,1364 +0,0 @@ -package remote - -import ( - "bytes" - "context" - "encoding/base64" - "errors" - "fmt" - "io" - "io/ioutil" - "math/rand" - "os" - "path/filepath" - "strings" - "sync" - "time" - - tfe "github.com/hashicorp/go-tfe" - "github.com/hashicorp/terraform/internal/terraform" - tfversion "github.com/hashicorp/terraform/version" - "github.com/mitchellh/copystructure" -) - -type mockClient struct { - Applies *mockApplies - ConfigurationVersions *mockConfigurationVersions - CostEstimates *mockCostEstimates - Organizations *mockOrganizations - Plans *mockPlans - PolicyChecks *mockPolicyChecks - Runs *mockRuns - StateVersions *mockStateVersions - Variables *mockVariables - Workspaces *mockWorkspaces -} - -func newMockClient() *mockClient { - c := &mockClient{} - c.Applies = newMockApplies(c) - c.ConfigurationVersions = newMockConfigurationVersions(c) - c.CostEstimates = newMockCostEstimates(c) - c.Organizations = newMockOrganizations(c) - c.Plans = newMockPlans(c) - c.PolicyChecks = newMockPolicyChecks(c) - c.Runs = newMockRuns(c) - c.StateVersions = newMockStateVersions(c) - c.Variables = newMockVariables(c) - c.Workspaces = newMockWorkspaces(c) - return c -} - -type mockApplies struct { - client *mockClient - applies map[string]*tfe.Apply - logs map[string]string -} - -func newMockApplies(client *mockClient) *mockApplies { - return &mockApplies{ - client: client, - applies: make(map[string]*tfe.Apply), - logs: make(map[string]string), - } -} - -// create is a helper function to create a mock apply that uses the configured -// working directory to find the logfile. -func (m *mockApplies) create(cvID, workspaceID string) (*tfe.Apply, error) { - c, ok := m.client.ConfigurationVersions.configVersions[cvID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - if c.Speculative { - // Speculative means its plan-only so we don't create a Apply. - return nil, nil - } - - id := generateID("apply-") - url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) - - a := &tfe.Apply{ - ID: id, - LogReadURL: url, - Status: tfe.ApplyPending, - } - - w, ok := m.client.Workspaces.workspaceIDs[workspaceID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - if w.AutoApply { - a.Status = tfe.ApplyRunning - } - - m.logs[url] = filepath.Join( - m.client.ConfigurationVersions.uploadPaths[cvID], - w.WorkingDirectory, - "apply.log", - ) - m.applies[a.ID] = a - - return a, nil -} - -func (m *mockApplies) Read(ctx context.Context, applyID string) (*tfe.Apply, error) { - a, ok := m.applies[applyID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - // Together with the mockLogReader this allows testing queued runs. - if a.Status == tfe.ApplyRunning { - a.Status = tfe.ApplyFinished - } - return a, nil -} - -func (m *mockApplies) Logs(ctx context.Context, applyID string) (io.Reader, error) { - a, err := m.Read(ctx, applyID) - if err != nil { - return nil, err - } - - logfile, ok := m.logs[a.LogReadURL] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - if _, err := os.Stat(logfile); os.IsNotExist(err) { - return bytes.NewBufferString("logfile does not exist"), nil - } - - logs, err := ioutil.ReadFile(logfile) - if err != nil { - return nil, err - } - - done := func() (bool, error) { - a, err := m.Read(ctx, applyID) - if err != nil { - return false, err - } - if a.Status != tfe.ApplyFinished { - return false, nil - } - return true, nil - } - - return &mockLogReader{ - done: done, - logs: bytes.NewBuffer(logs), - }, nil -} - -type mockConfigurationVersions struct { - client *mockClient - configVersions map[string]*tfe.ConfigurationVersion - uploadPaths map[string]string - uploadURLs map[string]*tfe.ConfigurationVersion -} - -func newMockConfigurationVersions(client *mockClient) *mockConfigurationVersions { - return &mockConfigurationVersions{ - client: client, - configVersions: make(map[string]*tfe.ConfigurationVersion), - uploadPaths: make(map[string]string), - uploadURLs: make(map[string]*tfe.ConfigurationVersion), - } -} - -func (m *mockConfigurationVersions) List(ctx context.Context, workspaceID string, options tfe.ConfigurationVersionListOptions) (*tfe.ConfigurationVersionList, error) { - cvl := &tfe.ConfigurationVersionList{} - for _, cv := range m.configVersions { - cvl.Items = append(cvl.Items, cv) - } - - cvl.Pagination = &tfe.Pagination{ - CurrentPage: 1, - NextPage: 1, - PreviousPage: 1, - TotalPages: 1, - TotalCount: len(cvl.Items), - } - - return cvl, nil -} - -func (m *mockConfigurationVersions) Create(ctx context.Context, workspaceID string, options tfe.ConfigurationVersionCreateOptions) (*tfe.ConfigurationVersion, error) { - id := generateID("cv-") - url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) - - cv := &tfe.ConfigurationVersion{ - ID: id, - Status: tfe.ConfigurationPending, - UploadURL: url, - } - - m.configVersions[cv.ID] = cv - m.uploadURLs[url] = cv - - return cv, nil -} - -func (m *mockConfigurationVersions) Read(ctx context.Context, cvID string) (*tfe.ConfigurationVersion, error) { - cv, ok := m.configVersions[cvID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - return cv, nil -} - -func (m *mockConfigurationVersions) Upload(ctx context.Context, url, path string) error { - cv, ok := m.uploadURLs[url] - if !ok { - return errors.New("404 not found") - } - m.uploadPaths[cv.ID] = path - cv.Status = tfe.ConfigurationUploaded - return nil -} - -type mockCostEstimates struct { - client *mockClient - estimations map[string]*tfe.CostEstimate - logs map[string]string -} - -func newMockCostEstimates(client *mockClient) *mockCostEstimates { - return &mockCostEstimates{ - client: client, - estimations: make(map[string]*tfe.CostEstimate), - logs: make(map[string]string), - } -} - -// create is a helper function to create a mock cost estimation that uses the -// configured working directory to find the logfile. -func (m *mockCostEstimates) create(cvID, workspaceID string) (*tfe.CostEstimate, error) { - id := generateID("ce-") - - ce := &tfe.CostEstimate{ - ID: id, - MatchedResourcesCount: 1, - ResourcesCount: 1, - DeltaMonthlyCost: "0.00", - ProposedMonthlyCost: "0.00", - Status: tfe.CostEstimateFinished, - } - - w, ok := m.client.Workspaces.workspaceIDs[workspaceID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - logfile := filepath.Join( - m.client.ConfigurationVersions.uploadPaths[cvID], - w.WorkingDirectory, - "cost-estimate.log", - ) - - if _, err := os.Stat(logfile); os.IsNotExist(err) { - return nil, nil - } - - m.logs[ce.ID] = logfile - m.estimations[ce.ID] = ce - - return ce, nil -} - -func (m *mockCostEstimates) Read(ctx context.Context, costEstimateID string) (*tfe.CostEstimate, error) { - ce, ok := m.estimations[costEstimateID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - return ce, nil -} - -func (m *mockCostEstimates) Logs(ctx context.Context, costEstimateID string) (io.Reader, error) { - ce, ok := m.estimations[costEstimateID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - logfile, ok := m.logs[ce.ID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - if _, err := os.Stat(logfile); os.IsNotExist(err) { - return bytes.NewBufferString("logfile does not exist"), nil - } - - logs, err := ioutil.ReadFile(logfile) - if err != nil { - return nil, err - } - - ce.Status = tfe.CostEstimateFinished - - return bytes.NewBuffer(logs), nil -} - -// mockInput is a mock implementation of terraform.UIInput. -type mockInput struct { - answers map[string]string -} - -func (m *mockInput) Input(ctx context.Context, opts *terraform.InputOpts) (string, error) { - v, ok := m.answers[opts.Id] - if !ok { - return "", fmt.Errorf("unexpected input request in test: %s", opts.Id) - } - if v == "wait-for-external-update" { - select { - case <-ctx.Done(): - case <-time.After(time.Minute): - } - } - delete(m.answers, opts.Id) - return v, nil -} - -type mockOrganizations struct { - client *mockClient - organizations map[string]*tfe.Organization -} - -func newMockOrganizations(client *mockClient) *mockOrganizations { - return &mockOrganizations{ - client: client, - organizations: make(map[string]*tfe.Organization), - } -} - -func (m *mockOrganizations) List(ctx context.Context, options tfe.OrganizationListOptions) (*tfe.OrganizationList, error) { - orgl := &tfe.OrganizationList{} - for _, org := range m.organizations { - orgl.Items = append(orgl.Items, org) - } - - orgl.Pagination = &tfe.Pagination{ - CurrentPage: 1, - NextPage: 1, - PreviousPage: 1, - TotalPages: 1, - TotalCount: len(orgl.Items), - } - - return orgl, nil -} - -// mockLogReader is a mock logreader that enables testing queued runs. -type mockLogReader struct { - done func() (bool, error) - logs *bytes.Buffer -} - -func (m *mockLogReader) Read(l []byte) (int, error) { - for { - if written, err := m.read(l); err != io.ErrNoProgress { - return written, err - } - time.Sleep(1 * time.Millisecond) - } -} - -func (m *mockLogReader) read(l []byte) (int, error) { - done, err := m.done() - if err != nil { - return 0, err - } - if !done { - return 0, io.ErrNoProgress - } - return m.logs.Read(l) -} - -func (m *mockOrganizations) Create(ctx context.Context, options tfe.OrganizationCreateOptions) (*tfe.Organization, error) { - org := &tfe.Organization{Name: *options.Name} - m.organizations[org.Name] = org - return org, nil -} - -func (m *mockOrganizations) Read(ctx context.Context, name string) (*tfe.Organization, error) { - org, ok := m.organizations[name] - if !ok { - return nil, tfe.ErrResourceNotFound - } - return org, nil -} - -func (m *mockOrganizations) Update(ctx context.Context, name string, options tfe.OrganizationUpdateOptions) (*tfe.Organization, error) { - org, ok := m.organizations[name] - if !ok { - return nil, tfe.ErrResourceNotFound - } - org.Name = *options.Name - return org, nil - -} - -func (m *mockOrganizations) Delete(ctx context.Context, name string) error { - delete(m.organizations, name) - return nil -} - -func (m *mockOrganizations) Capacity(ctx context.Context, name string) (*tfe.Capacity, error) { - var pending, running int - for _, r := range m.client.Runs.runs { - if r.Status == tfe.RunPending { - pending++ - continue - } - running++ - } - return &tfe.Capacity{Pending: pending, Running: running}, nil -} - -func (m *mockOrganizations) Entitlements(ctx context.Context, name string) (*tfe.Entitlements, error) { - return &tfe.Entitlements{ - Operations: true, - PrivateModuleRegistry: true, - Sentinel: true, - StateStorage: true, - Teams: true, - VCSIntegrations: true, - }, nil -} - -func (m *mockOrganizations) RunQueue(ctx context.Context, name string, options tfe.RunQueueOptions) (*tfe.RunQueue, error) { - rq := &tfe.RunQueue{} - - for _, r := range m.client.Runs.runs { - rq.Items = append(rq.Items, r) - } - - rq.Pagination = &tfe.Pagination{ - CurrentPage: 1, - NextPage: 1, - PreviousPage: 1, - TotalPages: 1, - TotalCount: len(rq.Items), - } - - return rq, nil -} - -type mockPlans struct { - client *mockClient - logs map[string]string - planOutputs map[string]string - plans map[string]*tfe.Plan -} - -func newMockPlans(client *mockClient) *mockPlans { - return &mockPlans{ - client: client, - logs: make(map[string]string), - planOutputs: make(map[string]string), - plans: make(map[string]*tfe.Plan), - } -} - -// create is a helper function to create a mock plan that uses the configured -// working directory to find the logfile. -func (m *mockPlans) create(cvID, workspaceID string) (*tfe.Plan, error) { - id := generateID("plan-") - url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) - - p := &tfe.Plan{ - ID: id, - LogReadURL: url, - Status: tfe.PlanPending, - } - - w, ok := m.client.Workspaces.workspaceIDs[workspaceID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - m.logs[url] = filepath.Join( - m.client.ConfigurationVersions.uploadPaths[cvID], - w.WorkingDirectory, - "plan.log", - ) - m.plans[p.ID] = p - - return p, nil -} - -func (m *mockPlans) Read(ctx context.Context, planID string) (*tfe.Plan, error) { - p, ok := m.plans[planID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - // Together with the mockLogReader this allows testing queued runs. - if p.Status == tfe.PlanRunning { - p.Status = tfe.PlanFinished - } - return p, nil -} - -func (m *mockPlans) Logs(ctx context.Context, planID string) (io.Reader, error) { - p, err := m.Read(ctx, planID) - if err != nil { - return nil, err - } - - logfile, ok := m.logs[p.LogReadURL] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - if _, err := os.Stat(logfile); os.IsNotExist(err) { - return bytes.NewBufferString("logfile does not exist"), nil - } - - logs, err := ioutil.ReadFile(logfile) - if err != nil { - return nil, err - } - - done := func() (bool, error) { - p, err := m.Read(ctx, planID) - if err != nil { - return false, err - } - if p.Status != tfe.PlanFinished { - return false, nil - } - return true, nil - } - - return &mockLogReader{ - done: done, - logs: bytes.NewBuffer(logs), - }, nil -} - -func (m *mockPlans) JSONOutput(ctx context.Context, planID string) ([]byte, error) { - planOutput, ok := m.planOutputs[planID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - return []byte(planOutput), nil -} - -type mockPolicyChecks struct { - client *mockClient - checks map[string]*tfe.PolicyCheck - logs map[string]string -} - -func newMockPolicyChecks(client *mockClient) *mockPolicyChecks { - return &mockPolicyChecks{ - client: client, - checks: make(map[string]*tfe.PolicyCheck), - logs: make(map[string]string), - } -} - -// create is a helper function to create a mock policy check that uses the -// configured working directory to find the logfile. -func (m *mockPolicyChecks) create(cvID, workspaceID string) (*tfe.PolicyCheck, error) { - id := generateID("pc-") - - pc := &tfe.PolicyCheck{ - ID: id, - Actions: &tfe.PolicyActions{}, - Permissions: &tfe.PolicyPermissions{}, - Scope: tfe.PolicyScopeOrganization, - Status: tfe.PolicyPending, - } - - w, ok := m.client.Workspaces.workspaceIDs[workspaceID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - logfile := filepath.Join( - m.client.ConfigurationVersions.uploadPaths[cvID], - w.WorkingDirectory, - "policy.log", - ) - - if _, err := os.Stat(logfile); os.IsNotExist(err) { - return nil, nil - } - - m.logs[pc.ID] = logfile - m.checks[pc.ID] = pc - - return pc, nil -} - -func (m *mockPolicyChecks) List(ctx context.Context, runID string, options tfe.PolicyCheckListOptions) (*tfe.PolicyCheckList, error) { - _, ok := m.client.Runs.runs[runID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - pcl := &tfe.PolicyCheckList{} - for _, pc := range m.checks { - pcl.Items = append(pcl.Items, pc) - } - - pcl.Pagination = &tfe.Pagination{ - CurrentPage: 1, - NextPage: 1, - PreviousPage: 1, - TotalPages: 1, - TotalCount: len(pcl.Items), - } - - return pcl, nil -} - -func (m *mockPolicyChecks) Read(ctx context.Context, policyCheckID string) (*tfe.PolicyCheck, error) { - pc, ok := m.checks[policyCheckID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - logfile, ok := m.logs[pc.ID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - if _, err := os.Stat(logfile); os.IsNotExist(err) { - return nil, fmt.Errorf("logfile does not exist") - } - - logs, err := ioutil.ReadFile(logfile) - if err != nil { - return nil, err - } - - switch { - case bytes.Contains(logs, []byte("Sentinel Result: true")): - pc.Status = tfe.PolicyPasses - case bytes.Contains(logs, []byte("Sentinel Result: false")): - switch { - case bytes.Contains(logs, []byte("hard-mandatory")): - pc.Status = tfe.PolicyHardFailed - case bytes.Contains(logs, []byte("soft-mandatory")): - pc.Actions.IsOverridable = true - pc.Permissions.CanOverride = true - pc.Status = tfe.PolicySoftFailed - } - default: - // As this is an unexpected state, we say the policy errored. - pc.Status = tfe.PolicyErrored - } - - return pc, nil -} - -func (m *mockPolicyChecks) Override(ctx context.Context, policyCheckID string) (*tfe.PolicyCheck, error) { - pc, ok := m.checks[policyCheckID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - pc.Status = tfe.PolicyOverridden - return pc, nil -} - -func (m *mockPolicyChecks) Logs(ctx context.Context, policyCheckID string) (io.Reader, error) { - pc, ok := m.checks[policyCheckID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - logfile, ok := m.logs[pc.ID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - if _, err := os.Stat(logfile); os.IsNotExist(err) { - return bytes.NewBufferString("logfile does not exist"), nil - } - - logs, err := ioutil.ReadFile(logfile) - if err != nil { - return nil, err - } - - switch { - case bytes.Contains(logs, []byte("Sentinel Result: true")): - pc.Status = tfe.PolicyPasses - case bytes.Contains(logs, []byte("Sentinel Result: false")): - switch { - case bytes.Contains(logs, []byte("hard-mandatory")): - pc.Status = tfe.PolicyHardFailed - case bytes.Contains(logs, []byte("soft-mandatory")): - pc.Actions.IsOverridable = true - pc.Permissions.CanOverride = true - pc.Status = tfe.PolicySoftFailed - } - default: - // As this is an unexpected state, we say the policy errored. - pc.Status = tfe.PolicyErrored - } - - return bytes.NewBuffer(logs), nil -} - -type mockRuns struct { - sync.Mutex - - client *mockClient - runs map[string]*tfe.Run - workspaces map[string][]*tfe.Run - - // If modifyNewRun is non-nil, the create method will call it just before - // saving a new run in the runs map, so that a calling test can mimic - // side-effects that a real server might apply in certain situations. - modifyNewRun func(client *mockClient, options tfe.RunCreateOptions, run *tfe.Run) -} - -func newMockRuns(client *mockClient) *mockRuns { - return &mockRuns{ - client: client, - runs: make(map[string]*tfe.Run), - workspaces: make(map[string][]*tfe.Run), - } -} - -func (m *mockRuns) List(ctx context.Context, workspaceID string, options tfe.RunListOptions) (*tfe.RunList, error) { - m.Lock() - defer m.Unlock() - - w, ok := m.client.Workspaces.workspaceIDs[workspaceID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - rl := &tfe.RunList{} - for _, run := range m.workspaces[w.ID] { - rc, err := copystructure.Copy(run) - if err != nil { - panic(err) - } - rl.Items = append(rl.Items, rc.(*tfe.Run)) - } - - rl.Pagination = &tfe.Pagination{ - CurrentPage: 1, - NextPage: 1, - PreviousPage: 1, - TotalPages: 1, - TotalCount: len(rl.Items), - } - - return rl, nil -} - -func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*tfe.Run, error) { - m.Lock() - defer m.Unlock() - - a, err := m.client.Applies.create(options.ConfigurationVersion.ID, options.Workspace.ID) - if err != nil { - return nil, err - } - - ce, err := m.client.CostEstimates.create(options.ConfigurationVersion.ID, options.Workspace.ID) - if err != nil { - return nil, err - } - - p, err := m.client.Plans.create(options.ConfigurationVersion.ID, options.Workspace.ID) - if err != nil { - return nil, err - } - - pc, err := m.client.PolicyChecks.create(options.ConfigurationVersion.ID, options.Workspace.ID) - if err != nil { - return nil, err - } - - r := &tfe.Run{ - ID: generateID("run-"), - Actions: &tfe.RunActions{IsCancelable: true}, - Apply: a, - CostEstimate: ce, - HasChanges: false, - Permissions: &tfe.RunPermissions{}, - Plan: p, - ReplaceAddrs: options.ReplaceAddrs, - Status: tfe.RunPending, - TargetAddrs: options.TargetAddrs, - } - - if options.Message != nil { - r.Message = *options.Message - } - - if pc != nil { - r.PolicyChecks = []*tfe.PolicyCheck{pc} - } - - if options.IsDestroy != nil { - r.IsDestroy = *options.IsDestroy - } - - if options.Refresh != nil { - r.Refresh = *options.Refresh - } - - if options.RefreshOnly != nil { - r.RefreshOnly = *options.RefreshOnly - } - - w, ok := m.client.Workspaces.workspaceIDs[options.Workspace.ID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - if w.CurrentRun == nil { - w.CurrentRun = r - } - - if m.modifyNewRun != nil { - // caller-provided callback may modify the run in-place to mimic - // side-effects that a real server might take in some situations. - m.modifyNewRun(m.client, options, r) - } - - m.runs[r.ID] = r - m.workspaces[options.Workspace.ID] = append(m.workspaces[options.Workspace.ID], r) - - return r, nil -} - -func (m *mockRuns) Read(ctx context.Context, runID string) (*tfe.Run, error) { - return m.ReadWithOptions(ctx, runID, nil) -} - -func (m *mockRuns) ReadWithOptions(ctx context.Context, runID string, _ *tfe.RunReadOptions) (*tfe.Run, error) { - m.Lock() - defer m.Unlock() - - r, ok := m.runs[runID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - pending := false - for _, r := range m.runs { - if r.ID != runID && r.Status == tfe.RunPending { - pending = true - break - } - } - - if !pending && r.Status == tfe.RunPending { - // Only update the status if there are no other pending runs. - r.Status = tfe.RunPlanning - r.Plan.Status = tfe.PlanRunning - } - - logs, _ := ioutil.ReadFile(m.client.Plans.logs[r.Plan.LogReadURL]) - if r.Status == tfe.RunPlanning && r.Plan.Status == tfe.PlanFinished { - if r.IsDestroy || bytes.Contains(logs, []byte("1 to add, 0 to change, 0 to destroy")) { - r.Actions.IsCancelable = false - r.Actions.IsConfirmable = true - r.HasChanges = true - r.Permissions.CanApply = true - } - - if bytes.Contains(logs, []byte("null_resource.foo: 1 error")) { - r.Actions.IsCancelable = false - r.HasChanges = false - r.Status = tfe.RunErrored - } - } - - // we must return a copy for the client - rc, err := copystructure.Copy(r) - if err != nil { - panic(err) - } - - return rc.(*tfe.Run), nil -} - -func (m *mockRuns) Apply(ctx context.Context, runID string, options tfe.RunApplyOptions) error { - m.Lock() - defer m.Unlock() - - r, ok := m.runs[runID] - if !ok { - return tfe.ErrResourceNotFound - } - if r.Status != tfe.RunPending { - // Only update the status if the run is not pending anymore. - r.Status = tfe.RunApplying - r.Actions.IsConfirmable = false - r.Apply.Status = tfe.ApplyRunning - } - return nil -} - -func (m *mockRuns) Cancel(ctx context.Context, runID string, options tfe.RunCancelOptions) error { - panic("not implemented") -} - -func (m *mockRuns) ForceCancel(ctx context.Context, runID string, options tfe.RunForceCancelOptions) error { - panic("not implemented") -} - -func (m *mockRuns) Discard(ctx context.Context, runID string, options tfe.RunDiscardOptions) error { - m.Lock() - defer m.Unlock() - - r, ok := m.runs[runID] - if !ok { - return tfe.ErrResourceNotFound - } - r.Status = tfe.RunDiscarded - r.Actions.IsConfirmable = false - return nil -} - -type mockStateVersions struct { - client *mockClient - states map[string][]byte - stateVersions map[string]*tfe.StateVersion - workspaces map[string][]string -} - -func newMockStateVersions(client *mockClient) *mockStateVersions { - return &mockStateVersions{ - client: client, - states: make(map[string][]byte), - stateVersions: make(map[string]*tfe.StateVersion), - workspaces: make(map[string][]string), - } -} - -func (m *mockStateVersions) List(ctx context.Context, options tfe.StateVersionListOptions) (*tfe.StateVersionList, error) { - svl := &tfe.StateVersionList{} - for _, sv := range m.stateVersions { - svl.Items = append(svl.Items, sv) - } - - svl.Pagination = &tfe.Pagination{ - CurrentPage: 1, - NextPage: 1, - PreviousPage: 1, - TotalPages: 1, - TotalCount: len(svl.Items), - } - - return svl, nil -} - -func (m *mockStateVersions) Create(ctx context.Context, workspaceID string, options tfe.StateVersionCreateOptions) (*tfe.StateVersion, error) { - id := generateID("sv-") - runID := os.Getenv("TFE_RUN_ID") - url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) - - if runID != "" && (options.Run == nil || runID != options.Run.ID) { - return nil, fmt.Errorf("option.Run.ID does not contain the ID exported by TFE_RUN_ID") - } - - sv := &tfe.StateVersion{ - ID: id, - DownloadURL: url, - Serial: *options.Serial, - } - - state, err := base64.StdEncoding.DecodeString(*options.State) - if err != nil { - return nil, err - } - - m.states[sv.DownloadURL] = state - m.stateVersions[sv.ID] = sv - m.workspaces[workspaceID] = append(m.workspaces[workspaceID], sv.ID) - - return sv, nil -} - -func (m *mockStateVersions) Read(ctx context.Context, svID string) (*tfe.StateVersion, error) { - return m.ReadWithOptions(ctx, svID, nil) -} - -func (m *mockStateVersions) ReadWithOptions(ctx context.Context, svID string, options *tfe.StateVersionReadOptions) (*tfe.StateVersion, error) { - sv, ok := m.stateVersions[svID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - return sv, nil -} - -func (m *mockStateVersions) Current(ctx context.Context, workspaceID string) (*tfe.StateVersion, error) { - return m.CurrentWithOptions(ctx, workspaceID, nil) -} - -func (m *mockStateVersions) CurrentWithOptions(ctx context.Context, workspaceID string, options *tfe.StateVersionCurrentOptions) (*tfe.StateVersion, error) { - w, ok := m.client.Workspaces.workspaceIDs[workspaceID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - svs, ok := m.workspaces[w.ID] - if !ok || len(svs) == 0 { - return nil, tfe.ErrResourceNotFound - } - - sv, ok := m.stateVersions[svs[len(svs)-1]] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - return sv, nil -} - -func (m *mockStateVersions) Download(ctx context.Context, url string) ([]byte, error) { - state, ok := m.states[url] - if !ok { - return nil, tfe.ErrResourceNotFound - } - return state, nil -} - -type mockVariables struct { - client *mockClient - workspaces map[string]*tfe.VariableList -} - -var _ tfe.Variables = (*mockVariables)(nil) - -func newMockVariables(client *mockClient) *mockVariables { - return &mockVariables{ - client: client, - workspaces: make(map[string]*tfe.VariableList), - } -} - -func (m *mockVariables) List(ctx context.Context, workspaceID string, options tfe.VariableListOptions) (*tfe.VariableList, error) { - vl := m.workspaces[workspaceID] - return vl, nil -} - -func (m *mockVariables) Create(ctx context.Context, workspaceID string, options tfe.VariableCreateOptions) (*tfe.Variable, error) { - v := &tfe.Variable{ - ID: generateID("var-"), - Key: *options.Key, - Category: *options.Category, - } - if options.Value != nil { - v.Value = *options.Value - } - if options.HCL != nil { - v.HCL = *options.HCL - } - if options.Sensitive != nil { - v.Sensitive = *options.Sensitive - } - - workspace := workspaceID - - if m.workspaces[workspace] == nil { - m.workspaces[workspace] = &tfe.VariableList{} - } - - vl := m.workspaces[workspace] - vl.Items = append(vl.Items, v) - - return v, nil -} - -func (m *mockVariables) Read(ctx context.Context, workspaceID string, variableID string) (*tfe.Variable, error) { - panic("not implemented") -} - -func (m *mockVariables) Update(ctx context.Context, workspaceID string, variableID string, options tfe.VariableUpdateOptions) (*tfe.Variable, error) { - panic("not implemented") -} - -func (m *mockVariables) Delete(ctx context.Context, workspaceID string, variableID string) error { - panic("not implemented") -} - -type mockWorkspaces struct { - client *mockClient - workspaceIDs map[string]*tfe.Workspace - workspaceNames map[string]*tfe.Workspace -} - -func newMockWorkspaces(client *mockClient) *mockWorkspaces { - return &mockWorkspaces{ - client: client, - workspaceIDs: make(map[string]*tfe.Workspace), - workspaceNames: make(map[string]*tfe.Workspace), - } -} - -func (m *mockWorkspaces) List(ctx context.Context, organization string, options tfe.WorkspaceListOptions) (*tfe.WorkspaceList, error) { - dummyWorkspaces := 10 - wl := &tfe.WorkspaceList{} - - // Get the prefix from the search options. - prefix := "" - if options.Search != nil { - prefix = *options.Search - } - - // Get all the workspaces that match the prefix. - var ws []*tfe.Workspace - for _, w := range m.workspaceIDs { - if strings.HasPrefix(w.Name, prefix) { - ws = append(ws, w) - } - } - - // Return an empty result if we have no matches. - if len(ws) == 0 { - wl.Pagination = &tfe.Pagination{ - CurrentPage: 1, - } - return wl, nil - } - - // Return dummy workspaces for the first page to test pagination. - if options.PageNumber <= 1 { - for i := 0; i < dummyWorkspaces; i++ { - wl.Items = append(wl.Items, &tfe.Workspace{ - ID: generateID("ws-"), - Name: fmt.Sprintf("dummy-workspace-%d", i), - }) - } - - wl.Pagination = &tfe.Pagination{ - CurrentPage: 1, - NextPage: 2, - TotalPages: 2, - TotalCount: len(wl.Items) + len(ws), - } - - return wl, nil - } - - // Return the actual workspaces that matched as the second page. - wl.Items = ws - wl.Pagination = &tfe.Pagination{ - CurrentPage: 2, - PreviousPage: 1, - TotalPages: 2, - TotalCount: len(wl.Items) + dummyWorkspaces, - } - - return wl, nil -} - -func (m *mockWorkspaces) Create(ctx context.Context, organization string, options tfe.WorkspaceCreateOptions) (*tfe.Workspace, error) { - if strings.HasSuffix(*options.Name, "no-operations") { - options.Operations = tfe.Bool(false) - } else if options.Operations == nil { - options.Operations = tfe.Bool(true) - } - w := &tfe.Workspace{ - ID: generateID("ws-"), - Name: *options.Name, - Operations: *options.Operations, - Permissions: &tfe.WorkspacePermissions{ - CanQueueApply: true, - CanQueueRun: true, - }, - } - if options.AutoApply != nil { - w.AutoApply = *options.AutoApply - } - if options.VCSRepo != nil { - w.VCSRepo = &tfe.VCSRepo{} - } - if options.TerraformVersion != nil { - w.TerraformVersion = *options.TerraformVersion - } else { - w.TerraformVersion = tfversion.String() - } - m.workspaceIDs[w.ID] = w - m.workspaceNames[w.Name] = w - return w, nil -} - -func (m *mockWorkspaces) Read(ctx context.Context, organization, workspace string) (*tfe.Workspace, error) { - // custom error for TestRemote_plan500 in backend_plan_test.go - if workspace == "network-error" { - return nil, errors.New("I'm a little teacup") - } - - w, ok := m.workspaceNames[workspace] - if !ok { - return nil, tfe.ErrResourceNotFound - } - return w, nil -} - -func (m *mockWorkspaces) ReadByID(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { - w, ok := m.workspaceIDs[workspaceID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - return w, nil -} - -func (m *mockWorkspaces) Update(ctx context.Context, organization, workspace string, options tfe.WorkspaceUpdateOptions) (*tfe.Workspace, error) { - w, ok := m.workspaceNames[workspace] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - if options.Operations != nil { - w.Operations = *options.Operations - } - if options.Name != nil { - w.Name = *options.Name - } - if options.TerraformVersion != nil { - w.TerraformVersion = *options.TerraformVersion - } - if options.WorkingDirectory != nil { - w.WorkingDirectory = *options.WorkingDirectory - } - - delete(m.workspaceNames, workspace) - m.workspaceNames[w.Name] = w - - return w, nil -} - -func (m *mockWorkspaces) UpdateByID(ctx context.Context, workspaceID string, options tfe.WorkspaceUpdateOptions) (*tfe.Workspace, error) { - w, ok := m.workspaceIDs[workspaceID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - - if options.Name != nil { - w.Name = *options.Name - } - if options.TerraformVersion != nil { - w.TerraformVersion = *options.TerraformVersion - } - if options.WorkingDirectory != nil { - w.WorkingDirectory = *options.WorkingDirectory - } - - delete(m.workspaceNames, w.Name) - m.workspaceNames[w.Name] = w - - return w, nil -} - -func (m *mockWorkspaces) Delete(ctx context.Context, organization, workspace string) error { - if w, ok := m.workspaceNames[workspace]; ok { - delete(m.workspaceIDs, w.ID) - } - delete(m.workspaceNames, workspace) - return nil -} - -func (m *mockWorkspaces) DeleteByID(ctx context.Context, workspaceID string) error { - if w, ok := m.workspaceIDs[workspaceID]; ok { - delete(m.workspaceIDs, w.Name) - } - delete(m.workspaceIDs, workspaceID) - return nil -} - -func (m *mockWorkspaces) RemoveVCSConnection(ctx context.Context, organization, workspace string) (*tfe.Workspace, error) { - w, ok := m.workspaceNames[workspace] - if !ok { - return nil, tfe.ErrResourceNotFound - } - w.VCSRepo = nil - return w, nil -} - -func (m *mockWorkspaces) RemoveVCSConnectionByID(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { - w, ok := m.workspaceIDs[workspaceID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - w.VCSRepo = nil - return w, nil -} - -func (m *mockWorkspaces) Lock(ctx context.Context, workspaceID string, options tfe.WorkspaceLockOptions) (*tfe.Workspace, error) { - w, ok := m.workspaceIDs[workspaceID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - if w.Locked { - return nil, tfe.ErrWorkspaceLocked - } - w.Locked = true - return w, nil -} - -func (m *mockWorkspaces) Unlock(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { - w, ok := m.workspaceIDs[workspaceID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - if !w.Locked { - return nil, tfe.ErrWorkspaceNotLocked - } - w.Locked = false - return w, nil -} - -func (m *mockWorkspaces) ForceUnlock(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { - w, ok := m.workspaceIDs[workspaceID] - if !ok { - return nil, tfe.ErrResourceNotFound - } - if !w.Locked { - return nil, tfe.ErrWorkspaceNotLocked - } - w.Locked = false - return w, nil -} - -func (m *mockWorkspaces) AssignSSHKey(ctx context.Context, workspaceID string, options tfe.WorkspaceAssignSSHKeyOptions) (*tfe.Workspace, error) { - panic("not implemented") -} - -func (m *mockWorkspaces) UnassignSSHKey(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { - panic("not implemented") -} - -func (m *mockWorkspaces) RemoteStateConsumers(ctx context.Context, workspaceID string) (*tfe.WorkspaceList, error) { - panic("not implemented") -} - -func (m *mockWorkspaces) AddRemoteStateConsumers(ctx context.Context, workspaceID string, options tfe.WorkspaceAddRemoteStateConsumersOptions) error { - panic("not implemented") -} - -func (m *mockWorkspaces) RemoveRemoteStateConsumers(ctx context.Context, workspaceID string, options tfe.WorkspaceRemoveRemoteStateConsumersOptions) error { - panic("not implemented") -} - -func (m *mockWorkspaces) UpdateRemoteStateConsumers(ctx context.Context, workspaceID string, options tfe.WorkspaceUpdateRemoteStateConsumersOptions) error { - panic("not implemented") -} - -func (m *mockWorkspaces) Readme(ctx context.Context, workspaceID string) (io.Reader, error) { - panic("not implemented") -} - -const alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - -func generateID(s string) string { - b := make([]byte, 16) - for i := range b { - b[i] = alphanumeric[rand.Intn(len(alphanumeric))] - } - return s + string(b) -} diff --git a/internal/backend/remote/backend_plan_test.go b/internal/backend/remote/backend_plan_test.go index 147c68e9d..95aacea97 100644 --- a/internal/backend/remote/backend_plan_test.go +++ b/internal/backend/remote/backend_plan_test.go @@ -13,6 +13,7 @@ import ( tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/cloud" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" @@ -313,11 +314,11 @@ func TestRemote_planWithoutRefresh(t *testing.T) { // We should find a run inside the mock client that has refresh set // to false. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*cloud.MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff(false, run.Refresh); diff != "" { t.Errorf("wrong Refresh setting in the created run\n%s", diff) } @@ -382,11 +383,11 @@ func TestRemote_planWithRefreshOnly(t *testing.T) { // We should find a run inside the mock client that has refresh-only set // to true. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*cloud.MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff(true, run.RefreshOnly); diff != "" { t.Errorf("wrong RefreshOnly setting in the created run\n%s", diff) } @@ -432,7 +433,7 @@ func TestRemote_planWithTarget(t *testing.T) { // When the backend code creates a new run, we'll tweak it so that it // has a cost estimation object with the "skipped_due_to_targeting" status, // emulating how a real server is expected to behave in that case. - b.client.Runs.(*mockRuns).modifyNewRun = func(client *mockClient, options tfe.RunCreateOptions, run *tfe.Run) { + b.client.Runs.(*cloud.MockRuns).ModifyNewRun = func(client *cloud.MockClient, options tfe.RunCreateOptions, run *tfe.Run) { const fakeID = "fake" // This is the cost estimate object embedded in the run itself which // the backend will use to learn the ID to request from the cost @@ -446,7 +447,7 @@ func TestRemote_planWithTarget(t *testing.T) { // the same ID indicated in the object above, where we'll then return // the status "skipped_due_to_targeting" to trigger the special skip // message in the backend output. - client.CostEstimates.estimations[fakeID] = &tfe.CostEstimate{ + client.CostEstimates.Estimations[fakeID] = &tfe.CostEstimate{ ID: fakeID, Status: "skipped_due_to_targeting", } @@ -483,11 +484,11 @@ func TestRemote_planWithTarget(t *testing.T) { // We should find a run inside the mock client that has the same // target address we requested above. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*cloud.MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff([]string{"null_resource.foo"}, run.TargetAddrs); diff != "" { t.Errorf("wrong TargetAddrs in the created run\n%s", diff) } @@ -558,11 +559,11 @@ func TestRemote_planWithReplace(t *testing.T) { // We should find a run inside the mock client that has the same // refresh address we requested above. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*cloud.MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff([]string{"null_resource.foo"}, run.ReplaceAddrs); diff != "" { t.Errorf("wrong ReplaceAddrs in the created run\n%s", diff) } diff --git a/internal/backend/remote/backend_state_test.go b/internal/backend/remote/backend_state_test.go index 3a1769c30..0503936b8 100644 --- a/internal/backend/remote/backend_state_test.go +++ b/internal/backend/remote/backend_state_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/cloud" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/remote" "github.com/hashicorp/terraform/internal/states/statefile" @@ -39,7 +40,7 @@ func TestRemoteClient_stateLock(t *testing.T) { func TestRemoteClient_withRunID(t *testing.T) { // Set the TFE_RUN_ID environment variable before creating the client! - if err := os.Setenv("TFE_RUN_ID", generateID("run-")); err != nil { + if err := os.Setenv("TFE_RUN_ID", cloud.GenerateID("run-")); err != nil { t.Fatalf("error setting env var TFE_RUN_ID: %v", err) } diff --git a/internal/backend/remote/testing.go b/internal/backend/remote/testing.go index f3c66941a..7dbb9e9b2 100644 --- a/internal/backend/remote/testing.go +++ b/internal/backend/remote/testing.go @@ -8,12 +8,14 @@ import ( "net/http/httptest" "path" "testing" + "time" tfe "github.com/hashicorp/go-tfe" svchost "github.com/hashicorp/terraform-svchost" "github.com/hashicorp/terraform-svchost/auth" "github.com/hashicorp/terraform-svchost/disco" "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/cloud" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/httpclient" @@ -39,6 +41,26 @@ var ( }) ) +// mockInput is a mock implementation of terraform.UIInput. +type mockInput struct { + answers map[string]string +} + +func (m *mockInput) Input(ctx context.Context, opts *terraform.InputOpts) (string, error) { + v, ok := m.answers[opts.Id] + if !ok { + return "", fmt.Errorf("unexpected input request in test: %s", opts.Id) + } + if v == "wait-for-external-update" { + select { + case <-ctx.Done(): + case <-time.After(time.Minute): + } + } + delete(m.answers, opts.Id) + return v, nil +} + func testInput(t *testing.T, answers map[string]string) *mockInput { return &mockInput{answers: answers} } @@ -111,7 +133,7 @@ func testBackend(t *testing.T, obj cty.Value) (*Remote, func()) { } // Get a new mock client. - mc := newMockClient() + mc := cloud.NewMockClient() // Replace the services we use with our mock services. b.CLI = cli.NewMockUi() diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index 140dafa8a..1100c31dc 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -299,11 +299,11 @@ func TestCloud_applyWithoutRefresh(t *testing.T) { // We should find a run inside the mock client that has refresh set // to false. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff(false, run.Refresh); diff != "" { t.Errorf("wrong Refresh setting in the created run\n%s", diff) } @@ -368,11 +368,11 @@ func TestCloud_applyWithRefreshOnly(t *testing.T) { // We should find a run inside the mock client that has refresh-only set // to true. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff(true, run.RefreshOnly); diff != "" { t.Errorf("wrong RefreshOnly setting in the created run\n%s", diff) } @@ -439,11 +439,11 @@ func TestCloud_applyWithTarget(t *testing.T) { // We should find a run inside the mock client that has the same // target address we requested above. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff([]string{"null_resource.foo"}, run.TargetAddrs); diff != "" { t.Errorf("wrong TargetAddrs in the created run\n%s", diff) } @@ -514,11 +514,11 @@ func TestCloud_applyWithReplace(t *testing.T) { // We should find a run inside the mock client that has the same // refresh address we requested above. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff([]string{"null_resource.foo"}, run.ReplaceAddrs); diff != "" { t.Errorf("wrong ReplaceAddrs in the created run\n%s", diff) } diff --git a/internal/cloud/backend_plan_test.go b/internal/cloud/backend_plan_test.go index 790c8cc03..cd836b46d 100644 --- a/internal/cloud/backend_plan_test.go +++ b/internal/cloud/backend_plan_test.go @@ -304,11 +304,11 @@ func TestCloud_planWithoutRefresh(t *testing.T) { // We should find a run inside the mock client that has refresh set // to false. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff(false, run.Refresh); diff != "" { t.Errorf("wrong Refresh setting in the created run\n%s", diff) } @@ -373,11 +373,11 @@ func TestCloud_planWithRefreshOnly(t *testing.T) { // We should find a run inside the mock client that has refresh-only set // to true. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff(true, run.RefreshOnly); diff != "" { t.Errorf("wrong RefreshOnly setting in the created run\n%s", diff) } @@ -423,7 +423,7 @@ func TestCloud_planWithTarget(t *testing.T) { // When the backend code creates a new run, we'll tweak it so that it // has a cost estimation object with the "skipped_due_to_targeting" status, // emulating how a real server is expected to behave in that case. - b.client.Runs.(*mockRuns).modifyNewRun = func(client *mockClient, options tfe.RunCreateOptions, run *tfe.Run) { + b.client.Runs.(*MockRuns).ModifyNewRun = func(client *MockClient, options tfe.RunCreateOptions, run *tfe.Run) { const fakeID = "fake" // This is the cost estimate object embedded in the run itself which // the backend will use to learn the ID to request from the cost @@ -437,7 +437,7 @@ func TestCloud_planWithTarget(t *testing.T) { // the same ID indicated in the object above, where we'll then return // the status "skipped_due_to_targeting" to trigger the special skip // message in the backend output. - client.CostEstimates.estimations[fakeID] = &tfe.CostEstimate{ + client.CostEstimates.Estimations[fakeID] = &tfe.CostEstimate{ ID: fakeID, Status: "skipped_due_to_targeting", } @@ -474,11 +474,11 @@ func TestCloud_planWithTarget(t *testing.T) { // We should find a run inside the mock client that has the same // target address we requested above. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff([]string{"null_resource.foo"}, run.TargetAddrs); diff != "" { t.Errorf("wrong TargetAddrs in the created run\n%s", diff) } @@ -549,11 +549,11 @@ func TestCloud_planWithReplace(t *testing.T) { // We should find a run inside the mock client that has the same // refresh address we requested above. - runsAPI := b.client.Runs.(*mockRuns) - if got, want := len(runsAPI.runs), 1; got != want { + runsAPI := b.client.Runs.(*MockRuns) + if got, want := len(runsAPI.Runs), 1; got != want { t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want) } - for _, run := range runsAPI.runs { + for _, run := range runsAPI.Runs { if diff := cmp.Diff([]string{"null_resource.foo"}, run.ReplaceAddrs); diff != "" { t.Errorf("wrong ReplaceAddrs in the created run\n%s", diff) } diff --git a/internal/cloud/backend_state_test.go b/internal/cloud/backend_state_test.go index dc2a45723..a94c60c6e 100644 --- a/internal/cloud/backend_state_test.go +++ b/internal/cloud/backend_state_test.go @@ -39,7 +39,7 @@ func TestRemoteClient_stateLock(t *testing.T) { func TestRemoteClient_withRunID(t *testing.T) { // Set the TFE_RUN_ID environment variable before creating the client! - if err := os.Setenv("TFE_RUN_ID", generateID("run-")); err != nil { + if err := os.Setenv("TFE_RUN_ID", GenerateID("run-")); err != nil { t.Fatalf("error setting env var TFE_RUN_ID: %v", err) } diff --git a/internal/cloud/testing.go b/internal/cloud/testing.go index 73668b2b0..7bb145b3c 100644 --- a/internal/cloud/testing.go +++ b/internal/cloud/testing.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "path" "testing" + "time" tfe "github.com/hashicorp/go-tfe" svchost "github.com/hashicorp/terraform-svchost" @@ -39,6 +40,26 @@ var ( }) ) +// mockInput is a mock implementation of terraform.UIInput. +type mockInput struct { + answers map[string]string +} + +func (m *mockInput) Input(ctx context.Context, opts *terraform.InputOpts) (string, error) { + v, ok := m.answers[opts.Id] + if !ok { + return "", fmt.Errorf("unexpected input request in test: %s", opts.Id) + } + if v == "wait-for-external-update" { + select { + case <-ctx.Done(): + case <-time.After(time.Minute): + } + } + delete(m.answers, opts.Id) + return v, nil +} + func testInput(t *testing.T, answers map[string]string) *mockInput { return &mockInput{answers: answers} } @@ -111,7 +132,7 @@ func testBackend(t *testing.T, obj cty.Value) (*Cloud, func()) { } // Get a new mock client. - mc := newMockClient() + mc := NewMockClient() // Replace the services we use with our mock services. b.CLI = cli.NewMockUi() diff --git a/internal/cloud/backend_mock.go b/internal/cloud/tfe_client_mock.go similarity index 75% rename from internal/cloud/backend_mock.go rename to internal/cloud/tfe_client_mock.go index 0ee036bc5..d575d82e8 100644 --- a/internal/cloud/backend_mock.go +++ b/internal/cloud/tfe_client_mock.go @@ -16,26 +16,25 @@ import ( "time" tfe "github.com/hashicorp/go-tfe" - "github.com/hashicorp/terraform/internal/terraform" tfversion "github.com/hashicorp/terraform/version" "github.com/mitchellh/copystructure" ) -type mockClient struct { - Applies *mockApplies - ConfigurationVersions *mockConfigurationVersions - CostEstimates *mockCostEstimates - Organizations *mockOrganizations - Plans *mockPlans - PolicyChecks *mockPolicyChecks - Runs *mockRuns - StateVersions *mockStateVersions - Variables *mockVariables - Workspaces *mockWorkspaces +type MockClient struct { + Applies *MockApplies + ConfigurationVersions *MockConfigurationVersions + CostEstimates *MockCostEstimates + Organizations *MockOrganizations + Plans *MockPlans + PolicyChecks *MockPolicyChecks + Runs *MockRuns + StateVersions *MockStateVersions + Variables *MockVariables + Workspaces *MockWorkspaces } -func newMockClient() *mockClient { - c := &mockClient{} +func NewMockClient() *MockClient { + c := &MockClient{} c.Applies = newMockApplies(c) c.ConfigurationVersions = newMockConfigurationVersions(c) c.CostEstimates = newMockCostEstimates(c) @@ -49,14 +48,14 @@ func newMockClient() *mockClient { return c } -type mockApplies struct { - client *mockClient +type MockApplies struct { + client *MockClient applies map[string]*tfe.Apply logs map[string]string } -func newMockApplies(client *mockClient) *mockApplies { - return &mockApplies{ +func newMockApplies(client *MockClient) *MockApplies { + return &MockApplies{ client: client, applies: make(map[string]*tfe.Apply), logs: make(map[string]string), @@ -65,7 +64,7 @@ func newMockApplies(client *mockClient) *mockApplies { // create is a helper function to create a mock apply that uses the configured // working directory to find the logfile. -func (m *mockApplies) create(cvID, workspaceID string) (*tfe.Apply, error) { +func (m *MockApplies) create(cvID, workspaceID string) (*tfe.Apply, error) { c, ok := m.client.ConfigurationVersions.configVersions[cvID] if !ok { return nil, tfe.ErrResourceNotFound @@ -75,7 +74,7 @@ func (m *mockApplies) create(cvID, workspaceID string) (*tfe.Apply, error) { return nil, nil } - id := generateID("apply-") + id := GenerateID("apply-") url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) a := &tfe.Apply{ @@ -103,7 +102,7 @@ func (m *mockApplies) create(cvID, workspaceID string) (*tfe.Apply, error) { return a, nil } -func (m *mockApplies) Read(ctx context.Context, applyID string) (*tfe.Apply, error) { +func (m *MockApplies) Read(ctx context.Context, applyID string) (*tfe.Apply, error) { a, ok := m.applies[applyID] if !ok { return nil, tfe.ErrResourceNotFound @@ -115,7 +114,7 @@ func (m *mockApplies) Read(ctx context.Context, applyID string) (*tfe.Apply, err return a, nil } -func (m *mockApplies) Logs(ctx context.Context, applyID string) (io.Reader, error) { +func (m *MockApplies) Logs(ctx context.Context, applyID string) (io.Reader, error) { a, err := m.Read(ctx, applyID) if err != nil { return nil, err @@ -152,15 +151,15 @@ func (m *mockApplies) Logs(ctx context.Context, applyID string) (io.Reader, erro }, nil } -type mockConfigurationVersions struct { - client *mockClient +type MockConfigurationVersions struct { + client *MockClient configVersions map[string]*tfe.ConfigurationVersion uploadPaths map[string]string uploadURLs map[string]*tfe.ConfigurationVersion } -func newMockConfigurationVersions(client *mockClient) *mockConfigurationVersions { - return &mockConfigurationVersions{ +func newMockConfigurationVersions(client *MockClient) *MockConfigurationVersions { + return &MockConfigurationVersions{ client: client, configVersions: make(map[string]*tfe.ConfigurationVersion), uploadPaths: make(map[string]string), @@ -168,7 +167,7 @@ func newMockConfigurationVersions(client *mockClient) *mockConfigurationVersions } } -func (m *mockConfigurationVersions) List(ctx context.Context, workspaceID string, options tfe.ConfigurationVersionListOptions) (*tfe.ConfigurationVersionList, error) { +func (m *MockConfigurationVersions) List(ctx context.Context, workspaceID string, options tfe.ConfigurationVersionListOptions) (*tfe.ConfigurationVersionList, error) { cvl := &tfe.ConfigurationVersionList{} for _, cv := range m.configVersions { cvl.Items = append(cvl.Items, cv) @@ -185,8 +184,8 @@ func (m *mockConfigurationVersions) List(ctx context.Context, workspaceID string return cvl, nil } -func (m *mockConfigurationVersions) Create(ctx context.Context, workspaceID string, options tfe.ConfigurationVersionCreateOptions) (*tfe.ConfigurationVersion, error) { - id := generateID("cv-") +func (m *MockConfigurationVersions) Create(ctx context.Context, workspaceID string, options tfe.ConfigurationVersionCreateOptions) (*tfe.ConfigurationVersion, error) { + id := GenerateID("cv-") url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) cv := &tfe.ConfigurationVersion{ @@ -201,7 +200,7 @@ func (m *mockConfigurationVersions) Create(ctx context.Context, workspaceID stri return cv, nil } -func (m *mockConfigurationVersions) Read(ctx context.Context, cvID string) (*tfe.ConfigurationVersion, error) { +func (m *MockConfigurationVersions) Read(ctx context.Context, cvID string) (*tfe.ConfigurationVersion, error) { cv, ok := m.configVersions[cvID] if !ok { return nil, tfe.ErrResourceNotFound @@ -209,7 +208,15 @@ func (m *mockConfigurationVersions) Read(ctx context.Context, cvID string) (*tfe return cv, nil } -func (m *mockConfigurationVersions) Upload(ctx context.Context, url, path string) error { +func (m *MockConfigurationVersions) ReadWithOptions(ctx context.Context, cvID string, options *tfe.ConfigurationVersionReadOptions) (*tfe.ConfigurationVersion, error) { + cv, ok := m.configVersions[cvID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + return cv, nil +} + +func (m *MockConfigurationVersions) Upload(ctx context.Context, url, path string) error { cv, ok := m.uploadURLs[url] if !ok { return errors.New("404 not found") @@ -219,24 +226,24 @@ func (m *mockConfigurationVersions) Upload(ctx context.Context, url, path string return nil } -type mockCostEstimates struct { - client *mockClient - estimations map[string]*tfe.CostEstimate +type MockCostEstimates struct { + client *MockClient + Estimations map[string]*tfe.CostEstimate logs map[string]string } -func newMockCostEstimates(client *mockClient) *mockCostEstimates { - return &mockCostEstimates{ +func newMockCostEstimates(client *MockClient) *MockCostEstimates { + return &MockCostEstimates{ client: client, - estimations: make(map[string]*tfe.CostEstimate), + Estimations: make(map[string]*tfe.CostEstimate), logs: make(map[string]string), } } // create is a helper function to create a mock cost estimation that uses the // configured working directory to find the logfile. -func (m *mockCostEstimates) create(cvID, workspaceID string) (*tfe.CostEstimate, error) { - id := generateID("ce-") +func (m *MockCostEstimates) create(cvID, workspaceID string) (*tfe.CostEstimate, error) { + id := GenerateID("ce-") ce := &tfe.CostEstimate{ ID: id, @@ -263,21 +270,21 @@ func (m *mockCostEstimates) create(cvID, workspaceID string) (*tfe.CostEstimate, } m.logs[ce.ID] = logfile - m.estimations[ce.ID] = ce + m.Estimations[ce.ID] = ce return ce, nil } -func (m *mockCostEstimates) Read(ctx context.Context, costEstimateID string) (*tfe.CostEstimate, error) { - ce, ok := m.estimations[costEstimateID] +func (m *MockCostEstimates) Read(ctx context.Context, costEstimateID string) (*tfe.CostEstimate, error) { + ce, ok := m.Estimations[costEstimateID] if !ok { return nil, tfe.ErrResourceNotFound } return ce, nil } -func (m *mockCostEstimates) Logs(ctx context.Context, costEstimateID string) (io.Reader, error) { - ce, ok := m.estimations[costEstimateID] +func (m *MockCostEstimates) Logs(ctx context.Context, costEstimateID string) (io.Reader, error) { + ce, ok := m.Estimations[costEstimateID] if !ok { return nil, tfe.ErrResourceNotFound } @@ -301,39 +308,19 @@ func (m *mockCostEstimates) Logs(ctx context.Context, costEstimateID string) (io return bytes.NewBuffer(logs), nil } -// mockInput is a mock implementation of terraform.UIInput. -type mockInput struct { - answers map[string]string -} - -func (m *mockInput) Input(ctx context.Context, opts *terraform.InputOpts) (string, error) { - v, ok := m.answers[opts.Id] - if !ok { - return "", fmt.Errorf("unexpected input request in test: %s", opts.Id) - } - if v == "wait-for-external-update" { - select { - case <-ctx.Done(): - case <-time.After(time.Minute): - } - } - delete(m.answers, opts.Id) - return v, nil -} - -type mockOrganizations struct { - client *mockClient +type MockOrganizations struct { + client *MockClient organizations map[string]*tfe.Organization } -func newMockOrganizations(client *mockClient) *mockOrganizations { - return &mockOrganizations{ +func newMockOrganizations(client *MockClient) *MockOrganizations { + return &MockOrganizations{ client: client, organizations: make(map[string]*tfe.Organization), } } -func (m *mockOrganizations) List(ctx context.Context, options tfe.OrganizationListOptions) (*tfe.OrganizationList, error) { +func (m *MockOrganizations) List(ctx context.Context, options tfe.OrganizationListOptions) (*tfe.OrganizationList, error) { orgl := &tfe.OrganizationList{} for _, org := range m.organizations { orgl.Items = append(orgl.Items, org) @@ -376,13 +363,13 @@ func (m *mockLogReader) read(l []byte) (int, error) { return m.logs.Read(l) } -func (m *mockOrganizations) Create(ctx context.Context, options tfe.OrganizationCreateOptions) (*tfe.Organization, error) { +func (m *MockOrganizations) Create(ctx context.Context, options tfe.OrganizationCreateOptions) (*tfe.Organization, error) { org := &tfe.Organization{Name: *options.Name} m.organizations[org.Name] = org return org, nil } -func (m *mockOrganizations) Read(ctx context.Context, name string) (*tfe.Organization, error) { +func (m *MockOrganizations) Read(ctx context.Context, name string) (*tfe.Organization, error) { org, ok := m.organizations[name] if !ok { return nil, tfe.ErrResourceNotFound @@ -390,7 +377,7 @@ func (m *mockOrganizations) Read(ctx context.Context, name string) (*tfe.Organiz return org, nil } -func (m *mockOrganizations) Update(ctx context.Context, name string, options tfe.OrganizationUpdateOptions) (*tfe.Organization, error) { +func (m *MockOrganizations) Update(ctx context.Context, name string, options tfe.OrganizationUpdateOptions) (*tfe.Organization, error) { org, ok := m.organizations[name] if !ok { return nil, tfe.ErrResourceNotFound @@ -400,14 +387,14 @@ func (m *mockOrganizations) Update(ctx context.Context, name string, options tfe } -func (m *mockOrganizations) Delete(ctx context.Context, name string) error { +func (m *MockOrganizations) Delete(ctx context.Context, name string) error { delete(m.organizations, name) return nil } -func (m *mockOrganizations) Capacity(ctx context.Context, name string) (*tfe.Capacity, error) { +func (m *MockOrganizations) Capacity(ctx context.Context, name string) (*tfe.Capacity, error) { var pending, running int - for _, r := range m.client.Runs.runs { + for _, r := range m.client.Runs.Runs { if r.Status == tfe.RunPending { pending++ continue @@ -417,7 +404,7 @@ func (m *mockOrganizations) Capacity(ctx context.Context, name string) (*tfe.Cap return &tfe.Capacity{Pending: pending, Running: running}, nil } -func (m *mockOrganizations) Entitlements(ctx context.Context, name string) (*tfe.Entitlements, error) { +func (m *MockOrganizations) Entitlements(ctx context.Context, name string) (*tfe.Entitlements, error) { return &tfe.Entitlements{ Operations: true, PrivateModuleRegistry: true, @@ -428,10 +415,10 @@ func (m *mockOrganizations) Entitlements(ctx context.Context, name string) (*tfe }, nil } -func (m *mockOrganizations) RunQueue(ctx context.Context, name string, options tfe.RunQueueOptions) (*tfe.RunQueue, error) { +func (m *MockOrganizations) RunQueue(ctx context.Context, name string, options tfe.RunQueueOptions) (*tfe.RunQueue, error) { rq := &tfe.RunQueue{} - for _, r := range m.client.Runs.runs { + for _, r := range m.client.Runs.Runs { rq.Items = append(rq.Items, r) } @@ -446,15 +433,15 @@ func (m *mockOrganizations) RunQueue(ctx context.Context, name string, options t return rq, nil } -type mockPlans struct { - client *mockClient +type MockPlans struct { + client *MockClient logs map[string]string planOutputs map[string]string plans map[string]*tfe.Plan } -func newMockPlans(client *mockClient) *mockPlans { - return &mockPlans{ +func newMockPlans(client *MockClient) *MockPlans { + return &MockPlans{ client: client, logs: make(map[string]string), planOutputs: make(map[string]string), @@ -464,8 +451,8 @@ func newMockPlans(client *mockClient) *mockPlans { // create is a helper function to create a mock plan that uses the configured // working directory to find the logfile. -func (m *mockPlans) create(cvID, workspaceID string) (*tfe.Plan, error) { - id := generateID("plan-") +func (m *MockPlans) create(cvID, workspaceID string) (*tfe.Plan, error) { + id := GenerateID("plan-") url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) p := &tfe.Plan{ @@ -489,7 +476,7 @@ func (m *mockPlans) create(cvID, workspaceID string) (*tfe.Plan, error) { return p, nil } -func (m *mockPlans) Read(ctx context.Context, planID string) (*tfe.Plan, error) { +func (m *MockPlans) Read(ctx context.Context, planID string) (*tfe.Plan, error) { p, ok := m.plans[planID] if !ok { return nil, tfe.ErrResourceNotFound @@ -501,7 +488,7 @@ func (m *mockPlans) Read(ctx context.Context, planID string) (*tfe.Plan, error) return p, nil } -func (m *mockPlans) Logs(ctx context.Context, planID string) (io.Reader, error) { +func (m *MockPlans) Logs(ctx context.Context, planID string) (io.Reader, error) { p, err := m.Read(ctx, planID) if err != nil { return nil, err @@ -538,7 +525,7 @@ func (m *mockPlans) Logs(ctx context.Context, planID string) (io.Reader, error) }, nil } -func (m *mockPlans) JSONOutput(ctx context.Context, planID string) ([]byte, error) { +func (m *MockPlans) JSONOutput(ctx context.Context, planID string) ([]byte, error) { planOutput, ok := m.planOutputs[planID] if !ok { return nil, tfe.ErrResourceNotFound @@ -547,14 +534,14 @@ func (m *mockPlans) JSONOutput(ctx context.Context, planID string) ([]byte, erro return []byte(planOutput), nil } -type mockPolicyChecks struct { - client *mockClient +type MockPolicyChecks struct { + client *MockClient checks map[string]*tfe.PolicyCheck logs map[string]string } -func newMockPolicyChecks(client *mockClient) *mockPolicyChecks { - return &mockPolicyChecks{ +func newMockPolicyChecks(client *MockClient) *MockPolicyChecks { + return &MockPolicyChecks{ client: client, checks: make(map[string]*tfe.PolicyCheck), logs: make(map[string]string), @@ -563,8 +550,8 @@ func newMockPolicyChecks(client *mockClient) *mockPolicyChecks { // create is a helper function to create a mock policy check that uses the // configured working directory to find the logfile. -func (m *mockPolicyChecks) create(cvID, workspaceID string) (*tfe.PolicyCheck, error) { - id := generateID("pc-") +func (m *MockPolicyChecks) create(cvID, workspaceID string) (*tfe.PolicyCheck, error) { + id := GenerateID("pc-") pc := &tfe.PolicyCheck{ ID: id, @@ -595,8 +582,8 @@ func (m *mockPolicyChecks) create(cvID, workspaceID string) (*tfe.PolicyCheck, e return pc, nil } -func (m *mockPolicyChecks) List(ctx context.Context, runID string, options tfe.PolicyCheckListOptions) (*tfe.PolicyCheckList, error) { - _, ok := m.client.Runs.runs[runID] +func (m *MockPolicyChecks) List(ctx context.Context, runID string, options tfe.PolicyCheckListOptions) (*tfe.PolicyCheckList, error) { + _, ok := m.client.Runs.Runs[runID] if !ok { return nil, tfe.ErrResourceNotFound } @@ -617,7 +604,7 @@ func (m *mockPolicyChecks) List(ctx context.Context, runID string, options tfe.P return pcl, nil } -func (m *mockPolicyChecks) Read(ctx context.Context, policyCheckID string) (*tfe.PolicyCheck, error) { +func (m *MockPolicyChecks) Read(ctx context.Context, policyCheckID string) (*tfe.PolicyCheck, error) { pc, ok := m.checks[policyCheckID] if !ok { return nil, tfe.ErrResourceNotFound @@ -657,7 +644,7 @@ func (m *mockPolicyChecks) Read(ctx context.Context, policyCheckID string) (*tfe return pc, nil } -func (m *mockPolicyChecks) Override(ctx context.Context, policyCheckID string) (*tfe.PolicyCheck, error) { +func (m *MockPolicyChecks) Override(ctx context.Context, policyCheckID string) (*tfe.PolicyCheck, error) { pc, ok := m.checks[policyCheckID] if !ok { return nil, tfe.ErrResourceNotFound @@ -666,7 +653,7 @@ func (m *mockPolicyChecks) Override(ctx context.Context, policyCheckID string) ( return pc, nil } -func (m *mockPolicyChecks) Logs(ctx context.Context, policyCheckID string) (io.Reader, error) { +func (m *MockPolicyChecks) Logs(ctx context.Context, policyCheckID string) (io.Reader, error) { pc, ok := m.checks[policyCheckID] if !ok { return nil, tfe.ErrResourceNotFound @@ -706,28 +693,28 @@ func (m *mockPolicyChecks) Logs(ctx context.Context, policyCheckID string) (io.R return bytes.NewBuffer(logs), nil } -type mockRuns struct { +type MockRuns struct { sync.Mutex - client *mockClient - runs map[string]*tfe.Run + client *MockClient + Runs map[string]*tfe.Run workspaces map[string][]*tfe.Run - // If modifyNewRun is non-nil, the create method will call it just before + // If ModifyNewRun is non-nil, the create method will call it just before // saving a new run in the runs map, so that a calling test can mimic // side-effects that a real server might apply in certain situations. - modifyNewRun func(client *mockClient, options tfe.RunCreateOptions, run *tfe.Run) + ModifyNewRun func(client *MockClient, options tfe.RunCreateOptions, run *tfe.Run) } -func newMockRuns(client *mockClient) *mockRuns { - return &mockRuns{ +func newMockRuns(client *MockClient) *MockRuns { + return &MockRuns{ client: client, - runs: make(map[string]*tfe.Run), + Runs: make(map[string]*tfe.Run), workspaces: make(map[string][]*tfe.Run), } } -func (m *mockRuns) List(ctx context.Context, workspaceID string, options tfe.RunListOptions) (*tfe.RunList, error) { +func (m *MockRuns) List(ctx context.Context, workspaceID string, options tfe.RunListOptions) (*tfe.RunList, error) { m.Lock() defer m.Unlock() @@ -756,7 +743,7 @@ func (m *mockRuns) List(ctx context.Context, workspaceID string, options tfe.Run return rl, nil } -func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*tfe.Run, error) { +func (m *MockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*tfe.Run, error) { m.Lock() defer m.Unlock() @@ -781,7 +768,7 @@ func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t } r := &tfe.Run{ - ID: generateID("run-"), + ID: GenerateID("run-"), Actions: &tfe.RunActions{IsCancelable: true}, Apply: a, CostEstimate: ce, @@ -821,33 +808,33 @@ func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t w.CurrentRun = r } - if m.modifyNewRun != nil { + if m.ModifyNewRun != nil { // caller-provided callback may modify the run in-place to mimic // side-effects that a real server might take in some situations. - m.modifyNewRun(m.client, options, r) + m.ModifyNewRun(m.client, options, r) } - m.runs[r.ID] = r + m.Runs[r.ID] = r m.workspaces[options.Workspace.ID] = append(m.workspaces[options.Workspace.ID], r) return r, nil } -func (m *mockRuns) Read(ctx context.Context, runID string) (*tfe.Run, error) { +func (m *MockRuns) Read(ctx context.Context, runID string) (*tfe.Run, error) { return m.ReadWithOptions(ctx, runID, nil) } -func (m *mockRuns) ReadWithOptions(ctx context.Context, runID string, _ *tfe.RunReadOptions) (*tfe.Run, error) { +func (m *MockRuns) ReadWithOptions(ctx context.Context, runID string, _ *tfe.RunReadOptions) (*tfe.Run, error) { m.Lock() defer m.Unlock() - r, ok := m.runs[runID] + r, ok := m.Runs[runID] if !ok { return nil, tfe.ErrResourceNotFound } pending := false - for _, r := range m.runs { + for _, r := range m.Runs { if r.ID != runID && r.Status == tfe.RunPending { pending = true break @@ -885,11 +872,11 @@ func (m *mockRuns) ReadWithOptions(ctx context.Context, runID string, _ *tfe.Run return rc.(*tfe.Run), nil } -func (m *mockRuns) Apply(ctx context.Context, runID string, options tfe.RunApplyOptions) error { +func (m *MockRuns) Apply(ctx context.Context, runID string, options tfe.RunApplyOptions) error { m.Lock() defer m.Unlock() - r, ok := m.runs[runID] + r, ok := m.Runs[runID] if !ok { return tfe.ErrResourceNotFound } @@ -902,19 +889,19 @@ func (m *mockRuns) Apply(ctx context.Context, runID string, options tfe.RunApply return nil } -func (m *mockRuns) Cancel(ctx context.Context, runID string, options tfe.RunCancelOptions) error { +func (m *MockRuns) Cancel(ctx context.Context, runID string, options tfe.RunCancelOptions) error { panic("not implemented") } -func (m *mockRuns) ForceCancel(ctx context.Context, runID string, options tfe.RunForceCancelOptions) error { +func (m *MockRuns) ForceCancel(ctx context.Context, runID string, options tfe.RunForceCancelOptions) error { panic("not implemented") } -func (m *mockRuns) Discard(ctx context.Context, runID string, options tfe.RunDiscardOptions) error { +func (m *MockRuns) Discard(ctx context.Context, runID string, options tfe.RunDiscardOptions) error { m.Lock() defer m.Unlock() - r, ok := m.runs[runID] + r, ok := m.Runs[runID] if !ok { return tfe.ErrResourceNotFound } @@ -923,15 +910,15 @@ func (m *mockRuns) Discard(ctx context.Context, runID string, options tfe.RunDis return nil } -type mockStateVersions struct { - client *mockClient +type MockStateVersions struct { + client *MockClient states map[string][]byte stateVersions map[string]*tfe.StateVersion workspaces map[string][]string } -func newMockStateVersions(client *mockClient) *mockStateVersions { - return &mockStateVersions{ +func newMockStateVersions(client *MockClient) *MockStateVersions { + return &MockStateVersions{ client: client, states: make(map[string][]byte), stateVersions: make(map[string]*tfe.StateVersion), @@ -939,7 +926,7 @@ func newMockStateVersions(client *mockClient) *mockStateVersions { } } -func (m *mockStateVersions) List(ctx context.Context, options tfe.StateVersionListOptions) (*tfe.StateVersionList, error) { +func (m *MockStateVersions) List(ctx context.Context, options tfe.StateVersionListOptions) (*tfe.StateVersionList, error) { svl := &tfe.StateVersionList{} for _, sv := range m.stateVersions { svl.Items = append(svl.Items, sv) @@ -956,8 +943,8 @@ func (m *mockStateVersions) List(ctx context.Context, options tfe.StateVersionLi return svl, nil } -func (m *mockStateVersions) Create(ctx context.Context, workspaceID string, options tfe.StateVersionCreateOptions) (*tfe.StateVersion, error) { - id := generateID("sv-") +func (m *MockStateVersions) Create(ctx context.Context, workspaceID string, options tfe.StateVersionCreateOptions) (*tfe.StateVersion, error) { + id := GenerateID("sv-") runID := os.Getenv("TFE_RUN_ID") url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) @@ -983,11 +970,11 @@ func (m *mockStateVersions) Create(ctx context.Context, workspaceID string, opti return sv, nil } -func (m *mockStateVersions) Read(ctx context.Context, svID string) (*tfe.StateVersion, error) { +func (m *MockStateVersions) Read(ctx context.Context, svID string) (*tfe.StateVersion, error) { return m.ReadWithOptions(ctx, svID, nil) } -func (m *mockStateVersions) ReadWithOptions(ctx context.Context, svID string, options *tfe.StateVersionReadOptions) (*tfe.StateVersion, error) { +func (m *MockStateVersions) ReadWithOptions(ctx context.Context, svID string, options *tfe.StateVersionReadOptions) (*tfe.StateVersion, error) { sv, ok := m.stateVersions[svID] if !ok { return nil, tfe.ErrResourceNotFound @@ -995,11 +982,11 @@ func (m *mockStateVersions) ReadWithOptions(ctx context.Context, svID string, op return sv, nil } -func (m *mockStateVersions) Current(ctx context.Context, workspaceID string) (*tfe.StateVersion, error) { +func (m *MockStateVersions) Current(ctx context.Context, workspaceID string) (*tfe.StateVersion, error) { return m.CurrentWithOptions(ctx, workspaceID, nil) } -func (m *mockStateVersions) CurrentWithOptions(ctx context.Context, workspaceID string, options *tfe.StateVersionCurrentOptions) (*tfe.StateVersion, error) { +func (m *MockStateVersions) CurrentWithOptions(ctx context.Context, workspaceID string, options *tfe.StateVersionCurrentOptions) (*tfe.StateVersion, error) { w, ok := m.client.Workspaces.workspaceIDs[workspaceID] if !ok { return nil, tfe.ErrResourceNotFound @@ -1018,7 +1005,7 @@ func (m *mockStateVersions) CurrentWithOptions(ctx context.Context, workspaceID return sv, nil } -func (m *mockStateVersions) Download(ctx context.Context, url string) ([]byte, error) { +func (m *MockStateVersions) Download(ctx context.Context, url string) ([]byte, error) { state, ok := m.states[url] if !ok { return nil, tfe.ErrResourceNotFound @@ -1026,28 +1013,32 @@ func (m *mockStateVersions) Download(ctx context.Context, url string) ([]byte, e return state, nil } -type mockVariables struct { - client *mockClient +func (m *MockStateVersions) Outputs(ctx context.Context, svID string, options tfe.StateVersionOutputsListOptions) ([]*tfe.StateVersionOutput, error) { + panic("not implemented") +} + +type MockVariables struct { + client *MockClient workspaces map[string]*tfe.VariableList } -var _ tfe.Variables = (*mockVariables)(nil) +var _ tfe.Variables = (*MockVariables)(nil) -func newMockVariables(client *mockClient) *mockVariables { - return &mockVariables{ +func newMockVariables(client *MockClient) *MockVariables { + return &MockVariables{ client: client, workspaces: make(map[string]*tfe.VariableList), } } -func (m *mockVariables) List(ctx context.Context, workspaceID string, options tfe.VariableListOptions) (*tfe.VariableList, error) { +func (m *MockVariables) List(ctx context.Context, workspaceID string, options tfe.VariableListOptions) (*tfe.VariableList, error) { vl := m.workspaces[workspaceID] return vl, nil } -func (m *mockVariables) Create(ctx context.Context, workspaceID string, options tfe.VariableCreateOptions) (*tfe.Variable, error) { +func (m *MockVariables) Create(ctx context.Context, workspaceID string, options tfe.VariableCreateOptions) (*tfe.Variable, error) { v := &tfe.Variable{ - ID: generateID("var-"), + ID: GenerateID("var-"), Key: *options.Key, Category: *options.Category, } @@ -1073,33 +1064,33 @@ func (m *mockVariables) Create(ctx context.Context, workspaceID string, options return v, nil } -func (m *mockVariables) Read(ctx context.Context, workspaceID string, variableID string) (*tfe.Variable, error) { +func (m *MockVariables) Read(ctx context.Context, workspaceID string, variableID string) (*tfe.Variable, error) { panic("not implemented") } -func (m *mockVariables) Update(ctx context.Context, workspaceID string, variableID string, options tfe.VariableUpdateOptions) (*tfe.Variable, error) { +func (m *MockVariables) Update(ctx context.Context, workspaceID string, variableID string, options tfe.VariableUpdateOptions) (*tfe.Variable, error) { panic("not implemented") } -func (m *mockVariables) Delete(ctx context.Context, workspaceID string, variableID string) error { +func (m *MockVariables) Delete(ctx context.Context, workspaceID string, variableID string) error { panic("not implemented") } -type mockWorkspaces struct { - client *mockClient +type MockWorkspaces struct { + client *MockClient workspaceIDs map[string]*tfe.Workspace workspaceNames map[string]*tfe.Workspace } -func newMockWorkspaces(client *mockClient) *mockWorkspaces { - return &mockWorkspaces{ +func newMockWorkspaces(client *MockClient) *MockWorkspaces { + return &MockWorkspaces{ client: client, workspaceIDs: make(map[string]*tfe.Workspace), workspaceNames: make(map[string]*tfe.Workspace), } } -func (m *mockWorkspaces) List(ctx context.Context, organization string, options tfe.WorkspaceListOptions) (*tfe.WorkspaceList, error) { +func (m *MockWorkspaces) List(ctx context.Context, organization string, options tfe.WorkspaceListOptions) (*tfe.WorkspaceList, error) { dummyWorkspaces := 10 wl := &tfe.WorkspaceList{} @@ -1129,7 +1120,7 @@ func (m *mockWorkspaces) List(ctx context.Context, organization string, options if options.PageNumber <= 1 { for i := 0; i < dummyWorkspaces; i++ { wl.Items = append(wl.Items, &tfe.Workspace{ - ID: generateID("ws-"), + ID: GenerateID("ws-"), Name: fmt.Sprintf("dummy-workspace-%d", i), }) } @@ -1156,14 +1147,14 @@ func (m *mockWorkspaces) List(ctx context.Context, organization string, options return wl, nil } -func (m *mockWorkspaces) Create(ctx context.Context, organization string, options tfe.WorkspaceCreateOptions) (*tfe.Workspace, error) { +func (m *MockWorkspaces) Create(ctx context.Context, organization string, options tfe.WorkspaceCreateOptions) (*tfe.Workspace, error) { if strings.HasSuffix(*options.Name, "no-operations") { options.Operations = tfe.Bool(false) } else if options.Operations == nil { options.Operations = tfe.Bool(true) } w := &tfe.Workspace{ - ID: generateID("ws-"), + ID: GenerateID("ws-"), Name: *options.Name, Operations: *options.Operations, Permissions: &tfe.WorkspacePermissions{ @@ -1187,7 +1178,7 @@ func (m *mockWorkspaces) Create(ctx context.Context, organization string, option return w, nil } -func (m *mockWorkspaces) Read(ctx context.Context, organization, workspace string) (*tfe.Workspace, error) { +func (m *MockWorkspaces) Read(ctx context.Context, organization, workspace string) (*tfe.Workspace, error) { // custom error for TestCloud_plan500 in backend_plan_test.go if workspace == "network-error" { return nil, errors.New("I'm a little teacup") @@ -1200,7 +1191,7 @@ func (m *mockWorkspaces) Read(ctx context.Context, organization, workspace strin return w, nil } -func (m *mockWorkspaces) ReadByID(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { +func (m *MockWorkspaces) ReadByID(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { w, ok := m.workspaceIDs[workspaceID] if !ok { return nil, tfe.ErrResourceNotFound @@ -1208,7 +1199,19 @@ func (m *mockWorkspaces) ReadByID(ctx context.Context, workspaceID string) (*tfe return w, nil } -func (m *mockWorkspaces) Update(ctx context.Context, organization, workspace string, options tfe.WorkspaceUpdateOptions) (*tfe.Workspace, error) { +func (m *MockWorkspaces) ReadWithOptions(ctx context.Context, organization string, workspace string, options *tfe.WorkspaceReadOptions) (*tfe.Workspace, error) { + panic("not implemented") +} + +func (m *MockWorkspaces) ReadByIDWithOptions(ctx context.Context, workspaceID string, options *tfe.WorkspaceReadOptions) (*tfe.Workspace, error) { + w, ok := m.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + return w, nil +} + +func (m *MockWorkspaces) Update(ctx context.Context, organization, workspace string, options tfe.WorkspaceUpdateOptions) (*tfe.Workspace, error) { w, ok := m.workspaceNames[workspace] if !ok { return nil, tfe.ErrResourceNotFound @@ -1233,7 +1236,7 @@ func (m *mockWorkspaces) Update(ctx context.Context, organization, workspace str return w, nil } -func (m *mockWorkspaces) UpdateByID(ctx context.Context, workspaceID string, options tfe.WorkspaceUpdateOptions) (*tfe.Workspace, error) { +func (m *MockWorkspaces) UpdateByID(ctx context.Context, workspaceID string, options tfe.WorkspaceUpdateOptions) (*tfe.Workspace, error) { w, ok := m.workspaceIDs[workspaceID] if !ok { return nil, tfe.ErrResourceNotFound @@ -1255,7 +1258,7 @@ func (m *mockWorkspaces) UpdateByID(ctx context.Context, workspaceID string, opt return w, nil } -func (m *mockWorkspaces) Delete(ctx context.Context, organization, workspace string) error { +func (m *MockWorkspaces) Delete(ctx context.Context, organization, workspace string) error { if w, ok := m.workspaceNames[workspace]; ok { delete(m.workspaceIDs, w.ID) } @@ -1263,7 +1266,7 @@ func (m *mockWorkspaces) Delete(ctx context.Context, organization, workspace str return nil } -func (m *mockWorkspaces) DeleteByID(ctx context.Context, workspaceID string) error { +func (m *MockWorkspaces) DeleteByID(ctx context.Context, workspaceID string) error { if w, ok := m.workspaceIDs[workspaceID]; ok { delete(m.workspaceIDs, w.Name) } @@ -1271,7 +1274,7 @@ func (m *mockWorkspaces) DeleteByID(ctx context.Context, workspaceID string) err return nil } -func (m *mockWorkspaces) RemoveVCSConnection(ctx context.Context, organization, workspace string) (*tfe.Workspace, error) { +func (m *MockWorkspaces) RemoveVCSConnection(ctx context.Context, organization, workspace string) (*tfe.Workspace, error) { w, ok := m.workspaceNames[workspace] if !ok { return nil, tfe.ErrResourceNotFound @@ -1280,7 +1283,7 @@ func (m *mockWorkspaces) RemoveVCSConnection(ctx context.Context, organization, return w, nil } -func (m *mockWorkspaces) RemoveVCSConnectionByID(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { +func (m *MockWorkspaces) RemoveVCSConnectionByID(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { w, ok := m.workspaceIDs[workspaceID] if !ok { return nil, tfe.ErrResourceNotFound @@ -1289,7 +1292,7 @@ func (m *mockWorkspaces) RemoveVCSConnectionByID(ctx context.Context, workspaceI return w, nil } -func (m *mockWorkspaces) Lock(ctx context.Context, workspaceID string, options tfe.WorkspaceLockOptions) (*tfe.Workspace, error) { +func (m *MockWorkspaces) Lock(ctx context.Context, workspaceID string, options tfe.WorkspaceLockOptions) (*tfe.Workspace, error) { w, ok := m.workspaceIDs[workspaceID] if !ok { return nil, tfe.ErrResourceNotFound @@ -1301,7 +1304,7 @@ func (m *mockWorkspaces) Lock(ctx context.Context, workspaceID string, options t return w, nil } -func (m *mockWorkspaces) Unlock(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { +func (m *MockWorkspaces) Unlock(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { w, ok := m.workspaceIDs[workspaceID] if !ok { return nil, tfe.ErrResourceNotFound @@ -1313,7 +1316,7 @@ func (m *mockWorkspaces) Unlock(ctx context.Context, workspaceID string) (*tfe.W return w, nil } -func (m *mockWorkspaces) ForceUnlock(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { +func (m *MockWorkspaces) ForceUnlock(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { w, ok := m.workspaceIDs[workspaceID] if !ok { return nil, tfe.ErrResourceNotFound @@ -1325,37 +1328,49 @@ func (m *mockWorkspaces) ForceUnlock(ctx context.Context, workspaceID string) (* return w, nil } -func (m *mockWorkspaces) AssignSSHKey(ctx context.Context, workspaceID string, options tfe.WorkspaceAssignSSHKeyOptions) (*tfe.Workspace, error) { +func (m *MockWorkspaces) AssignSSHKey(ctx context.Context, workspaceID string, options tfe.WorkspaceAssignSSHKeyOptions) (*tfe.Workspace, error) { panic("not implemented") } -func (m *mockWorkspaces) UnassignSSHKey(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { +func (m *MockWorkspaces) UnassignSSHKey(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { panic("not implemented") } -func (m *mockWorkspaces) RemoteStateConsumers(ctx context.Context, workspaceID string) (*tfe.WorkspaceList, error) { +func (m *MockWorkspaces) RemoteStateConsumers(ctx context.Context, workspaceID string) (*tfe.WorkspaceList, error) { panic("not implemented") } -func (m *mockWorkspaces) AddRemoteStateConsumers(ctx context.Context, workspaceID string, options tfe.WorkspaceAddRemoteStateConsumersOptions) error { +func (m *MockWorkspaces) AddRemoteStateConsumers(ctx context.Context, workspaceID string, options tfe.WorkspaceAddRemoteStateConsumersOptions) error { panic("not implemented") } -func (m *mockWorkspaces) RemoveRemoteStateConsumers(ctx context.Context, workspaceID string, options tfe.WorkspaceRemoveRemoteStateConsumersOptions) error { +func (m *MockWorkspaces) RemoveRemoteStateConsumers(ctx context.Context, workspaceID string, options tfe.WorkspaceRemoveRemoteStateConsumersOptions) error { panic("not implemented") } -func (m *mockWorkspaces) UpdateRemoteStateConsumers(ctx context.Context, workspaceID string, options tfe.WorkspaceUpdateRemoteStateConsumersOptions) error { +func (m *MockWorkspaces) UpdateRemoteStateConsumers(ctx context.Context, workspaceID string, options tfe.WorkspaceUpdateRemoteStateConsumersOptions) error { panic("not implemented") } -func (m *mockWorkspaces) Readme(ctx context.Context, workspaceID string) (io.Reader, error) { +func (m *MockWorkspaces) Readme(ctx context.Context, workspaceID string) (io.Reader, error) { + panic("not implemented") +} + +func (m *MockWorkspaces) Tags(ctx context.Context, workspaceID string, options tfe.WorkspaceTagListOptions) (*tfe.TagList, error) { + panic("not implemented") +} + +func (m *MockWorkspaces) AddTags(ctx context.Context, workspaceID string, options tfe.WorkspaceAddTagsOptions) error { + panic("not implemented") +} + +func (m *MockWorkspaces) RemoveTags(ctx context.Context, workspaceID string, options tfe.WorkspaceRemoveTagsOptions) error { panic("not implemented") } const alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" -func generateID(s string) string { +func GenerateID(s string) string { b := make([]byte, 16) for i := range b { b[i] = alphanumeric[rand.Intn(len(alphanumeric))] From 8f49757f89e7b2c24ee2b19a90dced46f2fc0200 Mon Sep 17 00:00:00 2001 From: Omar Ismail Date: Wed, 8 Sep 2021 19:02:25 -0400 Subject: [PATCH 11/78] Add testing for Cloud PrepareConfig --- internal/cloud/backend.go | 21 ++---------- internal/cloud/backend_test.go | 59 ++++++++++++++++++++++++++++++++++ internal/cloud/errors.go | 29 +++++++++++++++++ 3 files changed, 91 insertions(+), 18 deletions(-) create mode 100644 internal/cloud/errors.go diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 2a0549689..6fd6e6ace 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -151,12 +151,7 @@ func (b *Cloud) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) { } if val := obj.GetAttr("organization"); val.IsNull() || val.AsString() == "" { - diags = diags.Append(tfdiags.AttributeValue( - tfdiags.Error, - "Invalid organization value", - `The "organization" attribute value must not be empty.`, - cty.Path{cty.GetAttrStep{Name: "organization"}}, - )) + diags = diags.Append(invalidOrganizationConfigMissingValue) } var name, prefix string @@ -171,22 +166,12 @@ func (b *Cloud) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) { // Make sure that we have either a workspace name or a prefix. if name == "" && prefix == "" { - diags = diags.Append(tfdiags.AttributeValue( - tfdiags.Error, - "Invalid workspaces configuration", - `Either workspace "name" or "prefix" is required.`, - cty.Path{cty.GetAttrStep{Name: "workspaces"}}, - )) + diags = diags.Append(invalidWorkspaceConfigMissingValues) } // Make sure that only one of workspace name or a prefix is configured. if name != "" && prefix != "" { - diags = diags.Append(tfdiags.AttributeValue( - tfdiags.Error, - "Invalid workspaces configuration", - `Only one of workspace "name" or "prefix" is allowed.`, - cty.Path{cty.GetAttrStep{Name: "workspaces"}}, - )) + diags = diags.Append(invalidWorkspaceConfigMisconfiguration) } return obj, diags diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index 7862ad0a3..66c3f5bef 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -39,6 +39,65 @@ func TestCloud_backendNoDefault(t *testing.T) { backend.TestBackendStates(t, b) } +func TestCloud_PrepareConfig(t *testing.T) { + cases := map[string]struct { + config cty.Value + expectedErr string + }{ + "null organization": { + config: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + }), + }), + expectedErr: `Invalid organization value: The "organization" attribute value must not be empty.`, + }, + "null workspace": { + config: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("org"), + "workspaces": cty.NullVal(cty.String), + }), + expectedErr: `Invalid workspaces configuration: Either workspace "name" or "prefix" is required.`, + }, + "workspace: empty name and empty prefix": { + config: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("org"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "prefix": cty.NullVal(cty.String), + }), + }), + expectedErr: `Invalid workspaces configuration: Either workspace "name" or "prefix" is required.`, + }, + "workspace: name and prefix present": { + config: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("org"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.StringVal("app-"), + }), + }), + expectedErr: `Invalid workspaces configuration: Only one of workspace "name" or "prefix" is allowed.`, + }, + } + + for name, tc := range cases { + s := testServer(t) + b := New(testDisco(s)) + + // Validate + _, valDiags := b.PrepareConfig(tc.config) + if valDiags.Err() != nil && tc.expectedErr != "" { + actualErr := valDiags.Err().Error() + if !strings.Contains(actualErr, tc.expectedErr) { + t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err()) + } + } + } +} + func TestCloud_config(t *testing.T) { cases := map[string]struct { config cty.Value diff --git a/internal/cloud/errors.go b/internal/cloud/errors.go new file mode 100644 index 000000000..273f8a157 --- /dev/null +++ b/internal/cloud/errors.go @@ -0,0 +1,29 @@ +package cloud + +import ( + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +var ( + invalidOrganizationConfigMissingValue = tfdiags.AttributeValue( + tfdiags.Error, + "Invalid organization value", + `The "organization" attribute value must not be empty.`, + cty.Path{cty.GetAttrStep{Name: "organization"}}, + ) + + invalidWorkspaceConfigMissingValues = tfdiags.AttributeValue( + tfdiags.Error, + "Invalid workspaces configuration", + `Either workspace "name" or "prefix" is required.`, + cty.Path{cty.GetAttrStep{Name: "workspaces"}}, + ) + + invalidWorkspaceConfigMisconfiguration = tfdiags.AttributeValue( + tfdiags.Error, + "Invalid workspaces configuration", + `Only one of workspace "name" or "prefix" is allowed.`, + cty.Path{cty.GetAttrStep{Name: "workspaces"}}, + ) +) From 0a125c0400d82f490e8bce90900c0ba9667cb627 Mon Sep 17 00:00:00 2001 From: Omar Ismail Date: Wed, 8 Sep 2021 19:02:25 -0400 Subject: [PATCH 12/78] Cloud: refactor setting attributes, add tests --- internal/cloud/backend.go | 76 +++++++++++--------- internal/cloud/backend_test.go | 122 +++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 32 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 6fd6e6ace..7557e3f79 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -183,41 +183,14 @@ func (b *Cloud) Configure(obj cty.Value) tfdiags.Diagnostics { if obj.IsNull() { return diags } - - // Get the hostname. - if val := obj.GetAttr("hostname"); !val.IsNull() && val.AsString() != "" { - b.hostname = val.AsString() - } else { - b.hostname = defaultHostname - } - - // Get the organization. - if val := obj.GetAttr("organization"); !val.IsNull() { - b.organization = val.AsString() - } - - // Get the workspaces configuration block and retrieve the - // default workspace name and prefix. - if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() { - if val := workspaces.GetAttr("name"); !val.IsNull() { - b.workspace = val.AsString() - } - if val := workspaces.GetAttr("prefix"); !val.IsNull() { - b.prefix = val.AsString() - } - } - - // Determine if we are forced to use the local backend. - b.forceLocal = os.Getenv("TF_FORCE_LOCAL_BACKEND") != "" - - serviceID := tfeServiceID - if b.forceLocal { - serviceID = stateServiceID + diagErr := b.setConfigurationFields(obj) + if diagErr.HasErrors() { + return diagErr } // Discover the service URL to confirm that it provides the Terraform Cloud/Enterprise API // and to get the version constraints. - service, constraints, err := b.discover(serviceID) + service, constraints, err := b.discover() // First check any contraints we might have received. if constraints != nil { @@ -332,8 +305,47 @@ func (b *Cloud) Configure(obj cty.Value) tfdiags.Diagnostics { return diags } +func (b *Cloud) setConfigurationFields(obj cty.Value) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + // Get the hostname. + if val := obj.GetAttr("hostname"); !val.IsNull() && val.AsString() != "" { + b.hostname = val.AsString() + } else { + b.hostname = defaultHostname + } + + // Get the organization. + if val := obj.GetAttr("organization"); !val.IsNull() { + b.organization = val.AsString() + } + + // Get the workspaces configuration block and retrieve the + // default workspace name and prefix. + if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() { + + // PrepareConfig checks that you cannot set both of these. + if val := workspaces.GetAttr("name"); !val.IsNull() { + b.workspace = val.AsString() + } + if val := workspaces.GetAttr("prefix"); !val.IsNull() { + b.prefix = val.AsString() + } + } + + // Determine if we are forced to use the local backend. + b.forceLocal = os.Getenv("TF_FORCE_LOCAL_BACKEND") != "" + + return diags +} + // discover the TFC/E API service URL and version constraints. -func (b *Cloud) discover(serviceID string) (*url.URL, *disco.Constraints, error) { +func (b *Cloud) discover() (*url.URL, *disco.Constraints, error) { + serviceID := tfeServiceID + if b.forceLocal { + serviceID = stateServiceID + } + hostname, err := svchost.ForComparison(b.hostname) if err != nil { return nil, nil, err diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index 66c3f5bef..7f02f08e8 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -3,6 +3,7 @@ package cloud import ( "context" "fmt" + "os" "reflect" "strings" "testing" @@ -212,6 +213,127 @@ func TestCloud_config(t *testing.T) { } } +func TestCloud_setConfigurationFields(t *testing.T) { + originalForceBackendEnv := os.Getenv("TF_FORCE_LOCAL_BACKEND") + + cases := map[string]struct { + obj cty.Value + expectedHostname string + expectedOrganziation string + expectedWorkspacePrefix string + expectedWorkspaceName string + expectedForceLocal bool + setEnv func() + resetEnv func() + expectedErr string + }{ + "with hostname set": { + obj: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("hashicorp"), + "hostname": cty.StringVal("hashicorp.com"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + }), + }), + expectedHostname: "hashicorp.com", + expectedOrganziation: "hashicorp", + }, + "with hostname not set, set to default hostname": { + obj: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("hashicorp"), + "hostname": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + }), + }), + expectedHostname: defaultHostname, + expectedOrganziation: "hashicorp", + }, + "with workspace name set": { + obj: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("hashicorp"), + "hostname": cty.StringVal("hashicorp.com"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + }), + }), + expectedHostname: "hashicorp.com", + expectedOrganziation: "hashicorp", + expectedWorkspaceName: "prod", + }, + "with workspace prefix set": { + obj: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("hashicorp"), + "hostname": cty.StringVal("hashicorp.com"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "prefix": cty.StringVal("prod"), + }), + }), + expectedHostname: "hashicorp.com", + expectedOrganziation: "hashicorp", + expectedWorkspacePrefix: "prod", + }, + "with force local set": { + obj: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("hashicorp"), + "hostname": cty.StringVal("hashicorp.com"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "prefix": cty.StringVal("prod"), + }), + }), + expectedHostname: "hashicorp.com", + expectedOrganziation: "hashicorp", + expectedWorkspacePrefix: "prod", + setEnv: func() { + os.Setenv("TF_FORCE_LOCAL_BACKEND", "1") + }, + resetEnv: func() { + os.Setenv("TF_FORCE_LOCAL_BACKEND", originalForceBackendEnv) + }, + expectedForceLocal: true, + }, + } + + for name, tc := range cases { + b := &Cloud{} + + // if `setEnv` is set, then we expect `resetEnv` to also be set + if tc.setEnv != nil { + tc.setEnv() + defer tc.resetEnv() + } + + errDiags := b.setConfigurationFields(tc.obj) + if errDiags.HasErrors() || tc.expectedErr != "" { + actualErr := errDiags.Err().Error() + if !strings.Contains(actualErr, tc.expectedErr) { + t.Fatalf("%s: unexpected validation result: %v", name, errDiags.Err()) + } + } + + if tc.expectedHostname != "" && b.hostname != tc.expectedHostname { + t.Fatalf("%s: expected hostname %s to match actual hostname %s", name, tc.expectedHostname, b.hostname) + } + if tc.expectedOrganziation != "" && b.organization != tc.expectedOrganziation { + t.Fatalf("%s: expected organization %s to match actual organization %s", name, tc.expectedOrganziation, b.organization) + } + if tc.expectedWorkspacePrefix != "" && b.prefix != tc.expectedWorkspacePrefix { + t.Fatalf("%s: expected workspace prefix %s to match actual workspace prefix %s", name, tc.expectedWorkspacePrefix, b.prefix) + } + if tc.expectedWorkspaceName != "" && b.workspace != tc.expectedWorkspaceName { + t.Fatalf("%s: expected workspace name %s to match actual workspace name %s", name, tc.expectedWorkspaceName, b.workspace) + } + if tc.expectedForceLocal != false && b.forceLocal != tc.expectedForceLocal { + t.Fatalf("%s: expected force local backend to be set ", name) + } + } +} + func TestCloud_versionConstraints(t *testing.T) { cases := map[string]struct { config cty.Value From 922a8e448871e91c273e5bc3768b33f18a4c5f28 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Tue, 7 Sep 2021 15:54:33 -0500 Subject: [PATCH 13/78] Refactor private workspace fields into workspaceMapping A mostly cosemetic change; The fields 'workspace' and 'prefix' don't really describe well what they are from a caller, so change these to use a workspaceMapping struct to convey they are for implementing workspace mapping strategies from CLI -> TFC --- internal/cloud/backend.go | 61 +++++++++++++++------------- internal/cloud/backend_apply_test.go | 14 +++---- internal/cloud/backend_context.go | 6 +-- internal/cloud/backend_plan_test.go | 10 ++--- internal/cloud/backend_test.go | 12 +++--- internal/cloud/testing.go | 4 +- 6 files changed, 55 insertions(+), 52 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 7557e3f79..305bf8861 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -63,12 +63,9 @@ type Cloud struct { // organization is the organization that contains the target workspaces. organization string - // workspace is used to map the default workspace to a TFC workspace. - workspace string - - // prefix is used to filter down a set of workspaces that use a single - // configuration. - prefix string + // workspaceMapping contains strategies for mapping CLI workspaces in the working directory + // to remote Terraform Cloud workspaces. + workspaceMapping workspaceMapping // services is used for service discovery services *disco.Disco @@ -183,6 +180,7 @@ func (b *Cloud) Configure(obj cty.Value) tfdiags.Diagnostics { if obj.IsNull() { return diags } + diagErr := b.setConfigurationFields(obj) if diagErr.HasErrors() { return diagErr @@ -326,10 +324,10 @@ func (b *Cloud) setConfigurationFields(obj cty.Value) tfdiags.Diagnostics { // PrepareConfig checks that you cannot set both of these. if val := workspaces.GetAttr("name"); !val.IsNull() { - b.workspace = val.AsString() + b.workspaceMapping.name = val.AsString() } if val := workspaces.GetAttr("prefix"); !val.IsNull() { - b.prefix = val.AsString() + b.workspaceMapping.prefix = val.AsString() } } @@ -514,7 +512,7 @@ func (b *Cloud) retryLogHook(attemptNum int, resp *http.Response) { // Workspaces implements backend.Enhanced. func (b *Cloud) Workspaces() ([]string, error) { - if b.prefix == "" { + if b.workspaceMapping.prefix == "" { return nil, backend.ErrWorkspacesNotSupported } return b.workspaces() @@ -524,10 +522,10 @@ func (b *Cloud) Workspaces() ([]string, error) { func (b *Cloud) workspaces() ([]string, error) { options := tfe.WorkspaceListOptions{} switch { - case b.workspace != "": - options.Search = tfe.String(b.workspace) - case b.prefix != "": - options.Search = tfe.String(b.prefix) + case b.workspaceMapping.name != "": + options.Search = tfe.String(b.workspaceMapping.name) + case b.workspaceMapping.prefix != "": + options.Search = tfe.String(b.workspaceMapping.prefix) } // Create a slice to contain all the names. @@ -540,12 +538,12 @@ func (b *Cloud) workspaces() ([]string, error) { } for _, w := range wl.Items { - if b.workspace != "" && w.Name == b.workspace { + if b.workspaceMapping.name != "" && w.Name == b.workspaceMapping.name { names = append(names, backend.DefaultStateName) continue } - if b.prefix != "" && strings.HasPrefix(w.Name, b.prefix) { - names = append(names, strings.TrimPrefix(w.Name, b.prefix)) + if b.workspaceMapping.prefix != "" && strings.HasPrefix(w.Name, b.workspaceMapping.prefix) { + names = append(names, strings.TrimPrefix(w.Name, b.workspaceMapping.prefix)) } } @@ -566,19 +564,19 @@ func (b *Cloud) workspaces() ([]string, error) { // DeleteWorkspace implements backend.Enhanced. func (b *Cloud) DeleteWorkspace(name string) error { - if b.workspace == "" && name == backend.DefaultStateName { + if b.workspaceMapping.name == "" && name == backend.DefaultStateName { return backend.ErrDefaultWorkspaceNotSupported } - if b.prefix == "" && name != backend.DefaultStateName { + if b.workspaceMapping.prefix == "" && name != backend.DefaultStateName { return backend.ErrWorkspacesNotSupported } // Configure the remote workspace name. switch { case name == backend.DefaultStateName: - name = b.workspace - case b.prefix != "" && !strings.HasPrefix(name, b.prefix): - name = b.prefix + name + name = b.workspaceMapping.name + case b.workspaceMapping.prefix != "" && !strings.HasPrefix(name, b.workspaceMapping.prefix): + name = b.workspaceMapping.prefix + name } client := &remoteClient{ @@ -594,19 +592,19 @@ func (b *Cloud) DeleteWorkspace(name string) error { // StateMgr implements backend.Enhanced. func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { - if b.workspace == "" && name == backend.DefaultStateName { + if b.workspaceMapping.name == "" && name == backend.DefaultStateName { return nil, backend.ErrDefaultWorkspaceNotSupported } - if b.prefix == "" && name != backend.DefaultStateName { + if b.workspaceMapping.prefix == "" && name != backend.DefaultStateName { return nil, backend.ErrWorkspacesNotSupported } // Configure the remote workspace name. switch { case name == backend.DefaultStateName: - name = b.workspace - case b.prefix != "" && !strings.HasPrefix(name, b.prefix): - name = b.prefix + name + name = b.workspaceMapping.name + case b.workspaceMapping.prefix != "" && !strings.HasPrefix(name, b.workspaceMapping.prefix): + name = b.workspaceMapping.prefix + name } workspace, err := b.client.Workspaces.Read(context.Background(), b.organization, name) @@ -663,9 +661,9 @@ func (b *Cloud) Operation(ctx context.Context, op *backend.Operation) (*backend. name := op.Workspace switch { case op.Workspace == backend.DefaultStateName: - name = b.workspace - case b.prefix != "" && !strings.HasPrefix(op.Workspace, b.prefix): - name = b.prefix + op.Workspace + name = b.workspaceMapping.name + case b.workspaceMapping.prefix != "" && !strings.HasPrefix(op.Workspace, b.workspaceMapping.prefix): + name = b.workspaceMapping.prefix + op.Workspace } // Retrieve the workspace for this operation. @@ -974,6 +972,11 @@ func (b *Cloud) cliColorize() *colorstring.Colorize { } } +type workspaceMapping struct { + name string + prefix string +} + func generalError(msg string, err error) error { var diags tfdiags.Diagnostics diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index 1100c31dc..8fa2d9758 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -143,7 +143,7 @@ func TestCloud_applyWithoutPermissions(t *testing.T) { context.Background(), b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.prefix + "prod"), + Name: tfe.String(b.workspaceMapping.prefix + "prod"), }, ) if err != nil { @@ -183,7 +183,7 @@ func TestCloud_applyWithVCS(t *testing.T) { context.Background(), b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.prefix + "prod"), + Name: tfe.String(b.workspaceMapping.prefix + "prod"), VCSRepo: &tfe.VCSRepoOptions{}, }, ) @@ -900,7 +900,7 @@ func TestCloud_applyWithAutoApply(t *testing.T) { b.organization, tfe.WorkspaceCreateOptions{ AutoApply: tfe.Bool(true), - Name: tfe.String(b.prefix + "prod"), + Name: tfe.String(b.workspaceMapping.prefix + "prod"), }, ) if err != nil { @@ -1015,7 +1015,7 @@ func TestCloud_applyWorkspaceWithoutOperations(t *testing.T) { ctx, b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.prefix + "no-operations"), + Name: tfe.String(b.workspaceMapping.prefix + "no-operations"), }, ) if err != nil { @@ -1074,7 +1074,7 @@ func TestCloud_applyLockTimeout(t *testing.T) { ctx := context.Background() // Retrieve the workspace used to run this operation in. - w, err := b.client.Workspaces.Read(ctx, b.organization, b.workspace) + w, err := b.client.Workspaces.Read(ctx, b.organization, b.workspaceMapping.name) if err != nil { t.Fatalf("error retrieving workspace: %v", err) } @@ -1434,7 +1434,7 @@ func TestCloud_applyPolicySoftFailAutoApply(t *testing.T) { b.organization, tfe.WorkspaceCreateOptions{ AutoApply: tfe.Bool(true), - Name: tfe.String(b.prefix + "prod"), + Name: tfe.String(b.workspaceMapping.prefix + "prod"), }, ) if err != nil { @@ -1583,7 +1583,7 @@ func TestCloud_applyVersionCheck(t *testing.T) { _, err := b.client.Workspaces.Update( ctx, b.organization, - b.workspace, + b.workspaceMapping.name, tfe.WorkspaceUpdateOptions{ Operations: tfe.Bool(tc.hasOperations), TerraformVersion: tfe.String(tc.remoteVersion), diff --git a/internal/cloud/backend_context.go b/internal/cloud/backend_context.go index a564a6d3a..32a1e52a2 100644 --- a/internal/cloud/backend_context.go +++ b/internal/cloud/backend_context.go @@ -143,9 +143,9 @@ func (b *Cloud) getRemoteWorkspaceName(localWorkspaceName string) string { // The default workspace name is a special case, for when the backend // is configured to with to an exact remote workspace rather than with // a remote workspace _prefix_. - return b.workspace - case b.prefix != "" && !strings.HasPrefix(localWorkspaceName, b.prefix): - return b.prefix + localWorkspaceName + return b.workspaceMapping.name + case b.workspaceMapping.prefix != "" && !strings.HasPrefix(localWorkspaceName, b.workspaceMapping.prefix): + return b.workspaceMapping.prefix + localWorkspaceName default: return localWorkspaceName } diff --git a/internal/cloud/backend_plan_test.go b/internal/cloud/backend_plan_test.go index cd836b46d..45c1958b2 100644 --- a/internal/cloud/backend_plan_test.go +++ b/internal/cloud/backend_plan_test.go @@ -161,7 +161,7 @@ func TestCloud_planWithoutPermissions(t *testing.T) { context.Background(), b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.prefix + "prod"), + Name: tfe.String(b.workspaceMapping.prefix + "prod"), }, ) if err != nil { @@ -772,7 +772,7 @@ func TestCloud_planWorkspaceWithoutOperations(t *testing.T) { ctx, b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.prefix + "no-operations"), + Name: tfe.String(b.workspaceMapping.prefix + "no-operations"), }, ) if err != nil { @@ -818,7 +818,7 @@ func TestCloud_planLockTimeout(t *testing.T) { ctx := context.Background() // Retrieve the workspace used to run this operation in. - w, err := b.client.Workspaces.Read(ctx, b.organization, b.workspace) + w, err := b.client.Workspaces.Read(ctx, b.organization, b.workspaceMapping.name) if err != nil { t.Fatalf("error retrieving workspace: %v", err) } @@ -941,7 +941,7 @@ func TestCloud_planWithWorkingDirectory(t *testing.T) { } // Configure the workspace to use a custom working directory. - _, err := b.client.Workspaces.Update(context.Background(), b.organization, b.workspace, options) + _, err := b.client.Workspaces.Update(context.Background(), b.organization, b.workspaceMapping.name, options) if err != nil { t.Fatalf("error configuring working directory: %v", err) } @@ -986,7 +986,7 @@ func TestCloud_planWithWorkingDirectoryFromCurrentPath(t *testing.T) { } // Configure the workspace to use a custom working directory. - _, err := b.client.Workspaces.Update(context.Background(), b.organization, b.workspace, options) + _, err := b.client.Workspaces.Update(context.Background(), b.organization, b.workspaceMapping.name, options) if err != nil { t.Fatalf("error configuring working directory: %v", err) } diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index 7f02f08e8..cc45a3538 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -664,7 +664,7 @@ func TestCloud_StateMgr_versionCheck(t *testing.T) { if _, err := b.client.Workspaces.Update( context.Background(), b.organization, - b.workspace, + b.workspaceMapping.name, tfe.WorkspaceUpdateOptions{ TerraformVersion: tfe.String(v0140.String()), }, @@ -681,7 +681,7 @@ func TestCloud_StateMgr_versionCheck(t *testing.T) { if _, err := b.client.Workspaces.Update( context.Background(), b.organization, - b.workspace, + b.workspaceMapping.name, tfe.WorkspaceUpdateOptions{ TerraformVersion: tfe.String(v0135.String()), }, @@ -721,7 +721,7 @@ func TestCloud_StateMgr_versionCheckLatest(t *testing.T) { if _, err := b.client.Workspaces.Update( context.Background(), b.organization, - b.workspace, + b.workspaceMapping.name, tfe.WorkspaceUpdateOptions{ TerraformVersion: tfe.String("latest"), }, @@ -779,7 +779,7 @@ func TestCloud_VerifyWorkspaceTerraformVersion(t *testing.T) { if _, err := b.client.Workspaces.Update( context.Background(), b.organization, - b.workspace, + b.workspaceMapping.name, tfe.WorkspaceUpdateOptions{ Operations: tfe.Bool(tc.operations), TerraformVersion: tfe.String(tc.remote), @@ -830,7 +830,7 @@ func TestCloud_VerifyWorkspaceTerraformVersion_workspaceErrors(t *testing.T) { if _, err := b.client.Workspaces.Update( context.Background(), b.organization, - b.workspace, + b.workspaceMapping.name, tfe.WorkspaceUpdateOptions{ TerraformVersion: tfe.String("1.0.cheetarah"), }, @@ -878,7 +878,7 @@ func TestCloud_VerifyWorkspaceTerraformVersion_ignoreFlagSet(t *testing.T) { if _, err := b.client.Workspaces.Update( context.Background(), b.organization, - b.workspace, + b.workspaceMapping.name, tfe.WorkspaceUpdateOptions{ TerraformVersion: tfe.String(remote.String()), }, diff --git a/internal/cloud/testing.go b/internal/cloud/testing.go index 7bb145b3c..cfc8d3dce 100644 --- a/internal/cloud/testing.go +++ b/internal/cloud/testing.go @@ -161,9 +161,9 @@ func testBackend(t *testing.T, obj cty.Value) (*Cloud, func()) { } // Create the default workspace if required. - if b.workspace != "" { + if b.workspaceMapping.name != "" { _, err = b.client.Workspaces.Create(ctx, b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.workspace), + Name: tfe.String(b.workspaceMapping.name), }) if err != nil { t.Fatalf("error: %v", err) From 7a243379fbf6c4e18a4627576492e5563d15f888 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Thu, 9 Sep 2021 16:21:35 -0500 Subject: [PATCH 14/78] cloud: Refactor workspaceMapping concerns into strategy() --- internal/cloud/backend.go | 104 +++++++++++++++++++++++---------- internal/cloud/backend_test.go | 8 +-- 2 files changed, 76 insertions(+), 36 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 305bf8861..d92e1fa8c 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -151,23 +151,22 @@ func (b *Cloud) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) { diags = diags.Append(invalidOrganizationConfigMissingValue) } - var name, prefix string + workspaceMapping := workspaceMapping{} if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() { if val := workspaces.GetAttr("name"); !val.IsNull() { - name = val.AsString() + workspaceMapping.name = val.AsString() } if val := workspaces.GetAttr("prefix"); !val.IsNull() { - prefix = val.AsString() + workspaceMapping.prefix = val.AsString() } } - // Make sure that we have either a workspace name or a prefix. - if name == "" && prefix == "" { + switch workspaceMapping.strategy() { + // Make sure have a workspace mapping strategy present + case workspaceNoneStrategy: diags = diags.Append(invalidWorkspaceConfigMissingValues) - } - // Make sure that only one of workspace name or a prefix is configured. - if name != "" && prefix != "" { + case workspaceInvalidStrategy: diags = diags.Append(invalidWorkspaceConfigMisconfiguration) } @@ -512,19 +511,20 @@ func (b *Cloud) retryLogHook(attemptNum int, resp *http.Response) { // Workspaces implements backend.Enhanced. func (b *Cloud) Workspaces() ([]string, error) { - if b.workspaceMapping.prefix == "" { + if b.workspaceMapping.strategy() == workspaceNameStrategy { return nil, backend.ErrWorkspacesNotSupported } return b.workspaces() } -// workspaces returns a filtered list of remote workspace names. +// workspaces returns a filtered list of remote workspace names according to the workspace mapping +// strategy configured. func (b *Cloud) workspaces() ([]string, error) { options := tfe.WorkspaceListOptions{} - switch { - case b.workspaceMapping.name != "": + switch b.workspaceMapping.strategy() { + case workspaceNameStrategy: options.Search = tfe.String(b.workspaceMapping.name) - case b.workspaceMapping.prefix != "": + case workspacePrefixStrategy: options.Search = tfe.String(b.workspaceMapping.prefix) } @@ -538,12 +538,22 @@ func (b *Cloud) workspaces() ([]string, error) { } for _, w := range wl.Items { - if b.workspaceMapping.name != "" && w.Name == b.workspaceMapping.name { - names = append(names, backend.DefaultStateName) - continue - } - if b.workspaceMapping.prefix != "" && strings.HasPrefix(w.Name, b.workspaceMapping.prefix) { - names = append(names, strings.TrimPrefix(w.Name, b.workspaceMapping.prefix)) + switch b.workspaceMapping.strategy() { + case workspaceNameStrategy: + if w.Name == b.workspaceMapping.name { + names = append(names, backend.DefaultStateName) + continue + } + case workspacePrefixStrategy: + if strings.HasPrefix(w.Name, b.workspaceMapping.prefix) { + names = append(names, strings.TrimPrefix(w.Name, b.workspaceMapping.prefix)) + continue + } + default: + // Pass-through. "name" and "prefix" strategies are naive and do + // client-side filtering above, but for any other future + // strategy this filtering should be left to the API. + names = append(names, w.Name) } } @@ -564,10 +574,10 @@ func (b *Cloud) workspaces() ([]string, error) { // DeleteWorkspace implements backend.Enhanced. func (b *Cloud) DeleteWorkspace(name string) error { - if b.workspaceMapping.name == "" && name == backend.DefaultStateName { + if b.workspaceMapping.strategy() != workspaceNameStrategy && name == backend.DefaultStateName { return backend.ErrDefaultWorkspaceNotSupported } - if b.workspaceMapping.prefix == "" && name != backend.DefaultStateName { + if b.workspaceMapping.strategy() == workspaceNameStrategy && name != backend.DefaultStateName { return backend.ErrWorkspacesNotSupported } @@ -575,7 +585,7 @@ func (b *Cloud) DeleteWorkspace(name string) error { switch { case name == backend.DefaultStateName: name = b.workspaceMapping.name - case b.workspaceMapping.prefix != "" && !strings.HasPrefix(name, b.workspaceMapping.prefix): + case b.workspaceMapping.strategy() == workspacePrefixStrategy && !strings.HasPrefix(name, b.workspaceMapping.prefix): name = b.workspaceMapping.prefix + name } @@ -592,10 +602,10 @@ func (b *Cloud) DeleteWorkspace(name string) error { // StateMgr implements backend.Enhanced. func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { - if b.workspaceMapping.name == "" && name == backend.DefaultStateName { + if b.workspaceMapping.strategy() != workspaceNameStrategy && name == backend.DefaultStateName { return nil, backend.ErrDefaultWorkspaceNotSupported } - if b.workspaceMapping.prefix == "" && name != backend.DefaultStateName { + if b.workspaceMapping.strategy() == workspaceNameStrategy && name != backend.DefaultStateName { return nil, backend.ErrWorkspacesNotSupported } @@ -603,7 +613,7 @@ func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { switch { case name == backend.DefaultStateName: name = b.workspaceMapping.name - case b.workspaceMapping.prefix != "" && !strings.HasPrefix(name, b.workspaceMapping.prefix): + case b.workspaceMapping.strategy() == workspacePrefixStrategy && !strings.HasPrefix(name, b.workspaceMapping.prefix): name = b.workspaceMapping.prefix + name } @@ -662,7 +672,7 @@ func (b *Cloud) Operation(ctx context.Context, op *backend.Operation) (*backend. switch { case op.Workspace == backend.DefaultStateName: name = b.workspaceMapping.name - case b.workspaceMapping.prefix != "" && !strings.HasPrefix(op.Workspace, b.workspaceMapping.prefix): + case b.workspaceMapping.strategy() == workspacePrefixStrategy && !strings.HasPrefix(op.Workspace, b.workspaceMapping.prefix): name = b.workspaceMapping.prefix + op.Workspace } @@ -977,6 +987,29 @@ type workspaceMapping struct { prefix string } +type workspaceStrategy string + +const ( + workspaceNameStrategy workspaceStrategy = "name" + workspacePrefixStrategy workspaceStrategy = "prefix" + workspaceNoneStrategy workspaceStrategy = "none" + workspaceInvalidStrategy workspaceStrategy = "invalid" +) + +func (wm workspaceMapping) strategy() workspaceStrategy { + switch { + case wm.name != "" && wm.prefix == "": + return workspaceNameStrategy + case wm.name == "" && wm.prefix != "": + return workspacePrefixStrategy + case wm.name == "" && wm.prefix == "": + return workspaceNoneStrategy + default: + // Any other combination is invalid as each strategy is mutually exclusive + return workspaceInvalidStrategy + } +} + func generalError(msg string, err error) error { var diags tfdiags.Diagnostics @@ -1042,10 +1075,17 @@ var schemaDescriptions = map[string]string{ "organization": "The name of the organization containing the targeted workspace(s).", "token": "The token used to authenticate with Terraform Cloud/Enterprise. Typically this argument should not be set,\n" + "and 'terraform login' used instead; your credentials will then be fetched from your CLI configuration file or configured credential helper.", - "name": "A workspace name used to map the default workspace to a named remote workspace.\n" + - "When configured only the default workspace can be used. This option conflicts\n" + - "with \"prefix\"", - "prefix": "A prefix used to filter workspaces using a single configuration. New workspaces\n" + - "will automatically be prefixed with this prefix. If omitted only the default\n" + - "workspace can be used. This option conflicts with \"name\"", + "name": "The name of a single Terraform Cloud workspace to be used with this configuration.\n" + + "When configured only the specified workspace can be used. This option conflicts\n" + + "with \"prefix\".", + "prefix": "A name prefix used to select remote Terraform Cloud workspaces to be used for this\n" + + "single configuration. New workspaces will automatically be prefixed with this prefix. This option conflicts with \"name\".", } + +var workspaceConfigurationHelp = fmt.Sprintf(`The 'workspaces' block configures how Terraform CLI maps its workspaces for this +single configuration to workspaces within a Terraform Cloud organization. Two strategies are available: + +[bold]name[reset] - %s + +[bold]prefix[reset] - %s +`, schemaDescriptions["name"], schemaDescriptions["prefix"]) diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index cc45a3538..945c56378 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -322,11 +322,11 @@ func TestCloud_setConfigurationFields(t *testing.T) { if tc.expectedOrganziation != "" && b.organization != tc.expectedOrganziation { t.Fatalf("%s: expected organization %s to match actual organization %s", name, tc.expectedOrganziation, b.organization) } - if tc.expectedWorkspacePrefix != "" && b.prefix != tc.expectedWorkspacePrefix { - t.Fatalf("%s: expected workspace prefix %s to match actual workspace prefix %s", name, tc.expectedWorkspacePrefix, b.prefix) + if tc.expectedWorkspacePrefix != "" && b.workspaceMapping.prefix != tc.expectedWorkspacePrefix { + t.Fatalf("%s: expected workspace prefix %s to match actual workspace prefix %s", name, tc.expectedWorkspacePrefix, b.workspaceMapping.prefix) } - if tc.expectedWorkspaceName != "" && b.workspace != tc.expectedWorkspaceName { - t.Fatalf("%s: expected workspace name %s to match actual workspace name %s", name, tc.expectedWorkspaceName, b.workspace) + if tc.expectedWorkspaceName != "" && b.workspaceMapping.name != tc.expectedWorkspaceName { + t.Fatalf("%s: expected workspace name %s to match actual workspace name %s", name, tc.expectedWorkspaceName, b.workspaceMapping.name) } if tc.expectedForceLocal != false && b.forceLocal != tc.expectedForceLocal { t.Fatalf("%s: expected force local backend to be set ", name) From 6dcd0db2657cb9a47354e5c7c208fc8010f34ebb Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Mon, 13 Sep 2021 14:18:32 -0500 Subject: [PATCH 15/78] Map CLI workspaces by TFC tags --- internal/cloud/backend.go | 94 ++++++++++++++++------ internal/cloud/backend_test.go | 142 ++++++++++++++++++++++++++++++--- internal/cloud/errors.go | 8 +- internal/cloud/testing.go | 3 + 4 files changed, 209 insertions(+), 38 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index d92e1fa8c..9d7d2e5fc 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -26,6 +26,7 @@ import ( "github.com/mitchellh/cli" "github.com/mitchellh/colorstring" "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/gocty" backendLocal "github.com/hashicorp/terraform/internal/backend/local" ) @@ -104,17 +105,17 @@ func (b *Cloud) ConfigSchema() *configschema.Block { "hostname": { Type: cty.String, Optional: true, - Description: schemaDescriptions["hostname"], + Description: schemaDescriptionHostname, }, "organization": { Type: cty.String, Required: true, - Description: schemaDescriptions["organization"], + Description: schemaDescriptionOrganization, }, "token": { Type: cty.String, Optional: true, - Description: schemaDescriptions["token"], + Description: schemaDescriptionToken, }, }, @@ -125,12 +126,17 @@ func (b *Cloud) ConfigSchema() *configschema.Block { "name": { Type: cty.String, Optional: true, - Description: schemaDescriptions["name"], + Description: schemaDescriptionName, }, "prefix": { Type: cty.String, Optional: true, - Description: schemaDescriptions["prefix"], + Description: schemaDescriptionPrefix, + }, + "tags": { + Type: cty.Set(cty.String), + Optional: true, + Description: schemaDescriptionTags, }, }, }, @@ -159,6 +165,12 @@ func (b *Cloud) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) { if val := workspaces.GetAttr("prefix"); !val.IsNull() { workspaceMapping.prefix = val.AsString() } + if val := workspaces.GetAttr("tags"); !val.IsNull() { + err := gocty.FromCtyValue(val, &workspaceMapping.tags) + if err != nil { + log.Panicf("An unxpected error occurred: %s", err) + } + } } switch workspaceMapping.strategy() { @@ -328,6 +340,15 @@ func (b *Cloud) setConfigurationFields(obj cty.Value) tfdiags.Diagnostics { if val := workspaces.GetAttr("prefix"); !val.IsNull() { b.workspaceMapping.prefix = val.AsString() } + if val := workspaces.GetAttr("tags"); !val.IsNull() { + var tags []string + err := gocty.FromCtyValue(val, &tags) + if err != nil { + log.Panicf("An unxpected error occurred: %s", err) + } + + b.workspaceMapping.tags = tags + } } // Determine if we are forced to use the local backend. @@ -526,6 +547,9 @@ func (b *Cloud) workspaces() ([]string, error) { options.Search = tfe.String(b.workspaceMapping.name) case workspacePrefixStrategy: options.Search = tfe.String(b.workspaceMapping.prefix) + case workspaceTagsStrategy: + taglist := strings.Join(b.workspaceMapping.tags, ",") + options.Tags = &taglist } // Create a slice to contain all the names. @@ -551,7 +575,7 @@ func (b *Cloud) workspaces() ([]string, error) { } default: // Pass-through. "name" and "prefix" strategies are naive and do - // client-side filtering above, but for any other future + // client-side filtering above, but for tags and any other future // strategy this filtering should be left to the API. names = append(names, w.Name) } @@ -627,6 +651,13 @@ func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { Name: tfe.String(name), } + var tags []*tfe.Tag + for _, tag := range b.workspaceMapping.tags { + t := tfe.Tag{Name: tag} + tags = append(tags, &t) + } + options.Tags = tags + // We only set the Terraform Version for the new workspace if this is // a release candidate or a final release. if tfversion.Prerelease == "" || strings.HasPrefix(tfversion.Prerelease, "rc") { @@ -985,11 +1016,13 @@ func (b *Cloud) cliColorize() *colorstring.Colorize { type workspaceMapping struct { name string prefix string + tags []string } type workspaceStrategy string const ( + workspaceTagsStrategy workspaceStrategy = "tags" workspaceNameStrategy workspaceStrategy = "name" workspacePrefixStrategy workspaceStrategy = "prefix" workspaceNoneStrategy workspaceStrategy = "none" @@ -998,11 +1031,13 @@ const ( func (wm workspaceMapping) strategy() workspaceStrategy { switch { - case wm.name != "" && wm.prefix == "": + case len(wm.tags) > 0 && wm.name == "" && wm.prefix == "": + return workspaceTagsStrategy + case len(wm.tags) == 0 && wm.name != "" && wm.prefix == "": return workspaceNameStrategy - case wm.name == "" && wm.prefix != "": + case len(wm.tags) == 0 && wm.name == "" && wm.prefix != "": return workspacePrefixStrategy - case wm.name == "" && wm.prefix == "": + case len(wm.tags) == 0 && wm.name == "" && wm.prefix == "": return workspaceNoneStrategy default: // Any other combination is invalid as each strategy is mutually exclusive @@ -1070,22 +1105,33 @@ const operationNotCanceled = ` [reset][red]The remote operation was not cancelled.[reset] ` -var schemaDescriptions = map[string]string{ - "hostname": "The Terraform Enterprise hostname to connect to. This optional argument defaults to app.terraform.io for use with Terraform Cloud.", - "organization": "The name of the organization containing the targeted workspace(s).", - "token": "The token used to authenticate with Terraform Cloud/Enterprise. Typically this argument should not be set,\n" + - "and 'terraform login' used instead; your credentials will then be fetched from your CLI configuration file or configured credential helper.", - "name": "The name of a single Terraform Cloud workspace to be used with this configuration.\n" + - "When configured only the specified workspace can be used. This option conflicts\n" + - "with \"prefix\".", - "prefix": "A name prefix used to select remote Terraform Cloud workspaces to be used for this\n" + - "single configuration. New workspaces will automatically be prefixed with this prefix. This option conflicts with \"name\".", -} +var ( + workspaceConfigurationHelp = fmt.Sprintf( + `The 'workspaces' block configures how Terraform CLI maps its workspaces for this single +configuration to workspaces within a Terraform Cloud organization. Three strategies are available: -var workspaceConfigurationHelp = fmt.Sprintf(`The 'workspaces' block configures how Terraform CLI maps its workspaces for this -single configuration to workspaces within a Terraform Cloud organization. Two strategies are available: +[bold]tags[reset] - %s [bold]name[reset] - %s -[bold]prefix[reset] - %s -`, schemaDescriptions["name"], schemaDescriptions["prefix"]) +[bold]prefix[reset] - %s`, schemaDescriptionTags, schemaDescriptionName, schemaDescriptionPrefix) + + schemaDescriptionHostname = `The Terraform Enterprise hostname to connect to. This optional argument defaults to app.terraform.io +for use with Terraform Cloud.` + + schemaDescriptionOrganization = `The name of the organization containing the targeted workspace(s).` + + schemaDescriptionToken = `The token used to authenticate with Terraform Cloud/Enterprise. Typically this argument should not +be set, and 'terraform login' used instead; your credentials will then be fetched from your CLI +configuration file or configured credential helper.` + + schemaDescriptionTags = `A set of tags used to select remote Terraform Cloud workspaces to be used for this single +configuration. New workspaces will automatically be tagged with these tag values. Generally, this +is the primary and recommended strategy to use. This option conflicts with "prefix" and "name".` + + schemaDescriptionName = `The name of a single Terraform Cloud workspace to be used with this configuration When configured +only the specified workspace can be used. This option conflicts with "tags" and "prefix".` + + schemaDescriptionPrefix = `DEPRECATED. A name prefix used to select remote Terraform Cloud to be used for this single configuration. New +workspaces will automatically be prefixed with this prefix. This option conflicts with "tags" and "name".` +) diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index 945c56378..c5115d200 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -51,6 +51,7 @@ func TestCloud_PrepareConfig(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), expectedErr: `Invalid organization value: The "organization" attribute value must not be empty.`, @@ -60,17 +61,18 @@ func TestCloud_PrepareConfig(t *testing.T) { "organization": cty.StringVal("org"), "workspaces": cty.NullVal(cty.String), }), - expectedErr: `Invalid workspaces configuration: Either workspace "name" or "prefix" is required.`, + expectedErr: `Invalid workspaces configuration: Missing workspace mapping strategy. Either workspace "tags", "name", or "prefix" is required.`, }, - "workspace: empty name and empty prefix": { + "workspace: empty tags, name, and prefix": { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("org"), "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), - expectedErr: `Invalid workspaces configuration: Either workspace "name" or "prefix" is required.`, + expectedErr: `Invalid workspaces configuration: Missing workspace mapping strategy. Either workspace "tags", "name", or "prefix" is required.`, }, "workspace: name and prefix present": { config: cty.ObjectVal(map[string]cty.Value{ @@ -78,9 +80,25 @@ func TestCloud_PrepareConfig(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.StringVal("app-"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), - expectedErr: `Invalid workspaces configuration: Only one of workspace "name" or "prefix" is allowed.`, + expectedErr: `Invalid workspaces configuration: Only one of workspace "tags", "name", or "prefix" is allowed.`, + }, + "workspace: name and tags present": { + config: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("org"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("billing"), + }, + ), + }), + }), + expectedErr: `Invalid workspaces configuration: Only one of workspace "tags", "name", or "prefix" is allowed.`, }, } @@ -113,6 +131,7 @@ func TestCloud_config(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), confErr: "organization \"nonexisting\" at host app.terraform.io not found", @@ -125,6 +144,7 @@ func TestCloud_config(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), confErr: "Failed to request discovery document", @@ -138,10 +158,27 @@ func TestCloud_config(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), confErr: "terraform login localhost", }, + "with_tags": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "prefix": cty.NullVal(cty.String), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("billing"), + }, + ), + }), + }), + }, "with_a_name": { config: cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), @@ -150,6 +187,7 @@ func TestCloud_config(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), }, @@ -161,10 +199,11 @@ func TestCloud_config(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "prefix": cty.StringVal("my-app-"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), }, - "without_either_a_name_and_a_prefix": { + "without_a_name_prefix_or_tags": { config: cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), "organization": cty.StringVal("hashicorp"), @@ -172,9 +211,10 @@ func TestCloud_config(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), - valErr: `Either workspace "name" or "prefix" is required`, + valErr: `Missing workspace mapping strategy.`, }, "with_both_a_name_and_a_prefix": { config: cty.ObjectVal(map[string]cty.Value{ @@ -184,9 +224,27 @@ func TestCloud_config(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.StringVal("my-app-"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), - valErr: `Only one of workspace "name" or "prefix" is allowed`, + valErr: `Only one of workspace "tags", "name", or "prefix" is allowed.`, + }, + "with_both_a_name_and_tags": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("billing"), + }, + ), + }), + }), + valErr: `Only one of workspace "tags", "name", or "prefix" is allowed.`, }, "null config": { config: cty.NullVal(cty.EmptyObject), @@ -222,6 +280,7 @@ func TestCloud_setConfigurationFields(t *testing.T) { expectedOrganziation string expectedWorkspacePrefix string expectedWorkspaceName string + expectedWorkspaceTags []string expectedForceLocal bool setEnv func() resetEnv func() @@ -234,6 +293,7 @@ func TestCloud_setConfigurationFields(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), expectedHostname: "hashicorp.com", @@ -246,6 +306,7 @@ func TestCloud_setConfigurationFields(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), expectedHostname: defaultHostname, @@ -258,6 +319,7 @@ func TestCloud_setConfigurationFields(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), expectedHostname: "hashicorp.com", @@ -271,12 +333,31 @@ func TestCloud_setConfigurationFields(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "prefix": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), expectedHostname: "hashicorp.com", expectedOrganziation: "hashicorp", expectedWorkspacePrefix: "prod", }, + "with workspace tags set": { + obj: cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("hashicorp"), + "hostname": cty.StringVal("hashicorp.com"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "prefix": cty.NullVal(cty.String), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("billing"), + }, + ), + }), + }), + expectedHostname: "hashicorp.com", + expectedOrganziation: "hashicorp", + expectedWorkspaceTags: []string{"billing"}, + }, "with force local set": { obj: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("hashicorp"), @@ -284,6 +365,7 @@ func TestCloud_setConfigurationFields(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "prefix": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), expectedHostname: "hashicorp.com", @@ -317,16 +399,51 @@ func TestCloud_setConfigurationFields(t *testing.T) { } if tc.expectedHostname != "" && b.hostname != tc.expectedHostname { - t.Fatalf("%s: expected hostname %s to match actual hostname %s", name, tc.expectedHostname, b.hostname) + t.Fatalf("%s: expected hostname %s to match configured hostname %s", name, b.hostname, tc.expectedHostname) } if tc.expectedOrganziation != "" && b.organization != tc.expectedOrganziation { - t.Fatalf("%s: expected organization %s to match actual organization %s", name, tc.expectedOrganziation, b.organization) + t.Fatalf("%s: expected organization (%s) to match configured organization (%s)", name, b.organization, tc.expectedOrganziation) } if tc.expectedWorkspacePrefix != "" && b.workspaceMapping.prefix != tc.expectedWorkspacePrefix { - t.Fatalf("%s: expected workspace prefix %s to match actual workspace prefix %s", name, tc.expectedWorkspacePrefix, b.workspaceMapping.prefix) + t.Fatalf("%s: expected workspace prefix mapping (%s) to match configured workspace prefix (%s)", name, b.workspaceMapping.prefix, tc.expectedWorkspacePrefix) } if tc.expectedWorkspaceName != "" && b.workspaceMapping.name != tc.expectedWorkspaceName { - t.Fatalf("%s: expected workspace name %s to match actual workspace name %s", name, tc.expectedWorkspaceName, b.workspaceMapping.name) + t.Fatalf("%s: expected workspace name mapping (%s) to match configured workspace name (%s)", name, b.workspaceMapping.name, tc.expectedWorkspaceName) + } + if len(tc.expectedWorkspaceTags) > 0 { + presentSet := make(map[string]struct{}) + for _, tag := range b.workspaceMapping.tags { + presentSet[tag] = struct{}{} + } + + expectedSet := make(map[string]struct{}) + for _, tag := range tc.expectedWorkspaceTags { + expectedSet[tag] = struct{}{} + } + + var missing []string + var unexpected []string + + for _, expected := range tc.expectedWorkspaceTags { + if _, ok := presentSet[expected]; !ok { + missing = append(missing, expected) + } + } + + for _, actual := range b.workspaceMapping.tags { + if _, ok := expectedSet[actual]; !ok { + unexpected = append(missing, actual) + } + } + + if len(missing) > 0 { + t.Fatalf("%s: expected workspace tag mapping (%s) to contain the following tags: %s", name, b.workspaceMapping.tags, missing) + } + + if len(unexpected) > 0 { + t.Fatalf("%s: expected workspace tag mapping (%s) to NOT contain the following tags: %s", name, b.workspaceMapping.tags, unexpected) + } + } if tc.expectedForceLocal != false && b.forceLocal != tc.expectedForceLocal { t.Fatalf("%s: expected force local backend to be set ", name) @@ -349,6 +466,7 @@ func TestCloud_versionConstraints(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), version: "0.11.1", @@ -361,6 +479,7 @@ func TestCloud_versionConstraints(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), version: "0.0.1", @@ -374,6 +493,7 @@ func TestCloud_versionConstraints(t *testing.T) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), version: "10.0.1", diff --git a/internal/cloud/errors.go b/internal/cloud/errors.go index 273f8a157..81dfe7bb7 100644 --- a/internal/cloud/errors.go +++ b/internal/cloud/errors.go @@ -1,6 +1,8 @@ package cloud import ( + "fmt" + "github.com/hashicorp/terraform/internal/tfdiags" "github.com/zclconf/go-cty/cty" ) @@ -9,21 +11,21 @@ var ( invalidOrganizationConfigMissingValue = tfdiags.AttributeValue( tfdiags.Error, "Invalid organization value", - `The "organization" attribute value must not be empty.`, + `The "organization" attribute value must not be empty.\n\n%s`, cty.Path{cty.GetAttrStep{Name: "organization"}}, ) invalidWorkspaceConfigMissingValues = tfdiags.AttributeValue( tfdiags.Error, "Invalid workspaces configuration", - `Either workspace "name" or "prefix" is required.`, + fmt.Sprintf("Missing workspace mapping strategy. Either workspace \"tags\", \"name\", or \"prefix\" is required.\n\n%s", workspaceConfigurationHelp), cty.Path{cty.GetAttrStep{Name: "workspaces"}}, ) invalidWorkspaceConfigMisconfiguration = tfdiags.AttributeValue( tfdiags.Error, "Invalid workspaces configuration", - `Only one of workspace "name" or "prefix" is allowed.`, + fmt.Sprintf("Only one of workspace \"tags\", \"name\", or \"prefix\" is allowed.\n\n%s", workspaceConfigurationHelp), cty.Path{cty.GetAttrStep{Name: "workspaces"}}, ) ) diff --git a/internal/cloud/testing.go b/internal/cloud/testing.go index cfc8d3dce..726ba0595 100644 --- a/internal/cloud/testing.go +++ b/internal/cloud/testing.go @@ -72,6 +72,7 @@ func testBackendDefault(t *testing.T) (*Cloud, func()) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }) return testBackend(t, obj) @@ -85,6 +86,7 @@ func testBackendNoDefault(t *testing.T) (*Cloud, func()) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.NullVal(cty.String), "prefix": cty.StringVal("my-app-"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }) return testBackend(t, obj) @@ -98,6 +100,7 @@ func testBackendNoOperations(t *testing.T) (*Cloud, func()) { "workspaces": cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("prod"), "prefix": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }) return testBackend(t, obj) From 9bdf1d2579aa3afcd8ee18bbba009e3c0e3c8fe2 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Tue, 14 Sep 2021 15:20:04 -0500 Subject: [PATCH 16/78] Rename testBackendNoDefault "NoDefault" is now ambiguous with tags, and it does not imply using a prefix. --- internal/cloud/backend_apply_test.go | 8 ++++---- internal/cloud/backend_plan_test.go | 4 ++-- internal/cloud/backend_test.go | 6 +++--- internal/cloud/testing.go | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index 8fa2d9758..090f767cc 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -135,7 +135,7 @@ func TestCloud_applyCanceled(t *testing.T) { } func TestCloud_applyWithoutPermissions(t *testing.T) { - b, bCleanup := testBackendNoDefault(t) + b, bCleanup := testBackendWithPrefix(t) defer bCleanup() // Create a named workspace without permissions. @@ -175,7 +175,7 @@ func TestCloud_applyWithoutPermissions(t *testing.T) { } func TestCloud_applyWithVCS(t *testing.T) { - b, bCleanup := testBackendNoDefault(t) + b, bCleanup := testBackendWithPrefix(t) defer bCleanup() // Create a named workspace with a VCS. @@ -891,7 +891,7 @@ func TestCloud_applyDiscardedExternally(t *testing.T) { } func TestCloud_applyWithAutoApply(t *testing.T) { - b, bCleanup := testBackendNoDefault(t) + b, bCleanup := testBackendWithPrefix(t) defer bCleanup() // Create a named workspace that auto applies. @@ -1005,7 +1005,7 @@ func TestCloud_applyForceLocal(t *testing.T) { } func TestCloud_applyWorkspaceWithoutOperations(t *testing.T) { - b, bCleanup := testBackendNoDefault(t) + b, bCleanup := testBackendWithPrefix(t) defer bCleanup() ctx := context.Background() diff --git a/internal/cloud/backend_plan_test.go b/internal/cloud/backend_plan_test.go index 45c1958b2..a0a7ced71 100644 --- a/internal/cloud/backend_plan_test.go +++ b/internal/cloud/backend_plan_test.go @@ -153,7 +153,7 @@ func TestCloud_planLongLine(t *testing.T) { } func TestCloud_planWithoutPermissions(t *testing.T) { - b, bCleanup := testBackendNoDefault(t) + b, bCleanup := testBackendWithPrefix(t) defer bCleanup() // Create a named workspace without permissions. @@ -762,7 +762,7 @@ func TestCloud_planWithoutOperationsEntitlement(t *testing.T) { } func TestCloud_planWorkspaceWithoutOperations(t *testing.T) { - b, bCleanup := testBackendNoDefault(t) + b, bCleanup := testBackendWithPrefix(t) defer bCleanup() ctx := context.Background() diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index c5115d200..5580cc925 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -33,8 +33,8 @@ func TestCloud_backendDefault(t *testing.T) { backend.TestBackendStateForceUnlock(t, b, b) } -func TestCloud_backendNoDefault(t *testing.T) { - b, bCleanup := testBackendNoDefault(t) +func TestCloud_backendWithPrefix(t *testing.T) { + b, bCleanup := testBackendWithPrefix(t) defer bCleanup() backend.TestBackendStates(t, b) @@ -573,7 +573,7 @@ func TestCloud_addAndRemoveWorkspacesDefault(t *testing.T) { } func TestCloud_addAndRemoveWorkspacesNoDefault(t *testing.T) { - b, bCleanup := testBackendNoDefault(t) + b, bCleanup := testBackendWithPrefix(t) defer bCleanup() states, err := b.Workspaces() diff --git a/internal/cloud/testing.go b/internal/cloud/testing.go index 726ba0595..c5bee1c02 100644 --- a/internal/cloud/testing.go +++ b/internal/cloud/testing.go @@ -78,7 +78,7 @@ func testBackendDefault(t *testing.T) (*Cloud, func()) { return testBackend(t, obj) } -func testBackendNoDefault(t *testing.T) (*Cloud, func()) { +func testBackendWithPrefix(t *testing.T) (*Cloud, func()) { obj := cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), "organization": cty.StringVal("hashicorp"), From a97a1c8f66db4218f243d3f2b5dc59a9fc18a44b Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Tue, 14 Sep 2021 15:22:33 -0500 Subject: [PATCH 17/78] cloud.MockClient: Use Contains() to implement Search The TFC API doesn't behave this way; a search term just signals the substring was found within the name. --- internal/cloud/tfe_client_mock.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cloud/tfe_client_mock.go b/internal/cloud/tfe_client_mock.go index d575d82e8..2dea3096d 100644 --- a/internal/cloud/tfe_client_mock.go +++ b/internal/cloud/tfe_client_mock.go @@ -1103,7 +1103,7 @@ func (m *MockWorkspaces) List(ctx context.Context, organization string, options // Get all the workspaces that match the prefix. var ws []*tfe.Workspace for _, w := range m.workspaceIDs { - if strings.HasPrefix(w.Name, prefix) { + if strings.Contains(w.Name, prefix) { ws = append(ws, w) } } From 1791b711962a60e31233a979ca34dcef8843a182 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Wed, 15 Sep 2021 00:04:15 -0500 Subject: [PATCH 18/78] cloud: TestCloud_backendWithTags Implementing this test was quite a rabbithole, as in order to satisfy backendTestBackendStates() the workspaces returned from backend.Workspaces() must match exactly, and the shortcut taken to test pagination in 3cc58813f088fac3b9d008372052694ae1f75914 created an impossible circumstance that got plastered over with the fact that prefix filtering is done clientside, not by the API as it should be. Tagging does not rely on clientside filtering, and expects that the request made to the TFC API returns exactly those workspaces with the given tags. These changes include a better way to test pagination, wherein we actually create over a page worth of valid workspaces in the mock client and implement a simplified pagination behavior to match how the TFC API actually works. --- internal/backend/remote/backend_apply_test.go | 8 +- internal/cloud/backend_apply_test.go | 8 +- internal/cloud/backend_test.go | 24 ++++++ internal/cloud/testing.go | 18 +++++ internal/cloud/tfe_client_mock.go | 73 ++++++++++++------- 5 files changed, 91 insertions(+), 40 deletions(-) diff --git a/internal/backend/remote/backend_apply_test.go b/internal/backend/remote/backend_apply_test.go index 9b4286010..1f0d319bf 100644 --- a/internal/backend/remote/backend_apply_test.go +++ b/internal/backend/remote/backend_apply_test.go @@ -777,9 +777,7 @@ func TestRemote_applyApprovedExternally(t *testing.T) { wl, err := b.client.Workspaces.List( ctx, b.organization, - tfe.WorkspaceListOptions{ - ListOptions: tfe.ListOptions{PageNumber: 2, PageSize: 10}, - }, + tfe.WorkspaceListOptions{}, ) if err != nil { t.Fatalf("unexpected error listing workspaces: %v", err) @@ -853,9 +851,7 @@ func TestRemote_applyDiscardedExternally(t *testing.T) { wl, err := b.client.Workspaces.List( ctx, b.organization, - tfe.WorkspaceListOptions{ - ListOptions: tfe.ListOptions{PageNumber: 2, PageSize: 10}, - }, + tfe.WorkspaceListOptions{}, ) if err != nil { t.Fatalf("unexpected error listing workspaces: %v", err) diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index 090f767cc..99d74c1cc 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -767,9 +767,7 @@ func TestCloud_applyApprovedExternally(t *testing.T) { wl, err := b.client.Workspaces.List( ctx, b.organization, - tfe.WorkspaceListOptions{ - ListOptions: tfe.ListOptions{PageNumber: 2, PageSize: 10}, - }, + tfe.WorkspaceListOptions{}, ) if err != nil { t.Fatalf("unexpected error listing workspaces: %v", err) @@ -843,9 +841,7 @@ func TestCloud_applyDiscardedExternally(t *testing.T) { wl, err := b.client.Workspaces.List( ctx, b.organization, - tfe.WorkspaceListOptions{ - ListOptions: tfe.ListOptions{PageNumber: 2, PageSize: 10}, - }, + tfe.WorkspaceListOptions{}, ) if err != nil { t.Fatalf("unexpected error listing workspaces: %v", err) diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index 5580cc925..928182b53 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -40,6 +40,30 @@ func TestCloud_backendWithPrefix(t *testing.T) { backend.TestBackendStates(t, b) } +func TestCloud_backendWithTags(t *testing.T) { + b, bCleanup := testBackendWithTags(t) + defer bCleanup() + + backend.TestBackendStates(t, b) + + // Test pagination works + for i := 0; i < 25; i++ { + _, err := b.StateMgr(fmt.Sprintf("foo-%d", i+1)) + if err != nil { + t.Fatalf("error: %s", err) + } + } + + workspaces, err := b.Workspaces() + if err != nil { + t.Fatalf("error: %s", err) + } + actual := len(workspaces) + if actual != 26 { + t.Errorf("expected 26 workspaces (over one standard paginated response), got %d", actual) + } +} + func TestCloud_PrepareConfig(t *testing.T) { cases := map[string]struct { config cty.Value diff --git a/internal/cloud/testing.go b/internal/cloud/testing.go index c5bee1c02..d289a42c2 100644 --- a/internal/cloud/testing.go +++ b/internal/cloud/testing.go @@ -92,6 +92,24 @@ func testBackendWithPrefix(t *testing.T) (*Cloud, func()) { return testBackend(t, obj) } +func testBackendWithTags(t *testing.T) (*Cloud, func()) { + obj := cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "prefix": cty.NullVal(cty.String), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("billing"), + }, + ), + }), + }) + return testBackend(t, obj) +} + func testBackendNoOperations(t *testing.T) (*Cloud, func()) { obj := cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), diff --git a/internal/cloud/tfe_client_mock.go b/internal/cloud/tfe_client_mock.go index 2dea3096d..fd93c48d9 100644 --- a/internal/cloud/tfe_client_mock.go +++ b/internal/cloud/tfe_client_mock.go @@ -1091,20 +1091,36 @@ func newMockWorkspaces(client *MockClient) *MockWorkspaces { } func (m *MockWorkspaces) List(ctx context.Context, organization string, options tfe.WorkspaceListOptions) (*tfe.WorkspaceList, error) { - dummyWorkspaces := 10 wl := &tfe.WorkspaceList{} - // Get the prefix from the search options. - prefix := "" + // Get all the workspaces that match the Search value + searchValue := "" if options.Search != nil { - prefix = *options.Search + searchValue = *options.Search } - // Get all the workspaces that match the prefix. var ws []*tfe.Workspace + var tags []string + + if options.Tags != nil { + tags = strings.Split(*options.Tags, ",") + } for _, w := range m.workspaceIDs { - if strings.Contains(w.Name, prefix) { - ws = append(ws, w) + wTags := make(map[string]struct{}) + for _, wTag := range w.Tags { + wTags[wTag.Name] = struct{}{} + } + + if strings.Contains(w.Name, searchValue) { + tagsSatisfied := true + for _, tag := range tags { + if _, ok := wTags[tag]; !ok { + tagsSatisfied = false + } + } + if tagsSatisfied { + ws = append(ws, w) + } } } @@ -1116,32 +1132,27 @@ func (m *MockWorkspaces) List(ctx context.Context, organization string, options return wl, nil } - // Return dummy workspaces for the first page to test pagination. - if options.PageNumber <= 1 { - for i := 0; i < dummyWorkspaces; i++ { - wl.Items = append(wl.Items, &tfe.Workspace{ - ID: GenerateID("ws-"), - Name: fmt.Sprintf("dummy-workspace-%d", i), - }) - } + numPages := (len(ws) / 20) + 1 + currentPage := 1 + if options.PageNumber != 0 { + currentPage = options.PageNumber + } + previousPage := currentPage - 1 + nextPage := currentPage + 1 - wl.Pagination = &tfe.Pagination{ - CurrentPage: 1, - NextPage: 2, - TotalPages: 2, - TotalCount: len(wl.Items) + len(ws), + for i := ((currentPage - 1) * 20); i < ((currentPage-1)*20)+20; i++ { + if i > (len(ws) - 1) { + break } - - return wl, nil + wl.Items = append(wl.Items, ws[i]) } - // Return the actual workspaces that matched as the second page. - wl.Items = ws wl.Pagination = &tfe.Pagination{ - CurrentPage: 2, - PreviousPage: 1, - TotalPages: 2, - TotalCount: len(wl.Items) + dummyWorkspaces, + CurrentPage: currentPage, + NextPage: nextPage, + PreviousPage: previousPage, + TotalPages: numPages, + TotalCount: len(wl.Items), } return wl, nil @@ -1173,6 +1184,12 @@ func (m *MockWorkspaces) Create(ctx context.Context, organization string, option } else { w.TerraformVersion = tfversion.String() } + var tags []*tfe.Tag + for _, tag := range options.Tags { + tags = append(tags, tag) + w.TagNames = append(w.TagNames, tag.Name) + } + w.Tags = tags m.workspaceIDs[w.ID] = w m.workspaceNames[w.Name] = w return w, nil From 594a390f848833298c75ce7096e173e23d397de0 Mon Sep 17 00:00:00 2001 From: Omar Ismail Date: Wed, 22 Sep 2021 17:59:47 -0400 Subject: [PATCH 19/78] Use context parallelism --- internal/cloud/backend_apply.go | 2 +- internal/cloud/backend_apply_test.go | 6 ++++-- internal/cloud/backend_plan.go | 2 +- internal/cloud/backend_plan_test.go | 6 ++++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/internal/cloud/backend_apply.go b/internal/cloud/backend_apply.go index b52719593..74d167673 100644 --- a/internal/cloud/backend_apply.go +++ b/internal/cloud/backend_apply.go @@ -42,7 +42,7 @@ func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operatio return nil, diags.Err() } - if op.Parallelism != defaultParallelism { + if b.ContextOpts != nil && b.ContextOpts.Parallelism != defaultParallelism { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Custom parallelism values are currently not supported", diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index 99d74c1cc..629074e0a 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -46,7 +46,6 @@ func testOperationApplyWithTimeout(t *testing.T, configDir string, timeout time. return &backend.Operation{ ConfigDir: configDir, ConfigLoader: configLoader, - Parallelism: defaultParallelism, PlanRefresh: true, StateLocker: clistate.NewLocker(timeout, stateLockerView), Type: backend.OperationTypeApply, @@ -223,7 +222,10 @@ func TestCloud_applyWithParallelism(t *testing.T) { op, configCleanup, done := testOperationApply(t, "./testdata/apply") defer configCleanup() - op.Parallelism = 3 + if b.ContextOpts == nil { + b.ContextOpts = &terraform.ContextOpts{} + } + b.ContextOpts.Parallelism = 3 op.Workspace = backend.DefaultStateName run, err := b.Operation(context.Background(), op) diff --git a/internal/cloud/backend_plan.go b/internal/cloud/backend_plan.go index 3e3916b36..693210d4f 100644 --- a/internal/cloud/backend_plan.go +++ b/internal/cloud/backend_plan.go @@ -38,7 +38,7 @@ func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation return nil, diags.Err() } - if op.Parallelism != defaultParallelism { + if b.ContextOpts != nil && b.ContextOpts.Parallelism != defaultParallelism { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Custom parallelism values are currently not supported", diff --git a/internal/cloud/backend_plan_test.go b/internal/cloud/backend_plan_test.go index a0a7ced71..1dcfdeb07 100644 --- a/internal/cloud/backend_plan_test.go +++ b/internal/cloud/backend_plan_test.go @@ -44,7 +44,6 @@ func testOperationPlanWithTimeout(t *testing.T, configDir string, timeout time.D return &backend.Operation{ ConfigDir: configDir, ConfigLoader: configLoader, - Parallelism: defaultParallelism, PlanRefresh: true, StateLocker: clistate.NewLocker(timeout, stateLockerView), Type: backend.OperationTypePlan, @@ -198,7 +197,10 @@ func TestCloud_planWithParallelism(t *testing.T) { op, configCleanup, done := testOperationPlan(t, "./testdata/plan") defer configCleanup() - op.Parallelism = 3 + if b.ContextOpts == nil { + b.ContextOpts = &terraform.ContextOpts{} + } + b.ContextOpts.Parallelism = 3 op.Workspace = backend.DefaultStateName run, err := b.Operation(context.Background(), op) From a94b2405b18b70d6ea0982540769b6d9b36a0911 Mon Sep 17 00:00:00 2001 From: Omar Ismail Date: Wed, 22 Sep 2021 18:12:51 -0400 Subject: [PATCH 20/78] static checker --- internal/cloud/backend.go | 3 ++- internal/cloud/backend_apply_test.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 9d7d2e5fc..09a9995c7 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -922,7 +922,7 @@ func (b *Cloud) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Di // If the workspace has remote operations disabled, the remote Terraform // version is effectively meaningless, so we'll skip version verification. - if workspace.Operations == false { + if !workspace.Operations { return nil } @@ -1002,6 +1002,7 @@ func (b *Cloud) IsLocalOperations() bool { // as a helper to wrap any potentially colored strings. // // TODO SvH: Rename this back to Colorize as soon as we can pass -no-color. +//lint:ignore U1000 see above todo func (b *Cloud) cliColorize() *colorstring.Colorize { if b.CLIColor != nil { return b.CLIColor diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index 629074e0a..1af4b4726 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -1592,8 +1592,9 @@ func TestCloud_applyVersionCheck(t *testing.T) { } // RUN: prepare the apply operation and run it - op, configCleanup, done := testOperationApply(t, "./testdata/apply") + op, configCleanup, opDone := testOperationApply(t, "./testdata/apply") defer configCleanup() + defer opDone(t) streams, done := terminal.StreamsForTesting(t) view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) From d2b6b5f2b392894e7241231f5eaa13b9a2811020 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Mon, 20 Sep 2021 13:07:53 -0500 Subject: [PATCH 21/78] cloud: Align local and remote workspace name with 'name' strategy This changes the 'name' strategy to always align the local configured workspace name and the remote Terraform Cloud workspace, rather than the implicit use of the 'default' unnamed workspace being used instead. What this essentially means is that the Cloud integration does not fully support workspaces when configured for a single TFC workspace (as was the case with the 'remote' backend), but *does* use the backend.Workspaces() interface to allow for normal local behaviors like terraform.workspace to resolve to the correct name. It does this by always setting the local workspace name when the 'name' strategy is used, as a part of initialization. Part of the diff here is exporting all the previously unexported types for mapping workspaces. The command package (and init in particular) needs to be able to handle setting the local workspace in this particular scenario. --- internal/cloud/backend.go | 157 ++++++++++++------------- internal/cloud/backend_apply_test.go | 78 ++++++------ internal/cloud/backend_context.go | 6 +- internal/cloud/backend_context_test.go | 8 +- internal/cloud/backend_plan_test.go | 72 ++++++------ internal/cloud/backend_state_test.go | 5 +- internal/cloud/backend_test.go | 79 +++++++------ internal/cloud/testing.go | 11 +- 8 files changed, 205 insertions(+), 211 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 09a9995c7..22adb98d1 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -64,9 +64,9 @@ type Cloud struct { // organization is the organization that contains the target workspaces. organization string - // workspaceMapping contains strategies for mapping CLI workspaces in the working directory + // WorkspaceMapping contains strategies for mapping CLI workspaces in the working directory // to remote Terraform Cloud workspaces. - workspaceMapping workspaceMapping + WorkspaceMapping WorkspaceMapping // services is used for service discovery services *disco.Disco @@ -157,28 +157,28 @@ func (b *Cloud) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) { diags = diags.Append(invalidOrganizationConfigMissingValue) } - workspaceMapping := workspaceMapping{} + WorkspaceMapping := WorkspaceMapping{} if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() { if val := workspaces.GetAttr("name"); !val.IsNull() { - workspaceMapping.name = val.AsString() + WorkspaceMapping.Name = val.AsString() } if val := workspaces.GetAttr("prefix"); !val.IsNull() { - workspaceMapping.prefix = val.AsString() + WorkspaceMapping.Prefix = val.AsString() } if val := workspaces.GetAttr("tags"); !val.IsNull() { - err := gocty.FromCtyValue(val, &workspaceMapping.tags) + err := gocty.FromCtyValue(val, &WorkspaceMapping.Tags) if err != nil { log.Panicf("An unxpected error occurred: %s", err) } } } - switch workspaceMapping.strategy() { + switch WorkspaceMapping.Strategy() { // Make sure have a workspace mapping strategy present - case workspaceNoneStrategy: + case WorkspaceNoneStrategy: diags = diags.Append(invalidWorkspaceConfigMissingValues) // Make sure that only one of workspace name or a prefix is configured. - case workspaceInvalidStrategy: + case WorkspaceInvalidStrategy: diags = diags.Append(invalidWorkspaceConfigMisconfiguration) } @@ -335,10 +335,10 @@ func (b *Cloud) setConfigurationFields(obj cty.Value) tfdiags.Diagnostics { // PrepareConfig checks that you cannot set both of these. if val := workspaces.GetAttr("name"); !val.IsNull() { - b.workspaceMapping.name = val.AsString() + b.WorkspaceMapping.Name = val.AsString() } if val := workspaces.GetAttr("prefix"); !val.IsNull() { - b.workspaceMapping.prefix = val.AsString() + b.WorkspaceMapping.Prefix = val.AsString() } if val := workspaces.GetAttr("tags"); !val.IsNull() { var tags []string @@ -347,7 +347,7 @@ func (b *Cloud) setConfigurationFields(obj cty.Value) tfdiags.Diagnostics { log.Panicf("An unxpected error occurred: %s", err) } - b.workspaceMapping.tags = tags + b.WorkspaceMapping.Tags = tags } } @@ -530,31 +530,30 @@ func (b *Cloud) retryLogHook(attemptNum int, resp *http.Response) { } } -// Workspaces implements backend.Enhanced. +// Workspaces implements backend.Enhanced, returning a filtered list of workspace names according to +// the workspace mapping strategy configured. func (b *Cloud) Workspaces() ([]string, error) { - if b.workspaceMapping.strategy() == workspaceNameStrategy { - return nil, backend.ErrWorkspacesNotSupported - } - return b.workspaces() -} - -// workspaces returns a filtered list of remote workspace names according to the workspace mapping -// strategy configured. -func (b *Cloud) workspaces() ([]string, error) { - options := tfe.WorkspaceListOptions{} - switch b.workspaceMapping.strategy() { - case workspaceNameStrategy: - options.Search = tfe.String(b.workspaceMapping.name) - case workspacePrefixStrategy: - options.Search = tfe.String(b.workspaceMapping.prefix) - case workspaceTagsStrategy: - taglist := strings.Join(b.workspaceMapping.tags, ",") - options.Tags = &taglist - } - // Create a slice to contain all the names. var names []string + // If configured for a single workspace, return that exact name only. The StateMgr for this + // backend will automatically create the remote workspace if it does not yet exist. + if b.WorkspaceMapping.Strategy() == WorkspaceNameStrategy { + names = append(names, b.WorkspaceMapping.Name) + return names, nil + } + + // Otherwise, multiple workspaces are being mapped. Query Terraform Cloud for all the remote + // workspaces by the provided mapping strategy. + options := tfe.WorkspaceListOptions{} + switch b.WorkspaceMapping.Strategy() { + case WorkspacePrefixStrategy: + options.Search = tfe.String(b.WorkspaceMapping.Prefix) + case WorkspaceTagsStrategy: + taglist := strings.Join(b.WorkspaceMapping.Tags, ",") + options.Tags = &taglist + } + for { wl, err := b.client.Workspaces.List(context.Background(), b.organization, options) if err != nil { @@ -562,20 +561,15 @@ func (b *Cloud) workspaces() ([]string, error) { } for _, w := range wl.Items { - switch b.workspaceMapping.strategy() { - case workspaceNameStrategy: - if w.Name == b.workspaceMapping.name { - names = append(names, backend.DefaultStateName) - continue - } - case workspacePrefixStrategy: - if strings.HasPrefix(w.Name, b.workspaceMapping.prefix) { - names = append(names, strings.TrimPrefix(w.Name, b.workspaceMapping.prefix)) + switch b.WorkspaceMapping.Strategy() { + case WorkspacePrefixStrategy: + if strings.HasPrefix(w.Name, b.WorkspaceMapping.Prefix) { + names = append(names, strings.TrimPrefix(w.Name, b.WorkspaceMapping.Prefix)) continue } default: - // Pass-through. "name" and "prefix" strategies are naive and do - // client-side filtering above, but for tags and any other future + // Pass-through. The "prefix" strategy is naive and does + // client-side filtering, but for tags and any other future // strategy this filtering should be left to the API. names = append(names, w.Name) } @@ -598,19 +592,18 @@ func (b *Cloud) workspaces() ([]string, error) { // DeleteWorkspace implements backend.Enhanced. func (b *Cloud) DeleteWorkspace(name string) error { - if b.workspaceMapping.strategy() != workspaceNameStrategy && name == backend.DefaultStateName { + if name == backend.DefaultStateName { return backend.ErrDefaultWorkspaceNotSupported } - if b.workspaceMapping.strategy() == workspaceNameStrategy && name != backend.DefaultStateName { + + if b.WorkspaceMapping.Strategy() == WorkspaceNameStrategy { return backend.ErrWorkspacesNotSupported } // Configure the remote workspace name. switch { - case name == backend.DefaultStateName: - name = b.workspaceMapping.name - case b.workspaceMapping.strategy() == workspacePrefixStrategy && !strings.HasPrefix(name, b.workspaceMapping.prefix): - name = b.workspaceMapping.prefix + name + case b.WorkspaceMapping.Strategy() == WorkspacePrefixStrategy && !strings.HasPrefix(name, b.WorkspaceMapping.Prefix): + name = b.WorkspaceMapping.Prefix + name } client := &remoteClient{ @@ -626,19 +619,17 @@ func (b *Cloud) DeleteWorkspace(name string) error { // StateMgr implements backend.Enhanced. func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { - if b.workspaceMapping.strategy() != workspaceNameStrategy && name == backend.DefaultStateName { + if name == backend.DefaultStateName { return nil, backend.ErrDefaultWorkspaceNotSupported } - if b.workspaceMapping.strategy() == workspaceNameStrategy && name != backend.DefaultStateName { + + if b.WorkspaceMapping.Strategy() == WorkspaceNameStrategy && name != b.WorkspaceMapping.Name { return nil, backend.ErrWorkspacesNotSupported } - // Configure the remote workspace name. - switch { - case name == backend.DefaultStateName: - name = b.workspaceMapping.name - case b.workspaceMapping.strategy() == workspacePrefixStrategy && !strings.HasPrefix(name, b.workspaceMapping.prefix): - name = b.workspaceMapping.prefix + name + // If the prefix strategy is used, translate the local name to the TFC workspace name. + if b.WorkspaceMapping.Strategy() == WorkspacePrefixStrategy { + name = b.WorkspaceMapping.Prefix + name } workspace, err := b.client.Workspaces.Read(context.Background(), b.organization, name) @@ -652,7 +643,7 @@ func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { } var tags []*tfe.Tag - for _, tag := range b.workspaceMapping.tags { + for _, tag := range b.WorkspaceMapping.Tags { t := tfe.Tag{Name: tag} tags = append(tags, &t) } @@ -698,13 +689,11 @@ func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { // Operation implements backend.Enhanced. func (b *Cloud) Operation(ctx context.Context, op *backend.Operation) (*backend.RunningOperation, error) { - // Get the remote workspace name. name := op.Workspace - switch { - case op.Workspace == backend.DefaultStateName: - name = b.workspaceMapping.name - case b.workspaceMapping.strategy() == workspacePrefixStrategy && !strings.HasPrefix(op.Workspace, b.workspaceMapping.prefix): - name = b.workspaceMapping.prefix + op.Workspace + + // If the prefix strategy is used, translate the local name to the TFC workspace name. + if b.WorkspaceMapping.Strategy() == WorkspacePrefixStrategy { + name = b.WorkspaceMapping.Prefix + op.Workspace } // Retrieve the workspace for this operation. @@ -1014,35 +1003,35 @@ func (b *Cloud) cliColorize() *colorstring.Colorize { } } -type workspaceMapping struct { - name string - prefix string - tags []string +type WorkspaceMapping struct { + Name string + Prefix string + Tags []string } type workspaceStrategy string const ( - workspaceTagsStrategy workspaceStrategy = "tags" - workspaceNameStrategy workspaceStrategy = "name" - workspacePrefixStrategy workspaceStrategy = "prefix" - workspaceNoneStrategy workspaceStrategy = "none" - workspaceInvalidStrategy workspaceStrategy = "invalid" + WorkspaceTagsStrategy workspaceStrategy = "tags" + WorkspaceNameStrategy workspaceStrategy = "name" + WorkspacePrefixStrategy workspaceStrategy = "prefix" + WorkspaceNoneStrategy workspaceStrategy = "none" + WorkspaceInvalidStrategy workspaceStrategy = "invalid" ) -func (wm workspaceMapping) strategy() workspaceStrategy { +func (wm WorkspaceMapping) Strategy() workspaceStrategy { switch { - case len(wm.tags) > 0 && wm.name == "" && wm.prefix == "": - return workspaceTagsStrategy - case len(wm.tags) == 0 && wm.name != "" && wm.prefix == "": - return workspaceNameStrategy - case len(wm.tags) == 0 && wm.name == "" && wm.prefix != "": - return workspacePrefixStrategy - case len(wm.tags) == 0 && wm.name == "" && wm.prefix == "": - return workspaceNoneStrategy + case len(wm.Tags) > 0 && wm.Name == "" && wm.Prefix == "": + return WorkspaceTagsStrategy + case len(wm.Tags) == 0 && wm.Name != "" && wm.Prefix == "": + return WorkspaceNameStrategy + case len(wm.Tags) == 0 && wm.Name == "" && wm.Prefix != "": + return WorkspacePrefixStrategy + case len(wm.Tags) == 0 && wm.Name == "" && wm.Prefix == "": + return WorkspaceNoneStrategy default: // Any other combination is invalid as each strategy is mutually exclusive - return workspaceInvalidStrategy + return WorkspaceInvalidStrategy } } diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index 1af4b4726..9f464c3f3 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -67,7 +67,7 @@ func TestCloud_applyBasic(t *testing.T) { op.UIIn = input op.UIOut = b.CLI - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -97,7 +97,7 @@ func TestCloud_applyBasic(t *testing.T) { t.Fatalf("expected apply summery in output: %s", output) } - stateMgr, _ := b.StateMgr(backend.DefaultStateName) + stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName) // An error suggests that the state was not unlocked after apply if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { t.Fatalf("unexpected error locking state after apply: %s", err.Error()) @@ -112,7 +112,7 @@ func TestCloud_applyCanceled(t *testing.T) { defer configCleanup() defer done(t) - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -127,7 +127,7 @@ func TestCloud_applyCanceled(t *testing.T) { t.Fatal("expected apply operation to fail") } - stateMgr, _ := b.StateMgr(backend.DefaultStateName) + stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName) if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { t.Fatalf("unexpected error locking state after cancelling apply: %s", err.Error()) } @@ -142,7 +142,7 @@ func TestCloud_applyWithoutPermissions(t *testing.T) { context.Background(), b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.workspaceMapping.prefix + "prod"), + Name: tfe.String(b.WorkspaceMapping.Prefix + "prod"), }, ) if err != nil { @@ -182,7 +182,7 @@ func TestCloud_applyWithVCS(t *testing.T) { context.Background(), b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.workspaceMapping.prefix + "prod"), + Name: tfe.String(b.WorkspaceMapping.Prefix + "prod"), VCSRepo: &tfe.VCSRepoOptions{}, }, ) @@ -226,7 +226,7 @@ func TestCloud_applyWithParallelism(t *testing.T) { b.ContextOpts = &terraform.ContextOpts{} } b.ContextOpts.Parallelism = 3 - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -253,7 +253,7 @@ func TestCloud_applyWithPlan(t *testing.T) { defer configCleanup() op.PlanFile = &planfile.Reader{} - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -284,7 +284,7 @@ func TestCloud_applyWithoutRefresh(t *testing.T) { defer done(t) op.PlanRefresh = false - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -322,7 +322,7 @@ func TestCloud_applyWithoutRefreshIncompatibleAPIVersion(t *testing.T) { b.client.SetFakeRemoteAPIVersion("2.3") op.PlanRefresh = false - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -353,7 +353,7 @@ func TestCloud_applyWithRefreshOnly(t *testing.T) { defer done(t) op.PlanMode = plans.RefreshOnlyMode - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -391,7 +391,7 @@ func TestCloud_applyWithRefreshOnlyIncompatibleAPIVersion(t *testing.T) { b.client.SetFakeRemoteAPIVersion("2.3") op.PlanMode = plans.RefreshOnlyMode - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -424,7 +424,7 @@ func TestCloud_applyWithTarget(t *testing.T) { addr, _ := addrs.ParseAbsResourceStr("null_resource.foo") op.Targets = []addrs.Targetable{addr} - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -466,7 +466,7 @@ func TestCloud_applyWithTargetIncompatibleAPIVersion(t *testing.T) { addr, _ := addrs.ParseAbsResourceStr("null_resource.foo") op.Targets = []addrs.Targetable{addr} - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -499,7 +499,7 @@ func TestCloud_applyWithReplace(t *testing.T) { addr, _ := addrs.ParseAbsResourceInstanceStr("null_resource.foo") op.ForceReplace = []addrs.AbsResourceInstance{addr} - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -539,7 +539,7 @@ func TestCloud_applyWithReplaceIncompatibleAPIVersion(t *testing.T) { addr, _ := addrs.ParseAbsResourceInstanceStr("null_resource.foo") op.ForceReplace = []addrs.AbsResourceInstance{addr} - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -569,7 +569,7 @@ func TestCloud_applyWithVariables(t *testing.T) { defer configCleanup() op.Variables = testVariables(terraform.ValueFromNamedFile, "foo", "bar") - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -595,7 +595,7 @@ func TestCloud_applyNoConfig(t *testing.T) { op, configCleanup, done := testOperationApply(t, "./testdata/empty") defer configCleanup() - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -616,7 +616,7 @@ func TestCloud_applyNoConfig(t *testing.T) { t.Fatalf("expected configuration files error, got: %v", errOutput) } - stateMgr, _ := b.StateMgr(backend.DefaultStateName) + stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName) // An error suggests that the state was not unlocked after apply if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { t.Fatalf("unexpected error locking state after failed apply: %s", err.Error()) @@ -631,7 +631,7 @@ func TestCloud_applyNoChanges(t *testing.T) { defer configCleanup() defer done(t) - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -668,7 +668,7 @@ func TestCloud_applyNoApprove(t *testing.T) { op.UIIn = input op.UIOut = b.CLI - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -709,7 +709,7 @@ func TestCloud_applyAutoApprove(t *testing.T) { op.AutoApprove = true op.UIIn = input op.UIOut = b.CLI - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -754,7 +754,7 @@ func TestCloud_applyApprovedExternally(t *testing.T) { op.UIIn = input op.UIOut = b.CLI - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName ctx := context.Background() @@ -828,7 +828,7 @@ func TestCloud_applyDiscardedExternally(t *testing.T) { op.UIIn = input op.UIOut = b.CLI - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName ctx := context.Background() @@ -898,7 +898,7 @@ func TestCloud_applyWithAutoApply(t *testing.T) { b.organization, tfe.WorkspaceCreateOptions{ AutoApply: tfe.Bool(true), - Name: tfe.String(b.workspaceMapping.prefix + "prod"), + Name: tfe.String(b.WorkspaceMapping.Prefix + "prod"), }, ) if err != nil { @@ -967,7 +967,7 @@ func TestCloud_applyForceLocal(t *testing.T) { op.UIIn = input op.UIOut = b.CLI - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName streams, done := terminal.StreamsForTesting(t) view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) @@ -1013,7 +1013,7 @@ func TestCloud_applyWorkspaceWithoutOperations(t *testing.T) { ctx, b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.workspaceMapping.prefix + "no-operations"), + Name: tfe.String(b.WorkspaceMapping.Prefix + "no-operations"), }, ) if err != nil { @@ -1072,7 +1072,7 @@ func TestCloud_applyLockTimeout(t *testing.T) { ctx := context.Background() // Retrieve the workspace used to run this operation in. - w, err := b.client.Workspaces.Read(ctx, b.organization, b.workspaceMapping.name) + w, err := b.client.Workspaces.Read(ctx, b.organization, b.WorkspaceMapping.Name) if err != nil { t.Fatalf("error retrieving workspace: %v", err) } @@ -1103,7 +1103,7 @@ func TestCloud_applyLockTimeout(t *testing.T) { op.UIIn = input op.UIOut = b.CLI - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName _, err = b.Operation(context.Background(), op) if err != nil { @@ -1154,7 +1154,7 @@ func TestCloud_applyDestroy(t *testing.T) { op.PlanMode = plans.DestroyMode op.UIIn = input op.UIOut = b.CLI - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -1200,7 +1200,7 @@ func TestCloud_applyDestroyNoConfig(t *testing.T) { op.PlanMode = plans.DestroyMode op.UIIn = input op.UIOut = b.CLI - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -1234,7 +1234,7 @@ func TestCloud_applyPolicyPass(t *testing.T) { op.UIIn = input op.UIOut = b.CLI - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -1281,7 +1281,7 @@ func TestCloud_applyPolicyHardFail(t *testing.T) { op.UIIn = input op.UIOut = b.CLI - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -1337,7 +1337,7 @@ func TestCloud_applyPolicySoftFail(t *testing.T) { op.AutoApprove = false op.UIIn = input op.UIOut = b.CLI - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -1383,7 +1383,7 @@ func TestCloud_applyPolicySoftFailAutoApproveSuccess(t *testing.T) { op.AutoApprove = true op.UIIn = input op.UIOut = b.CLI - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -1432,7 +1432,7 @@ func TestCloud_applyPolicySoftFailAutoApply(t *testing.T) { b.organization, tfe.WorkspaceCreateOptions{ AutoApply: tfe.Bool(true), - Name: tfe.String(b.workspaceMapping.prefix + "prod"), + Name: tfe.String(b.WorkspaceMapping.Prefix + "prod"), }, ) if err != nil { @@ -1492,7 +1492,7 @@ func TestCloud_applyWithRemoteError(t *testing.T) { defer configCleanup() defer done(t) - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -1581,7 +1581,7 @@ func TestCloud_applyVersionCheck(t *testing.T) { _, err := b.client.Workspaces.Update( ctx, b.organization, - b.workspaceMapping.name, + b.WorkspaceMapping.Name, tfe.WorkspaceUpdateOptions{ Operations: tfe.Bool(tc.hasOperations), TerraformVersion: tfe.String(tc.remoteVersion), @@ -1606,7 +1606,7 @@ func TestCloud_applyVersionCheck(t *testing.T) { op.UIIn = input op.UIOut = b.CLI - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(ctx, op) if err != nil { diff --git a/internal/cloud/backend_context.go b/internal/cloud/backend_context.go index 32a1e52a2..95c80c5cb 100644 --- a/internal/cloud/backend_context.go +++ b/internal/cloud/backend_context.go @@ -143,9 +143,9 @@ func (b *Cloud) getRemoteWorkspaceName(localWorkspaceName string) string { // The default workspace name is a special case, for when the backend // is configured to with to an exact remote workspace rather than with // a remote workspace _prefix_. - return b.workspaceMapping.name - case b.workspaceMapping.prefix != "" && !strings.HasPrefix(localWorkspaceName, b.workspaceMapping.prefix): - return b.workspaceMapping.prefix + localWorkspaceName + return b.WorkspaceMapping.Name + case b.WorkspaceMapping.Prefix != "" && !strings.HasPrefix(localWorkspaceName, b.WorkspaceMapping.Prefix): + return b.WorkspaceMapping.Prefix + localWorkspaceName default: return localWorkspaceName } diff --git a/internal/cloud/backend_context_test.go b/internal/cloud/backend_context_test.go index 683ad98eb..7a7668a83 100644 --- a/internal/cloud/backend_context_test.go +++ b/internal/cloud/backend_context_test.go @@ -182,7 +182,7 @@ func TestRemoteContextWithVars(t *testing.T) { _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir) defer configCleanup() - workspaceID, err := b.getRemoteWorkspaceID(context.Background(), backend.DefaultStateName) + workspaceID, err := b.getRemoteWorkspaceID(context.Background(), testBackendSingleWorkspaceName) if err != nil { t.Fatal(err) } @@ -194,7 +194,7 @@ func TestRemoteContextWithVars(t *testing.T) { ConfigDir: configDir, ConfigLoader: configLoader, StateLocker: clistate.NewLocker(0, view), - Workspace: backend.DefaultStateName, + Workspace: testBackendSingleWorkspaceName, } v := test.Opts @@ -216,7 +216,7 @@ func TestRemoteContextWithVars(t *testing.T) { } // When Context() returns an error, it should unlock the state, // so re-locking it is expected to succeed. - stateMgr, _ := b.StateMgr(backend.DefaultStateName) + stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName) if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { t.Fatalf("unexpected error locking state: %s", err.Error()) } @@ -225,7 +225,7 @@ func TestRemoteContextWithVars(t *testing.T) { t.Fatalf("unexpected error\ngot: %s\nwant: ", diags.Err().Error()) } // When Context() succeeds, this should fail w/ "workspace already locked" - stateMgr, _ := b.StateMgr(backend.DefaultStateName) + stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName) if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err == nil { t.Fatal("unexpected success locking state after Context") } diff --git a/internal/cloud/backend_plan_test.go b/internal/cloud/backend_plan_test.go index 1dcfdeb07..a76841f2b 100644 --- a/internal/cloud/backend_plan_test.go +++ b/internal/cloud/backend_plan_test.go @@ -59,7 +59,7 @@ func TestCloud_planBasic(t *testing.T) { defer configCleanup() defer done(t) - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -82,7 +82,7 @@ func TestCloud_planBasic(t *testing.T) { t.Fatalf("expected plan summary in output: %s", output) } - stateMgr, _ := b.StateMgr(backend.DefaultStateName) + stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName) // An error suggests that the state was not unlocked after the operation finished if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { t.Fatalf("unexpected error locking state after successful plan: %s", err.Error()) @@ -97,7 +97,7 @@ func TestCloud_planCanceled(t *testing.T) { defer configCleanup() defer done(t) - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -112,7 +112,7 @@ func TestCloud_planCanceled(t *testing.T) { t.Fatal("expected plan operation to fail") } - stateMgr, _ := b.StateMgr(backend.DefaultStateName) + stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName) // An error suggests that the state was not unlocked after the operation finished if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { t.Fatalf("unexpected error locking state after cancelled plan: %s", err.Error()) @@ -127,7 +127,7 @@ func TestCloud_planLongLine(t *testing.T) { defer configCleanup() defer done(t) - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -160,7 +160,7 @@ func TestCloud_planWithoutPermissions(t *testing.T) { context.Background(), b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.workspaceMapping.prefix + "prod"), + Name: tfe.String(b.WorkspaceMapping.Prefix + "prod"), }, ) if err != nil { @@ -201,7 +201,7 @@ func TestCloud_planWithParallelism(t *testing.T) { b.ContextOpts = &terraform.ContextOpts{} } b.ContextOpts.Parallelism = 3 - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -228,7 +228,7 @@ func TestCloud_planWithPlan(t *testing.T) { defer configCleanup() op.PlanFile = &planfile.Reader{} - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -258,7 +258,7 @@ func TestCloud_planWithPath(t *testing.T) { defer configCleanup() op.PlanOutPath = "./testdata/plan" - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -289,7 +289,7 @@ func TestCloud_planWithoutRefresh(t *testing.T) { defer done(t) op.PlanRefresh = false - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -327,7 +327,7 @@ func TestCloud_planWithoutRefreshIncompatibleAPIVersion(t *testing.T) { b.client.SetFakeRemoteAPIVersion("2.3") op.PlanRefresh = false - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -358,7 +358,7 @@ func TestCloud_planWithRefreshOnly(t *testing.T) { defer done(t) op.PlanMode = plans.RefreshOnlyMode - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -396,7 +396,7 @@ func TestCloud_planWithRefreshOnlyIncompatibleAPIVersion(t *testing.T) { b.client.SetFakeRemoteAPIVersion("2.3") op.PlanMode = plans.RefreshOnlyMode - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -452,7 +452,7 @@ func TestCloud_planWithTarget(t *testing.T) { addr, _ := addrs.ParseAbsResourceStr("null_resource.foo") op.Targets = []addrs.Targetable{addr} - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -501,7 +501,7 @@ func TestCloud_planWithTargetIncompatibleAPIVersion(t *testing.T) { addr, _ := addrs.ParseAbsResourceStr("null_resource.foo") op.Targets = []addrs.Targetable{addr} - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -534,7 +534,7 @@ func TestCloud_planWithReplace(t *testing.T) { addr, _ := addrs.ParseAbsResourceInstanceStr("null_resource.foo") op.ForceReplace = []addrs.AbsResourceInstance{addr} - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -574,7 +574,7 @@ func TestCloud_planWithReplaceIncompatibleAPIVersion(t *testing.T) { addr, _ := addrs.ParseAbsResourceInstanceStr("null_resource.foo") op.ForceReplace = []addrs.AbsResourceInstance{addr} - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -604,7 +604,7 @@ func TestCloud_planWithVariables(t *testing.T) { defer configCleanup() op.Variables = testVariables(terraform.ValueFromCLIArg, "foo", "bar") - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -630,7 +630,7 @@ func TestCloud_planNoConfig(t *testing.T) { op, configCleanup, done := testOperationPlan(t, "./testdata/empty") defer configCleanup() - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -660,7 +660,7 @@ func TestCloud_planNoChanges(t *testing.T) { defer configCleanup() defer done(t) - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -699,7 +699,7 @@ func TestCloud_planForceLocal(t *testing.T) { defer configCleanup() defer done(t) - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName streams, done := terminal.StreamsForTesting(t) view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) @@ -735,7 +735,7 @@ func TestCloud_planWithoutOperationsEntitlement(t *testing.T) { defer configCleanup() defer done(t) - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName streams, done := terminal.StreamsForTesting(t) view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) @@ -774,7 +774,7 @@ func TestCloud_planWorkspaceWithoutOperations(t *testing.T) { ctx, b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.workspaceMapping.prefix + "no-operations"), + Name: tfe.String(b.WorkspaceMapping.Prefix + "no-operations"), }, ) if err != nil { @@ -820,7 +820,7 @@ func TestCloud_planLockTimeout(t *testing.T) { ctx := context.Background() // Retrieve the workspace used to run this operation in. - w, err := b.client.Workspaces.Read(ctx, b.organization, b.workspaceMapping.name) + w, err := b.client.Workspaces.Read(ctx, b.organization, b.WorkspaceMapping.Name) if err != nil { t.Fatalf("error retrieving workspace: %v", err) } @@ -851,7 +851,7 @@ func TestCloud_planLockTimeout(t *testing.T) { op.UIIn = input op.UIOut = b.CLI - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName _, err = b.Operation(context.Background(), op) if err != nil { @@ -893,7 +893,7 @@ func TestCloud_planDestroy(t *testing.T) { defer done(t) op.PlanMode = plans.DestroyMode - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -918,7 +918,7 @@ func TestCloud_planDestroyNoConfig(t *testing.T) { defer done(t) op.PlanMode = plans.DestroyMode - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -943,7 +943,7 @@ func TestCloud_planWithWorkingDirectory(t *testing.T) { } // Configure the workspace to use a custom working directory. - _, err := b.client.Workspaces.Update(context.Background(), b.organization, b.workspaceMapping.name, options) + _, err := b.client.Workspaces.Update(context.Background(), b.organization, b.WorkspaceMapping.Name, options) if err != nil { t.Fatalf("error configuring working directory: %v", err) } @@ -952,7 +952,7 @@ func TestCloud_planWithWorkingDirectory(t *testing.T) { defer configCleanup() defer done(t) - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -988,7 +988,7 @@ func TestCloud_planWithWorkingDirectoryFromCurrentPath(t *testing.T) { } // Configure the workspace to use a custom working directory. - _, err := b.client.Workspaces.Update(context.Background(), b.organization, b.workspaceMapping.name, options) + _, err := b.client.Workspaces.Update(context.Background(), b.organization, b.WorkspaceMapping.Name, options) if err != nil { t.Fatalf("error configuring working directory: %v", err) } @@ -1011,7 +1011,7 @@ func TestCloud_planWithWorkingDirectoryFromCurrentPath(t *testing.T) { defer configCleanup() defer done(t) - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -1043,7 +1043,7 @@ func TestCloud_planCostEstimation(t *testing.T) { defer configCleanup() defer done(t) - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -1078,7 +1078,7 @@ func TestCloud_planPolicyPass(t *testing.T) { defer configCleanup() defer done(t) - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -1112,7 +1112,7 @@ func TestCloud_planPolicyHardFail(t *testing.T) { op, configCleanup, done := testOperationPlan(t, "./testdata/plan-policy-hard-failed") defer configCleanup() - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -1152,7 +1152,7 @@ func TestCloud_planPolicySoftFail(t *testing.T) { op, configCleanup, done := testOperationPlan(t, "./testdata/plan-policy-soft-failed") defer configCleanup() - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { @@ -1193,7 +1193,7 @@ func TestCloud_planWithRemoteError(t *testing.T) { defer configCleanup() defer done(t) - op.Workspace = backend.DefaultStateName + op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) if err != nil { diff --git a/internal/cloud/backend_state_test.go b/internal/cloud/backend_state_test.go index a94c60c6e..e39dd211f 100644 --- a/internal/cloud/backend_state_test.go +++ b/internal/cloud/backend_state_test.go @@ -5,7 +5,6 @@ import ( "os" "testing" - "github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/remote" "github.com/hashicorp/terraform/internal/states/statefile" @@ -24,12 +23,12 @@ func TestRemoteClient_stateLock(t *testing.T) { b, bCleanup := testBackendDefault(t) defer bCleanup() - s1, err := b.StateMgr(backend.DefaultStateName) + s1, err := b.StateMgr(testBackendSingleWorkspaceName) if err != nil { t.Fatalf("expected no error, got %v", err) } - s2, err := b.StateMgr(backend.DefaultStateName) + s2, err := b.StateMgr(testBackendSingleWorkspaceName) if err != nil { t.Fatalf("expected no error, got %v", err) } diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index 928182b53..9f1a5190b 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -24,13 +24,30 @@ func TestCloud(t *testing.T) { var _ backend.CLI = New(nil) } -func TestCloud_backendDefault(t *testing.T) { +func TestCloud_backendWithName(t *testing.T) { b, bCleanup := testBackendDefault(t) defer bCleanup() - backend.TestBackendStates(t, b) - backend.TestBackendStateLocks(t, b, b) - backend.TestBackendStateForceUnlock(t, b, b) + workspaces, err := b.Workspaces() + if err != nil { + t.Fatalf("error: %v", err) + } + + if len(workspaces) != 1 || workspaces[0] != testBackendSingleWorkspaceName { + t.Fatalf("should only have a single configured workspace matching the configured 'name' strategy, but got: %#v", workspaces) + } + + if _, err := b.StateMgr("foo"); err != backend.ErrWorkspacesNotSupported { + t.Fatalf("expected fetching a state which is NOT the single configured workspace to have an ErrWorkspacesNotSupported error, but got: %v", err) + } + + if err := b.DeleteWorkspace(testBackendSingleWorkspaceName); err != backend.ErrWorkspacesNotSupported { + t.Fatalf("expected deleting the single configured workspace name to result in an error, but got: %v", err) + } + + if err := b.DeleteWorkspace("foo"); err != backend.ErrWorkspacesNotSupported { + t.Fatalf("expected deleting a workspace which is NOT the configured workspace name to result in an error, but got: %v", err) + } } func TestCloud_backendWithPrefix(t *testing.T) { @@ -428,15 +445,15 @@ func TestCloud_setConfigurationFields(t *testing.T) { if tc.expectedOrganziation != "" && b.organization != tc.expectedOrganziation { t.Fatalf("%s: expected organization (%s) to match configured organization (%s)", name, b.organization, tc.expectedOrganziation) } - if tc.expectedWorkspacePrefix != "" && b.workspaceMapping.prefix != tc.expectedWorkspacePrefix { - t.Fatalf("%s: expected workspace prefix mapping (%s) to match configured workspace prefix (%s)", name, b.workspaceMapping.prefix, tc.expectedWorkspacePrefix) + if tc.expectedWorkspacePrefix != "" && b.WorkspaceMapping.Prefix != tc.expectedWorkspacePrefix { + t.Fatalf("%s: expected workspace prefix mapping (%s) to match configured workspace prefix (%s)", name, b.WorkspaceMapping.Prefix, tc.expectedWorkspacePrefix) } - if tc.expectedWorkspaceName != "" && b.workspaceMapping.name != tc.expectedWorkspaceName { - t.Fatalf("%s: expected workspace name mapping (%s) to match configured workspace name (%s)", name, b.workspaceMapping.name, tc.expectedWorkspaceName) + if tc.expectedWorkspaceName != "" && b.WorkspaceMapping.Name != tc.expectedWorkspaceName { + t.Fatalf("%s: expected workspace name mapping (%s) to match configured workspace name (%s)", name, b.WorkspaceMapping.Name, tc.expectedWorkspaceName) } if len(tc.expectedWorkspaceTags) > 0 { presentSet := make(map[string]struct{}) - for _, tag := range b.workspaceMapping.tags { + for _, tag := range b.WorkspaceMapping.Tags { presentSet[tag] = struct{}{} } @@ -454,18 +471,18 @@ func TestCloud_setConfigurationFields(t *testing.T) { } } - for _, actual := range b.workspaceMapping.tags { + for _, actual := range b.WorkspaceMapping.Tags { if _, ok := expectedSet[actual]; !ok { unexpected = append(missing, actual) } } if len(missing) > 0 { - t.Fatalf("%s: expected workspace tag mapping (%s) to contain the following tags: %s", name, b.workspaceMapping.tags, missing) + t.Fatalf("%s: expected workspace tag mapping (%s) to contain the following tags: %s", name, b.WorkspaceMapping.Tags, missing) } if len(unexpected) > 0 { - t.Fatalf("%s: expected workspace tag mapping (%s) to NOT contain the following tags: %s", name, b.workspaceMapping.tags, unexpected) + t.Fatalf("%s: expected workspace tag mapping (%s) to NOT contain the following tags: %s", name, b.WorkspaceMapping.Tags, unexpected) } } @@ -575,28 +592,16 @@ func TestCloud_addAndRemoveWorkspacesDefault(t *testing.T) { b, bCleanup := testBackendDefault(t) defer bCleanup() - if _, err := b.Workspaces(); err != backend.ErrWorkspacesNotSupported { - t.Fatalf("expected error %v, got %v", backend.ErrWorkspacesNotSupported, err) - } - - if _, err := b.StateMgr(backend.DefaultStateName); err != nil { + if _, err := b.StateMgr(testBackendSingleWorkspaceName); err != nil { t.Fatalf("expected no error, got %v", err) } - if _, err := b.StateMgr("prod"); err != backend.ErrWorkspacesNotSupported { - t.Fatalf("expected error %v, got %v", backend.ErrWorkspacesNotSupported, err) - } - - if err := b.DeleteWorkspace(backend.DefaultStateName); err != nil { - t.Fatalf("expected no error, got %v", err) - } - - if err := b.DeleteWorkspace("prod"); err != backend.ErrWorkspacesNotSupported { + if err := b.DeleteWorkspace(testBackendSingleWorkspaceName); err != backend.ErrWorkspacesNotSupported { t.Fatalf("expected error %v, got %v", backend.ErrWorkspacesNotSupported, err) } } -func TestCloud_addAndRemoveWorkspacesNoDefault(t *testing.T) { +func TestCloud_addAndRemoveWorkspacesWithPrefix(t *testing.T) { b, bCleanup := testBackendWithPrefix(t) defer bCleanup() @@ -808,7 +813,7 @@ func TestCloud_StateMgr_versionCheck(t *testing.T) { if _, err := b.client.Workspaces.Update( context.Background(), b.organization, - b.workspaceMapping.name, + b.WorkspaceMapping.Name, tfe.WorkspaceUpdateOptions{ TerraformVersion: tfe.String(v0140.String()), }, @@ -817,7 +822,7 @@ func TestCloud_StateMgr_versionCheck(t *testing.T) { } // This should succeed - if _, err := b.StateMgr(backend.DefaultStateName); err != nil { + if _, err := b.StateMgr(testBackendSingleWorkspaceName); err != nil { t.Fatalf("expected no error, got %v", err) } @@ -825,7 +830,7 @@ func TestCloud_StateMgr_versionCheck(t *testing.T) { if _, err := b.client.Workspaces.Update( context.Background(), b.organization, - b.workspaceMapping.name, + b.WorkspaceMapping.Name, tfe.WorkspaceUpdateOptions{ TerraformVersion: tfe.String(v0135.String()), }, @@ -835,7 +840,7 @@ func TestCloud_StateMgr_versionCheck(t *testing.T) { // This should fail want := `Remote workspace Terraform version "0.13.5" does not match local Terraform version "0.14.0"` - if _, err := b.StateMgr(backend.DefaultStateName); err.Error() != want { + if _, err := b.StateMgr(testBackendSingleWorkspaceName); err.Error() != want { t.Fatalf("wrong error\n got: %v\nwant: %v", err.Error(), want) } } @@ -865,7 +870,7 @@ func TestCloud_StateMgr_versionCheckLatest(t *testing.T) { if _, err := b.client.Workspaces.Update( context.Background(), b.organization, - b.workspaceMapping.name, + b.WorkspaceMapping.Name, tfe.WorkspaceUpdateOptions{ TerraformVersion: tfe.String("latest"), }, @@ -874,7 +879,7 @@ func TestCloud_StateMgr_versionCheckLatest(t *testing.T) { } // This should succeed despite not being a string match - if _, err := b.StateMgr(backend.DefaultStateName); err != nil { + if _, err := b.StateMgr(testBackendSingleWorkspaceName); err != nil { t.Fatalf("expected no error, got %v", err) } } @@ -923,7 +928,7 @@ func TestCloud_VerifyWorkspaceTerraformVersion(t *testing.T) { if _, err := b.client.Workspaces.Update( context.Background(), b.organization, - b.workspaceMapping.name, + b.WorkspaceMapping.Name, tfe.WorkspaceUpdateOptions{ Operations: tfe.Bool(tc.operations), TerraformVersion: tfe.String(tc.remote), @@ -974,7 +979,7 @@ func TestCloud_VerifyWorkspaceTerraformVersion_workspaceErrors(t *testing.T) { if _, err := b.client.Workspaces.Update( context.Background(), b.organization, - b.workspaceMapping.name, + b.WorkspaceMapping.Name, tfe.WorkspaceUpdateOptions{ TerraformVersion: tfe.String("1.0.cheetarah"), }, @@ -1022,7 +1027,7 @@ func TestCloud_VerifyWorkspaceTerraformVersion_ignoreFlagSet(t *testing.T) { if _, err := b.client.Workspaces.Update( context.Background(), b.organization, - b.workspaceMapping.name, + b.WorkspaceMapping.Name, tfe.WorkspaceUpdateOptions{ TerraformVersion: tfe.String(remote.String()), }, @@ -1041,7 +1046,7 @@ func TestCloud_VerifyWorkspaceTerraformVersion_ignoreFlagSet(t *testing.T) { if got, want := diags[0].Description().Summary, "Terraform version mismatch"; got != want { t.Errorf("wrong summary: got %s, want %s", got, want) } - wantDetail := "The local Terraform version (0.14.0) does not match the configured version for remote workspace hashicorp/prod (0.13.5)." + wantDetail := "The local Terraform version (0.14.0) does not match the configured version for remote workspace hashicorp/app-prod (0.13.5)." if got := diags[0].Description().Detail; got != wantDetail { t.Errorf("wrong summary: got %s, want %s", got, wantDetail) } diff --git a/internal/cloud/testing.go b/internal/cloud/testing.go index d289a42c2..9dd570e69 100644 --- a/internal/cloud/testing.go +++ b/internal/cloud/testing.go @@ -38,6 +38,7 @@ var ( credsSrc = auth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{ tfeHost: {"token": testCred}, }) + testBackendSingleWorkspaceName = "app-prod" ) // mockInput is a mock implementation of terraform.UIInput. @@ -70,7 +71,7 @@ func testBackendDefault(t *testing.T) (*Cloud, func()) { "organization": cty.StringVal("hashicorp"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("prod"), + "name": cty.StringVal(testBackendSingleWorkspaceName), "prefix": cty.NullVal(cty.String), "tags": cty.NullVal(cty.Set(cty.String)), }), @@ -116,7 +117,7 @@ func testBackendNoOperations(t *testing.T) (*Cloud, func()) { "organization": cty.StringVal("no-operations"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("prod"), + "name": cty.StringVal(testBackendSingleWorkspaceName), "prefix": cty.NullVal(cty.String), "tags": cty.NullVal(cty.Set(cty.String)), }), @@ -128,7 +129,7 @@ func testRemoteClient(t *testing.T) remote.Client { b, bCleanup := testBackendDefault(t) defer bCleanup() - raw, err := b.StateMgr(backend.DefaultStateName) + raw, err := b.StateMgr(testBackendSingleWorkspaceName) if err != nil { t.Fatalf("error: %v", err) } @@ -182,9 +183,9 @@ func testBackend(t *testing.T, obj cty.Value) (*Cloud, func()) { } // Create the default workspace if required. - if b.workspaceMapping.name != "" { + if b.WorkspaceMapping.Name != "" { _, err = b.client.Workspaces.Create(ctx, b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.workspaceMapping.name), + Name: tfe.String(b.WorkspaceMapping.Name), }) if err != nil { t.Fatalf("error: %v", err) From 74e087dc299243d2bf97ba07b2887338bf2f1a31 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Mon, 20 Sep 2021 16:54:41 -0500 Subject: [PATCH 22/78] Rename cloud.testBackendDefault -> cloud.testBackendWithName --- internal/cloud/backend_apply_test.go | 60 +++++++++++++------------- internal/cloud/backend_context_test.go | 2 +- internal/cloud/backend_plan_test.go | 58 ++++++++++++------------- internal/cloud/backend_state_test.go | 2 +- internal/cloud/backend_test.go | 18 ++++---- internal/cloud/testing.go | 4 +- 6 files changed, 72 insertions(+), 72 deletions(-) diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index 9f464c3f3..1ae00946a 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -54,7 +54,7 @@ func testOperationApplyWithTimeout(t *testing.T, configDir string, timeout time. } func TestCloud_applyBasic(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply") @@ -105,7 +105,7 @@ func TestCloud_applyBasic(t *testing.T) { } func TestCloud_applyCanceled(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply") @@ -216,7 +216,7 @@ func TestCloud_applyWithVCS(t *testing.T) { } func TestCloud_applyWithParallelism(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply") @@ -246,7 +246,7 @@ func TestCloud_applyWithParallelism(t *testing.T) { } func TestCloud_applyWithPlan(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply") @@ -276,7 +276,7 @@ func TestCloud_applyWithPlan(t *testing.T) { } func TestCloud_applyWithoutRefresh(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply") @@ -313,7 +313,7 @@ func TestCloud_applyWithoutRefresh(t *testing.T) { } func TestCloud_applyWithoutRefreshIncompatibleAPIVersion(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply") @@ -345,7 +345,7 @@ func TestCloud_applyWithoutRefreshIncompatibleAPIVersion(t *testing.T) { } func TestCloud_applyWithRefreshOnly(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply") @@ -382,7 +382,7 @@ func TestCloud_applyWithRefreshOnly(t *testing.T) { } func TestCloud_applyWithRefreshOnlyIncompatibleAPIVersion(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply") @@ -414,7 +414,7 @@ func TestCloud_applyWithRefreshOnlyIncompatibleAPIVersion(t *testing.T) { } func TestCloud_applyWithTarget(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply") @@ -453,7 +453,7 @@ func TestCloud_applyWithTarget(t *testing.T) { } func TestCloud_applyWithTargetIncompatibleAPIVersion(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply") @@ -489,7 +489,7 @@ func TestCloud_applyWithTargetIncompatibleAPIVersion(t *testing.T) { } func TestCloud_applyWithReplace(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply") @@ -528,7 +528,7 @@ func TestCloud_applyWithReplace(t *testing.T) { } func TestCloud_applyWithReplaceIncompatibleAPIVersion(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply") @@ -562,7 +562,7 @@ func TestCloud_applyWithReplaceIncompatibleAPIVersion(t *testing.T) { } func TestCloud_applyWithVariables(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply-variables") @@ -589,7 +589,7 @@ func TestCloud_applyWithVariables(t *testing.T) { } func TestCloud_applyNoConfig(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/empty") @@ -624,7 +624,7 @@ func TestCloud_applyNoConfig(t *testing.T) { } func TestCloud_applyNoChanges(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply-no-changes") @@ -656,7 +656,7 @@ func TestCloud_applyNoChanges(t *testing.T) { } func TestCloud_applyNoApprove(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply") @@ -695,7 +695,7 @@ func TestCloud_applyNoApprove(t *testing.T) { } func TestCloud_applyAutoApprove(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply") @@ -741,7 +741,7 @@ func TestCloud_applyAutoApprove(t *testing.T) { } func TestCloud_applyApprovedExternally(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply") @@ -815,7 +815,7 @@ func TestCloud_applyApprovedExternally(t *testing.T) { } func TestCloud_applyDiscardedExternally(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply") @@ -954,7 +954,7 @@ func TestCloud_applyForceLocal(t *testing.T) { } defer os.Unsetenv("TF_FORCE_LOCAL_BACKEND") - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply") @@ -1066,7 +1066,7 @@ func TestCloud_applyWorkspaceWithoutOperations(t *testing.T) { } func TestCloud_applyLockTimeout(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() ctx := context.Background() @@ -1140,7 +1140,7 @@ func TestCloud_applyLockTimeout(t *testing.T) { } func TestCloud_applyDestroy(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply-destroy") @@ -1186,7 +1186,7 @@ func TestCloud_applyDestroy(t *testing.T) { } func TestCloud_applyDestroyNoConfig(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() input := testInput(t, map[string]string{ @@ -1221,7 +1221,7 @@ func TestCloud_applyDestroyNoConfig(t *testing.T) { } func TestCloud_applyPolicyPass(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply-policy-passed") @@ -1269,7 +1269,7 @@ func TestCloud_applyPolicyPass(t *testing.T) { } func TestCloud_applyPolicyHardFail(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply-policy-hard-failed") @@ -1322,7 +1322,7 @@ func TestCloud_applyPolicyHardFail(t *testing.T) { } func TestCloud_applyPolicySoftFail(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply-policy-soft-failed") @@ -1372,7 +1372,7 @@ func TestCloud_applyPolicySoftFail(t *testing.T) { } func TestCloud_applyPolicySoftFailAutoApproveSuccess(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply-policy-soft-failed") @@ -1423,7 +1423,7 @@ func TestCloud_applyPolicySoftFailAutoApproveSuccess(t *testing.T) { } func TestCloud_applyPolicySoftFailAutoApply(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() // Create a named workspace that auto applies. @@ -1485,7 +1485,7 @@ func TestCloud_applyPolicySoftFailAutoApply(t *testing.T) { } func TestCloud_applyWithRemoteError(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply-with-error") @@ -1553,7 +1553,7 @@ func TestCloud_applyVersionCheck(t *testing.T) { for name, tc := range testCases { t.Run(name, func(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() // SETUP: Save original local version state and restore afterwards diff --git a/internal/cloud/backend_context_test.go b/internal/cloud/backend_context_test.go index 7a7668a83..e1001ee52 100644 --- a/internal/cloud/backend_context_test.go +++ b/internal/cloud/backend_context_test.go @@ -176,7 +176,7 @@ func TestRemoteContextWithVars(t *testing.T) { t.Run(name, func(t *testing.T) { configDir := "./testdata/empty" - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir) diff --git a/internal/cloud/backend_plan_test.go b/internal/cloud/backend_plan_test.go index a76841f2b..19221dc90 100644 --- a/internal/cloud/backend_plan_test.go +++ b/internal/cloud/backend_plan_test.go @@ -52,7 +52,7 @@ func testOperationPlanWithTimeout(t *testing.T, configDir string, timeout time.D } func TestCloud_planBasic(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan") @@ -90,7 +90,7 @@ func TestCloud_planBasic(t *testing.T) { } func TestCloud_planCanceled(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan") @@ -120,7 +120,7 @@ func TestCloud_planCanceled(t *testing.T) { } func TestCloud_planLongLine(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan-long-line") @@ -191,7 +191,7 @@ func TestCloud_planWithoutPermissions(t *testing.T) { } func TestCloud_planWithParallelism(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan") @@ -221,7 +221,7 @@ func TestCloud_planWithParallelism(t *testing.T) { } func TestCloud_planWithPlan(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan") @@ -251,7 +251,7 @@ func TestCloud_planWithPlan(t *testing.T) { } func TestCloud_planWithPath(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan") @@ -281,7 +281,7 @@ func TestCloud_planWithPath(t *testing.T) { } func TestCloud_planWithoutRefresh(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan") @@ -318,7 +318,7 @@ func TestCloud_planWithoutRefresh(t *testing.T) { } func TestCloud_planWithoutRefreshIncompatibleAPIVersion(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan") @@ -350,7 +350,7 @@ func TestCloud_planWithoutRefreshIncompatibleAPIVersion(t *testing.T) { } func TestCloud_planWithRefreshOnly(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan") @@ -387,7 +387,7 @@ func TestCloud_planWithRefreshOnly(t *testing.T) { } func TestCloud_planWithRefreshOnlyIncompatibleAPIVersion(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan") @@ -419,7 +419,7 @@ func TestCloud_planWithRefreshOnlyIncompatibleAPIVersion(t *testing.T) { } func TestCloud_planWithTarget(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() // When the backend code creates a new run, we'll tweak it so that it @@ -488,7 +488,7 @@ func TestCloud_planWithTarget(t *testing.T) { } func TestCloud_planWithTargetIncompatibleAPIVersion(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan") @@ -524,7 +524,7 @@ func TestCloud_planWithTargetIncompatibleAPIVersion(t *testing.T) { } func TestCloud_planWithReplace(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan") @@ -563,7 +563,7 @@ func TestCloud_planWithReplace(t *testing.T) { } func TestCloud_planWithReplaceIncompatibleAPIVersion(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan") @@ -597,7 +597,7 @@ func TestCloud_planWithReplaceIncompatibleAPIVersion(t *testing.T) { } func TestCloud_planWithVariables(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan-variables") @@ -624,7 +624,7 @@ func TestCloud_planWithVariables(t *testing.T) { } func TestCloud_planNoConfig(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/empty") @@ -653,7 +653,7 @@ func TestCloud_planNoConfig(t *testing.T) { } func TestCloud_planNoChanges(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan-no-changes") @@ -692,7 +692,7 @@ func TestCloud_planForceLocal(t *testing.T) { } defer os.Unsetenv("TF_FORCE_LOCAL_BACKEND") - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan") @@ -814,7 +814,7 @@ func TestCloud_planWorkspaceWithoutOperations(t *testing.T) { } func TestCloud_planLockTimeout(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() ctx := context.Background() @@ -885,7 +885,7 @@ func TestCloud_planLockTimeout(t *testing.T) { } func TestCloud_planDestroy(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan") @@ -910,7 +910,7 @@ func TestCloud_planDestroy(t *testing.T) { } func TestCloud_planDestroyNoConfig(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/empty") @@ -935,7 +935,7 @@ func TestCloud_planDestroyNoConfig(t *testing.T) { } func TestCloud_planWithWorkingDirectory(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() options := tfe.WorkspaceUpdateOptions{ @@ -980,7 +980,7 @@ func TestCloud_planWithWorkingDirectory(t *testing.T) { } func TestCloud_planWithWorkingDirectoryFromCurrentPath(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() options := tfe.WorkspaceUpdateOptions{ @@ -1036,7 +1036,7 @@ func TestCloud_planWithWorkingDirectoryFromCurrentPath(t *testing.T) { } func TestCloud_planCostEstimation(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan-cost-estimation") @@ -1071,7 +1071,7 @@ func TestCloud_planCostEstimation(t *testing.T) { } func TestCloud_planPolicyPass(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan-policy-passed") @@ -1106,7 +1106,7 @@ func TestCloud_planPolicyPass(t *testing.T) { } func TestCloud_planPolicyHardFail(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan-policy-hard-failed") @@ -1146,7 +1146,7 @@ func TestCloud_planPolicyHardFail(t *testing.T) { } func TestCloud_planPolicySoftFail(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan-policy-soft-failed") @@ -1186,7 +1186,7 @@ func TestCloud_planPolicySoftFail(t *testing.T) { } func TestCloud_planWithRemoteError(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan-with-error") @@ -1218,7 +1218,7 @@ func TestCloud_planWithRemoteError(t *testing.T) { } func TestCloud_planOtherError(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan") diff --git a/internal/cloud/backend_state_test.go b/internal/cloud/backend_state_test.go index e39dd211f..63c970438 100644 --- a/internal/cloud/backend_state_test.go +++ b/internal/cloud/backend_state_test.go @@ -20,7 +20,7 @@ func TestRemoteClient(t *testing.T) { } func TestRemoteClient_stateLock(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() s1, err := b.StateMgr(testBackendSingleWorkspaceName) diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index 9f1a5190b..9ac894467 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -25,7 +25,7 @@ func TestCloud(t *testing.T) { } func TestCloud_backendWithName(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() workspaces, err := b.Workspaces() @@ -574,7 +574,7 @@ func TestCloud_versionConstraints(t *testing.T) { } func TestCloud_localBackend(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() local, ok := b.local.(*backendLocal.Local) @@ -589,7 +589,7 @@ func TestCloud_localBackend(t *testing.T) { } func TestCloud_addAndRemoveWorkspacesDefault(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() if _, err := b.StateMgr(testBackendSingleWorkspaceName); err != nil { @@ -683,7 +683,7 @@ func TestCloud_addAndRemoveWorkspacesWithPrefix(t *testing.T) { } func TestCloud_checkConstraints(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() cases := map[string]struct { @@ -785,7 +785,7 @@ func TestCloud_checkConstraints(t *testing.T) { } func TestCloud_StateMgr_versionCheck(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() // Some fixed versions for testing with. This logic is a simple string @@ -846,7 +846,7 @@ func TestCloud_StateMgr_versionCheck(t *testing.T) { } func TestCloud_StateMgr_versionCheckLatest(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() v0140 := version.Must(version.NewSemver("0.14.0")) @@ -903,7 +903,7 @@ func TestCloud_VerifyWorkspaceTerraformVersion(t *testing.T) { } for _, tc := range testCases { t.Run(fmt.Sprintf("local %s, remote %s", tc.local, tc.remote), func(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() local := version.Must(version.NewSemver(tc.local)) @@ -955,7 +955,7 @@ func TestCloud_VerifyWorkspaceTerraformVersion(t *testing.T) { } func TestCloud_VerifyWorkspaceTerraformVersion_workspaceErrors(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() // Attempting to check the version against a workspace which doesn't exist @@ -997,7 +997,7 @@ func TestCloud_VerifyWorkspaceTerraformVersion_workspaceErrors(t *testing.T) { } func TestCloud_VerifyWorkspaceTerraformVersion_ignoreFlagSet(t *testing.T) { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() // If the ignore flag is set, the behaviour changes diff --git a/internal/cloud/testing.go b/internal/cloud/testing.go index 9dd570e69..b2811fe2f 100644 --- a/internal/cloud/testing.go +++ b/internal/cloud/testing.go @@ -65,7 +65,7 @@ func testInput(t *testing.T, answers map[string]string) *mockInput { return &mockInput{answers: answers} } -func testBackendDefault(t *testing.T) (*Cloud, func()) { +func testBackendWithName(t *testing.T) (*Cloud, func()) { obj := cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), "organization": cty.StringVal("hashicorp"), @@ -126,7 +126,7 @@ func testBackendNoOperations(t *testing.T) (*Cloud, func()) { } func testRemoteClient(t *testing.T) remote.Client { - b, bCleanup := testBackendDefault(t) + b, bCleanup := testBackendWithName(t) defer bCleanup() raw, err := b.StateMgr(testBackendSingleWorkspaceName) From aba7d9659642bcd025fe9e53b35a013df6e4f101 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Fri, 24 Sep 2021 09:03:12 -0500 Subject: [PATCH 23/78] cloud: Support interop from 0.14 to 1.1 The previous conservative guarantee that we would not make backwards incompatible changes to the state file format until at least Terraform 1.1 can now be extended. Terraform 0.14 through 1.1 will be able to interoperably use state files, so we can update the remote backend version compatibility check accordingly. This is a port of https://github.com/hashicorp/terraform/pull/29645 --- internal/cloud/backend.go | 6 +++--- internal/cloud/backend_test.go | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 22adb98d1..0356740b0 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -940,9 +940,9 @@ func (b *Cloud) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Di // are aware of are: // // - 0.14.0 is guaranteed to be compatible with versions up to but not - // including 1.1.0 - v110 := version.Must(version.NewSemver("1.1.0")) - if tfversion.SemVer.LessThan(v110) && remoteVersion.LessThan(v110) { + // including 1.2.0 + v120 := version.Must(version.NewSemver("1.2.0")) + if tfversion.SemVer.LessThan(v120) && remoteVersion.LessThan(v120) { return diags } // - Any new Terraform state version will require at least minor patch diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index 9ac894467..4c3546ec5 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -896,7 +896,8 @@ func TestCloud_VerifyWorkspaceTerraformVersion(t *testing.T) { {"0.14.0", "0.13.5", false, false}, {"0.14.0", "0.14.1", true, false}, {"0.14.0", "1.0.99", true, false}, - {"0.14.0", "1.1.0", true, true}, + {"0.14.0", "1.1.0", true, false}, + {"0.14.0", "1.2.0", true, true}, {"1.2.0", "1.2.99", true, false}, {"1.2.0", "1.3.0", true, true}, {"0.15.0", "latest", true, false}, From ee384e8716f304961fd1126fd258c0f3b65c8017 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Wed, 29 Sep 2021 12:41:44 -0500 Subject: [PATCH 24/78] Attempt to set current Terraform version on new workspaces, always --- internal/cloud/backend.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 0356740b0..91c3f6976 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -649,11 +649,7 @@ func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { } options.Tags = tags - // We only set the Terraform Version for the new workspace if this is - // a release candidate or a final release. - if tfversion.Prerelease == "" || strings.HasPrefix(tfversion.Prerelease, "rc") { - options.TerraformVersion = tfe.String(tfversion.String()) - } + options.TerraformVersion = tfe.String(tfversion.String()) workspace, err = b.client.Workspaces.Create(context.Background(), b.organization, options) if err != nil { From a387af6c61263f397739233dc85c08dea70422d6 Mon Sep 17 00:00:00 2001 From: Omar Ismail Date: Wed, 22 Sep 2021 17:53:33 -0400 Subject: [PATCH 25/78] Add auto-approve logic, e2e tests --- go.mod | 4 +- go.sum | 8 +- internal/cloud/backend_apply.go | 78 ++--- internal/cloud/backend_apply_test.go | 93 +++++- internal/cloud/backend_plan.go | 1 + internal/cloud/e2e/apply_auto_approve_test.go | 280 ++++++++++++++++++ internal/cloud/e2e/helper_test.go | 50 ++++ internal/cloud/e2e/main_test.go | 198 +++++++++++++ internal/cloud/tfe_client_mock.go | 2 +- 9 files changed, 646 insertions(+), 68 deletions(-) create mode 100644 internal/cloud/e2e/apply_auto_approve_test.go create mode 100644 internal/cloud/e2e/helper_test.go create mode 100644 internal/cloud/e2e/main_test.go diff --git a/go.mod b/go.mod index 9074b31d8..6dfa7bdcc 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/davecgh/go-spew v1.1.1 github.com/dylanmei/winrmtest v0.0.0-20190225150635-99b7fe2fddf1 github.com/go-test/deep v1.0.3 - github.com/golang/mock v1.5.0 + github.com/golang/mock v1.6.0 github.com/golang/protobuf v1.5.2 github.com/google/go-cmp v0.5.5 github.com/google/uuid v1.2.0 @@ -40,7 +40,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-plugin v1.4.3 github.com/hashicorp/go-retryablehttp v0.5.2 - github.com/hashicorp/go-tfe v0.18.1-0.20210902165242-26689edbfddf + github.com/hashicorp/go-tfe v0.19.1-0.20210922134841-a2c1784e9c00 github.com/hashicorp/go-uuid v1.0.1 github.com/hashicorp/go-version v1.2.1 github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f diff --git a/go.sum b/go.sum index 4a2064597..aaba3e315 100644 --- a/go.sum +++ b/go.sum @@ -241,8 +241,9 @@ github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFU github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -375,8 +376,8 @@ github.com/hashicorp/go-slug v0.7.0/go.mod h1:Ib+IWBYfEfJGI1ZyXMGNbu2BU+aa3Dzu41 github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-tfe v0.18.1-0.20210902165242-26689edbfddf h1:Tn5cI9kacNyO40ztxmwfAaHrOGd7dELLSAueV2Xfv38= -github.com/hashicorp/go-tfe v0.18.1-0.20210902165242-26689edbfddf/go.mod h1:7lChm1Mjsh0ofrUNkP8MHljUFrnKNZNTw36S6qSbJZU= +github.com/hashicorp/go-tfe v0.19.1-0.20210922134841-a2c1784e9c00 h1:51ARk47jO4piKzhhbwk6u67ErvSuBj4cu2f2VS9HkgI= +github.com/hashicorp/go-tfe v0.19.1-0.20210922134841-a2c1784e9c00/go.mod h1:U5Iy307L+MazGg0uF8annDtaxAbPp4ElFZ9uPMrjw/I= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -933,6 +934,7 @@ golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.7 h1:6j8CgantCy3yc8JGBqkDLMKWqZ0RDU2g1HVgacojGWQ= golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= diff --git a/internal/cloud/backend_apply.go b/internal/cloud/backend_apply.go index 74d167673..02f006c2c 100644 --- a/internal/cloud/backend_apply.go +++ b/internal/cloud/backend_apply.go @@ -181,72 +181,40 @@ func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operatio } // Return if the run cannot be confirmed. - if !w.AutoApply && !r.Actions.IsConfirmable { + if !op.AutoApprove && !r.Actions.IsConfirmable { return r, nil } - // Since we already checked the permissions before creating the run - // this should never happen. But it doesn't hurt to keep this in as - // a safeguard for any unexpected situations. - if !w.AutoApply && !r.Permissions.CanApply { - // Make sure we discard the run if possible. - if r.Actions.IsDiscardable { - err = b.client.Runs.Discard(stopCtx, r.ID, tfe.RunDiscardOptions{}) - if err != nil { - switch op.PlanMode { - case plans.DestroyMode: - return r, generalError("Failed to discard destroy", err) - default: - return r, generalError("Failed to discard apply", err) - } - } - } - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Insufficient rights to approve the pending changes", - fmt.Sprintf("There are pending changes, but the provided credentials have "+ - "insufficient rights to approve them. The run will be discarded to prevent "+ - "it from blocking the queue waiting for external approval. To queue a run "+ - "that can be approved by someone else, please use the 'Queue Plan' button in "+ - "the web UI:\nhttps://%s/app/%s/%s/runs", b.hostname, b.organization, op.Workspace), - )) - return r, diags.Err() - } - mustConfirm := (op.UIIn != nil && op.UIOut != nil) && !op.AutoApprove - if !w.AutoApply { - if mustConfirm { - opts := &terraform.InputOpts{Id: "approve"} + if mustConfirm { + opts := &terraform.InputOpts{Id: "approve"} - if op.PlanMode == plans.DestroyMode { - opts.Query = "\nDo you really want to destroy all resources in workspace \"" + op.Workspace + "\"?" - opts.Description = "Terraform will destroy all your managed infrastructure, as shown above.\n" + - "There is no undo. Only 'yes' will be accepted to confirm." - } else { - opts.Query = "\nDo you want to perform these actions in workspace \"" + op.Workspace + "\"?" - opts.Description = "Terraform will perform the actions described above.\n" + - "Only 'yes' will be accepted to approve." - } - - err = b.confirm(stopCtx, op, opts, r, "yes") - if err != nil && err != errRunApproved { - return r, err - } + if op.PlanMode == plans.DestroyMode { + opts.Query = "\nDo you really want to destroy all resources in workspace \"" + op.Workspace + "\"?" + opts.Description = "Terraform will destroy all your managed infrastructure, as shown above.\n" + + "There is no undo. Only 'yes' will be accepted to confirm." + } else { + opts.Query = "\nDo you want to perform these actions in workspace \"" + op.Workspace + "\"?" + opts.Description = "Terraform will perform the actions described above.\n" + + "Only 'yes' will be accepted to approve." } - if err != errRunApproved { - if err = b.client.Runs.Apply(stopCtx, r.ID, tfe.RunApplyOptions{}); err != nil { - return r, generalError("Failed to approve the apply command", err) - } + err = b.confirm(stopCtx, op, opts, r, "yes") + if err != nil && err != errRunApproved { + return r, err + } + } else { + // If we don't need to ask for confirmation, insert a blank + // line to separate the ouputs. + if b.CLI != nil { + b.CLI.Output("") } } - // If we don't need to ask for confirmation, insert a blank - // line to separate the ouputs. - if w.AutoApply || !mustConfirm { - if b.CLI != nil { - b.CLI.Output("") + if !op.AutoApprove && err != errRunApproved { + if err = b.client.Runs.Apply(stopCtx, r.ID, tfe.RunApplyOptions{}); err != nil { + return r, generalError("Failed to approve the apply command", err) } } diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index 1ae00946a..ff98d9928 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -2,6 +2,7 @@ package cloud import ( "context" + "fmt" "os" "os/signal" "strings" @@ -9,6 +10,7 @@ import ( "testing" "time" + gomock "github.com/golang/mock/gomock" "github.com/google/go-cmp/cmp" tfe "github.com/hashicorp/go-tfe" version "github.com/hashicorp/go-version" @@ -697,6 +699,14 @@ func TestCloud_applyNoApprove(t *testing.T) { func TestCloud_applyAutoApprove(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() + ctrl := gomock.NewController(t) + + applyMock := tfe.NewMockApplies(ctrl) + // This needs three new lines because we check for a minimum of three lines + // in the parsing of logs in `opApply` function. + logs := strings.NewReader(applySuccessOneResourceAdded) + applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil) + b.client.Applies = applyMock op, configCleanup, done := testOperationApply(t, "./testdata/apply") defer configCleanup() @@ -888,17 +898,24 @@ func TestCloud_applyDiscardedExternally(t *testing.T) { } } -func TestCloud_applyWithAutoApply(t *testing.T) { +func TestCloud_applyWithAutoApprove(t *testing.T) { b, bCleanup := testBackendWithPrefix(t) defer bCleanup() + ctrl := gomock.NewController(t) + + applyMock := tfe.NewMockApplies(ctrl) + // This needs three new lines because we check for a minimum of three lines + // in the parsing of logs in `opApply` function. + logs := strings.NewReader(applySuccessOneResourceAdded) + applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil) + b.client.Applies = applyMock // Create a named workspace that auto applies. _, err := b.client.Workspaces.Create( context.Background(), b.organization, tfe.WorkspaceCreateOptions{ - AutoApply: tfe.Bool(true), - Name: tfe.String(b.WorkspaceMapping.Prefix + "prod"), + Name: tfe.String(b.WorkspaceMapping.Prefix + "prod"), }, ) if err != nil { @@ -916,6 +933,7 @@ func TestCloud_applyWithAutoApply(t *testing.T) { op.UIIn = input op.UIOut = b.CLI op.Workspace = "prod" + op.AutoApprove = true run, err := b.Operation(context.Background(), op) if err != nil { @@ -1374,6 +1392,34 @@ func TestCloud_applyPolicySoftFail(t *testing.T) { func TestCloud_applyPolicySoftFailAutoApproveSuccess(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() + ctrl := gomock.NewController(t) + + policyCheckMock := tfe.NewMockPolicyChecks(ctrl) + // This needs three new lines because we check for a minimum of three lines + // in the parsing of logs in `opApply` function. + logs := strings.NewReader(fmt.Sprintf("%s\n%s", sentinelSoftFail, applySuccessOneResourceAdded)) + + pc := &tfe.PolicyCheck{ + ID: "pc-1", + Actions: &tfe.PolicyActions{ + IsOverridable: true, + }, + Permissions: &tfe.PolicyPermissions{ + CanOverride: true, + }, + Scope: tfe.PolicyScopeOrganization, + Status: tfe.PolicySoftFailed, + } + policyCheckMock.EXPECT().Read(gomock.Any(), gomock.Any()).Return(pc, nil) + policyCheckMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil) + policyCheckMock.EXPECT().Override(gomock.Any(), gomock.Any()).Return(nil, nil) + b.client.PolicyChecks = policyCheckMock + applyMock := tfe.NewMockApplies(ctrl) + // This needs three new lines because we check for a minimum of three lines + // in the parsing of logs in `opApply` function. + logs = strings.NewReader("\n\n\n1 added, 0 changed, 0 destroyed") + applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil) + b.client.Applies = applyMock op, configCleanup, done := testOperationApply(t, "./testdata/apply-policy-soft-failed") defer configCleanup() @@ -1422,17 +1468,24 @@ func TestCloud_applyPolicySoftFailAutoApproveSuccess(t *testing.T) { } } -func TestCloud_applyPolicySoftFailAutoApply(t *testing.T) { +func TestCloud_applyPolicySoftFailAutoApprove(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() + ctrl := gomock.NewController(t) + + applyMock := tfe.NewMockApplies(ctrl) + // This needs three new lines because we check for a minimum of three lines + // in the parsing of logs in `opApply` function. + logs := strings.NewReader(applySuccessOneResourceAdded) + applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil) + b.client.Applies = applyMock // Create a named workspace that auto applies. _, err := b.client.Workspaces.Create( context.Background(), b.organization, tfe.WorkspaceCreateOptions{ - AutoApply: tfe.Bool(true), - Name: tfe.String(b.WorkspaceMapping.Prefix + "prod"), + Name: tfe.String(b.WorkspaceMapping.Prefix + "prod"), }, ) if err != nil { @@ -1451,6 +1504,7 @@ func TestCloud_applyPolicySoftFailAutoApply(t *testing.T) { op.UIIn = input op.UIOut = b.CLI op.Workspace = "prod" + op.AutoApprove = true run, err := b.Operation(context.Background(), op) if err != nil { @@ -1465,7 +1519,7 @@ func TestCloud_applyPolicySoftFailAutoApply(t *testing.T) { t.Fatalf("expected a non-empty plan") } - if len(input.answers) != 1 { + if len(input.answers) != 2 { t.Fatalf("expected an unused answer, got: %v", input.answers) } @@ -1656,3 +1710,28 @@ func TestCloud_applyVersionCheck(t *testing.T) { }) } } + +const applySuccessOneResourceAdded = ` +Terraform v0.11.10 + +Initializing plugins and modules... +null_resource.hello: Creating... +null_resource.hello: Creation complete after 0s (ID: 8657651096157629581) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. +` + +const sentinelSoftFail = ` +Sentinel Result: false + +Sentinel evaluated to false because one or more Sentinel policies evaluated +to false. This false was not due to an undefined value or runtime error. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: false + +FALSE - Passthrough.sentinel:1:1 - Rule "main" +` diff --git a/internal/cloud/backend_plan.go b/internal/cloud/backend_plan.go index 693210d4f..8b61cae5b 100644 --- a/internal/cloud/backend_plan.go +++ b/internal/cloud/backend_plan.go @@ -277,6 +277,7 @@ in order to capture the filesystem context the remote workspace expects: ConfigurationVersion: cv, Refresh: tfe.Bool(op.PlanRefresh), Workspace: w, + AutoApply: tfe.Bool(op.AutoApprove), } switch op.PlanMode { diff --git a/internal/cloud/e2e/apply_auto_approve_test.go b/internal/cloud/e2e/apply_auto_approve_test.go new file mode 100644 index 000000000..919c457e2 --- /dev/null +++ b/internal/cloud/e2e/apply_auto_approve_test.go @@ -0,0 +1,280 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "context" + "fmt" + "io/ioutil" + "log" + "os" + "strings" + "testing" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/e2e" +) + +type tfCommand struct { + command []string + expectedOutput string + expectedErr string +} + +func Test_terraform_apply_autoApprove(t *testing.T) { + ctx := context.Background() + cases := map[string]struct { + setup func(t *testing.T) (map[string]string, func()) + commands []tfCommand + validations func(t *testing.T, orgName, wsName string) + }{ + "workspace manual apply, terraform apply without auto-approve": { + setup: func(t *testing.T) (map[string]string, func()) { + org, orgCleanup := createOrganization(t) + wOpts := tfe.WorkspaceCreateOptions{ + Name: tfe.String(randomString(t)), + TerraformVersion: tfe.String(terraformVersion), + AutoApply: tfe.Bool(false), + } + workspace := createWorkspace(t, org, wOpts) + cleanup := func() { + defer orgCleanup() + } + names := map[string]string{ + "organization": org.Name, + "workspace": workspace.Name, + } + + return names, cleanup + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedOutput: "Terraform has been successfully initialized", + expectedErr: "", + }, + { + command: []string{"apply"}, + expectedOutput: "Do you want to perform these actions in workspace", + expectedErr: "Error asking approve", + }, + }, + validations: func(t *testing.T, orgName, wsName string) { + workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, wsName, &tfe.WorkspaceReadOptions{Include: "current_run"}) + if err != nil { + t.Fatal(err) + } + if workspace.CurrentRun == nil { + t.Fatal("Expected workspace to have run, but got nil") + } + if workspace.CurrentRun.Status != tfe.RunPlanned { + t.Fatalf("Expected run status to be `planned`, but is %s", workspace.CurrentRun.Status) + } + }, + }, + "workspace auto apply, terraform apply without auto-approve": { + setup: func(t *testing.T) (map[string]string, func()) { + org, orgCleanup := createOrganization(t) + wOpts := tfe.WorkspaceCreateOptions{ + Name: tfe.String(randomString(t)), + TerraformVersion: tfe.String(terraformVersion), + AutoApply: tfe.Bool(true), + } + workspace := createWorkspace(t, org, wOpts) + cleanup := func() { + defer orgCleanup() + } + names := map[string]string{ + "organization": org.Name, + "workspace": workspace.Name, + } + + return names, cleanup + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedOutput: "Terraform has been successfully initialized", + expectedErr: "", + }, + { + command: []string{"apply"}, + expectedOutput: "Do you want to perform these actions in workspace", + expectedErr: "Error asking approve", + }, + }, + validations: func(t *testing.T, orgName, wsName string) { + workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, wsName, &tfe.WorkspaceReadOptions{Include: "current_run"}) + if err != nil { + t.Fatal(err) + } + if workspace.CurrentRun == nil { + t.Fatalf("Expected workspace to have run, but got nil") + } + if workspace.CurrentRun.Status != tfe.RunPlanned { + t.Fatalf("Expected run status to be `planned`, but is %s", workspace.CurrentRun.Status) + } + }, + }, + "workspace manual apply, terraform apply auto-approve": { + setup: func(t *testing.T) (map[string]string, func()) { + org, orgCleanup := createOrganization(t) + wOpts := tfe.WorkspaceCreateOptions{ + Name: tfe.String(randomString(t)), + TerraformVersion: tfe.String(terraformVersion), + AutoApply: tfe.Bool(false), + } + workspace := createWorkspace(t, org, wOpts) + cleanup := func() { + defer orgCleanup() + } + names := map[string]string{ + "organization": org.Name, + "workspace": workspace.Name, + } + + return names, cleanup + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedOutput: "Terraform has been successfully initialized", + expectedErr: "", + }, + { + command: []string{"apply", "-auto-approve"}, + expectedOutput: "Apply complete! Resources: 1 added, 0 changed, 0 destroyed.", + expectedErr: "", + }, + }, + validations: func(t *testing.T, orgName, wsName string) { + workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, wsName, &tfe.WorkspaceReadOptions{Include: "current_run"}) + if err != nil { + t.Fatal(err) + } + if workspace.CurrentRun == nil { + t.Fatalf("Expected workspace to have run, but got nil") + } + if workspace.CurrentRun.Status != tfe.RunApplied { + t.Fatalf("Expected run status to be `applied`, but is %s", workspace.CurrentRun.Status) + } + }, + }, + "workspace auto apply, terraform apply auto-approve": { + setup: func(t *testing.T) (map[string]string, func()) { + org, orgCleanup := createOrganization(t) + + wOpts := tfe.WorkspaceCreateOptions{ + Name: tfe.String(randomString(t)), + TerraformVersion: tfe.String(terraformVersion), + AutoApply: tfe.Bool(true), + } + workspace := createWorkspace(t, org, wOpts) + cleanup := func() { + defer orgCleanup() + } + names := map[string]string{ + "organization": org.Name, + "workspace": workspace.Name, + } + + return names, cleanup + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedOutput: "Terraform has been successfully initialized", + expectedErr: "", + }, + { + command: []string{"apply", "-auto-approve"}, + expectedOutput: "Apply complete! Resources: 1 added, 0 changed, 0 destroyed.", + expectedErr: "", + }, + }, + validations: func(t *testing.T, orgName, wsName string) { + workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, wsName, &tfe.WorkspaceReadOptions{Include: "current_run"}) + if err != nil { + t.Fatal(err) + } + if workspace.CurrentRun == nil { + t.Fatalf("Expected workspace to have run, but got nil") + } + if workspace.CurrentRun.Status != tfe.RunApplied { + t.Fatalf("Expected run status to be `applied`, but is %s", workspace.CurrentRun.Status) + } + }, + }, + } + for name, tc := range cases { + log.Println("Test: ", name) + resourceData, cleanup := tc.setup(t) + defer cleanup() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + orgName := resourceData["organization"] + wsName := resourceData["workspace"] + tfBlock := createTerraformBlock(orgName, wsName) + writeMainTF(t, tfBlock, tmpDir) + tf := e2e.NewBinary(terraformBin, tmpDir) + defer tf.Close() + tf.AddEnv("TF_LOG=debug") + tf.AddEnv(cliConfigFileEnv) + + for _, cmd := range tc.commands { + stdout, stderr, err := tf.Run(cmd.command...) + if cmd.expectedErr == "" && err != nil { + t.Fatalf("Expected no error, but got %v. stderr\n: %s", err, stderr) + } + if cmd.expectedErr != "" { + if !strings.Contains(stderr, cmd.expectedErr) { + t.Fatalf("Expected to find error %s, but got %s", cmd.expectedErr, stderr) + } + } + + if cmd.expectedOutput != "" && !strings.Contains(stdout, cmd.expectedOutput) { + t.Fatalf("Expected to find output %s, but did not find in\n%s", cmd.expectedOutput, stdout) + } + } + + tc.validations(t, orgName, wsName) + } +} + +func createTerraformBlock(org, ws string) string { + return fmt.Sprintf( + `terraform { + cloud { + hostname = "%s" + organization = "%s" + + workspaces { + name = "%s" + } + } +} + +resource "random_pet" "server" { + keepers = { + uuid = uuid() + } + + length = 3 +}`, tfeHostname, org, ws) +} + +func writeMainTF(t *testing.T, block string, dir string) { + f, err := os.Create(fmt.Sprintf("%s/main.tf", dir)) + if err != nil { + t.Fatal(err) + } + _, err = f.WriteString(block) + if err != nil { + t.Fatal(err) + } + f.Close() +} diff --git a/internal/cloud/e2e/helper_test.go b/internal/cloud/e2e/helper_test.go new file mode 100644 index 000000000..7793b917f --- /dev/null +++ b/internal/cloud/e2e/helper_test.go @@ -0,0 +1,50 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "context" + "fmt" + "testing" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/go-uuid" +) + +func createOrganization(t *testing.T) (*tfe.Organization, func()) { + ctx := context.Background() + org, err := tfeClient.Organizations.Create(ctx, tfe.OrganizationCreateOptions{ + Name: tfe.String("tst-" + randomString(t)), + Email: tfe.String(fmt.Sprintf("%s@tfe.local", randomString(t))), + }) + if err != nil { + t.Fatal(err) + } + + return org, 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) + } + } +} + +func createWorkspace(t *testing.T, org *tfe.Organization, wOpts tfe.WorkspaceCreateOptions) *tfe.Workspace { + ctx := context.Background() + w, err := tfeClient.Workspaces.Create(ctx, org.Name, wOpts) + if err != nil { + t.Fatal(err) + } + + return w +} + +func randomString(t *testing.T) string { + v, err := uuid.GenerateUUID() + if err != nil { + t.Fatal(err) + } + return v +} diff --git a/internal/cloud/e2e/main_test.go b/internal/cloud/e2e/main_test.go new file mode 100644 index 000000000..414442170 --- /dev/null +++ b/internal/cloud/e2e/main_test.go @@ -0,0 +1,198 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "os" + "os/exec" + "strings" + "testing" + + tfe "github.com/hashicorp/go-tfe" +) + +var terraformVersion string +var terraformBin string +var cliConfigFileEnv string + +var tfeClient *tfe.Client +var tfeHostname string +var tfeToken string + +func TestMain(m *testing.M) { + log.SetFlags(log.LstdFlags | log.Lshortfile) + if !accTest() { + // if TF_ACC is not set, we want to skip all these tests. + return + } + teardown := setup() + code := m.Run() + teardown() + + os.Exit(code) +} + +func accTest() bool { + // TF_ACC is set when we want to run acceptance tests, meaning it relies on + // network access. + return os.Getenv("TF_ACC") != "" +} + +func setup() func() { + setTfeClient() + teardown := setupBinary() + setVersion() + ensureVersionExists() + + return func() { + teardown() + } +} + +func setTfeClient() { + hostname := os.Getenv("TFE_HOSTNAME") + token := os.Getenv("TFE_TOKEN") + if hostname == "" { + log.Fatalf("hostname cannot be empty") + } + if token == "" { + log.Fatalf("token cannot be empty") + } + tfeHostname = hostname + tfeToken = token + + cfg := &tfe.Config{ + Address: fmt.Sprintf("https://%s", hostname), + Token: token, + } + + // Create a new TFE client. + client, err := tfe.NewClient(cfg) + if err != nil { + log.Fatal(err) + } + tfeClient = client +} + +func setupBinary() func() { + log.Println("Setting up terraform binary") + tmpTerraformBinaryDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + log.Fatal(err) + } + log.Println(tmpTerraformBinaryDir) + currentDir, err := os.Getwd() + defer os.Chdir(currentDir) + if err != nil { + log.Fatal(err) + } + // Getting top level dir + dirPaths := strings.Split(currentDir, "/") + log.Println(currentDir) + topLevel := len(dirPaths) - 3 + topDir := strings.Join(dirPaths[0:topLevel], "/") + + if err := os.Chdir(topDir); err != nil { + log.Fatal(err) + } + + cmd := exec.Command("go", "build", "-o", tmpTerraformBinaryDir) + err = cmd.Run() + if err != nil { + log.Fatal(err) + } + + credFile := fmt.Sprintf("%s/dev.tfrc", tmpTerraformBinaryDir) + writeCredRC(credFile) + + terraformBin = fmt.Sprintf("%s/terraform", tmpTerraformBinaryDir) + cliConfigFileEnv = fmt.Sprintf("TF_CLI_CONFIG_FILE=%s", credFile) + + return func() { + os.RemoveAll(tmpTerraformBinaryDir) + } +} + +func setVersion() { + log.Println("Retrieving version") + cmd := exec.Command(terraformBin, "version", "-json") + out, err := cmd.Output() + if err != nil { + log.Fatal(fmt.Sprintf("Could not output terraform version: %v", err)) + } + var data map[string]interface{} + if err := json.Unmarshal(out, &data); err != nil { + log.Fatal(fmt.Sprintf("Could not unmarshal version output: %v", err)) + } + + out, err = exec.Command("git", "rev-parse", "HEAD").Output() + if err != nil { + log.Fatal(fmt.Sprintf("Could not execute go build command: %v", err)) + } + + hash := string(out)[0:8] + + terraformVersion = fmt.Sprintf("%s-%s", data["terraform_version"].(string), hash) +} + +func ensureVersionExists() { + opts := tfe.AdminTerraformVersionsListOptions{ + ListOptions: tfe.ListOptions{ + PageNumber: 1, + PageSize: 100, + }, + } + hasVersion := false + +findTfVersion: + for { + tfVersionList, err := tfeClient.Admin.TerraformVersions.List(context.Background(), opts) + if err != nil { + log.Fatalf("Could not retrieve list of terraform versions: %v", err) + } + for _, item := range tfVersionList.Items { + if item.Version == terraformVersion { + hasVersion = true + break findTfVersion + } + } + + // Exit the loop when we've seen all pages. + if tfVersionList.CurrentPage >= tfVersionList.TotalPages { + break + } + + // Update the page number to get the next page. + opts.PageNumber = tfVersionList.NextPage + } + + if !hasVersion { + log.Fatalf("Terraform Version %s does not exist in the list. Please add it.", terraformVersion) + } +} + +func writeCredRC(file string) { + creds := credentialBlock() + f, err := os.Create(file) + if err != nil { + log.Fatal(err) + } + _, err = f.WriteString(creds) + if err != nil { + log.Fatal(err) + } + f.Close() +} + +func credentialBlock() string { + return fmt.Sprintf(` +credentials "%s" { + token = "%s" +}`, tfeHostname, tfeToken) +} diff --git a/internal/cloud/tfe_client_mock.go b/internal/cloud/tfe_client_mock.go index fd93c48d9..518602c3c 100644 --- a/internal/cloud/tfe_client_mock.go +++ b/internal/cloud/tfe_client_mock.go @@ -1353,7 +1353,7 @@ func (m *MockWorkspaces) UnassignSSHKey(ctx context.Context, workspaceID string) panic("not implemented") } -func (m *MockWorkspaces) RemoteStateConsumers(ctx context.Context, workspaceID string) (*tfe.WorkspaceList, error) { +func (m *MockWorkspaces) RemoteStateConsumers(ctx context.Context, workspaceID string, options *tfe.RemoteStateConsumersListOptions) (*tfe.WorkspaceList, error) { panic("not implemented") } From d5567b479c6b580c02bd90bd587854799811cdbe Mon Sep 17 00:00:00 2001 From: Omar Ismail Date: Mon, 4 Oct 2021 09:57:46 -0400 Subject: [PATCH 26/78] Fix terraform version in cloud e2e tests to ignore prerelease. (#29) --- internal/cloud/e2e/main_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/cloud/e2e/main_test.go b/internal/cloud/e2e/main_test.go index 414442170..310032848 100644 --- a/internal/cloud/e2e/main_test.go +++ b/internal/cloud/e2e/main_test.go @@ -59,10 +59,10 @@ func setTfeClient() { hostname := os.Getenv("TFE_HOSTNAME") token := os.Getenv("TFE_TOKEN") if hostname == "" { - log.Fatalf("hostname cannot be empty") + log.Fatal("hostname cannot be empty") } if token == "" { - log.Fatalf("token cannot be empty") + log.Fatal("token cannot be empty") } tfeHostname = hostname tfeToken = token @@ -138,7 +138,9 @@ func setVersion() { hash := string(out)[0:8] - terraformVersion = fmt.Sprintf("%s-%s", data["terraform_version"].(string), hash) + fullVersion := data["terraform_version"].(string) + version := strings.Split(fullVersion, "-")[0] + terraformVersion = fmt.Sprintf("%s-%s", version, hash) } func ensureVersionExists() { From a4e196d0677c64149bcbd8297cf4a03d1c6de8a9 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Thu, 7 Oct 2021 18:56:25 -0500 Subject: [PATCH 27/78] Port backend/local: Check dependency lock consistency before any operations This is a port of the changes made in #29683 --- internal/cloud/backend_apply_test.go | 19 +++++++++++++------ internal/cloud/backend_plan_test.go | 19 +++++++++++++------ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index ff98d9928..bd819e556 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -19,6 +19,7 @@ import ( "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" + "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planfile" @@ -45,13 +46,19 @@ func testOperationApplyWithTimeout(t *testing.T, configDir string, timeout time. stateLockerView := views.NewStateLocker(arguments.ViewHuman, view) operationView := views.NewOperation(arguments.ViewHuman, false, view) + // Many of our tests use an overridden "null" provider that's just in-memory + // inside the test process, not a separate plugin on disk. + depLocks := depsfile.NewLocks() + depLocks.SetProviderOverridden(addrs.MustParseProviderSourceString("registry.terraform.io/hashicorp/null")) + return &backend.Operation{ - ConfigDir: configDir, - ConfigLoader: configLoader, - PlanRefresh: true, - StateLocker: clistate.NewLocker(timeout, stateLockerView), - Type: backend.OperationTypeApply, - View: operationView, + ConfigDir: configDir, + ConfigLoader: configLoader, + PlanRefresh: true, + StateLocker: clistate.NewLocker(timeout, stateLockerView), + Type: backend.OperationTypeApply, + View: operationView, + DependencyLocks: depLocks, }, configCleanup, done } diff --git a/internal/cloud/backend_plan_test.go b/internal/cloud/backend_plan_test.go index 19221dc90..3683f1b4f 100644 --- a/internal/cloud/backend_plan_test.go +++ b/internal/cloud/backend_plan_test.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" + "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planfile" @@ -41,13 +42,19 @@ func testOperationPlanWithTimeout(t *testing.T, configDir string, timeout time.D stateLockerView := views.NewStateLocker(arguments.ViewHuman, view) operationView := views.NewOperation(arguments.ViewHuman, false, view) + // Many of our tests use an overridden "null" provider that's just in-memory + // inside the test process, not a separate plugin on disk. + depLocks := depsfile.NewLocks() + depLocks.SetProviderOverridden(addrs.MustParseProviderSourceString("registry.terraform.io/hashicorp/null")) + return &backend.Operation{ - ConfigDir: configDir, - ConfigLoader: configLoader, - PlanRefresh: true, - StateLocker: clistate.NewLocker(timeout, stateLockerView), - Type: backend.OperationTypePlan, - View: operationView, + ConfigDir: configDir, + ConfigLoader: configLoader, + PlanRefresh: true, + StateLocker: clistate.NewLocker(timeout, stateLockerView), + Type: backend.OperationTypePlan, + View: operationView, + DependencyLocks: depLocks, }, configCleanup, done } From 412679522df42f56856455066c802f20caac3b27 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Tue, 5 Oct 2021 15:32:17 -0500 Subject: [PATCH 28/78] Remove all constraint checking This is an outdated mechanism that isn't used anymore. --- internal/cloud/backend.go | 136 ++---------------------- internal/cloud/backend_test.go | 184 --------------------------------- 2 files changed, 7 insertions(+), 313 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 91c3f6976..bd71d23ba 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -198,19 +198,9 @@ func (b *Cloud) Configure(obj cty.Value) tfdiags.Diagnostics { } // Discover the service URL to confirm that it provides the Terraform Cloud/Enterprise API - // and to get the version constraints. - service, constraints, err := b.discover() + service, err := b.discover() - // First check any contraints we might have received. - if constraints != nil { - diags = diags.Append(b.checkConstraints(constraints)) - if diags.HasErrors() { - return diags - } - } - - // When we don't have any constraints errors, also check for discovery - // errors before we continue. + // Check for errors before we continue. if err != nil { diags = diags.Append(tfdiags.AttributeValue( tfdiags.Error, @@ -358,7 +348,7 @@ func (b *Cloud) setConfigurationFields(obj cty.Value) tfdiags.Diagnostics { } // discover the TFC/E API service URL and version constraints. -func (b *Cloud) discover() (*url.URL, *disco.Constraints, error) { +func (b *Cloud) discover() (*url.URL, error) { serviceID := tfeServiceID if b.forceLocal { serviceID = stateServiceID @@ -366,124 +356,21 @@ func (b *Cloud) discover() (*url.URL, *disco.Constraints, error) { hostname, err := svchost.ForComparison(b.hostname) if err != nil { - return nil, nil, err + return nil, err } host, err := b.services.Discover(hostname) if err != nil { - return nil, nil, err + return nil, err } service, err := host.ServiceURL(serviceID) // Return the error, unless its a disco.ErrVersionNotSupported error. if _, ok := err.(*disco.ErrVersionNotSupported); !ok && err != nil { - return nil, nil, err + return nil, err } - // We purposefully ignore the error and return the previous error, as - // checking for version constraints is considered optional. - constraints, _ := host.VersionConstraints(serviceID, "terraform") - - return service, constraints, err -} - -// checkConstraints checks service version constrains against our own -// version and returns rich and informational diagnostics in case any -// incompatibilities are detected. -func (b *Cloud) checkConstraints(c *disco.Constraints) tfdiags.Diagnostics { - var diags tfdiags.Diagnostics - - if c == nil || c.Minimum == "" || c.Maximum == "" { - return diags - } - - // Generate a parsable constraints string. - excluding := "" - if len(c.Excluding) > 0 { - excluding = fmt.Sprintf(", != %s", strings.Join(c.Excluding, ", != ")) - } - constStr := fmt.Sprintf(">= %s%s, <= %s", c.Minimum, excluding, c.Maximum) - - // Create the constraints to check against. - constraints, err := version.NewConstraint(constStr) - if err != nil { - return diags.Append(checkConstraintsWarning(err)) - } - - // Create the version to check. - v, err := version.NewVersion(tfversion.Version) - if err != nil { - return diags.Append(checkConstraintsWarning(err)) - } - - // Return if we satisfy all constraints. - if constraints.Check(v) { - return diags - } - - // Find out what action (upgrade/downgrade) we should advice. - minimum, err := version.NewVersion(c.Minimum) - if err != nil { - return diags.Append(checkConstraintsWarning(err)) - } - - maximum, err := version.NewVersion(c.Maximum) - if err != nil { - return diags.Append(checkConstraintsWarning(err)) - } - - var excludes []*version.Version - for _, exclude := range c.Excluding { - v, err := version.NewVersion(exclude) - if err != nil { - return diags.Append(checkConstraintsWarning(err)) - } - excludes = append(excludes, v) - } - - // Sort all the excludes. - sort.Sort(version.Collection(excludes)) - - var action, toVersion string - switch { - case minimum.GreaterThan(v): - action = "upgrade" - toVersion = ">= " + minimum.String() - case maximum.LessThan(v): - action = "downgrade" - toVersion = "<= " + maximum.String() - case len(excludes) > 0: - // Get the latest excluded version. - action = "upgrade" - toVersion = "> " + excludes[len(excludes)-1].String() - } - - switch { - case len(excludes) == 1: - excluding = fmt.Sprintf(", excluding version %s", excludes[0].String()) - case len(excludes) > 1: - var vs []string - for _, v := range excludes { - vs = append(vs, v.String()) - } - excluding = fmt.Sprintf(", excluding versions %s", strings.Join(vs, ", ")) - default: - excluding = "" - } - - summary := fmt.Sprintf("Incompatible Terraform version v%s", v.String()) - details := fmt.Sprintf( - "The configured Terraform Enterprise backend is compatible with Terraform "+ - "versions >= %s, <= %s%s.", c.Minimum, c.Maximum, excluding, - ) - - if action != "" && toVersion != "" { - summary = fmt.Sprintf("Please %s Terraform to %s", action, toVersion) - details += fmt.Sprintf(" Please %s to a supported version and try again.", action) - } - - // Return the customized and informational error message. - return diags.Append(tfdiags.Sourceless(tfdiags.Error, summary, details)) + return service, err } // token returns the token for this host as configured in the credentials @@ -1063,15 +950,6 @@ func generalError(msg string, err error) error { } } -func checkConstraintsWarning(err error) tfdiags.Diagnostic { - return tfdiags.Sourceless( - tfdiags.Warning, - fmt.Sprintf("Failed to check version constraints: %v", err), - "Checking version constraints is considered optional, but this is an"+ - "unexpected error which should be reported.", - ) -} - // The newline in this error is to make it look good in the CLI! const initialRetryError = ` [reset][yellow]There was an error connecting to Terraform Cloud. Please do not exit diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index 4c3546ec5..f6d8ca14b 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -10,7 +10,6 @@ import ( tfe "github.com/hashicorp/go-tfe" version "github.com/hashicorp/go-version" - "github.com/hashicorp/terraform-svchost/disco" "github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/tfdiags" tfversion "github.com/hashicorp/terraform/version" @@ -492,87 +491,6 @@ func TestCloud_setConfigurationFields(t *testing.T) { } } -func TestCloud_versionConstraints(t *testing.T) { - cases := map[string]struct { - config cty.Value - prerelease string - version string - result string - }{ - "compatible version": { - config: cty.ObjectVal(map[string]cty.Value{ - "hostname": cty.NullVal(cty.String), - "organization": cty.StringVal("hashicorp"), - "token": cty.NullVal(cty.String), - "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("prod"), - "prefix": cty.NullVal(cty.String), - "tags": cty.NullVal(cty.Set(cty.String)), - }), - }), - version: "0.11.1", - }, - "version too old": { - config: cty.ObjectVal(map[string]cty.Value{ - "hostname": cty.NullVal(cty.String), - "organization": cty.StringVal("hashicorp"), - "token": cty.NullVal(cty.String), - "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("prod"), - "prefix": cty.NullVal(cty.String), - "tags": cty.NullVal(cty.Set(cty.String)), - }), - }), - version: "0.0.1", - result: "upgrade Terraform to >= 0.1.0", - }, - "version too new": { - config: cty.ObjectVal(map[string]cty.Value{ - "hostname": cty.NullVal(cty.String), - "organization": cty.StringVal("hashicorp"), - "token": cty.NullVal(cty.String), - "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("prod"), - "prefix": cty.NullVal(cty.String), - "tags": cty.NullVal(cty.Set(cty.String)), - }), - }), - version: "10.0.1", - result: "downgrade Terraform to <= 10.0.0", - }, - } - - // Save and restore the actual version. - p := tfversion.Prerelease - v := tfversion.Version - defer func() { - tfversion.Prerelease = p - tfversion.Version = v - }() - - for name, tc := range cases { - s := testServer(t) - b := New(testDisco(s)) - - // Set the version for this test. - tfversion.Prerelease = tc.prerelease - tfversion.Version = tc.version - - // Validate - _, valDiags := b.PrepareConfig(tc.config) - if valDiags.HasErrors() { - t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err()) - } - - // Configure - confDiags := b.Configure(tc.config) - if (confDiags.Err() != nil || tc.result != "") && - (confDiags.Err() == nil || !strings.Contains(confDiags.Err().Error(), tc.result)) { - t.Fatalf("%s: unexpected configure result: %v", name, confDiags.Err()) - } - } -} - func TestCloud_localBackend(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() @@ -682,108 +600,6 @@ func TestCloud_addAndRemoveWorkspacesWithPrefix(t *testing.T) { } } -func TestCloud_checkConstraints(t *testing.T) { - b, bCleanup := testBackendWithName(t) - defer bCleanup() - - cases := map[string]struct { - constraints *disco.Constraints - prerelease string - version string - result string - }{ - "compatible version": { - constraints: &disco.Constraints{ - Minimum: "0.11.0", - Maximum: "0.11.11", - }, - version: "0.11.1", - result: "", - }, - "version too old": { - constraints: &disco.Constraints{ - Minimum: "0.11.0", - Maximum: "0.11.11", - }, - version: "0.10.1", - result: "upgrade Terraform to >= 0.11.0", - }, - "version too new": { - constraints: &disco.Constraints{ - Minimum: "0.11.0", - Maximum: "0.11.11", - }, - version: "0.12.0", - result: "downgrade Terraform to <= 0.11.11", - }, - "version excluded - ordered": { - constraints: &disco.Constraints{ - Minimum: "0.11.0", - Excluding: []string{"0.11.7", "0.11.8"}, - Maximum: "0.11.11", - }, - version: "0.11.7", - result: "upgrade Terraform to > 0.11.8", - }, - "version excluded - unordered": { - constraints: &disco.Constraints{ - Minimum: "0.11.0", - Excluding: []string{"0.11.8", "0.11.6"}, - Maximum: "0.11.11", - }, - version: "0.11.6", - result: "upgrade Terraform to > 0.11.8", - }, - "list versions": { - constraints: &disco.Constraints{ - Minimum: "0.11.0", - Maximum: "0.11.11", - }, - version: "0.10.1", - result: "versions >= 0.11.0, <= 0.11.11.", - }, - "list exclusion": { - constraints: &disco.Constraints{ - Minimum: "0.11.0", - Excluding: []string{"0.11.6"}, - Maximum: "0.11.11", - }, - version: "0.11.6", - result: "excluding version 0.11.6.", - }, - "list exclusions": { - constraints: &disco.Constraints{ - Minimum: "0.11.0", - Excluding: []string{"0.11.8", "0.11.6"}, - Maximum: "0.11.11", - }, - version: "0.11.6", - result: "excluding versions 0.11.6, 0.11.8.", - }, - } - - // Save and restore the actual version. - p := tfversion.Prerelease - v := tfversion.Version - defer func() { - tfversion.Prerelease = p - tfversion.Version = v - }() - - for name, tc := range cases { - // Set the version for this test. - tfversion.Prerelease = tc.prerelease - tfversion.Version = tc.version - - // Check the constraints. - diags := b.checkConstraints(tc.constraints) - if (diags.Err() != nil || tc.result != "") && - (diags.Err() == nil || !strings.Contains(diags.Err().Error(), tc.result)) { - t.Fatalf("%s: unexpected constraints result: %v", name, diags.Err()) - } - } -} - func TestCloud_StateMgr_versionCheck(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() From 46e47ed3791816a47bd7dc3b2d88f9d587b475b0 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Tue, 5 Oct 2021 15:51:48 -0500 Subject: [PATCH 29/78] Use the 'tfe' service for everything. The 'tfe' service was appended to with various versions to denote a new 'feature' implemented by a new 'service'. This quickly proved to not be scalable, as adding an entry to the discovery document from every feature is bad. The new mechanism added was checking the TFP-API-Version header on requests for a version, instead. So we'll remove the separation here between different tfe service 'versions' and the separate 'state' service and Just Use TFE, as well as the TFP-API-Version header for all feature versioning., as well as the TFP-API-Version header for all feature versioning. --- internal/cloud/backend.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index bd71d23ba..d1b493b1c 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -34,8 +34,7 @@ import ( const ( defaultHostname = "app.terraform.io" defaultParallelism = 10 - stateServiceID = "state.v2" - tfeServiceID = "tfe.v2.1" + tfeServiceID = "tfe.v2" ) // Cloud is an implementation of EnhancedBackend in service of the Terraform Cloud/Enterprise @@ -349,11 +348,6 @@ func (b *Cloud) setConfigurationFields(obj cty.Value) tfdiags.Diagnostics { // discover the TFC/E API service URL and version constraints. func (b *Cloud) discover() (*url.URL, error) { - serviceID := tfeServiceID - if b.forceLocal { - serviceID = stateServiceID - } - hostname, err := svchost.ForComparison(b.hostname) if err != nil { return nil, err @@ -364,7 +358,7 @@ func (b *Cloud) discover() (*url.URL, error) { return nil, err } - service, err := host.ServiceURL(serviceID) + service, err := host.ServiceURL(tfeServiceID) // Return the error, unless its a disco.ErrVersionNotSupported error. if _, ok := err.(*disco.ErrVersionNotSupported); !ok && err != nil { return nil, err From 7cc53fe1636f7e3b738adbda4d26cd934db77c7e Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Thu, 7 Oct 2021 23:42:41 -0500 Subject: [PATCH 30/78] cloud: Set minimum TFE version These changes remove all of the preexisting version checking for individual features, wiping the slate clean with an overall minimum requirement of a future TFP-API-Version 2.5, which at the time of this writing is expected to be TFE v202112-1. It also actually provides that expected TFE version as an actionable error message, rather than generically saying that it isn't supported or using the somewhat opaque API version header. --- internal/cloud/backend.go | 23 ++++- internal/cloud/backend_apply.go | 70 -------------- internal/cloud/backend_apply_test.go | 134 --------------------------- internal/cloud/backend_plan.go | 70 -------------- internal/cloud/backend_plan_test.go | 134 --------------------------- internal/cloud/backend_test.go | 38 ++++++++ internal/cloud/testing.go | 66 ++++++++----- internal/cloud/versioning.go | 12 +++ 8 files changed, 112 insertions(+), 435 deletions(-) create mode 100644 internal/cloud/versioning.go diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index d1b493b1c..730d8be83 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -265,10 +265,10 @@ func (b *Cloud) Configure(obj cty.Value) tfdiags.Diagnostics { if err != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, - "Failed to create the Terraform Enterprise client", + "Failed to create the Terraform Cloud/Enterprise client", fmt.Sprintf( `Encountered an unexpected error while creating the `+ - `Terraform Enterprise client: %s.`, err, + `Terraform Cloud/Enterprise client: %s.`, err, ), )) return diags @@ -293,6 +293,25 @@ func (b *Cloud) Configure(obj cty.Value) tfdiags.Diagnostics { return diags } + // Check for the minimum version of Terraform Enterprise required. + // + // For API versions prior to 2.3, RemoteAPIVersion will return an empty string, + // so if there's an error when parsing the RemoteAPIVersion, it's handled as + // equivalent to an API version < 2.3. + currentAPIVersion, parseErr := version.NewVersion(b.client.RemoteAPIVersion()) + desiredAPIVersion, _ := version.NewVersion("2.5") + + if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Unsupported Terraform Enterprise version", + fmt.Sprintf( + `The 'cloud' option requires Terraform Enterprise %s or later.`, + apiToMinimumTFEVersion["2.5"], + ), + )) + } + // Configure a local backend for when we need to run operations locally. b.local = backendLocal.NewWithBackend(b) b.forceLocal = b.forceLocal || !entitlements.Operations diff --git a/internal/cloud/backend_apply.go b/internal/cloud/backend_apply.go index 02f006c2c..65f51ff6c 100644 --- a/internal/cloud/backend_apply.go +++ b/internal/cloud/backend_apply.go @@ -8,7 +8,6 @@ import ( "log" tfe "github.com/hashicorp/go-tfe" - version "github.com/hashicorp/go-version" "github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/terraform" @@ -87,75 +86,6 @@ func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operatio )) } - // For API versions prior to 2.3, RemoteAPIVersion will return an empty string, - // so if there's an error when parsing the RemoteAPIVersion, it's handled as - // equivalent to an API version < 2.3. - currentAPIVersion, parseErr := version.NewVersion(b.client.RemoteAPIVersion()) - - if !op.PlanRefresh { - desiredAPIVersion, _ := version.NewVersion("2.4") - - if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Planning without refresh is not supported", - fmt.Sprintf( - `The Terraform Enterprise installation at %s does not support the -refresh=false option for `+ - `remote plans.`, - b.hostname, - ), - )) - } - } - - if op.PlanMode == plans.RefreshOnlyMode { - desiredAPIVersion, _ := version.NewVersion("2.4") - - if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Refresh-only mode is not supported", - fmt.Sprintf( - `The Terraform Enterprise installation at %s does not support -refresh-only mode for `+ - `remote plans.`, - b.hostname, - ), - )) - } - } - - if len(op.ForceReplace) != 0 { - desiredAPIVersion, _ := version.NewVersion("2.4") - - if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Planning resource replacements is not supported", - fmt.Sprintf( - `The Terraform Enterprise installation at %s does not support the -replace option for `+ - `remote plans.`, - b.hostname, - ), - )) - } - } - - if len(op.Targets) != 0 { - desiredAPIVersion, _ := version.NewVersion("2.3") - - if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Resource targeting is not supported", - fmt.Sprintf( - `The Terraform Enterprise installation at %s does not support the -target option for `+ - `remote plans.`, - b.hostname, - ), - )) - } - } - // Return if there are any errors. if diags.HasErrors() { return nil, diags.Err() diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index bd819e556..ad01c0d43 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -321,38 +321,6 @@ func TestCloud_applyWithoutRefresh(t *testing.T) { } } -func TestCloud_applyWithoutRefreshIncompatibleAPIVersion(t *testing.T) { - b, bCleanup := testBackendWithName(t) - defer bCleanup() - - op, configCleanup, done := testOperationApply(t, "./testdata/apply") - defer configCleanup() - - b.client.SetFakeRemoteAPIVersion("2.3") - - op.PlanRefresh = false - op.Workspace = testBackendSingleWorkspaceName - - run, err := b.Operation(context.Background(), op) - if err != nil { - t.Fatalf("error starting operation: %v", err) - } - - <-run.Done() - output := done(t) - if run.Result == backend.OperationSuccess { - t.Fatal("expected apply operation to fail") - } - if !run.PlanEmpty { - t.Fatalf("expected plan to be empty") - } - - errOutput := output.Stderr() - if !strings.Contains(errOutput, "Planning without refresh is not supported") { - t.Fatalf("expected a not supported error, got: %v", errOutput) - } -} - func TestCloud_applyWithRefreshOnly(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() @@ -390,38 +358,6 @@ func TestCloud_applyWithRefreshOnly(t *testing.T) { } } -func TestCloud_applyWithRefreshOnlyIncompatibleAPIVersion(t *testing.T) { - b, bCleanup := testBackendWithName(t) - defer bCleanup() - - op, configCleanup, done := testOperationApply(t, "./testdata/apply") - defer configCleanup() - - b.client.SetFakeRemoteAPIVersion("2.3") - - op.PlanMode = plans.RefreshOnlyMode - op.Workspace = testBackendSingleWorkspaceName - - run, err := b.Operation(context.Background(), op) - if err != nil { - t.Fatalf("error starting operation: %v", err) - } - - <-run.Done() - output := done(t) - if run.Result == backend.OperationSuccess { - t.Fatal("expected apply operation to fail") - } - if !run.PlanEmpty { - t.Fatalf("expected plan to be empty") - } - - errOutput := output.Stderr() - if !strings.Contains(errOutput, "Refresh-only mode is not supported") { - t.Fatalf("expected a not supported error, got: %v", errOutput) - } -} - func TestCloud_applyWithTarget(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() @@ -461,42 +397,6 @@ func TestCloud_applyWithTarget(t *testing.T) { } } -func TestCloud_applyWithTargetIncompatibleAPIVersion(t *testing.T) { - b, bCleanup := testBackendWithName(t) - defer bCleanup() - - op, configCleanup, done := testOperationApply(t, "./testdata/apply") - defer configCleanup() - - // Set the tfe client's RemoteAPIVersion to an empty string, to mimic - // API versions prior to 2.3. - b.client.SetFakeRemoteAPIVersion("") - - addr, _ := addrs.ParseAbsResourceStr("null_resource.foo") - - op.Targets = []addrs.Targetable{addr} - op.Workspace = testBackendSingleWorkspaceName - - run, err := b.Operation(context.Background(), op) - if err != nil { - t.Fatalf("error starting operation: %v", err) - } - - <-run.Done() - output := done(t) - if run.Result == backend.OperationSuccess { - t.Fatal("expected apply operation to fail") - } - if !run.PlanEmpty { - t.Fatalf("expected plan to be empty") - } - - errOutput := output.Stderr() - if !strings.Contains(errOutput, "Resource targeting is not supported") { - t.Fatalf("expected a targeting error, got: %v", errOutput) - } -} - func TestCloud_applyWithReplace(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() @@ -536,40 +436,6 @@ func TestCloud_applyWithReplace(t *testing.T) { } } -func TestCloud_applyWithReplaceIncompatibleAPIVersion(t *testing.T) { - b, bCleanup := testBackendWithName(t) - defer bCleanup() - - op, configCleanup, done := testOperationApply(t, "./testdata/apply") - defer configCleanup() - - b.client.SetFakeRemoteAPIVersion("2.3") - - addr, _ := addrs.ParseAbsResourceInstanceStr("null_resource.foo") - - op.ForceReplace = []addrs.AbsResourceInstance{addr} - op.Workspace = testBackendSingleWorkspaceName - - run, err := b.Operation(context.Background(), op) - if err != nil { - t.Fatalf("error starting operation: %v", err) - } - - <-run.Done() - output := done(t) - if run.Result == backend.OperationSuccess { - t.Fatal("expected apply operation to fail") - } - if !run.PlanEmpty { - t.Fatalf("expected plan to be empty") - } - - errOutput := output.Stderr() - if !strings.Contains(errOutput, "Planning resource replacements is not supported") { - t.Fatalf("expected a not supported error, got: %v", errOutput) - } -} - func TestCloud_applyWithVariables(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() diff --git a/internal/cloud/backend_plan.go b/internal/cloud/backend_plan.go index 8b61cae5b..5455ec06e 100644 --- a/internal/cloud/backend_plan.go +++ b/internal/cloud/backend_plan.go @@ -15,7 +15,6 @@ import ( "time" tfe "github.com/hashicorp/go-tfe" - version "github.com/hashicorp/go-version" "github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/tfdiags" @@ -93,75 +92,6 @@ func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation )) } - // For API versions prior to 2.3, RemoteAPIVersion will return an empty string, - // so if there's an error when parsing the RemoteAPIVersion, it's handled as - // equivalent to an API version < 2.3. - currentAPIVersion, parseErr := version.NewVersion(b.client.RemoteAPIVersion()) - - if len(op.Targets) != 0 { - desiredAPIVersion, _ := version.NewVersion("2.3") - - if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Resource targeting is not supported", - fmt.Sprintf( - `The Terraform Enterprise installation at %s does not support the -target option for `+ - `remote plans.`, - b.hostname, - ), - )) - } - } - - if !op.PlanRefresh { - desiredAPIVersion, _ := version.NewVersion("2.4") - - if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Planning without refresh is not supported", - fmt.Sprintf( - `The Terraform Enterprise installation at %s does not support the -refresh=false option for `+ - `remote plans.`, - b.hostname, - ), - )) - } - } - - if len(op.ForceReplace) != 0 { - desiredAPIVersion, _ := version.NewVersion("2.4") - - if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Planning resource replacements is not supported", - fmt.Sprintf( - `The Terraform Enterprise installation at %s does not support the -replace option for `+ - `remote plans.`, - b.hostname, - ), - )) - } - } - - if op.PlanMode == plans.RefreshOnlyMode { - desiredAPIVersion, _ := version.NewVersion("2.4") - - if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Refresh-only mode is not supported", - fmt.Sprintf( - `The Terraform Enterprise installation at %s does not support -refresh-only mode for `+ - `remote plans.`, - b.hostname, - ), - )) - } - } - // Return if there are any errors. if diags.HasErrors() { return nil, diags.Err() diff --git a/internal/cloud/backend_plan_test.go b/internal/cloud/backend_plan_test.go index 3683f1b4f..3a6d2e74b 100644 --- a/internal/cloud/backend_plan_test.go +++ b/internal/cloud/backend_plan_test.go @@ -324,38 +324,6 @@ func TestCloud_planWithoutRefresh(t *testing.T) { } } -func TestCloud_planWithoutRefreshIncompatibleAPIVersion(t *testing.T) { - b, bCleanup := testBackendWithName(t) - defer bCleanup() - - op, configCleanup, done := testOperationPlan(t, "./testdata/plan") - defer configCleanup() - - b.client.SetFakeRemoteAPIVersion("2.3") - - op.PlanRefresh = false - op.Workspace = testBackendSingleWorkspaceName - - run, err := b.Operation(context.Background(), op) - if err != nil { - t.Fatalf("error starting operation: %v", err) - } - - <-run.Done() - output := done(t) - if run.Result == backend.OperationSuccess { - t.Fatal("expected plan operation to fail") - } - if !run.PlanEmpty { - t.Fatalf("expected plan to be empty") - } - - errOutput := output.Stderr() - if !strings.Contains(errOutput, "Planning without refresh is not supported") { - t.Fatalf("expected not supported error, got: %v", errOutput) - } -} - func TestCloud_planWithRefreshOnly(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() @@ -393,38 +361,6 @@ func TestCloud_planWithRefreshOnly(t *testing.T) { } } -func TestCloud_planWithRefreshOnlyIncompatibleAPIVersion(t *testing.T) { - b, bCleanup := testBackendWithName(t) - defer bCleanup() - - op, configCleanup, done := testOperationPlan(t, "./testdata/plan") - defer configCleanup() - - b.client.SetFakeRemoteAPIVersion("2.3") - - op.PlanMode = plans.RefreshOnlyMode - op.Workspace = testBackendSingleWorkspaceName - - run, err := b.Operation(context.Background(), op) - if err != nil { - t.Fatalf("error starting operation: %v", err) - } - - <-run.Done() - output := done(t) - if run.Result == backend.OperationSuccess { - t.Fatal("expected plan operation to fail") - } - if !run.PlanEmpty { - t.Fatalf("expected plan to be empty") - } - - errOutput := output.Stderr() - if !strings.Contains(errOutput, "Refresh-only mode is not supported") { - t.Fatalf("expected not supported error, got: %v", errOutput) - } -} - func TestCloud_planWithTarget(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() @@ -494,42 +430,6 @@ func TestCloud_planWithTarget(t *testing.T) { } } -func TestCloud_planWithTargetIncompatibleAPIVersion(t *testing.T) { - b, bCleanup := testBackendWithName(t) - defer bCleanup() - - op, configCleanup, done := testOperationPlan(t, "./testdata/plan") - defer configCleanup() - - // Set the tfe client's RemoteAPIVersion to an empty string, to mimic - // API versions prior to 2.3. - b.client.SetFakeRemoteAPIVersion("") - - addr, _ := addrs.ParseAbsResourceStr("null_resource.foo") - - op.Targets = []addrs.Targetable{addr} - op.Workspace = testBackendSingleWorkspaceName - - run, err := b.Operation(context.Background(), op) - if err != nil { - t.Fatalf("error starting operation: %v", err) - } - - <-run.Done() - output := done(t) - if run.Result == backend.OperationSuccess { - t.Fatal("expected plan operation to fail") - } - if !run.PlanEmpty { - t.Fatalf("expected plan to be empty") - } - - errOutput := output.Stderr() - if !strings.Contains(errOutput, "Resource targeting is not supported") { - t.Fatalf("expected a targeting error, got: %v", errOutput) - } -} - func TestCloud_planWithReplace(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() @@ -569,40 +469,6 @@ func TestCloud_planWithReplace(t *testing.T) { } } -func TestCloud_planWithReplaceIncompatibleAPIVersion(t *testing.T) { - b, bCleanup := testBackendWithName(t) - defer bCleanup() - - op, configCleanup, done := testOperationPlan(t, "./testdata/plan") - defer configCleanup() - - b.client.SetFakeRemoteAPIVersion("2.3") - - addr, _ := addrs.ParseAbsResourceInstanceStr("null_resource.foo") - - op.ForceReplace = []addrs.AbsResourceInstance{addr} - op.Workspace = testBackendSingleWorkspaceName - - run, err := b.Operation(context.Background(), op) - if err != nil { - t.Fatalf("error starting operation: %v", err) - } - - <-run.Done() - output := done(t) - if run.Result == backend.OperationSuccess { - t.Fatal("expected plan operation to fail") - } - if !run.PlanEmpty { - t.Fatalf("expected plan to be empty") - } - - errOutput := output.Stderr() - if !strings.Contains(errOutput, "Planning resource replacements is not supported") { - t.Fatalf("expected not supported error, got: %v", errOutput) - } -} - func TestCloud_planWithVariables(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index f6d8ca14b..01072898c 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -3,6 +3,7 @@ package cloud import ( "context" "fmt" + "net/http" "os" "reflect" "strings" @@ -311,6 +312,43 @@ func TestCloud_config(t *testing.T) { } } +func TestCloud_configVerifyMinimumTFEVersion(t *testing.T) { + config := cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "prefix": cty.NullVal(cty.String), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("billing"), + }, + ), + }), + }) + + handlers := map[string]func(http.ResponseWriter, *http.Request){ + "/api/v2/ping": func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("TFP-API-Version", "2.4") + }, + } + s := testServerWithHandlers(handlers) + + b := New(testDisco(s)) + + confDiags := b.Configure(config) + if confDiags.Err() == nil { + t.Fatalf("expected configure to error") + } + + expected := "The 'cloud' option requires Terraform Enterprise v202201-1 or later." + if !strings.Contains(confDiags.Err().Error(), expected) { + t.Fatalf("expected configure to error with %q, got %q", expected, confDiags.Err().Error()) + } +} + func TestCloud_setConfigurationFields(t *testing.T) { originalForceBackendEnv := os.Getenv("TF_FORCE_LOCAL_BACKEND") diff --git a/internal/cloud/testing.go b/internal/cloud/testing.go index b2811fe2f..75e53c5bc 100644 --- a/internal/cloud/testing.go +++ b/internal/cloud/testing.go @@ -144,13 +144,13 @@ func testBackend(t *testing.T, obj cty.Value) (*Cloud, func()) { // Configure the backend so the client is created. newObj, valDiags := b.PrepareConfig(obj) if len(valDiags) != 0 { - t.Fatal(valDiags.ErrWithWarnings()) + t.Fatalf("testBackend: backend.PrepareConfig() failed: %s", valDiags.ErrWithWarnings()) } obj = newObj confDiags := b.Configure(obj) if len(confDiags) != 0 { - t.Fatal(confDiags.ErrWithWarnings()) + t.Fatalf("testBackend: backend.Configure() failed: %s", confDiags.ErrWithWarnings()) } // Get a new mock client. @@ -215,22 +215,42 @@ func testLocalBackend(t *testing.T, cloud *Cloud) backend.Enhanced { return b } -// testServer returns a *httptest.Server used for local testing. +// testServer returns a started *httptest.Server used for local testing with the default set of +// request handlers. func testServer(t *testing.T) *httptest.Server { - mux := http.NewServeMux() + return testServerWithHandlers(testDefaultRequestHandlers) +} +// testServerWithHandlers returns a started *httptest.Server with the given set of request handlers +// overriding any default request handlers (testDefaultRequestHandlers). +func testServerWithHandlers(handlers map[string]func(http.ResponseWriter, *http.Request)) *httptest.Server { + mux := http.NewServeMux() + for route, handler := range handlers { + mux.HandleFunc(route, handler) + } + for route, handler := range testDefaultRequestHandlers { + if handlers[route] == nil { + mux.HandleFunc(route, handler) + } + } + + return httptest.NewServer(mux) +} + +// testDefaultRequestHandlers is a map of request handlers intended to be used in a request +// multiplexer for a test server. A caller may use testServerWithHandlers to start a server with +// this base set of routes, and override a particular route for whatever edge case is being tested. +var testDefaultRequestHandlers = map[string]func(http.ResponseWriter, *http.Request){ // Respond to service discovery calls. - mux.HandleFunc("/well-known/terraform.json", func(w http.ResponseWriter, r *http.Request) { + "/well-known/terraform.json": func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") io.WriteString(w, `{ - "state.v2": "/api/v2/", - "tfe.v2.1": "/api/v2/", - "versions.v1": "/v1/versions/" + "tfe.v2": "/api/v2/", }`) - }) + }, // Respond to service version constraints calls. - mux.HandleFunc("/v1/versions/", func(w http.ResponseWriter, r *http.Request) { + "/v1/versions/": func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") io.WriteString(w, fmt.Sprintf(`{ "service": "%s", @@ -238,16 +258,16 @@ func testServer(t *testing.T) *httptest.Server { "minimum": "0.1.0", "maximum": "10.0.0" }`, path.Base(r.URL.Path))) - }) + }, // Respond to pings to get the API version header. - mux.HandleFunc("/api/v2/ping", func(w http.ResponseWriter, r *http.Request) { + "/api/v2/ping": func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - w.Header().Set("TFP-API-Version", "2.4") - }) + w.Header().Set("TFP-API-Version", "2.5") + }, // Respond to the initial query to read the hashicorp org entitlements. - mux.HandleFunc("/api/v2/organizations/hashicorp/entitlement-set", func(w http.ResponseWriter, r *http.Request) { + "/api/v2/organizations/hashicorp/entitlement-set": func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/vnd.api+json") io.WriteString(w, `{ "data": { @@ -263,10 +283,10 @@ func testServer(t *testing.T) *httptest.Server { } } }`) - }) + }, // Respond to the initial query to read the no-operations org entitlements. - mux.HandleFunc("/api/v2/organizations/no-operations/entitlement-set", func(w http.ResponseWriter, r *http.Request) { + "/api/v2/organizations/no-operations/entitlement-set": func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/vnd.api+json") io.WriteString(w, `{ "data": { @@ -282,11 +302,11 @@ func testServer(t *testing.T) *httptest.Server { } } }`) - }) + }, // All tests that are assumed to pass will use the hashicorp organization, // so for all other organization requests we will return a 404. - mux.HandleFunc("/api/v2/organizations/", func(w http.ResponseWriter, r *http.Request) { + "/api/v2/organizations/": func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(404) io.WriteString(w, `{ "errors": [ @@ -296,18 +316,14 @@ func testServer(t *testing.T) *httptest.Server { } ] }`) - }) - - return httptest.NewServer(mux) + }, } // testDisco returns a *disco.Disco mapping app.terraform.io and // localhost to a local test server. func testDisco(s *httptest.Server) *disco.Disco { services := map[string]interface{}{ - "state.v2": fmt.Sprintf("%s/api/v2/", s.URL), - "tfe.v2.1": fmt.Sprintf("%s/api/v2/", s.URL), - "versions.v1": fmt.Sprintf("%s/v1/versions/", s.URL), + "tfe.v2": fmt.Sprintf("%s/api/v2/", s.URL), } d := disco.NewWithCredentialsSource(credsSrc) d.SetUserAgent(httpclient.TerraformUserAgent(version.String())) diff --git a/internal/cloud/versioning.go b/internal/cloud/versioning.go new file mode 100644 index 000000000..b07fd74aa --- /dev/null +++ b/internal/cloud/versioning.go @@ -0,0 +1,12 @@ +package cloud + +// This simple map exists to translate TFP-API-Version strings to the TFE release where it was +// introduced, to provide actionable feedback on features that may be unsupported by the TFE +// installation but present in this version of Terraform. +// +// The cloud package here, introduced in Terraform 1.1.0, requires a minimum of 2.5 (v202201-1) +// The TFP-API-Version header that this refers to was introduced in 2.3 (v202006-1), so an absent +// header can be considered < 2.3. +var apiToMinimumTFEVersion = map[string]string{ + "2.5": "v202201-1", +} From 55fc590904c90c07e91b205f727c5106e560b6ed Mon Sep 17 00:00:00 2001 From: Omar Ismail Date: Wed, 22 Sep 2021 17:53:33 -0400 Subject: [PATCH 31/78] Teraform Cloud Backend State Migration * determining source or destination to cloud * handling single to single state migrations to cloud, using a name strategy or a tags strategy * Add end-to-end tests for state migration. --- go.mod | 2 + go.sum | 3 + internal/cloud/e2e/apply_auto_approve_test.go | 30 +-- internal/cloud/e2e/helper_test.go | 78 +++++++ internal/cloud/e2e/main_test.go | 38 ---- .../e2e/migrate_state_multi_to_tfc_test.go | 18 ++ ...igrate_state_remote_backend_to_tfc_test.go | 31 +++ .../e2e/migrate_state_single_to_tfc_test.go | 196 ++++++++++++++++++ .../e2e/migrate_state_tfc_to_other_test.go | 113 ++++++++++ .../e2e/migrate_state_tfc_to_tfc_test.go | 21 ++ internal/command/meta_backend_migrate.go | 122 +++++++++-- 11 files changed, 568 insertions(+), 84 deletions(-) create mode 100644 internal/cloud/e2e/migrate_state_multi_to_tfc_test.go create mode 100644 internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go create mode 100644 internal/cloud/e2e/migrate_state_single_to_tfc_test.go create mode 100644 internal/cloud/e2e/migrate_state_tfc_to_other_test.go create mode 100644 internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go diff --git a/go.mod b/go.mod index 6dfa7bdcc..9910399a3 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ require ( cloud.google.com/go/storage v1.10.0 github.com/Azure/azure-sdk-for-go v52.5.0+incompatible github.com/Azure/go-autorest/autorest v0.11.18 + github.com/Netflix/go-expect v0.0.0-20211003183012-e1a7c020ce25 github.com/agext/levenshtein v1.2.3 github.com/aliyun/alibaba-cloud-sdk-go v0.0.0-20190329064014-6e358769c32a github.com/aliyun/aliyun-oss-go-sdk v0.0.0-20190103054945-8205d1f41e70 @@ -154,6 +155,7 @@ require ( github.com/jstemmer/go-junit-report v0.9.1 // indirect github.com/jtolds/gls v4.2.1+incompatible // indirect github.com/klauspost/compress v1.11.2 // indirect + github.com/kr/pty v1.1.1 // indirect github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 // indirect github.com/mattn/go-colorable v0.1.6 // indirect github.com/mitchellh/go-testing-interface v1.0.0 // indirect diff --git a/go.sum b/go.sum index aaba3e315..4b6b9daa3 100644 --- a/go.sum +++ b/go.sum @@ -89,6 +89,8 @@ github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuN github.com/Microsoft/go-winio v0.5.0 h1:Elr9Wn+sGKPlkaBvwu4mTrxtmOp3F3yV9qhaHbXGjwU= github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/Netflix/go-expect v0.0.0-20211003183012-e1a7c020ce25 h1:hWfsqBaNZUHztXA78g7Y2Jj3rDQaTCZhhFwz43i2VlA= +github.com/Netflix/go-expect v0.0.0-20211003183012-e1a7c020ce25/go.mod h1:68ORG0HSEWDuH5Eh73AFbYWZ1zT4Y+b0vhOa+vZRUdI= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM= @@ -453,6 +455,7 @@ github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFB github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= diff --git a/internal/cloud/e2e/apply_auto_approve_test.go b/internal/cloud/e2e/apply_auto_approve_test.go index 919c457e2..8ece4836d 100644 --- a/internal/cloud/e2e/apply_auto_approve_test.go +++ b/internal/cloud/e2e/apply_auto_approve_test.go @@ -16,12 +16,6 @@ import ( "github.com/hashicorp/terraform/internal/e2e" ) -type tfCommand struct { - command []string - expectedOutput string - expectedErr string -} - func Test_terraform_apply_autoApprove(t *testing.T) { ctx := context.Background() cases := map[string]struct { @@ -218,7 +212,7 @@ func Test_terraform_apply_autoApprove(t *testing.T) { } orgName := resourceData["organization"] wsName := resourceData["workspace"] - tfBlock := createTerraformBlock(orgName, wsName) + tfBlock := terraformConfigCloudBackendName(orgName, wsName) writeMainTF(t, tfBlock, tmpDir) tf := e2e.NewBinary(terraformBin, tmpDir) defer tf.Close() @@ -245,28 +239,6 @@ func Test_terraform_apply_autoApprove(t *testing.T) { } } -func createTerraformBlock(org, ws string) string { - return fmt.Sprintf( - `terraform { - cloud { - hostname = "%s" - organization = "%s" - - workspaces { - name = "%s" - } - } -} - -resource "random_pet" "server" { - keepers = { - uuid = uuid() - } - - length = 3 -}`, tfeHostname, org, ws) -} - func writeMainTF(t *testing.T, block string, dir string) { f, err := os.Create(fmt.Sprintf("%s/main.tf", dir)) if err != nil { diff --git a/internal/cloud/e2e/helper_test.go b/internal/cloud/e2e/helper_test.go index 7793b917f..cad01a5c1 100644 --- a/internal/cloud/e2e/helper_test.go +++ b/internal/cloud/e2e/helper_test.go @@ -7,11 +7,30 @@ import ( "context" "fmt" "testing" + "time" tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/go-uuid" ) +const ( + expectConsoleTimeout = 15 * time.Second +) + +type tfCommand struct { + command []string + expectedOutput string + expectedErr string + expectError bool + userInput []string + postInputOutput string +} + +type operationSets struct { + commands []tfCommand + prep func(t *testing.T, orgName, dir string) +} + func createOrganization(t *testing.T) (*tfe.Organization, func()) { ctx := context.Background() org, err := tfeClient.Organizations.Create(ctx, tfe.OrganizationCreateOptions{ @@ -48,3 +67,62 @@ func randomString(t *testing.T) string { } return v } + +func terraformConfigLocalBackend() string { + return fmt.Sprintf(` +terraform { + backend "local" { + } +} + +output "val" { + value = "${terraform.workspace}" +} +`) +} + +func terraformConfigCloudBackendTags(org, tag string) string { + return fmt.Sprintf(` +terraform { + cloud { + hostname = "%s" + organization = "%s" + + workspaces { + tags = ["%s"] + } + } +} + +resource "random_pet" "server" { + keepers = { + uuid = uuid() + } + + length = 3 +} +`, tfeHostname, org, tag) +} + +func terraformConfigCloudBackendName(org, name string) string { + return fmt.Sprintf(` +terraform { + cloud { + hostname = "%s" + organization = "%s" + + workspaces { + name = "%s" + } + } +} + +resource "random_pet" "server" { + keepers = { + uuid = uuid() + } + + length = 3 +} +`, tfeHostname, org, name) +} diff --git a/internal/cloud/e2e/main_test.go b/internal/cloud/e2e/main_test.go index 310032848..58653e002 100644 --- a/internal/cloud/e2e/main_test.go +++ b/internal/cloud/e2e/main_test.go @@ -4,7 +4,6 @@ package main import ( - "context" "encoding/json" "fmt" "io/ioutil" @@ -48,7 +47,6 @@ func setup() func() { setTfeClient() teardown := setupBinary() setVersion() - ensureVersionExists() return func() { teardown() @@ -143,42 +141,6 @@ func setVersion() { terraformVersion = fmt.Sprintf("%s-%s", version, hash) } -func ensureVersionExists() { - opts := tfe.AdminTerraformVersionsListOptions{ - ListOptions: tfe.ListOptions{ - PageNumber: 1, - PageSize: 100, - }, - } - hasVersion := false - -findTfVersion: - for { - tfVersionList, err := tfeClient.Admin.TerraformVersions.List(context.Background(), opts) - if err != nil { - log.Fatalf("Could not retrieve list of terraform versions: %v", err) - } - for _, item := range tfVersionList.Items { - if item.Version == terraformVersion { - hasVersion = true - break findTfVersion - } - } - - // Exit the loop when we've seen all pages. - if tfVersionList.CurrentPage >= tfVersionList.TotalPages { - break - } - - // Update the page number to get the next page. - opts.PageNumber = tfVersionList.NextPage - } - - if !hasVersion { - log.Fatalf("Terraform Version %s does not exist in the list. Please add it.", terraformVersion) - } -} - func writeCredRC(file string) { creds := credentialBlock() f, err := os.Create(file) diff --git a/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go new file mode 100644 index 000000000..a6c4f8190 --- /dev/null +++ b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go @@ -0,0 +1,18 @@ +package main + +import ( + "testing" +) + +/* + "multi" == multi-backend, multiple workspaces + -- when cloud config == name -> + ---- prompt -> do you want to ONLY migrate the current workspace + + -- when cloud config == tags + -- If Default present, prompt to rename default. + -- Then -> Prompt with * +*/ +func Test_migrate_multi_to_tfc(t *testing.T) { + t.Skip("todo: see comments") +} diff --git a/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go b/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go new file mode 100644 index 000000000..6819ea7aa --- /dev/null +++ b/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "testing" +) + +// REMOTE BACKEND +/* + - RB name -> TFC name + -- straight copy if only if different name, or same WS name in diff org + -- other + -- ensure that the local workspace, after migration, is the new name (in the tfc config block) + - RB name -> TFC tags + -- just add tag, if in same org + -- If new org, if WS exists, just add tag + -- If new org, if WS not exists, create and add tag + - RB prefix -> TFC name + -- create if not exists + -- migrate the current worksapce state to ws name + - RB prefix -> TFC tags + -- update previous workspaces (prefix + local) with cloud config tag + -- Rename the local workspaces to match the TFC workspaces (prefix + former local, ie app-prod). inform user + +*/ +func Test_migrate_remote_backend_name_to_tfc(t *testing.T) { + t.Skip("todo: see comments") +} + +func Test_migrate_remote_backend_prefix_to_tfc(t *testing.T) { + t.Skip("todo: see comments") +} diff --git a/internal/cloud/e2e/migrate_state_single_to_tfc_test.go b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go new file mode 100644 index 000000000..28292419c --- /dev/null +++ b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go @@ -0,0 +1,196 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "context" + "fmt" + "io/ioutil" + "os" + "testing" + + expect "github.com/Netflix/go-expect" + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/e2e" +) + +func Test_migrate_single_to_tfc(t *testing.T) { + ctx := context.Background() + + cases := map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "migrate using cloud workspace name strategy": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tfBlock := terraformConfigLocalBackend() + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedOutput: `Successfully configured the backend "local"!`, + }, + { + command: []string{"apply"}, + userInput: []string{"yes"}, + expectedOutput: `Do you want to perform these actions?`, + postInputOutput: `Apply complete!`, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "new-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state"}, + expectedOutput: `Do you want to copy existing state to the new backend?`, + userInput: []string{"yes"}, + postInputOutput: `Successfully configured the backend "cloud"!`, + }, + { + command: []string{"workspace", "list"}, + expectedOutput: `new-workspace`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{}) + if err != nil { + t.Fatal(err) + } + ws := wsList.Items[0] + if ws.Name != "new-workspace" { + t.Fatalf("Expected workspace to be `new-workspace`, but is %s", ws.Name) + } + }, + }, + "migrate using cloud workspace tags strategy": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tfBlock := terraformConfigLocalBackend() + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedOutput: `Successfully configured the backend "local"!`, + }, + { + command: []string{"apply"}, + userInput: []string{"yes"}, + expectedOutput: `Do you want to perform these actions?`, + postInputOutput: `Apply complete!`, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state"}, + expectedOutput: `The "cloud" backend configuration only allows named workspaces!`, + userInput: []string{"new-workspace", "yes"}, + postInputOutput: `Successfully configured the backend "cloud"!`, + }, + { + command: []string{"workspace", "list"}, + expectedOutput: `new-workspace`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{ + Tags: tfe.String("app"), + }) + if err != nil { + t.Fatal(err) + } + ws := wsList.Items[0] + if ws.Name != "new-workspace" { + t.Fatalf("Expected workspace to be `new-workspace`, but is %s", ws.Name) + } + }, + }, + } + + for name, tc := range cases { + fmt.Println("Test: ", name) + organization, cleanup := createOrganization(t) + defer cleanup() + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=info") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedOutput) + if err != nil { + t.Fatal(err) + } + } + + if len(tfCmd.userInput) > 0 { + for _, input := range tfCmd.userInput { + exp.SendLine(input) + } + } + + if tfCmd.postInputOutput != "" { + _, err := exp.ExpectString(tfCmd.postInputOutput) + if err != nil { + t.Fatal(err) + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } + } +} diff --git a/internal/cloud/e2e/migrate_state_tfc_to_other_test.go b/internal/cloud/e2e/migrate_state_tfc_to_other_test.go new file mode 100644 index 000000000..750a315b8 --- /dev/null +++ b/internal/cloud/e2e/migrate_state_tfc_to_other_test.go @@ -0,0 +1,113 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "fmt" + "io/ioutil" + "os" + "testing" + + expect "github.com/Netflix/go-expect" + "github.com/hashicorp/terraform/internal/e2e" +) + +func Test_migrate_tfc_to_other(t *testing.T) { + cases := map[string]struct { + operations []operationSets + }{ + "migrate from cloud to local backend": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "new-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedOutput: `Successfully configured the backend "cloud"!`, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tfBlock := terraformConfigLocalBackend() + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state"}, + expectedOutput: `Migrating state from Terraform Cloud to another backend is not yet implemented.`, + expectError: true, + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + fmt.Println("Test: ", name) + organization, cleanup := createOrganization(t) + defer cleanup() + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=info") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedOutput) + if err != nil { + t.Fatal(err) + } + } + + if len(tfCmd.userInput) > 0 { + for _, input := range tfCmd.userInput { + exp.SendLine(input) + } + } + + if tfCmd.postInputOutput != "" { + _, err := exp.ExpectString(tfCmd.postInputOutput) + if err != nil { + t.Fatal(err) + } + } + + err = cmd.Wait() + if err != nil && !tfCmd.expectError { + t.Fatal(err) + } + } + } + } +} diff --git a/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go b/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go new file mode 100644 index 000000000..17bbc0506 --- /dev/null +++ b/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "testing" +) + +/* + + If org to org, treat it like a new backend. Then go through the multi/single logic + + If same org, but name/tag changes + config name -> config name + -- straight copy + config name -> config tags + -- jsut add tag to workspace. + config tags -> config name + -- straight copy +*/ +func Test_migrate_tfc_to_tfc(t *testing.T) { + t.Skip("todo: see comments") +} diff --git a/internal/command/meta_backend_migrate.go b/internal/command/meta_backend_migrate.go index e998b0ffd..132bd96ef 100644 --- a/internal/command/meta_backend_migrate.go +++ b/internal/command/meta_backend_migrate.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/cloud" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" @@ -46,25 +47,18 @@ func (m *Meta) backendMigrateState(opts *backendMigrateOpts) error { log.Printf("[TRACE] backendMigrateState: need to migrate from %q to %q backend config", opts.SourceType, opts.DestinationType) // We need to check what the named state status is. If we're converting // from multi-state to single-state for example, we need to handle that. - var sourceSingleState, destinationSingleState bool - sourceWorkspaces, err := opts.Source.Workspaces() - if err == backend.ErrWorkspacesNotSupported { - sourceSingleState = true - err = nil - } - if err != nil { - return fmt.Errorf(strings.TrimSpace( - errMigrateLoadStates), opts.SourceType, err) - } + var sourceSingleState, destinationSingleState, sourceTFC, destinationTFC bool - destinationWorkspaces, err := opts.Destination.Workspaces() - if err == backend.ErrWorkspacesNotSupported { - destinationSingleState = true - err = nil - } + _, sourceTFC = opts.Source.(*cloud.Cloud) + _, destinationTFC = opts.Destination.(*cloud.Cloud) + + sourceWorkspaces, sourceSingleState, err := retrieveWorkspaces(opts.Source, opts.SourceType) if err != nil { - return fmt.Errorf(strings.TrimSpace( - errMigrateLoadStates), opts.DestinationType, err) + return err + } + destinationWorkspaces, destinationSingleState, err := retrieveWorkspaces(opts.Destination, opts.SourceType) + if err != nil { + return err } // Set up defaults @@ -103,6 +97,9 @@ func (m *Meta) backendMigrateState(opts *backendMigrateOpts) error { // Determine migration behavior based on whether the source/destination // supports multi-state. switch { + case sourceTFC || destinationTFC: + return m.backendMigrateTFC(opts) + // Single-state to single-state. This is the easiest case: we just // copy the default state directly. case sourceSingleState && destinationSingleState: @@ -497,6 +494,91 @@ func (m *Meta) backendMigrateNonEmptyConfirm( return m.confirm(inputOpts) } +func retrieveWorkspaces(back backend.Backend, sourceType string) ([]string, bool, error) { + var singleState bool + var err error + workspaces, err := back.Workspaces() + if err == backend.ErrWorkspacesNotSupported { + singleState = true + err = nil + } + if err != nil { + return nil, singleState, fmt.Errorf(strings.TrimSpace( + errMigrateLoadStates), sourceType, err) + } + + return workspaces, singleState, err +} + +func (m *Meta) backendMigrateTFC(opts *backendMigrateOpts) error { + _, sourceTFC := opts.Source.(*cloud.Cloud) + cloudBackendDestination, destinationTFC := opts.Destination.(*cloud.Cloud) + + sourceWorkspaces, sourceSingleState, err := retrieveWorkspaces(opts.Source, opts.SourceType) + if err != nil { + return err + } + //to be used below, not yet implamented + // destinationWorkspaces, destinationSingleState + _, _, err = retrieveWorkspaces(opts.Destination, opts.SourceType) + if err != nil { + return err + } + + // from TFC to non-TFC backend + if sourceTFC && !destinationTFC { + // From Terraform Cloud to another backend. This is not yet implemented, and + // we recommend people to use the TFC API. + return fmt.Errorf(strings.TrimSpace(errTFCMigrateNotYetImplemented)) + } + + // from TFC to TFC + if sourceTFC && destinationTFC { + // TODO: see internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go for notes + panic("not yet implemented") + } + + // Everything below, by the above two conditionals, now assumes that the + // destination is always Terraform Cloud (TFC). + + sourceSingle := sourceSingleState || (len(sourceWorkspaces) == 1 && sourceWorkspaces[0] == backend.DefaultStateName) + if sourceSingle { + if cloudBackendDestination.WorkspaceMapping.Strategy() == cloud.WorkspaceNameStrategy { + // If we know the name via WorkspaceNameStrategy, then set the + // destinationWorkspace to the new Name and skip the user prompt. Here the + // destinationWorkspace is not set to `default` thereby we will create it + // in TFC if it does not exist. + opts.destinationWorkspace = cloudBackendDestination.WorkspaceMapping.Name + } + // Run normal single-to-single state migration + // This will handle both situations where the new cloud backend + // configuration is using a workspace.name strategy or workspace.tags + // strategy. + return m.backendMigrateState_s_s(opts) + } + + destinationTagsStrategy := cloudBackendDestination.WorkspaceMapping.Strategy() == cloud.WorkspaceTagsStrategy + destinationNameStrategy := cloudBackendDestination.WorkspaceMapping.Strategy() == cloud.WorkspaceNameStrategy + + multiSource := !sourceSingleState && len(sourceWorkspaces) > 1 + if multiSource && destinationNameStrategy { + // we have to take the current workspace from the source and migrate that + // over to destination. Since there is multiple sources, and we are using a + // name strategy, we will only migrate the current workspace. + panic("not yet implemented") + } + + // Multiple sources, and using tags strategy. So migrate every source + // workspace over to new one, prompt for workspace name pattern (*), + // and start migrating, and create tags for each workspace. + if multiSource && destinationTagsStrategy { + // TODO: see internal/cloud/e2e/migrate_state_multi_to_tfc_test.go for notes + panic("not yet implemented") + } + + return nil +} + const errMigrateLoadStates = ` Error inspecting states in the %q backend: %s @@ -541,6 +623,12 @@ The state in the previous backend remains intact and unmodified. Please resolve the error above and try again. ` +const errTFCMigrateNotYetImplemented = ` +Migrating state from Terraform Cloud to another backend is not yet implemented. + +Please use the API to do this: https://www.terraform.io/docs/cloud/api/state-versions.html +` + const inputBackendMigrateEmpty = ` Pre-existing state was found while migrating the previous %q backend to the newly configured %q backend. No existing state was found in the newly From dbbfae5a1cbd5a1a7bd5526b8ce06fece9ec9381 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Wed, 6 Oct 2021 16:10:25 -0600 Subject: [PATCH 32/78] refactor ParseVariableValues into separate operations 1. ParseDeclaredValues: parses unparsed variables into terraform.InputValues 2. ProbeUndeclaredVariableValues: compares variable declarations with unparsed values to warn/error about undeclared variables --- internal/backend/unparsed_value.go | 193 +++++++++++++--------- internal/backend/unparsed_value_test.go | 206 +++++++++++++++++------- 2 files changed, 271 insertions(+), 128 deletions(-) diff --git a/internal/backend/unparsed_value.go b/internal/backend/unparsed_value.go index abd16ef9e..91c982582 100644 --- a/internal/backend/unparsed_value.go +++ b/internal/backend/unparsed_value.go @@ -25,6 +25,121 @@ type UnparsedVariableValue interface { ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) } +// ParseUndeclaredVariableValues processes a map of unparsed variable values +// and returns an input values map of the ones not declared in the specified +// declaration map along with detailed diagnostics about values of undeclared +// variables being present, depending on the source of these values. If more +// than two undeclared values are present in file form (config, auto, -var-file) +// the remaining errors are summarized to avoid a massive list of errors. +func ParseUndeclaredVariableValues(vv map[string]UnparsedVariableValue, decls map[string]*configs.Variable) (terraform.InputValues, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + ret := make(terraform.InputValues, len(vv)) + seenUndeclaredInFile := 0 + + for name, rv := range vv { + if _, declared := decls[name]; declared { + // Only interested in parsing undeclared variables + continue + } + + val, valDiags := rv.ParseVariableValue(configs.VariableParseLiteral) + if valDiags.HasErrors() { + continue + } + + ret[name] = val + + switch val.SourceType { + case terraform.ValueFromConfig, terraform.ValueFromAutoFile, terraform.ValueFromNamedFile: + // We allow undeclared names for variable values from files and warn in case + // users have forgotten a variable {} declaration or have a typo in their var name. + // Some users will actively ignore this warning because they use a .tfvars file + // across multiple configurations. + if seenUndeclaredInFile < 2 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Warning, + "Value for undeclared variable", + fmt.Sprintf("The root module does not declare a variable named %q but a value was found in file %q. If you meant to use this value, add a \"variable\" block to the configuration.\n\nTo silence these warnings, use TF_VAR_... environment variables to provide certain \"global\" settings to all configurations in your organization. To reduce the verbosity of these warnings, use the -compact-warnings option.", name, val.SourceRange.Filename), + )) + } + seenUndeclaredInFile++ + + case terraform.ValueFromEnvVar: + // We allow and ignore undeclared names for environment + // variables, because users will often set these globally + // when they are used across many (but not necessarily all) + // configurations. + case terraform.ValueFromCLIArg: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Value for undeclared variable", + fmt.Sprintf("A variable named %q was assigned on the command line, but the root module does not declare a variable of that name. To use this value, add a \"variable\" block to the configuration.", name), + )) + default: + // For all other source types we are more vague, but other situations + // don't generally crop up at this layer in practice. + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Value for undeclared variable", + fmt.Sprintf("A variable named %q was assigned a value, but the root module does not declare a variable of that name. To use this value, add a \"variable\" block to the configuration.", name), + )) + } + } + + if seenUndeclaredInFile > 2 { + extras := seenUndeclaredInFile - 2 + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: "Values for undeclared variables", + Detail: fmt.Sprintf("In addition to the other similar warnings shown, %d other variable(s) defined without being declared.", extras), + }) + } + + return ret, diags +} + +// ParseDeclaredVariableValues processes a map of unparsed variable values +// and returns an input values map of the ones declared in the specified +// variable declaration mapping. Diagnostics will be populating with +// any variable parsing errors encountered within this collection. +func ParseDeclaredVariableValues(vv map[string]UnparsedVariableValue, decls map[string]*configs.Variable) (terraform.InputValues, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + ret := make(terraform.InputValues, len(vv)) + + for name, rv := range vv { + var mode configs.VariableParsingMode + config, declared := decls[name] + + if declared { + mode = config.ParsingMode + } else { + // Only interested in parsing declared variables + continue + } + + val, valDiags := rv.ParseVariableValue(mode) + diags = diags.Append(valDiags) + if valDiags.HasErrors() { + continue + } + + ret[name] = val + } + + return ret, diags +} + +// Checks all given terraform.InputValues variable maps for the existance of +// a named variable +func isDefinedAny(name string, maps ...terraform.InputValues) bool { + for _, m := range maps { + if _, defined := m[name]; defined { + return true + } + } + return false +} + // ParseVariableValues processes a map of unparsed variable values by // correlating each one with the given variable declarations which should // be from a root module. @@ -42,87 +157,17 @@ type UnparsedVariableValue interface { // that were successfully processed, allowing for careful analysis of the // partial result. func ParseVariableValues(vv map[string]UnparsedVariableValue, decls map[string]*configs.Variable) (terraform.InputValues, tfdiags.Diagnostics) { - var diags tfdiags.Diagnostics - ret := make(terraform.InputValues, len(vv)) + ret, diags := ParseDeclaredVariableValues(vv, decls) + undeclared, diagsUndeclared := ParseUndeclaredVariableValues(vv, decls) - // Currently we're generating only warnings for undeclared variables - // defined in files (see below) but we only want to generate a few warnings - // at a time because existing deployments may have lots of these and - // the result can therefore be overwhelming. - seenUndeclaredInFile := 0 - - for name, rv := range vv { - var mode configs.VariableParsingMode - config, declared := decls[name] - if declared { - mode = config.ParsingMode - } else { - mode = configs.VariableParseLiteral - } - - val, valDiags := rv.ParseVariableValue(mode) - diags = diags.Append(valDiags) - if valDiags.HasErrors() { - continue - } - - if !declared { - switch val.SourceType { - case terraform.ValueFromConfig, terraform.ValueFromAutoFile, terraform.ValueFromNamedFile: - // We allow undeclared names for variable values from files and warn in case - // users have forgotten a variable {} declaration or have a typo in their var name. - // Some users will actively ignore this warning because they use a .tfvars file - // across multiple configurations. - if seenUndeclaredInFile < 2 { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Warning, - "Value for undeclared variable", - fmt.Sprintf("The root module does not declare a variable named %q but a value was found in file %q. If you meant to use this value, add a \"variable\" block to the configuration.\n\nTo silence these warnings, use TF_VAR_... environment variables to provide certain \"global\" settings to all configurations in your organization. To reduce the verbosity of these warnings, use the -compact-warnings option.", name, val.SourceRange.Filename), - )) - } - seenUndeclaredInFile++ - - case terraform.ValueFromEnvVar: - // We allow and ignore undeclared names for environment - // variables, because users will often set these globally - // when they are used across many (but not necessarily all) - // configurations. - case terraform.ValueFromCLIArg: - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Value for undeclared variable", - fmt.Sprintf("A variable named %q was assigned on the command line, but the root module does not declare a variable of that name. To use this value, add a \"variable\" block to the configuration.", name), - )) - default: - // For all other source types we are more vague, but other situations - // don't generally crop up at this layer in practice. - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Value for undeclared variable", - fmt.Sprintf("A variable named %q was assigned a value, but the root module does not declare a variable of that name. To use this value, add a \"variable\" block to the configuration.", name), - )) - } - continue - } - - ret[name] = val - } - - if seenUndeclaredInFile > 2 { - extras := seenUndeclaredInFile - 2 - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagWarning, - Summary: "Values for undeclared variables", - Detail: fmt.Sprintf("In addition to the other similar warnings shown, %d other variable(s) defined without being declared.", extras), - }) - } + diags = diags.Append(diagsUndeclared) // By this point we should've gathered all of the required root module // variables from one of the many possible sources. We'll now populate // any we haven't gathered as their defaults and fail if any of the // missing ones are required. for name, vc := range decls { - if _, defined := ret[name]; defined { + if isDefinedAny(name, ret, undeclared) { continue } diff --git a/internal/backend/unparsed_value_test.go b/internal/backend/unparsed_value_test.go index 6df7c226a..981c84a43 100644 --- a/internal/backend/unparsed_value_test.go +++ b/internal/backend/unparsed_value_test.go @@ -13,7 +13,7 @@ import ( "github.com/hashicorp/terraform/internal/tfdiags" ) -func TestParseVariableValuesUndeclared(t *testing.T) { +func TestUnparsedValue(t *testing.T) { vv := map[string]UnparsedVariableValue{ "undeclared0": testUnparsedVariableValue("0"), "undeclared1": testUnparsedVariableValue("1"), @@ -59,66 +59,164 @@ func TestParseVariableValuesUndeclared(t *testing.T) { }, } - gotVals, diags := ParseVariableValues(vv, decls) - for _, diag := range diags { - t.Logf("%s: %s", diag.Description().Summary, diag.Description().Detail) - } - if got, want := len(diags), 4; got != want { - t.Fatalf("wrong number of diagnostics %d; want %d", got, want) - } - const undeclSingular = `Value for undeclared variable` const undeclPlural = `Values for undeclared variables` - const missingRequired = `No value for required variable` - if got, want := diags[0].Description().Summary, undeclSingular; got != want { - t.Errorf("wrong summary for diagnostic 0\ngot: %s\nwant: %s", got, want) - } - if got, want := diags[1].Description().Summary, undeclSingular; got != want { - t.Errorf("wrong summary for diagnostic 1\ngot: %s\nwant: %s", got, want) - } - if got, want := diags[2].Description().Summary, undeclPlural; got != want { - t.Errorf("wrong summary for diagnostic 2\ngot: %s\nwant: %s", got, want) - } - if got, want := diags[2].Description().Detail, "3 other variable(s)"; !strings.Contains(got, want) { - t.Errorf("wrong detail for diagnostic 2\ngot: %s\nmust contain: %s", got, want) - } - if got, want := diags[3].Description().Summary, missingRequired; got != want { - t.Errorf("wrong summary for diagnostic 3\ngot: %s\nwant: %s", got, want) - } + t.Run("ParseDeclaredVariableValues", func(t *testing.T) { + gotVals, diags := ParseDeclaredVariableValues(vv, decls) - wantVals := terraform.InputValues{ - "declared1": { - Value: cty.StringVal("5"), - SourceType: terraform.ValueFromNamedFile, - SourceRange: tfdiags.SourceRange{ - Filename: "fake.tfvars", - Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, - End: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + if got, want := len(diags), 0; got != want { + t.Fatalf("wrong number of diagnostics %d; want %d", got, want) + } + + wantVals := terraform.InputValues{ + "declared1": { + Value: cty.StringVal("5"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + }, }, - }, - "missing1": { - Value: cty.DynamicVal, - SourceType: terraform.ValueFromConfig, - SourceRange: tfdiags.SourceRange{ - Filename: "fake.tf", - Start: tfdiags.SourcePos{Line: 3, Column: 1, Byte: 0}, - End: tfdiags.SourcePos{Line: 3, Column: 1, Byte: 0}, + } + + if diff := cmp.Diff(wantVals, gotVals, cmp.Comparer(cty.Value.RawEquals)); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + }) + + t.Run("ParseUndeclaredVariableValues", func(t *testing.T) { + gotVals, diags := ParseUndeclaredVariableValues(vv, decls) + + if got, want := len(diags), 3; got != want { + t.Fatalf("wrong number of diagnostics %d; want %d", got, want) + } + + if got, want := diags[0].Description().Summary, undeclSingular; got != want { + t.Errorf("wrong summary for diagnostic 0\ngot: %s\nwant: %s", got, want) + } + + if got, want := diags[1].Description().Summary, undeclSingular; got != want { + t.Errorf("wrong summary for diagnostic 1\ngot: %s\nwant: %s", got, want) + } + + if got, want := diags[2].Description().Summary, undeclPlural; got != want { + t.Errorf("wrong summary for diagnostic 2\ngot: %s\nwant: %s", got, want) + } + + wantVals := terraform.InputValues{ + "undeclared0": { + Value: cty.StringVal("0"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1}, + End: tfdiags.SourcePos{Line: 1, Column: 1}, + }, }, - }, - "missing2": { - Value: cty.StringVal("default for missing2"), - SourceType: terraform.ValueFromConfig, - SourceRange: tfdiags.SourceRange{ - Filename: "fake.tf", - Start: tfdiags.SourcePos{Line: 4, Column: 1, Byte: 0}, - End: tfdiags.SourcePos{Line: 4, Column: 1, Byte: 0}, + "undeclared1": { + Value: cty.StringVal("1"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1}, + End: tfdiags.SourcePos{Line: 1, Column: 1}, + }, }, - }, - } - if diff := cmp.Diff(wantVals, gotVals, cmp.Comparer(cty.Value.RawEquals)); diff != "" { - t.Errorf("wrong result\n%s", diff) - } + "undeclared2": { + Value: cty.StringVal("2"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1}, + End: tfdiags.SourcePos{Line: 1, Column: 1}, + }, + }, + "undeclared3": { + Value: cty.StringVal("3"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1}, + End: tfdiags.SourcePos{Line: 1, Column: 1}, + }, + }, + "undeclared4": { + Value: cty.StringVal("4"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1}, + End: tfdiags.SourcePos{Line: 1, Column: 1}, + }, + }, + } + if diff := cmp.Diff(wantVals, gotVals, cmp.Comparer(cty.Value.RawEquals)); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + }) + + t.Run("ParseVariableValues", func(t *testing.T) { + gotVals, diags := ParseVariableValues(vv, decls) + for _, diag := range diags { + t.Logf("%s: %s", diag.Description().Summary, diag.Description().Detail) + } + if got, want := len(diags), 4; got != want { + t.Fatalf("wrong number of diagnostics %d; want %d", got, want) + } + + const missingRequired = `No value for required variable` + + if got, want := diags[0].Description().Summary, undeclSingular; got != want { + t.Errorf("wrong summary for diagnostic 0\ngot: %s\nwant: %s", got, want) + } + if got, want := diags[1].Description().Summary, undeclSingular; got != want { + t.Errorf("wrong summary for diagnostic 1\ngot: %s\nwant: %s", got, want) + } + if got, want := diags[2].Description().Summary, undeclPlural; got != want { + t.Errorf("wrong summary for diagnostic 2\ngot: %s\nwant: %s", got, want) + } + if got, want := diags[2].Description().Detail, "3 other variable(s)"; !strings.Contains(got, want) { + t.Errorf("wrong detail for diagnostic 2\ngot: %s\nmust contain: %s", got, want) + } + if got, want := diags[3].Description().Summary, missingRequired; got != want { + t.Errorf("wrong summary for diagnostic 3\ngot: %s\nwant: %s", got, want) + } + + wantVals := terraform.InputValues{ + "declared1": { + Value: cty.StringVal("5"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + }, + }, + "missing1": { + Value: cty.DynamicVal, + SourceType: terraform.ValueFromConfig, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tf", + Start: tfdiags.SourcePos{Line: 3, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 3, Column: 1, Byte: 0}, + }, + }, + "missing2": { + Value: cty.StringVal("default for missing2"), + SourceType: terraform.ValueFromConfig, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tf", + Start: tfdiags.SourcePos{Line: 4, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 4, Column: 1, Byte: 0}, + }, + }, + } + if diff := cmp.Diff(wantVals, gotVals, cmp.Comparer(cty.Value.RawEquals)); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + }) } type testUnparsedVariableValue string From dd856f8a1b80bbc8e60aa172339ac8acc312a833 Mon Sep 17 00:00:00 2001 From: Omar Ismail Date: Mon, 23 Aug 2021 13:35:54 -0400 Subject: [PATCH 33/78] Add run variables to cloud plan operations from non-file sources Run variables allow the run API to accept input variables not found in the configuration slug (the file-based ones plus workspace vars) --- go.mod | 6 +- go.sum | 12 +-- internal/cloud/backend_apply.go | 17 ---- internal/cloud/backend_apply_test.go | 18 ++-- internal/cloud/backend_common.go | 38 -------- internal/cloud/backend_plan.go | 35 ++++---- internal/cloud/backend_plan_test.go | 18 ++-- internal/cloud/cloud_variables.go | 52 +++++++++++ internal/cloud/cloud_variables_test.go | 119 +++++++++++++++++++++++++ 9 files changed, 220 insertions(+), 95 deletions(-) create mode 100644 internal/cloud/cloud_variables.go create mode 100644 internal/cloud/cloud_variables_test.go diff --git a/go.mod b/go.mod index 9910399a3..5c09bc4b6 100644 --- a/go.mod +++ b/go.mod @@ -40,9 +40,9 @@ require ( github.com/hashicorp/go-hclog v0.15.0 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-plugin v1.4.3 - github.com/hashicorp/go-retryablehttp v0.5.2 - github.com/hashicorp/go-tfe v0.19.1-0.20210922134841-a2c1784e9c00 - github.com/hashicorp/go-uuid v1.0.1 + github.com/hashicorp/go-retryablehttp v0.7.0 + github.com/hashicorp/go-tfe v0.19.1-0.20211001235029-ff29186e11db + github.com/hashicorp/go-uuid v1.0.2 github.com/hashicorp/go-version v1.2.1 github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f github.com/hashicorp/hcl/v2 v2.10.1 diff --git a/go.sum b/go.sum index 4b6b9daa3..8972a8d9e 100644 --- a/go.sum +++ b/go.sum @@ -352,6 +352,7 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-getter v1.5.2 h1:XDo8LiAcDisiqZdv0TKgz+HtX3WN7zA2JD1R1tjsabE= github.com/hashicorp/go-getter v1.5.2/go.mod h1:orNH3BTYLu/fIxGIdLjLoAJHWMDQ/UKQr5O4m3iBuoo= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v0.15.0 h1:qMuK0wxsoW4D0ddCCYwPSTm4KQv1X1ke3WmPWZ0Mvsk= @@ -367,8 +368,8 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-plugin v1.4.3 h1:DXmvivbWD5qdiBts9TpBC7BYL1Aia5sxbRgQB+v6UZM= github.com/hashicorp/go-plugin v1.4.3/go.mod h1:5fGEH17QVwTTcR0zV7yhDPLLmFX9YSZ38b18Udy6vYQ= -github.com/hashicorp/go-retryablehttp v0.5.2 h1:AoISa4P4IsW0/m4T6St8Yw38gTl5GtBAgfkhYh1xAz4= -github.com/hashicorp/go-retryablehttp v0.5.2/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-retryablehttp v0.7.0 h1:eu1EI/mbirUgP5C8hVsTNaGZreBDlYiwC1FZWkvQPQ4= +github.com/hashicorp/go-retryablehttp v0.7.0/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= @@ -378,11 +379,12 @@ github.com/hashicorp/go-slug v0.7.0/go.mod h1:Ib+IWBYfEfJGI1ZyXMGNbu2BU+aa3Dzu41 github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-tfe v0.19.1-0.20210922134841-a2c1784e9c00 h1:51ARk47jO4piKzhhbwk6u67ErvSuBj4cu2f2VS9HkgI= -github.com/hashicorp/go-tfe v0.19.1-0.20210922134841-a2c1784e9c00/go.mod h1:U5Iy307L+MazGg0uF8annDtaxAbPp4ElFZ9uPMrjw/I= +github.com/hashicorp/go-tfe v0.19.1-0.20211001235029-ff29186e11db h1:e7oSI8NO5bAJBGI8pTxvLkI/0/LMxRaBkJS9hFdKuMk= +github.com/hashicorp/go-tfe v0.19.1-0.20211001235029-ff29186e11db/go.mod h1:gyXLXbpBVxA2F/6opah8XBsOkZJxHYQmghl0OWi8keI= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.0.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= diff --git a/internal/cloud/backend_apply.go b/internal/cloud/backend_apply.go index 65f51ff6c..85c447e60 100644 --- a/internal/cloud/backend_apply.go +++ b/internal/cloud/backend_apply.go @@ -3,7 +3,6 @@ package cloud import ( "bufio" "context" - "fmt" "io" "log" @@ -59,22 +58,6 @@ func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operatio )) } - if b.hasExplicitVariableValues(op) { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Run variables are currently not supported", - fmt.Sprintf( - "Terraform Cloud does not support setting run variables from command line arguments at this time. "+ - "Currently the only to way to pass variables is by "+ - "creating a '*.auto.tfvars' variables file. This file will automatically "+ - "be loaded when the workspace is configured to use "+ - "Terraform v0.10.0 or later.\n\nAdditionally you can also set variables on "+ - "the workspace in the web UI:\nhttps://%s/app/%s/%s/variables", - b.hostname, b.organization, op.Workspace, - ), - )) - } - if !op.HasConfig() && op.PlanMode != plans.DestroyMode { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index ad01c0d43..0151f39bc 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -436,14 +436,15 @@ func TestCloud_applyWithReplace(t *testing.T) { } } -func TestCloud_applyWithVariables(t *testing.T) { +func TestCloud_applyWithRequiredVariables(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationApply(t, "./testdata/apply-variables") defer configCleanup() + defer done(t) - op.Variables = testVariables(terraform.ValueFromNamedFile, "foo", "bar") + op.Variables = testVariables(terraform.ValueFromNamedFile, "foo") // "bar" variable value missing op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) @@ -452,14 +453,15 @@ func TestCloud_applyWithVariables(t *testing.T) { } <-run.Done() - output := done(t) - if run.Result == backend.OperationSuccess { - t.Fatal("expected apply operation to fail") + // The usual error of a required variable being missing is deferred and the operation + // is successful + if run.Result != backend.OperationSuccess { + t.Fatal("expected plan operation to succeed") } - errOutput := output.Stderr() - if !strings.Contains(errOutput, "variables are currently not supported") { - t.Fatalf("expected a variables error, got: %v", errOutput) + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running apply in Terraform Cloud") { + t.Fatalf("unexpected TFC header in output: %s", output) } } diff --git a/internal/cloud/backend_common.go b/internal/cloud/backend_common.go index 92f28a1dd..2ca3fc1a2 100644 --- a/internal/cloud/backend_common.go +++ b/internal/cloud/backend_common.go @@ -208,44 +208,6 @@ func (b *Cloud) waitForRun(stopCtx, cancelCtx context.Context, op *backend.Opera } } -// hasExplicitVariableValues is a best-effort check to determine whether the -// user has provided -var or -var-file arguments to a remote operation. -// -// The results may be inaccurate if the configuration is invalid or if -// individual variable values are invalid. That's okay because we only use this -// result to hint the user to set variables a different way. It's always the -// remote system's responsibility to do final validation of the input. -func (b *Cloud) hasExplicitVariableValues(op *backend.Operation) bool { - // Load the configuration using the caller-provided configuration loader. - config, _, configDiags := op.ConfigLoader.LoadConfigWithSnapshot(op.ConfigDir) - if configDiags.HasErrors() { - // If we can't load the configuration then we'll assume no explicit - // variable values just to let the remote operation start and let - // the remote system return the same set of configuration errors. - return false - } - - // We're intentionally ignoring the diagnostics here because validation - // of the variable values is the responsibilty of the remote system. Our - // goal here is just to make a best effort count of how many variable - // values are coming from -var or -var-file CLI arguments so that we can - // hint the user that those are not supported for remote operations. - variables, _ := backend.ParseVariableValues(op.Variables, config.Module.Variables) - - // Check for explicitly-defined (-var and -var-file) variables, which the - // Terraform Cloud currently does not support. All other source types are okay, - // because they are implicit from the execution context anyway and so - // their final values will come from the _remote_ execution context. - for _, v := range variables { - switch v.SourceType { - case terraform.ValueFromCLIArg, terraform.ValueFromNamedFile: - return true - } - } - - return false -} - func (b *Cloud) costEstimate(stopCtx, cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error { if r.CostEstimate == nil { return nil diff --git a/internal/cloud/backend_plan.go b/internal/cloud/backend_plan.go index 5455ec06e..4abb22ee3 100644 --- a/internal/cloud/backend_plan.go +++ b/internal/cloud/backend_plan.go @@ -64,22 +64,6 @@ func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation )) } - if b.hasExplicitVariableValues(op) { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Run variables are currently not supported", - fmt.Sprintf( - "Terraform Cloud does not support setting run variables from command line arguments at this time. "+ - "Currently the only to way to pass variables is by "+ - "creating a '*.auto.tfvars' variables file. This file will automatically "+ - "be loaded when the workspace is configured to use "+ - "Terraform v0.10.0 or later.\n\nAdditionally you can also set variables on "+ - "the workspace in the web UI:\nhttps://%s/app/%s/%s/variables", - b.hostname, b.organization, op.Workspace, - ), - )) - } - if !op.HasConfig() && op.PlanMode != plans.DestroyMode { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, @@ -241,6 +225,25 @@ in order to capture the filesystem context the remote workspace expects: } } + config, _, configDiags := op.ConfigLoader.LoadConfigWithSnapshot(op.ConfigDir) + if configDiags.HasErrors() { + return nil, fmt.Errorf("error loading config with snapshot: %w", configDiags.Errs()[0]) + } + variables, varDiags := ParseCloudRunVariables(op.Variables, config.Module.Variables) + + if varDiags.HasErrors() { + return nil, varDiags.Err() + } + + runVariables := make([]*tfe.RunVariable, len(variables)) + for name, value := range variables { + runVariables = append(runVariables, &tfe.RunVariable{ + Key: name, + Value: value, + }) + } + runOptions.Variables = runVariables + r, err := b.client.Runs.Create(stopCtx, runOptions) if err != nil { return r, generalError("Failed to create run", err) diff --git a/internal/cloud/backend_plan_test.go b/internal/cloud/backend_plan_test.go index 3a6d2e74b..6c08c3762 100644 --- a/internal/cloud/backend_plan_test.go +++ b/internal/cloud/backend_plan_test.go @@ -469,14 +469,15 @@ func TestCloud_planWithReplace(t *testing.T) { } } -func TestCloud_planWithVariables(t *testing.T) { +func TestCloud_planWithRequiredVariables(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() op, configCleanup, done := testOperationPlan(t, "./testdata/plan-variables") defer configCleanup() + defer done(t) - op.Variables = testVariables(terraform.ValueFromCLIArg, "foo", "bar") + op.Variables = testVariables(terraform.ValueFromCLIArg, "foo") // "bar" variable value missing op.Workspace = testBackendSingleWorkspaceName run, err := b.Operation(context.Background(), op) @@ -485,14 +486,15 @@ func TestCloud_planWithVariables(t *testing.T) { } <-run.Done() - output := done(t) - if run.Result == backend.OperationSuccess { - t.Fatal("expected plan operation to fail") + // The usual error of a required variable being missing is deferred and the operation + // is successful + if run.Result != backend.OperationSuccess { + t.Fatal("expected plan operation to succeed") } - errOutput := output.Stderr() - if !strings.Contains(errOutput, "variables are currently not supported") { - t.Fatalf("expected a variables error, got: %v", errOutput) + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Running plan in Terraform Cloud") { + t.Fatalf("unexpected TFC header in output: %s", output) } } diff --git a/internal/cloud/cloud_variables.go b/internal/cloud/cloud_variables.go new file mode 100644 index 000000000..9a9002828 --- /dev/null +++ b/internal/cloud/cloud_variables.go @@ -0,0 +1,52 @@ +package cloud + +import ( + "encoding/json" + "fmt" + + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" + ctyjson "github.com/zclconf/go-cty/cty/json" +) + +func allowedSourceType(source terraform.ValueSourceType) bool { + return source == terraform.ValueFromNamedFile || source == terraform.ValueFromCLIArg || source == terraform.ValueFromEnvVar +} + +// ParseCloudRunVariables accepts a mapping of unparsed values and a mapping of variable +// declarations and returns a name/value variable map appropriate for an API run context, +// that is, containing declared string variables only sourced from non-file inputs like CLI args +// and environment variables. However, all variable parsing diagnostics are returned +// in order to allow callers to short circuit cloud runs that contain variable +// declaration or parsing errors. The only exception is that missing required values are not +// considered errors because they may be defined within the cloud workspace. +func ParseCloudRunVariables(vv map[string]backend.UnparsedVariableValue, decls map[string]*configs.Variable) (map[string]string, tfdiags.Diagnostics) { + declared, diags := backend.ParseDeclaredVariableValues(vv, decls) + _, undedeclaredDiags := backend.ParseUndeclaredVariableValues(vv, decls) + diags = diags.Append(undedeclaredDiags) + + ret := make(map[string]string, len(declared)) + + // Even if there are parsing or declaration errors, populate the return map with the + // variables that could be used for cloud runs + for name, v := range declared { + if !allowedSourceType(v.SourceType) { + continue + } + + valueData, err := ctyjson.Marshal(v.Value, v.Value.Type()) + if err != nil { + return nil, diags.Append(fmt.Errorf("error marshaling input variable value as json: %w", err)) + } + var variableValue string + if err = json.Unmarshal(valueData, &variableValue); err != nil { + // This should never happen since cty marshaled the value to begin with without error + return nil, diags.Append(fmt.Errorf("error unmarshaling run variable: %w", err)) + } + ret[name] = variableValue + } + + return ret, diags +} diff --git a/internal/cloud/cloud_variables_test.go b/internal/cloud/cloud_variables_test.go new file mode 100644 index 000000000..f8a8a5f1c --- /dev/null +++ b/internal/cloud/cloud_variables_test.go @@ -0,0 +1,119 @@ +package cloud + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +func TestParseCloudRunVariables(t *testing.T) { + t.Run("populates variables from allowed sources", func(t *testing.T) { + vv := map[string]backend.UnparsedVariableValue{ + "undeclared": testUnparsedVariableValue{source: terraform.ValueFromCLIArg, value: "0"}, + "declaredFromConfig": testUnparsedVariableValue{source: terraform.ValueFromConfig, value: "1"}, + "declaredFromNamedFile": testUnparsedVariableValue{source: terraform.ValueFromNamedFile, value: "2"}, + "declaredFromCLIArg": testUnparsedVariableValue{source: terraform.ValueFromCLIArg, value: "3"}, + "declaredFromEnvVar": testUnparsedVariableValue{source: terraform.ValueFromEnvVar, value: "4"}, + } + + decls := map[string]*configs.Variable{ + "declaredFromConfig": { + Name: "declaredFromConfig", + Type: cty.String, + ConstraintType: cty.String, + ParsingMode: configs.VariableParseLiteral, + DeclRange: hcl.Range{ + Filename: "fake.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + }, + }, + "declaredFromNamedFile": { + Name: "declaredFromNamedFile", + Type: cty.String, + ConstraintType: cty.String, + ParsingMode: configs.VariableParseLiteral, + DeclRange: hcl.Range{ + Filename: "fake.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + }, + }, + "declaredFromCLIArg": { + Name: "declaredFromCLIArg", + Type: cty.String, + ConstraintType: cty.String, + ParsingMode: configs.VariableParseLiteral, + DeclRange: hcl.Range{ + Filename: "fake.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + }, + }, + "declaredFromEnvVar": { + Name: "declaredFromEnvVar", + Type: cty.String, + ConstraintType: cty.String, + ParsingMode: configs.VariableParseLiteral, + DeclRange: hcl.Range{ + Filename: "fake.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + }, + }, + "missing": { + Name: "missing", + Type: cty.String, + ConstraintType: cty.String, + Default: cty.StringVal("2"), + ParsingMode: configs.VariableParseLiteral, + DeclRange: hcl.Range{ + Filename: "fake.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + }, + }, + } + wantVals := make(map[string]string) + + wantVals["declaredFromNamedFile"] = "2" + wantVals["declaredFromCLIArg"] = "3" + wantVals["declaredFromEnvVar"] = "4" + + gotVals, diags := ParseCloudRunVariables(vv, decls) + if diff := cmp.Diff(wantVals, gotVals, cmp.Comparer(cty.Value.RawEquals)); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + + if got, want := len(diags), 1; got != want { + t.Fatalf("expected 1 variable error: %v, got %v", diags.Err(), want) + } + + if got, want := diags[0].Description().Summary, "Value for undeclared variable"; got != want { + t.Errorf("wrong summary for diagnostic 0\ngot: %s\nwant: %s", got, want) + } + }) +} + +type testUnparsedVariableValue struct { + source terraform.ValueSourceType + value string +} + +func (v testUnparsedVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) { + return &terraform.InputValue{ + Value: cty.StringVal(v.value), + SourceType: v.source, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + }, + }, nil +} From 9963bf940a546d0e583bdf28a82830ead8b3e97e Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Mon, 11 Oct 2021 11:43:39 -0600 Subject: [PATCH 34/78] refactor: only fetch variables if unset variables are not allowed --- internal/cloud/backend_context.go | 32 +++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/internal/cloud/backend_context.go b/internal/cloud/backend_context.go index 95c80c5cb..24ed97854 100644 --- a/internal/cloud/backend_context.go +++ b/internal/cloud/backend_context.go @@ -82,22 +82,6 @@ func (b *Cloud) LocalRun(op *backend.Operation) (*backend.LocalRun, statemgr.Ful } ret.Config = config - // The underlying API expects us to use the opaque workspace id to request - // variables, so we'll need to look that up using our organization name - // and workspace name. - remoteWorkspaceID, err := b.getRemoteWorkspaceID(context.Background(), op.Workspace) - if err != nil { - diags = diags.Append(fmt.Errorf("error finding remote workspace: %w", err)) - return nil, nil, diags - } - - log.Printf("[TRACE] cloud: retrieving variables from workspace %s/%s (%s)", remoteWorkspaceName, b.organization, remoteWorkspaceID) - tfeVariables, err := b.client.Variables.List(context.Background(), remoteWorkspaceID, tfe.VariableListOptions{}) - if err != nil && err != tfe.ErrResourceNotFound { - diags = diags.Append(fmt.Errorf("error loading variables: %w", err)) - return nil, nil, diags - } - if op.AllowUnsetVariables { // If we're not going to use the variables in an operation we'll be // more lax about them, stubbing out any unset ones as unknown. @@ -105,6 +89,22 @@ func (b *Cloud) LocalRun(op *backend.Operation) (*backend.LocalRun, statemgr.Ful // but not enough information to run a real operation (plan, apply, etc) ret.PlanOpts.SetVariables = stubAllVariables(op.Variables, config.Module.Variables) } else { + // The underlying API expects us to use the opaque workspace id to request + // variables, so we'll need to look that up using our organization name + // and workspace name. + remoteWorkspaceID, err := b.getRemoteWorkspaceID(context.Background(), op.Workspace) + if err != nil { + diags = diags.Append(fmt.Errorf("error finding remote workspace: %w", err)) + return nil, nil, diags + } + + log.Printf("[TRACE] cloud: retrieving variables from workspace %s/%s (%s)", remoteWorkspaceName, b.organization, remoteWorkspaceID) + tfeVariables, err := b.client.Variables.List(context.Background(), remoteWorkspaceID, tfe.VariableListOptions{}) + if err != nil && err != tfe.ErrResourceNotFound { + diags = diags.Append(fmt.Errorf("error loading variables: %w", err)) + return nil, nil, diags + } + if tfeVariables != nil { if op.Variables == nil { op.Variables = make(map[string]backend.UnparsedVariableValue) From bf02b5cb5362d5203855959288c7e0cfd3f2e2d0 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Mon, 11 Oct 2021 16:14:44 -0600 Subject: [PATCH 35/78] e2e test for cloud run variables --- internal/cloud/e2e/apply_auto_approve_test.go | 14 -- internal/cloud/e2e/helper_test.go | 18 +++ internal/cloud/e2e/run_variables_test.go | 144 ++++++++++++++++++ 3 files changed, 162 insertions(+), 14 deletions(-) create mode 100644 internal/cloud/e2e/run_variables_test.go diff --git a/internal/cloud/e2e/apply_auto_approve_test.go b/internal/cloud/e2e/apply_auto_approve_test.go index 8ece4836d..4e5a65a02 100644 --- a/internal/cloud/e2e/apply_auto_approve_test.go +++ b/internal/cloud/e2e/apply_auto_approve_test.go @@ -5,10 +5,8 @@ package main import ( "context" - "fmt" "io/ioutil" "log" - "os" "strings" "testing" @@ -238,15 +236,3 @@ func Test_terraform_apply_autoApprove(t *testing.T) { tc.validations(t, orgName, wsName) } } - -func writeMainTF(t *testing.T, block string, dir string) { - f, err := os.Create(fmt.Sprintf("%s/main.tf", dir)) - if err != nil { - t.Fatal(err) - } - _, err = f.WriteString(block) - if err != nil { - t.Fatal(err) - } - f.Close() -} diff --git a/internal/cloud/e2e/helper_test.go b/internal/cloud/e2e/helper_test.go index cad01a5c1..5af66bf1c 100644 --- a/internal/cloud/e2e/helper_test.go +++ b/internal/cloud/e2e/helper_test.go @@ -6,6 +6,7 @@ package main import ( "context" "fmt" + "os" "testing" "time" @@ -31,6 +32,11 @@ type operationSets struct { prep func(t *testing.T, orgName, dir string) } +type testCases map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) +} + func createOrganization(t *testing.T) (*tfe.Organization, func()) { ctx := context.Background() org, err := tfeClient.Organizations.Create(ctx, tfe.OrganizationCreateOptions{ @@ -126,3 +132,15 @@ resource "random_pet" "server" { } `, tfeHostname, org, name) } + +func writeMainTF(t *testing.T, block string, dir string) { + f, err := os.Create(fmt.Sprintf("%s/main.tf", dir)) + if err != nil { + t.Fatal(err) + } + _, err = f.WriteString(block) + if err != nil { + t.Fatal(err) + } + f.Close() +} diff --git a/internal/cloud/e2e/run_variables_test.go b/internal/cloud/e2e/run_variables_test.go new file mode 100644 index 000000000..ba20077a1 --- /dev/null +++ b/internal/cloud/e2e/run_variables_test.go @@ -0,0 +1,144 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "fmt" + "io/ioutil" + "os" + "testing" + + expect "github.com/Netflix/go-expect" + "github.com/hashicorp/terraform/internal/e2e" +) + +func terraformConfigRequiredVariable(org, name string) string { + return fmt.Sprintf(` +terraform { + cloud { + hostname = "%s" + organization = "%s" + + workspaces { + name = "%s" + } + } +} + +variable "foo" { + type = string +} + +variable "baz" { + type = string +} + +output "test_cli" { + value = var.foo +} + +output "test_env" { + value = var.baz +} + +`, tfeHostname, org, name) +} + +func Test_cloud_run_variables(t *testing.T) { + cases := testCases{ + "run variables from CLI arg": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "new-workspace" + tfBlock := terraformConfigRequiredVariable(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedOutput: `Successfully configured the backend "cloud"!`, + }, + { + command: []string{"plan", "-var", "foo=bar"}, + expectedOutput: ` + test_cli = "bar"`, + }, + { + command: []string{"plan", "-var", "foo=bar"}, + expectedOutput: ` + test_env = "qux"`, + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + fmt.Println("Test: ", name) + organization, cleanup := createOrganization(t) + defer cleanup() + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=info") + tf.AddEnv("TF_CLI_ARGS=-no-color") + tf.AddEnv("TF_VAR_baz=qux") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedOutput) + if err != nil { + t.Fatal(err) + } + } + + if len(tfCmd.userInput) > 0 { + for _, input := range tfCmd.userInput { + exp.SendLine(input) + } + } + + if tfCmd.postInputOutput != "" { + _, err := exp.ExpectString(tfCmd.postInputOutput) + if err != nil { + t.Fatal(err) + } + } + + err = cmd.Wait() + if err != nil && !tfCmd.expectError { + t.Fatal(err) + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } + } + } +} From edbc84420c9ecec0babeec04ae84b95e13d3b666 Mon Sep 17 00:00:00 2001 From: Barrett Clark Date: Fri, 8 Oct 2021 14:36:37 -0500 Subject: [PATCH 36/78] Run apply -refresh-state instead of refresh When a user runs `terraform refresh` we give them an error message that tells them to run `terraform apply -refresh-state`. We could just run that command for them, though. That is what this PR does. --- internal/cloud/backend.go | 16 ++++- internal/cloud/backend_plan.go | 2 +- internal/cloud/backend_refresh_test.go | 79 +++++++++++++++++++++++++ internal/cloud/testdata/refresh/main.tf | 6 ++ internal/command/refresh.go | 7 +++ 5 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 internal/cloud/backend_refresh_test.go create mode 100644 internal/cloud/testdata/refresh/main.tf diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 730d8be83..2e611745e 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/terraform-svchost/disco" "github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/states/remote" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terraform" @@ -643,9 +644,16 @@ func (b *Cloud) Operation(ctx context.Context, op *backend.Operation) (*backend. case backend.OperationTypeApply: f = b.opApply case backend.OperationTypeRefresh: - return nil, fmt.Errorf( - "\n\nThe \"refresh\" operation is not supported when using Terraform Cloud. " + - "Use \"terraform apply -refresh-only\" instead.") + // The `terraform refresh` command has been deprecated in favor of `terraform apply -refresh-state`. + // Rather than respond with an error telling the user to run the other command we can just run + // that command instead. We will tell the user what we are doing, and then do it. + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(refreshToApplyRefresh) + "\n")) + } + op.PlanMode = plans.RefreshOnlyMode + op.PlanRefresh = true + op.AutoApprove = true + f = b.opApply default: return nil, fmt.Errorf( "\n\nTerraform Cloud does not support the %q operation.", op.Type) @@ -982,6 +990,8 @@ const operationNotCanceled = ` [reset][red]The remote operation was not cancelled.[reset] ` +const refreshToApplyRefresh = `[bold][yellow]Proceeding with 'terraform apply -refresh-only -auto-approve'.[reset]` + var ( workspaceConfigurationHelp = fmt.Sprintf( `The 'workspaces' block configures how Terraform CLI maps its workspaces for this single diff --git a/internal/cloud/backend_plan.go b/internal/cloud/backend_plan.go index 4abb22ee3..1956a9c82 100644 --- a/internal/cloud/backend_plan.go +++ b/internal/cloud/backend_plan.go @@ -87,7 +87,7 @@ func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation func (b *Cloud) plan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) { if b.CLI != nil { header := planDefaultHeader - if op.Type == backend.OperationTypeApply { + if op.Type == backend.OperationTypeApply || op.Type == backend.OperationTypeRefresh { header = applyDefaultHeader } b.CLI.Output(b.Colorize().Color(strings.TrimSpace(header) + "\n")) diff --git a/internal/cloud/backend_refresh_test.go b/internal/cloud/backend_refresh_test.go new file mode 100644 index 000000000..3abb93577 --- /dev/null +++ b/internal/cloud/backend_refresh_test.go @@ -0,0 +1,79 @@ +package cloud + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/command/clistate" + "github.com/hashicorp/terraform/internal/command/views" + "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/states/statemgr" + "github.com/hashicorp/terraform/internal/terminal" + "github.com/mitchellh/cli" +) + +func testOperationRefresh(t *testing.T, configDir string) (*backend.Operation, func(), func(*testing.T) *terminal.TestOutput) { + t.Helper() + + return testOperationRefreshWithTimeout(t, configDir, 0) +} + +func testOperationRefreshWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backend.Operation, func(), func(*testing.T) *terminal.TestOutput) { + t.Helper() + + _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir) + + streams, done := terminal.StreamsForTesting(t) + view := views.NewView(streams) + stateLockerView := views.NewStateLocker(arguments.ViewHuman, view) + operationView := views.NewOperation(arguments.ViewHuman, false, view) + + return &backend.Operation{ + ConfigDir: configDir, + ConfigLoader: configLoader, + PlanRefresh: true, + StateLocker: clistate.NewLocker(timeout, stateLockerView), + Type: backend.OperationTypeRefresh, + View: operationView, + }, configCleanup, done +} + +func TestCloud_refreshBasicActuallyRunsApplyRefresh(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationRefresh(t, "./testdata/refresh") + defer configCleanup() + defer done(t) + + op.UIOut = b.CLI + b.CLIColor = b.cliColorize() + op.PlanMode = plans.RefreshOnlyMode + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Proceeding with 'terraform apply -refresh-only -auto-approve'") { + t.Fatalf("expected TFC header in output: %s", output) + } + + stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName) + // An error suggests that the state was not unlocked after apply + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { + t.Fatalf("unexpected error locking state after apply: %s", err.Error()) + } +} diff --git a/internal/cloud/testdata/refresh/main.tf b/internal/cloud/testdata/refresh/main.tf new file mode 100644 index 000000000..8d61d5f51 --- /dev/null +++ b/internal/cloud/testdata/refresh/main.tf @@ -0,0 +1,6 @@ +resource "random_pet" "always_new" { + keepers = { + uuid = uuid() # Force a new name each time + } + length = 3 +} diff --git a/internal/command/refresh.go b/internal/command/refresh.go index 1bb5d3933..c7c041eb7 100644 --- a/internal/command/refresh.go +++ b/internal/command/refresh.go @@ -17,10 +17,17 @@ type RefreshCommand struct { } func (c *RefreshCommand) Run(rawArgs []string) int { + var diags tfdiags.Diagnostics + // Parse and apply global view arguments common, rawArgs := arguments.ParseView(rawArgs) c.View.Configure(common) + // Propagate -no-color for the remote backend's legacy use of Ui. This + // should be removed when the remote backend is migrated to views. + c.Meta.color = !common.NoColor + c.Meta.Color = c.Meta.color + // Parse and validate flags args, diags := arguments.ParseRefresh(rawArgs) From 83337de6540da9012453669f3fa5400795aa9db7 Mon Sep 17 00:00:00 2001 From: Barrett Clark Date: Mon, 11 Oct 2021 16:26:07 -0500 Subject: [PATCH 37/78] Remove prefix from the cloud backend config Now that we have tags we no longer need prefix. --- internal/cloud/backend.go | 73 ++------ internal/cloud/backend_apply_test.go | 18 +- internal/cloud/backend_context.go | 7 +- internal/cloud/backend_plan_test.go | 8 +- internal/cloud/backend_test.go | 246 ++++++--------------------- internal/cloud/errors.go | 4 +- internal/cloud/testing.go | 27 +-- 7 files changed, 81 insertions(+), 302 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 2e611745e..2bf5d3c4d 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -128,11 +128,6 @@ func (b *Cloud) ConfigSchema() *configschema.Block { Optional: true, Description: schemaDescriptionName, }, - "prefix": { - Type: cty.String, - Optional: true, - Description: schemaDescriptionPrefix, - }, "tags": { Type: cty.Set(cty.String), Optional: true, @@ -162,9 +157,6 @@ func (b *Cloud) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) { if val := workspaces.GetAttr("name"); !val.IsNull() { WorkspaceMapping.Name = val.AsString() } - if val := workspaces.GetAttr("prefix"); !val.IsNull() { - WorkspaceMapping.Prefix = val.AsString() - } if val := workspaces.GetAttr("tags"); !val.IsNull() { err := gocty.FromCtyValue(val, &WorkspaceMapping.Tags) if err != nil { @@ -177,7 +169,7 @@ func (b *Cloud) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) { // Make sure have a workspace mapping strategy present case WorkspaceNoneStrategy: diags = diags.Append(invalidWorkspaceConfigMissingValues) - // Make sure that only one of workspace name or a prefix is configured. + // Make sure that a workspace name is configured. case WorkspaceInvalidStrategy: diags = diags.Append(invalidWorkspaceConfigMisconfiguration) } @@ -339,16 +331,13 @@ func (b *Cloud) setConfigurationFields(obj cty.Value) tfdiags.Diagnostics { } // Get the workspaces configuration block and retrieve the - // default workspace name and prefix. + // default workspace name. if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() { // PrepareConfig checks that you cannot set both of these. if val := workspaces.GetAttr("name"); !val.IsNull() { b.WorkspaceMapping.Name = val.AsString() } - if val := workspaces.GetAttr("prefix"); !val.IsNull() { - b.WorkspaceMapping.Prefix = val.AsString() - } if val := workspaces.GetAttr("tags"); !val.IsNull() { var tags []string err := gocty.FromCtyValue(val, &tags) @@ -447,10 +436,7 @@ func (b *Cloud) Workspaces() ([]string, error) { // Otherwise, multiple workspaces are being mapped. Query Terraform Cloud for all the remote // workspaces by the provided mapping strategy. options := tfe.WorkspaceListOptions{} - switch b.WorkspaceMapping.Strategy() { - case WorkspacePrefixStrategy: - options.Search = tfe.String(b.WorkspaceMapping.Prefix) - case WorkspaceTagsStrategy: + if b.WorkspaceMapping.Strategy() == WorkspaceTagsStrategy { taglist := strings.Join(b.WorkspaceMapping.Tags, ",") options.Tags = &taglist } @@ -462,18 +448,7 @@ func (b *Cloud) Workspaces() ([]string, error) { } for _, w := range wl.Items { - switch b.WorkspaceMapping.Strategy() { - case WorkspacePrefixStrategy: - if strings.HasPrefix(w.Name, b.WorkspaceMapping.Prefix) { - names = append(names, strings.TrimPrefix(w.Name, b.WorkspaceMapping.Prefix)) - continue - } - default: - // Pass-through. The "prefix" strategy is naive and does - // client-side filtering, but for tags and any other future - // strategy this filtering should be left to the API. - names = append(names, w.Name) - } + names = append(names, w.Name) } // Exit the loop when we've seen all pages. @@ -502,11 +477,6 @@ func (b *Cloud) DeleteWorkspace(name string) error { } // Configure the remote workspace name. - switch { - case b.WorkspaceMapping.Strategy() == WorkspacePrefixStrategy && !strings.HasPrefix(name, b.WorkspaceMapping.Prefix): - name = b.WorkspaceMapping.Prefix + name - } - client := &remoteClient{ client: b.client, organization: b.organization, @@ -528,11 +498,6 @@ func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { return nil, backend.ErrWorkspacesNotSupported } - // If the prefix strategy is used, translate the local name to the TFC workspace name. - if b.WorkspaceMapping.Strategy() == WorkspacePrefixStrategy { - name = b.WorkspaceMapping.Prefix + name - } - workspace, err := b.client.Workspaces.Read(context.Background(), b.organization, name) if err != nil && err != tfe.ErrResourceNotFound { return nil, fmt.Errorf("Failed to retrieve workspace %s: %v", name, err) @@ -588,11 +553,6 @@ func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { func (b *Cloud) Operation(ctx context.Context, op *backend.Operation) (*backend.RunningOperation, error) { name := op.Workspace - // If the prefix strategy is used, translate the local name to the TFC workspace name. - if b.WorkspaceMapping.Strategy() == WorkspacePrefixStrategy { - name = b.WorkspaceMapping.Prefix + op.Workspace - } - // Retrieve the workspace for this operation. w, err := b.client.Workspaces.Read(ctx, b.organization, name) if err != nil { @@ -908,9 +868,8 @@ func (b *Cloud) cliColorize() *colorstring.Colorize { } type WorkspaceMapping struct { - Name string - Prefix string - Tags []string + Name string + Tags []string } type workspaceStrategy string @@ -918,20 +877,17 @@ type workspaceStrategy string const ( WorkspaceTagsStrategy workspaceStrategy = "tags" WorkspaceNameStrategy workspaceStrategy = "name" - WorkspacePrefixStrategy workspaceStrategy = "prefix" WorkspaceNoneStrategy workspaceStrategy = "none" WorkspaceInvalidStrategy workspaceStrategy = "invalid" ) func (wm WorkspaceMapping) Strategy() workspaceStrategy { switch { - case len(wm.Tags) > 0 && wm.Name == "" && wm.Prefix == "": + case len(wm.Tags) > 0 && wm.Name == "": return WorkspaceTagsStrategy - case len(wm.Tags) == 0 && wm.Name != "" && wm.Prefix == "": + case len(wm.Tags) == 0 && wm.Name != "": return WorkspaceNameStrategy - case len(wm.Tags) == 0 && wm.Name == "" && wm.Prefix != "": - return WorkspacePrefixStrategy - case len(wm.Tags) == 0 && wm.Name == "" && wm.Prefix == "": + case len(wm.Tags) == 0 && wm.Name == "": return WorkspaceNoneStrategy default: // Any other combination is invalid as each strategy is mutually exclusive @@ -999,9 +955,7 @@ configuration to workspaces within a Terraform Cloud organization. Three strateg [bold]tags[reset] - %s -[bold]name[reset] - %s - -[bold]prefix[reset] - %s`, schemaDescriptionTags, schemaDescriptionName, schemaDescriptionPrefix) +[bold]name[reset] - %s`, schemaDescriptionTags, schemaDescriptionName) schemaDescriptionHostname = `The Terraform Enterprise hostname to connect to. This optional argument defaults to app.terraform.io for use with Terraform Cloud.` @@ -1014,11 +968,8 @@ configuration file or configured credential helper.` schemaDescriptionTags = `A set of tags used to select remote Terraform Cloud workspaces to be used for this single configuration. New workspaces will automatically be tagged with these tag values. Generally, this -is the primary and recommended strategy to use. This option conflicts with "prefix" and "name".` +is the primary and recommended strategy to use. This option conflicts with "name".` schemaDescriptionName = `The name of a single Terraform Cloud workspace to be used with this configuration When configured -only the specified workspace can be used. This option conflicts with "tags" and "prefix".` - - schemaDescriptionPrefix = `DEPRECATED. A name prefix used to select remote Terraform Cloud to be used for this single configuration. New -workspaces will automatically be prefixed with this prefix. This option conflicts with "tags" and "name".` +only the specified workspace can be used. This option conflicts with "tags".` ) diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index 0151f39bc..fc5e99337 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -143,7 +143,7 @@ func TestCloud_applyCanceled(t *testing.T) { } func TestCloud_applyWithoutPermissions(t *testing.T) { - b, bCleanup := testBackendWithPrefix(t) + b, bCleanup := testBackendWithTags(t) defer bCleanup() // Create a named workspace without permissions. @@ -151,7 +151,7 @@ func TestCloud_applyWithoutPermissions(t *testing.T) { context.Background(), b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.WorkspaceMapping.Prefix + "prod"), + Name: tfe.String("prod"), }, ) if err != nil { @@ -183,7 +183,7 @@ func TestCloud_applyWithoutPermissions(t *testing.T) { } func TestCloud_applyWithVCS(t *testing.T) { - b, bCleanup := testBackendWithPrefix(t) + b, bCleanup := testBackendWithTags(t) defer bCleanup() // Create a named workspace with a VCS. @@ -191,7 +191,7 @@ func TestCloud_applyWithVCS(t *testing.T) { context.Background(), b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.WorkspaceMapping.Prefix + "prod"), + Name: tfe.String("prod"), VCSRepo: &tfe.VCSRepoOptions{}, }, ) @@ -774,7 +774,7 @@ func TestCloud_applyDiscardedExternally(t *testing.T) { } func TestCloud_applyWithAutoApprove(t *testing.T) { - b, bCleanup := testBackendWithPrefix(t) + b, bCleanup := testBackendWithTags(t) defer bCleanup() ctrl := gomock.NewController(t) @@ -790,7 +790,7 @@ func TestCloud_applyWithAutoApprove(t *testing.T) { context.Background(), b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.WorkspaceMapping.Prefix + "prod"), + Name: tfe.String("prod"), }, ) if err != nil { @@ -896,7 +896,7 @@ func TestCloud_applyForceLocal(t *testing.T) { } func TestCloud_applyWorkspaceWithoutOperations(t *testing.T) { - b, bCleanup := testBackendWithPrefix(t) + b, bCleanup := testBackendWithTags(t) defer bCleanup() ctx := context.Background() @@ -906,7 +906,7 @@ func TestCloud_applyWorkspaceWithoutOperations(t *testing.T) { ctx, b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.WorkspaceMapping.Prefix + "no-operations"), + Name: tfe.String("no-operations"), }, ) if err != nil { @@ -1360,7 +1360,7 @@ func TestCloud_applyPolicySoftFailAutoApprove(t *testing.T) { context.Background(), b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.WorkspaceMapping.Prefix + "prod"), + Name: tfe.String("prod"), }, ) if err != nil { diff --git a/internal/cloud/backend_context.go b/internal/cloud/backend_context.go index 24ed97854..27afadd2f 100644 --- a/internal/cloud/backend_context.go +++ b/internal/cloud/backend_context.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log" - "strings" tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/hcl/v2" @@ -140,12 +139,8 @@ func (b *Cloud) LocalRun(op *backend.Operation) (*backend.LocalRun, statemgr.Ful func (b *Cloud) getRemoteWorkspaceName(localWorkspaceName string) string { switch { case localWorkspaceName == backend.DefaultStateName: - // The default workspace name is a special case, for when the backend - // is configured to with to an exact remote workspace rather than with - // a remote workspace _prefix_. + // The default workspace name is a special case return b.WorkspaceMapping.Name - case b.WorkspaceMapping.Prefix != "" && !strings.HasPrefix(localWorkspaceName, b.WorkspaceMapping.Prefix): - return b.WorkspaceMapping.Prefix + localWorkspaceName default: return localWorkspaceName } diff --git a/internal/cloud/backend_plan_test.go b/internal/cloud/backend_plan_test.go index 6c08c3762..ba2508091 100644 --- a/internal/cloud/backend_plan_test.go +++ b/internal/cloud/backend_plan_test.go @@ -159,7 +159,7 @@ func TestCloud_planLongLine(t *testing.T) { } func TestCloud_planWithoutPermissions(t *testing.T) { - b, bCleanup := testBackendWithPrefix(t) + b, bCleanup := testBackendWithTags(t) defer bCleanup() // Create a named workspace without permissions. @@ -167,7 +167,7 @@ func TestCloud_planWithoutPermissions(t *testing.T) { context.Background(), b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.WorkspaceMapping.Prefix + "prod"), + Name: tfe.String("prod"), }, ) if err != nil { @@ -639,7 +639,7 @@ func TestCloud_planWithoutOperationsEntitlement(t *testing.T) { } func TestCloud_planWorkspaceWithoutOperations(t *testing.T) { - b, bCleanup := testBackendWithPrefix(t) + b, bCleanup := testBackendWithTags(t) defer bCleanup() ctx := context.Background() @@ -649,7 +649,7 @@ func TestCloud_planWorkspaceWithoutOperations(t *testing.T) { ctx, b.organization, tfe.WorkspaceCreateOptions{ - Name: tfe.String(b.WorkspaceMapping.Prefix + "no-operations"), + Name: tfe.String("no-operations"), }, ) if err != nil { diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index 01072898c..ed9a675ee 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" "os" - "reflect" "strings" "testing" @@ -50,13 +49,6 @@ func TestCloud_backendWithName(t *testing.T) { } } -func TestCloud_backendWithPrefix(t *testing.T) { - b, bCleanup := testBackendWithPrefix(t) - defer bCleanup() - - backend.TestBackendStates(t, b) -} - func TestCloud_backendWithTags(t *testing.T) { b, bCleanup := testBackendWithTags(t) defer bCleanup() @@ -90,9 +82,8 @@ func TestCloud_PrepareConfig(t *testing.T) { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("prod"), - "prefix": cty.NullVal(cty.String), - "tags": cty.NullVal(cty.Set(cty.String)), + "name": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), expectedErr: `Invalid organization value: The "organization" attribute value must not be empty.`, @@ -102,36 +93,33 @@ func TestCloud_PrepareConfig(t *testing.T) { "organization": cty.StringVal("org"), "workspaces": cty.NullVal(cty.String), }), - expectedErr: `Invalid workspaces configuration: Missing workspace mapping strategy. Either workspace "tags", "name", or "prefix" is required.`, + expectedErr: `Invalid workspaces configuration: Missing workspace mapping strategy. Either workspace "tags" or "name" is required.`, }, - "workspace: empty tags, name, and prefix": { + "workspace: empty tags, name": { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("org"), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.NullVal(cty.String), - "prefix": cty.NullVal(cty.String), - "tags": cty.NullVal(cty.Set(cty.String)), + "name": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), - expectedErr: `Invalid workspaces configuration: Missing workspace mapping strategy. Either workspace "tags", "name", or "prefix" is required.`, + expectedErr: `Invalid workspaces configuration: Missing workspace mapping strategy. Either workspace "tags" or "name" is required.`, }, - "workspace: name and prefix present": { + "workspace: name present": { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("org"), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("prod"), - "prefix": cty.StringVal("app-"), - "tags": cty.NullVal(cty.Set(cty.String)), + "name": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), - expectedErr: `Invalid workspaces configuration: Only one of workspace "tags", "name", or "prefix" is allowed.`, + expectedErr: `Invalid workspaces configuration: Only one of workspace "tags" or "name" is allowed.`, }, "workspace: name and tags present": { config: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("org"), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("prod"), - "prefix": cty.NullVal(cty.String), + "name": cty.StringVal("prod"), "tags": cty.SetVal( []cty.Value{ cty.StringVal("billing"), @@ -139,7 +127,7 @@ func TestCloud_PrepareConfig(t *testing.T) { ), }), }), - expectedErr: `Invalid workspaces configuration: Only one of workspace "tags", "name", or "prefix" is allowed.`, + expectedErr: `Invalid workspaces configuration: Only one of workspace "tags" or "name" is allowed.`, }, } @@ -170,9 +158,8 @@ func TestCloud_config(t *testing.T) { "organization": cty.StringVal("nonexisting"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("prod"), - "prefix": cty.NullVal(cty.String), - "tags": cty.NullVal(cty.Set(cty.String)), + "name": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), confErr: "organization \"nonexisting\" at host app.terraform.io not found", @@ -183,9 +170,8 @@ func TestCloud_config(t *testing.T) { "organization": cty.StringVal("hashicorp"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("prod"), - "prefix": cty.NullVal(cty.String), - "tags": cty.NullVal(cty.Set(cty.String)), + "name": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), confErr: "Failed to request discovery document", @@ -197,9 +183,8 @@ func TestCloud_config(t *testing.T) { "organization": cty.StringVal("hashicorp"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("prod"), - "prefix": cty.NullVal(cty.String), - "tags": cty.NullVal(cty.Set(cty.String)), + "name": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), confErr: "terraform login localhost", @@ -210,8 +195,7 @@ func TestCloud_config(t *testing.T) { "organization": cty.StringVal("hashicorp"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.NullVal(cty.String), - "prefix": cty.NullVal(cty.String), + "name": cty.NullVal(cty.String), "tags": cty.SetVal( []cty.Value{ cty.StringVal("billing"), @@ -226,58 +210,30 @@ func TestCloud_config(t *testing.T) { "organization": cty.StringVal("hashicorp"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("prod"), - "prefix": cty.NullVal(cty.String), - "tags": cty.NullVal(cty.Set(cty.String)), + "name": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), }, - "with_a_prefix": { + "without_a_name_tags": { config: cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), "organization": cty.StringVal("hashicorp"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.NullVal(cty.String), - "prefix": cty.StringVal("my-app-"), - "tags": cty.NullVal(cty.Set(cty.String)), - }), - }), - }, - "without_a_name_prefix_or_tags": { - config: cty.ObjectVal(map[string]cty.Value{ - "hostname": cty.NullVal(cty.String), - "organization": cty.StringVal("hashicorp"), - "token": cty.NullVal(cty.String), - "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.NullVal(cty.String), - "prefix": cty.NullVal(cty.String), - "tags": cty.NullVal(cty.Set(cty.String)), + "name": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), valErr: `Missing workspace mapping strategy.`, }, - "with_both_a_name_and_a_prefix": { - config: cty.ObjectVal(map[string]cty.Value{ - "hostname": cty.NullVal(cty.String), - "organization": cty.StringVal("hashicorp"), - "token": cty.NullVal(cty.String), - "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("prod"), - "prefix": cty.StringVal("my-app-"), - "tags": cty.NullVal(cty.Set(cty.String)), - }), - }), - valErr: `Only one of workspace "tags", "name", or "prefix" is allowed.`, - }, "with_both_a_name_and_tags": { config: cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), "organization": cty.StringVal("hashicorp"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("prod"), - "prefix": cty.NullVal(cty.String), + "name": cty.StringVal("prod"), "tags": cty.SetVal( []cty.Value{ cty.StringVal("billing"), @@ -285,7 +241,7 @@ func TestCloud_config(t *testing.T) { ), }), }), - valErr: `Only one of workspace "tags", "name", or "prefix" is allowed.`, + valErr: `Only one of workspace "tags" or "name" is allowed.`, }, "null config": { config: cty.NullVal(cty.EmptyObject), @@ -318,8 +274,7 @@ func TestCloud_configVerifyMinimumTFEVersion(t *testing.T) { "organization": cty.StringVal("hashicorp"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.NullVal(cty.String), - "prefix": cty.NullVal(cty.String), + "name": cty.NullVal(cty.String), "tags": cty.SetVal( []cty.Value{ cty.StringVal("billing"), @@ -353,25 +308,23 @@ func TestCloud_setConfigurationFields(t *testing.T) { originalForceBackendEnv := os.Getenv("TF_FORCE_LOCAL_BACKEND") cases := map[string]struct { - obj cty.Value - expectedHostname string - expectedOrganziation string - expectedWorkspacePrefix string - expectedWorkspaceName string - expectedWorkspaceTags []string - expectedForceLocal bool - setEnv func() - resetEnv func() - expectedErr string + obj cty.Value + expectedHostname string + expectedOrganziation string + expectedWorkspaceName string + expectedWorkspaceTags []string + expectedForceLocal bool + setEnv func() + resetEnv func() + expectedErr string }{ "with hostname set": { obj: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("hashicorp"), "hostname": cty.StringVal("hashicorp.com"), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("prod"), - "prefix": cty.NullVal(cty.String), - "tags": cty.NullVal(cty.Set(cty.String)), + "name": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), expectedHostname: "hashicorp.com", @@ -382,9 +335,8 @@ func TestCloud_setConfigurationFields(t *testing.T) { "organization": cty.StringVal("hashicorp"), "hostname": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("prod"), - "prefix": cty.NullVal(cty.String), - "tags": cty.NullVal(cty.Set(cty.String)), + "name": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), expectedHostname: defaultHostname, @@ -395,36 +347,20 @@ func TestCloud_setConfigurationFields(t *testing.T) { "organization": cty.StringVal("hashicorp"), "hostname": cty.StringVal("hashicorp.com"), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("prod"), - "prefix": cty.NullVal(cty.String), - "tags": cty.NullVal(cty.Set(cty.String)), + "name": cty.StringVal("prod"), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), expectedHostname: "hashicorp.com", expectedOrganziation: "hashicorp", expectedWorkspaceName: "prod", }, - "with workspace prefix set": { - obj: cty.ObjectVal(map[string]cty.Value{ - "organization": cty.StringVal("hashicorp"), - "hostname": cty.StringVal("hashicorp.com"), - "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.NullVal(cty.String), - "prefix": cty.StringVal("prod"), - "tags": cty.NullVal(cty.Set(cty.String)), - }), - }), - expectedHostname: "hashicorp.com", - expectedOrganziation: "hashicorp", - expectedWorkspacePrefix: "prod", - }, "with workspace tags set": { obj: cty.ObjectVal(map[string]cty.Value{ "organization": cty.StringVal("hashicorp"), "hostname": cty.StringVal("hashicorp.com"), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.NullVal(cty.String), - "prefix": cty.NullVal(cty.String), + "name": cty.NullVal(cty.String), "tags": cty.SetVal( []cty.Value{ cty.StringVal("billing"), @@ -441,14 +377,12 @@ func TestCloud_setConfigurationFields(t *testing.T) { "organization": cty.StringVal("hashicorp"), "hostname": cty.StringVal("hashicorp.com"), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.NullVal(cty.String), - "prefix": cty.StringVal("prod"), - "tags": cty.NullVal(cty.Set(cty.String)), + "name": cty.NullVal(cty.String), + "tags": cty.NullVal(cty.Set(cty.String)), }), }), - expectedHostname: "hashicorp.com", - expectedOrganziation: "hashicorp", - expectedWorkspacePrefix: "prod", + expectedHostname: "hashicorp.com", + expectedOrganziation: "hashicorp", setEnv: func() { os.Setenv("TF_FORCE_LOCAL_BACKEND", "1") }, @@ -482,9 +416,6 @@ func TestCloud_setConfigurationFields(t *testing.T) { if tc.expectedOrganziation != "" && b.organization != tc.expectedOrganziation { t.Fatalf("%s: expected organization (%s) to match configured organization (%s)", name, b.organization, tc.expectedOrganziation) } - if tc.expectedWorkspacePrefix != "" && b.WorkspaceMapping.Prefix != tc.expectedWorkspacePrefix { - t.Fatalf("%s: expected workspace prefix mapping (%s) to match configured workspace prefix (%s)", name, b.WorkspaceMapping.Prefix, tc.expectedWorkspacePrefix) - } if tc.expectedWorkspaceName != "" && b.WorkspaceMapping.Name != tc.expectedWorkspaceName { t.Fatalf("%s: expected workspace name mapping (%s) to match configured workspace name (%s)", name, b.WorkspaceMapping.Name, tc.expectedWorkspaceName) } @@ -557,87 +488,6 @@ func TestCloud_addAndRemoveWorkspacesDefault(t *testing.T) { } } -func TestCloud_addAndRemoveWorkspacesWithPrefix(t *testing.T) { - b, bCleanup := testBackendWithPrefix(t) - defer bCleanup() - - states, err := b.Workspaces() - if err != nil { - t.Fatal(err) - } - - expectedWorkspaces := []string(nil) - if !reflect.DeepEqual(states, expectedWorkspaces) { - t.Fatalf("expected states %#+v, got %#+v", expectedWorkspaces, states) - } - - if _, err := b.StateMgr(backend.DefaultStateName); err != backend.ErrDefaultWorkspaceNotSupported { - t.Fatalf("expected error %v, got %v", backend.ErrDefaultWorkspaceNotSupported, err) - } - - expectedA := "test_A" - if _, err := b.StateMgr(expectedA); err != nil { - t.Fatal(err) - } - - states, err = b.Workspaces() - if err != nil { - t.Fatal(err) - } - - expectedWorkspaces = append(expectedWorkspaces, expectedA) - if !reflect.DeepEqual(states, expectedWorkspaces) { - t.Fatalf("expected %#+v, got %#+v", expectedWorkspaces, states) - } - - expectedB := "test_B" - if _, err := b.StateMgr(expectedB); err != nil { - t.Fatal(err) - } - - states, err = b.Workspaces() - if err != nil { - t.Fatal(err) - } - - expectedWorkspaces = append(expectedWorkspaces, expectedB) - if !reflect.DeepEqual(states, expectedWorkspaces) { - t.Fatalf("expected %#+v, got %#+v", expectedWorkspaces, states) - } - - if err := b.DeleteWorkspace(backend.DefaultStateName); err != backend.ErrDefaultWorkspaceNotSupported { - t.Fatalf("expected error %v, got %v", backend.ErrDefaultWorkspaceNotSupported, err) - } - - if err := b.DeleteWorkspace(expectedA); err != nil { - t.Fatal(err) - } - - states, err = b.Workspaces() - if err != nil { - t.Fatal(err) - } - - expectedWorkspaces = []string{expectedB} - if !reflect.DeepEqual(states, expectedWorkspaces) { - t.Fatalf("expected %#+v got %#+v", expectedWorkspaces, states) - } - - if err := b.DeleteWorkspace(expectedB); err != nil { - t.Fatal(err) - } - - states, err = b.Workspaces() - if err != nil { - t.Fatal(err) - } - - expectedWorkspaces = []string(nil) - if !reflect.DeepEqual(states, expectedWorkspaces) { - t.Fatalf("expected %#+v, got %#+v", expectedWorkspaces, states) - } -} - func TestCloud_StateMgr_versionCheck(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() diff --git a/internal/cloud/errors.go b/internal/cloud/errors.go index 81dfe7bb7..2c5910030 100644 --- a/internal/cloud/errors.go +++ b/internal/cloud/errors.go @@ -18,14 +18,14 @@ var ( invalidWorkspaceConfigMissingValues = tfdiags.AttributeValue( tfdiags.Error, "Invalid workspaces configuration", - fmt.Sprintf("Missing workspace mapping strategy. Either workspace \"tags\", \"name\", or \"prefix\" is required.\n\n%s", workspaceConfigurationHelp), + fmt.Sprintf("Missing workspace mapping strategy. Either workspace \"tags\" or \"name\" is required.\n\n%s", workspaceConfigurationHelp), cty.Path{cty.GetAttrStep{Name: "workspaces"}}, ) invalidWorkspaceConfigMisconfiguration = tfdiags.AttributeValue( tfdiags.Error, "Invalid workspaces configuration", - fmt.Sprintf("Only one of workspace \"tags\", \"name\", or \"prefix\" is allowed.\n\n%s", workspaceConfigurationHelp), + fmt.Sprintf("Only one of workspace \"tags\" or \"name\" is allowed.\n\n%s", workspaceConfigurationHelp), cty.Path{cty.GetAttrStep{Name: "workspaces"}}, ) ) diff --git a/internal/cloud/testing.go b/internal/cloud/testing.go index 75e53c5bc..e7be4748c 100644 --- a/internal/cloud/testing.go +++ b/internal/cloud/testing.go @@ -71,23 +71,8 @@ func testBackendWithName(t *testing.T) (*Cloud, func()) { "organization": cty.StringVal("hashicorp"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal(testBackendSingleWorkspaceName), - "prefix": cty.NullVal(cty.String), - "tags": cty.NullVal(cty.Set(cty.String)), - }), - }) - return testBackend(t, obj) -} - -func testBackendWithPrefix(t *testing.T) (*Cloud, func()) { - obj := cty.ObjectVal(map[string]cty.Value{ - "hostname": cty.NullVal(cty.String), - "organization": cty.StringVal("hashicorp"), - "token": cty.NullVal(cty.String), - "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.NullVal(cty.String), - "prefix": cty.StringVal("my-app-"), - "tags": cty.NullVal(cty.Set(cty.String)), + "name": cty.StringVal(testBackendSingleWorkspaceName), + "tags": cty.NullVal(cty.Set(cty.String)), }), }) return testBackend(t, obj) @@ -99,8 +84,7 @@ func testBackendWithTags(t *testing.T) (*Cloud, func()) { "organization": cty.StringVal("hashicorp"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.NullVal(cty.String), - "prefix": cty.NullVal(cty.String), + "name": cty.NullVal(cty.String), "tags": cty.SetVal( []cty.Value{ cty.StringVal("billing"), @@ -117,9 +101,8 @@ func testBackendNoOperations(t *testing.T) (*Cloud, func()) { "organization": cty.StringVal("no-operations"), "token": cty.NullVal(cty.String), "workspaces": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal(testBackendSingleWorkspaceName), - "prefix": cty.NullVal(cty.String), - "tags": cty.NullVal(cty.Set(cty.String)), + "name": cty.StringVal(testBackendSingleWorkspaceName), + "tags": cty.NullVal(cty.Set(cty.String)), }), }) return testBackend(t, obj) From 0bae48bc0183473cc246311c35ba85c976a11743 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Tue, 12 Oct 2021 12:14:02 -0600 Subject: [PATCH 38/78] update to tfc-integration branch of go-tfe --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 5c09bc4b6..66522fce6 100644 --- a/go.mod +++ b/go.mod @@ -41,7 +41,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-plugin v1.4.3 github.com/hashicorp/go-retryablehttp v0.7.0 - github.com/hashicorp/go-tfe v0.19.1-0.20211001235029-ff29186e11db + github.com/hashicorp/go-tfe v0.19.1-0.20211012181137-3666eed9e8e9 github.com/hashicorp/go-uuid v1.0.2 github.com/hashicorp/go-version v1.2.1 github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f diff --git a/go.sum b/go.sum index 8972a8d9e..c5635216d 100644 --- a/go.sum +++ b/go.sum @@ -379,8 +379,8 @@ github.com/hashicorp/go-slug v0.7.0/go.mod h1:Ib+IWBYfEfJGI1ZyXMGNbu2BU+aa3Dzu41 github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-tfe v0.19.1-0.20211001235029-ff29186e11db h1:e7oSI8NO5bAJBGI8pTxvLkI/0/LMxRaBkJS9hFdKuMk= -github.com/hashicorp/go-tfe v0.19.1-0.20211001235029-ff29186e11db/go.mod h1:gyXLXbpBVxA2F/6opah8XBsOkZJxHYQmghl0OWi8keI= +github.com/hashicorp/go-tfe v0.19.1-0.20211012181137-3666eed9e8e9 h1:97QOrhJha4EmU+mUC1ubf15B40CjdcObULUop49+u8c= +github.com/hashicorp/go-tfe v0.19.1-0.20211012181137-3666eed9e8e9/go.mod h1:gyXLXbpBVxA2F/6opah8XBsOkZJxHYQmghl0OWi8keI= 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.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= From b43daeaa8d72d66f5a990ed2ffbe5942e12bcde3 Mon Sep 17 00:00:00 2001 From: Nick Fagerlund Date: Fri, 24 Sep 2021 13:08:03 -0700 Subject: [PATCH 39/78] Cloud backend: accept version constraints from workspaces The cloud backend (and remote before it) previously expected a TFC workspace's `terraform-version` attribute to be either the magic string `"latest"` or an explicit semver value. But a workspace might have a version constraint instead (like `~> 1.1.0`), in which case the version check would blow up. This commit checks whether `terraform-version` is a valid version constraint before erroring out, and if so, returns success if the local version meets the constraint. Because it's not practical to deeply introspect the slice of version space defined by a constraint, this check is slightly less robust than the version comparisons below it: - It can give a false OK on open-ended constraints like `>= 1.1.0`. Say you're running 1.3.0, it changed the state format, and the TFE instance admin has not yet added any 1.3.x Terraform versions; your workspace will now break. - It will give a false not-OK when using different minor versions within a range that we know to be compatible, e.g. remote constraint of `~> 0.15.0` and local version of 1.1.0. - This would be totally useless with the pre-0.14 versions of Terraform, where patch releases could change state format... but we're not going back in time to add this feature to them anyway. Still, in the most common likely case (`~> x.y.z`), it'll complain at you (with an error you can choose to override) if you're not using the same minor version, and that seems proportionate, useful, and expected. --- internal/cloud/backend.go | 68 ++++++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 2bf5d3c4d..418658417 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -742,10 +742,12 @@ func (b *Cloud) IgnoreVersionConflict() { } // VerifyWorkspaceTerraformVersion compares the local Terraform version against -// the workspace's configured Terraform version. If they are equal, this means -// that there are no compatibility concerns, so it returns no diagnostics. +// the workspace's configured Terraform version. If they are compatible, this +// means that there are no state compatibility concerns, so it returns no +// diagnostics. // -// If the versions differ, +// If the versions aren't compatible, it returns an error (or, if +// b.ignoreVersionConflict is set, a warning). func (b *Cloud) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Diagnostics { var diags tfdiags.Diagnostics @@ -779,13 +781,55 @@ func (b *Cloud) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Di return nil } + // Even if ignoring version conflicts, it may still be useful to call this + // method and warn the user about a mismatch between the local and remote + // Terraform versions. + severity := tfdiags.Error + if b.ignoreVersionConflict { + severity = tfdiags.Warning + } + suggestion := " If you're sure you want to upgrade the state, you can force Terraform to continue using the -ignore-remote-version flag. This may result in an unusable workspace." + if b.ignoreVersionConflict { + suggestion = "" + } + remoteVersion, err := version.NewSemver(workspace.TerraformVersion) if err != nil { + // If it's not a valid version, it might be a valid version constraint: + remoteConstraint, err := version.NewConstraint(workspace.TerraformVersion) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Error looking up workspace", + fmt.Sprintf("Invalid Terraform version or version constraint: %s", err), + )) + return diags + } + + // Avoiding tfversion.SemVer because it omits the prerelease prefix, and we + // want constraints like `~> 1.2.0-beta1` to be possible. + fullTfversion := version.Must(version.NewSemver(tfversion.String())) + + // If it's a constraint, we only ensure that the local version meets it. + // This can result in both false positives and false negatives, but in the + // most common case (~> x.y.z) it's useful enough. + if remoteConstraint.Check(fullTfversion) { + return diags + } + diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Error looking up workspace", - fmt.Sprintf("Invalid Terraform version: %s", err), + severity, + "Terraform version mismatch", + fmt.Sprintf( + "The local Terraform version (%s) does not meet the version requirements for remote workspace %s/%s (%s).%s", + tfversion.String(), + b.organization, + workspace.Name, + workspace.TerraformVersion, + suggestion, + ), )) + return diags } @@ -818,18 +862,6 @@ func (b *Cloud) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Di } } - // Even if ignoring version conflicts, it may still be useful to call this - // method and warn the user about a mismatch between the local and remote - // Terraform versions. - severity := tfdiags.Error - if b.ignoreVersionConflict { - severity = tfdiags.Warning - } - - suggestion := " If you're sure you want to upgrade the state, you can force Terraform to continue using the -ignore-remote-version flag. This may result in an unusable workspace." - if b.ignoreVersionConflict { - suggestion = "" - } diags = diags.Append(tfdiags.Sourceless( severity, "Terraform version mismatch", From 9857097b3479f4ed49b5f300328f2d942f9c135a Mon Sep 17 00:00:00 2001 From: Nick Fagerlund Date: Fri, 24 Sep 2021 17:27:02 -0700 Subject: [PATCH 40/78] Add tests for version constraints in workspace terraform-versions --- internal/cloud/backend_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index ed9a675ee..56fe9ebe1 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -605,6 +605,15 @@ func TestCloud_VerifyWorkspaceTerraformVersion(t *testing.T) { {"1.2.0", "1.2.99", true, false}, {"1.2.0", "1.3.0", true, true}, {"0.15.0", "latest", true, false}, + {"1.1.5", "~> 1.1.1", true, false}, + {"1.1.5", "> 1.1.0, < 1.3.0", true, false}, + {"1.1.5", "~> 1.0.1", true, true}, + // pre-release versions are comparable within their pre-release stage (dev, + // alpha, beta), but not comparable to different stages and not comparable + // to final releases. + {"1.1.0-beta1", "~> 1.1.0-beta", true, false}, + {"1.1.0", "~> 1.1.0-beta", true, true}, + {"1.1.0-beta1", "~> 1.1.0-dev", true, true}, } for _, tc := range testCases { t.Run(fmt.Sprintf("local %s, remote %s", tc.local, tc.remote), func(t *testing.T) { From 83538fdd6bd4cdfd6435f2e97e1d438e736c9750 Mon Sep 17 00:00:00 2001 From: Barrett Clark Date: Wed, 29 Sep 2021 16:55:43 -0500 Subject: [PATCH 41/78] Shift errors to error file, improve error text --- internal/cloud/backend.go | 46 +++++----------------------------- internal/cloud/backend_test.go | 4 +-- internal/cloud/errors.go | 41 ++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 42 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 418658417..5fdd2b12c 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -784,25 +784,15 @@ func (b *Cloud) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Di // Even if ignoring version conflicts, it may still be useful to call this // method and warn the user about a mismatch between the local and remote // Terraform versions. - severity := tfdiags.Error - if b.ignoreVersionConflict { - severity = tfdiags.Warning - } - suggestion := " If you're sure you want to upgrade the state, you can force Terraform to continue using the -ignore-remote-version flag. This may result in an unusable workspace." - if b.ignoreVersionConflict { - suggestion = "" - } - remoteVersion, err := version.NewSemver(workspace.TerraformVersion) if err != nil { + log.Printf("[DEBUG] Invalid Terraform version (%s); will try to parse as version constraint", workspace.TerraformVersion) + } + if remoteVersion == nil { // If it's not a valid version, it might be a valid version constraint: remoteConstraint, err := version.NewConstraint(workspace.TerraformVersion) if err != nil { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Error looking up workspace", - fmt.Sprintf("Invalid Terraform version or version constraint: %s", err), - )) + diags = diags.Append(terraformInvalidVersionOrConstraint(b.ignoreVersionConflict, workspace.TerraformVersion)) return diags } @@ -817,19 +807,7 @@ func (b *Cloud) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Di return diags } - diags = diags.Append(tfdiags.Sourceless( - severity, - "Terraform version mismatch", - fmt.Sprintf( - "The local Terraform version (%s) does not meet the version requirements for remote workspace %s/%s (%s).%s", - tfversion.String(), - b.organization, - workspace.Name, - workspace.TerraformVersion, - suggestion, - ), - )) - + diags = diags.Append(terraformMismatchDiagnostic(b.ignoreVersionConflict, b.organization, workspace, tfversion.String())) return diags } @@ -862,19 +840,7 @@ func (b *Cloud) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Di } } - diags = diags.Append(tfdiags.Sourceless( - severity, - "Terraform version mismatch", - fmt.Sprintf( - "The local Terraform version (%s) does not match the configured version for remote workspace %s/%s (%s).%s", - tfversion.String(), - b.organization, - workspace.Name, - workspace.TerraformVersion, - suggestion, - ), - )) - + diags = diags.Append(terraformMismatchDiagnostic(b.ignoreVersionConflict, b.organization, workspace, tfversion.String())) return diags } diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index 56fe9ebe1..38e4db8d6 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -705,7 +705,7 @@ func TestCloud_VerifyWorkspaceTerraformVersion_workspaceErrors(t *testing.T) { if len(diags) != 1 { t.Fatal("expected diag, but none returned") } - if got := diags.Err().Error(); !strings.Contains(got, "Error looking up workspace: Invalid Terraform version") { + if got := diags.Err().Error(); !strings.Contains(got, "Terraform version error: The remote workspace specified") { t.Fatalf("unexpected error: %s", got) } } @@ -760,7 +760,7 @@ func TestCloud_VerifyWorkspaceTerraformVersion_ignoreFlagSet(t *testing.T) { if got, want := diags[0].Description().Summary, "Terraform version mismatch"; got != want { t.Errorf("wrong summary: got %s, want %s", got, want) } - wantDetail := "The local Terraform version (0.14.0) does not match the configured version for remote workspace hashicorp/app-prod (0.13.5)." + wantDetail := "The local Terraform version (0.14.0) does not meet the version requirements for remote workspace hashicorp/app-prod (0.13.5)." if got := diags[0].Description().Detail; got != wantDetail { t.Errorf("wrong summary: got %s, want %s", got, wantDetail) } diff --git a/internal/cloud/errors.go b/internal/cloud/errors.go index 2c5910030..b51f44338 100644 --- a/internal/cloud/errors.go +++ b/internal/cloud/errors.go @@ -2,7 +2,9 @@ package cloud import ( "fmt" + "strings" + tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/terraform/internal/tfdiags" "github.com/zclconf/go-cty/cty" ) @@ -29,3 +31,42 @@ var ( cty.Path{cty.GetAttrStep{Name: "workspaces"}}, ) ) + +func terraformMismatchDiagnostic(ignoreVersionConflict bool, organization string, workspace *tfe.Workspace, tfversion string) tfdiags.Diagnostic { + severity := tfdiags.Error + if ignoreVersionConflict { + severity = tfdiags.Warning + } + + suggestion := "If you're sure you want to upgrade the state, you can force Terraform to continue using the -ignore-remote-version flag. This may result in an unusable workspace." + if ignoreVersionConflict { + suggestion = "" + } + + description := fmt.Sprintf( + "The local Terraform version (%s) does not meet the version requirements for remote workspace %s/%s (%s).\n\n%s", + tfversion, + organization, + workspace.Name, + workspace.TerraformVersion, + suggestion, + ) + description = strings.TrimSpace(description) + return tfdiags.Sourceless(severity, "Terraform version mismatch", description) +} + +func terraformInvalidVersionOrConstraint(ignoreVersionConflict bool, tfversion string) tfdiags.Diagnostic { + severity := tfdiags.Error + if ignoreVersionConflict { + severity = tfdiags.Warning + } + + suggestion := "If you're sure you want to upgrade the state, you can force Terraform to continue using the -ignore-remote-version flag. This may result in an unusable workspace." + if ignoreVersionConflict { + suggestion = "" + } + + description := fmt.Sprintf("The remote workspace specified an invalid Terraform version or version constraint: %s\n\n%s", tfversion, suggestion) + description = strings.TrimSpace(description) + return tfdiags.Sourceless(severity, "Terraform version error", description) +} From baa72ce235c40da70df1c796fb5ef44fdd15dd38 Mon Sep 17 00:00:00 2001 From: Nick Fagerlund Date: Tue, 12 Oct 2021 17:59:56 -0700 Subject: [PATCH 42/78] Simplify logic flow: everything is a constraint Explicit version strings are actually also version constraints! And the special comparisons we were doing to allow a range of compatible versions can also be expressed as version constraints. Bonus: also simplify the way we handle version check errors, by composing the messages inline and only extracting the repetitive parts into a function. --- internal/cloud/backend.go | 102 ++++++++++++++++----------------- internal/cloud/backend_test.go | 6 +- internal/cloud/errors.go | 39 ++----------- 3 files changed, 60 insertions(+), 87 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 5fdd2b12c..08dafb275 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -781,66 +781,66 @@ func (b *Cloud) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Di return nil } - // Even if ignoring version conflicts, it may still be useful to call this - // method and warn the user about a mismatch between the local and remote - // Terraform versions. - remoteVersion, err := version.NewSemver(workspace.TerraformVersion) + remoteConstraint, err := version.NewConstraint(workspace.TerraformVersion) if err != nil { - log.Printf("[DEBUG] Invalid Terraform version (%s); will try to parse as version constraint", workspace.TerraformVersion) - } - if remoteVersion == nil { - // If it's not a valid version, it might be a valid version constraint: - remoteConstraint, err := version.NewConstraint(workspace.TerraformVersion) - if err != nil { - diags = diags.Append(terraformInvalidVersionOrConstraint(b.ignoreVersionConflict, workspace.TerraformVersion)) - return diags - } - - // Avoiding tfversion.SemVer because it omits the prerelease prefix, and we - // want constraints like `~> 1.2.0-beta1` to be possible. - fullTfversion := version.Must(version.NewSemver(tfversion.String())) - - // If it's a constraint, we only ensure that the local version meets it. - // This can result in both false positives and false negatives, but in the - // most common case (~> x.y.z) it's useful enough. - if remoteConstraint.Check(fullTfversion) { - return diags - } - - diags = diags.Append(terraformMismatchDiagnostic(b.ignoreVersionConflict, b.organization, workspace, tfversion.String())) + message := fmt.Sprintf( + "The remote workspace specified an invalid Terraform version or constraint (%s), "+ + "and it isn't possible to determine whether the local Terraform version (%s) is compatible.", + workspace.TerraformVersion, + tfversion.String(), + ) + diags = diags.Append(incompatibleWorkspaceTerraformVersion(message, b.ignoreVersionConflict)) return diags } - v014 := version.Must(version.NewSemver("0.14.0")) - if tfversion.SemVer.LessThan(v014) || remoteVersion.LessThan(v014) { - // Versions of Terraform prior to 0.14.0 will refuse to load state files - // written by a newer version of Terraform, even if it is only a patch - // level difference. As a result we require an exact match. - if tfversion.SemVer.Equal(remoteVersion) { - return diags - } - } - if tfversion.SemVer.GreaterThanOrEqual(v014) && remoteVersion.GreaterThanOrEqual(v014) { - // Versions of Terraform after 0.14.0 should be compatible with each - // other. At the time this code was written, the only constraints we - // are aware of are: - // - // - 0.14.0 is guaranteed to be compatible with versions up to but not - // including 1.2.0 + // If the workspace has a literal Terraform version, see if we can use a + // looser version constraint. + remoteVersion, _ := version.NewSemver(workspace.TerraformVersion) + if remoteVersion != nil { + v014 := version.Must(version.NewSemver("0.14.0")) v120 := version.Must(version.NewSemver("1.2.0")) - if tfversion.SemVer.LessThan(v120) && remoteVersion.LessThan(v120) { - return diags + + // Versions from 0.14 through the early 1.x series should be compatible + // (though we don't know about 1.2 yet). + if remoteVersion.GreaterThanOrEqual(v014) && remoteVersion.LessThan(v120) { + early1xCompatible, err := version.NewConstraint(fmt.Sprintf(">= 0.14.0, < %s", v120.String())) + if err != nil { + panic(err) + } + remoteConstraint = early1xCompatible } - // - Any new Terraform state version will require at least minor patch - // increment, so x.y.* will always be compatible with each other - tfvs := tfversion.SemVer.Segments64() - rwvs := remoteVersion.Segments64() - if len(tfvs) == 3 && len(rwvs) == 3 && tfvs[0] == rwvs[0] && tfvs[1] == rwvs[1] { - return diags + + // Any future new state format will require at least a minor version + // increment, so x.y.* will always be compatible with each other. + if remoteVersion.GreaterThanOrEqual(v120) { + rwvs := remoteVersion.Segments64() + if len(rwvs) >= 3 { + // ~> x.y.0 + minorVersionCompatible, err := version.NewConstraint(fmt.Sprintf("~> %d.%d.0", rwvs[0], rwvs[1])) + if err != nil { + panic(err) + } + remoteConstraint = minorVersionCompatible + } } } - diags = diags.Append(terraformMismatchDiagnostic(b.ignoreVersionConflict, b.organization, workspace, tfversion.String())) + // Re-parsing tfversion.String because tfversion.SemVer omits the prerelease + // prefix, and we want to allow constraints like `~> 1.2.0-beta1`. + fullTfversion := version.Must(version.NewSemver(tfversion.String())) + + if remoteConstraint.Check(fullTfversion) { + return diags + } + + message := fmt.Sprintf( + "The local Terraform version (%s) does not meet the version requirements for remote workspace %s/%s (%s).", + tfversion.String(), + b.organization, + workspace.Name, + workspace.TerraformVersion, + ) + diags = diags.Append(incompatibleWorkspaceTerraformVersion(message, b.ignoreVersionConflict)) return diags } diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index 38e4db8d6..cd2995887 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -656,7 +656,7 @@ func TestCloud_VerifyWorkspaceTerraformVersion(t *testing.T) { if len(diags) != 1 { t.Fatal("expected diag, but none returned") } - if got := diags.Err().Error(); !strings.Contains(got, "Terraform version mismatch") { + if got := diags.Err().Error(); !strings.Contains(got, "Incompatible Terraform version") { t.Fatalf("unexpected error: %s", got) } } else { @@ -705,7 +705,7 @@ func TestCloud_VerifyWorkspaceTerraformVersion_workspaceErrors(t *testing.T) { if len(diags) != 1 { t.Fatal("expected diag, but none returned") } - if got := diags.Err().Error(); !strings.Contains(got, "Terraform version error: The remote workspace specified") { + if got := diags.Err().Error(); !strings.Contains(got, "Incompatible Terraform version: The remote workspace specified") { t.Fatalf("unexpected error: %s", got) } } @@ -757,7 +757,7 @@ func TestCloud_VerifyWorkspaceTerraformVersion_ignoreFlagSet(t *testing.T) { if got, want := diags[0].Severity(), tfdiags.Warning; got != want { t.Errorf("wrong severity: got %#v, want %#v", got, want) } - if got, want := diags[0].Description().Summary, "Terraform version mismatch"; got != want { + if got, want := diags[0].Description().Summary, "Incompatible Terraform version"; got != want { t.Errorf("wrong summary: got %s, want %s", got, want) } wantDetail := "The local Terraform version (0.14.0) does not meet the version requirements for remote workspace hashicorp/app-prod (0.13.5)." diff --git a/internal/cloud/errors.go b/internal/cloud/errors.go index b51f44338..58494f70e 100644 --- a/internal/cloud/errors.go +++ b/internal/cloud/errors.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" - tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/terraform/internal/tfdiags" "github.com/zclconf/go-cty/cty" ) @@ -32,41 +31,15 @@ var ( ) ) -func terraformMismatchDiagnostic(ignoreVersionConflict bool, organization string, workspace *tfe.Workspace, tfversion string) tfdiags.Diagnostic { +const ignoreRemoteVersionHelp = "If you're sure you want to upgrade the state, you can force Terraform to continue using the -ignore-remote-version flag. This may result in an unusable workspace." + +func incompatibleWorkspaceTerraformVersion(message string, ignoreVersionConflict bool) tfdiags.Diagnostic { severity := tfdiags.Error + suggestion := ignoreRemoteVersionHelp if ignoreVersionConflict { severity = tfdiags.Warning - } - - suggestion := "If you're sure you want to upgrade the state, you can force Terraform to continue using the -ignore-remote-version flag. This may result in an unusable workspace." - if ignoreVersionConflict { suggestion = "" } - - description := fmt.Sprintf( - "The local Terraform version (%s) does not meet the version requirements for remote workspace %s/%s (%s).\n\n%s", - tfversion, - organization, - workspace.Name, - workspace.TerraformVersion, - suggestion, - ) - description = strings.TrimSpace(description) - return tfdiags.Sourceless(severity, "Terraform version mismatch", description) -} - -func terraformInvalidVersionOrConstraint(ignoreVersionConflict bool, tfversion string) tfdiags.Diagnostic { - severity := tfdiags.Error - if ignoreVersionConflict { - severity = tfdiags.Warning - } - - suggestion := "If you're sure you want to upgrade the state, you can force Terraform to continue using the -ignore-remote-version flag. This may result in an unusable workspace." - if ignoreVersionConflict { - suggestion = "" - } - - description := fmt.Sprintf("The remote workspace specified an invalid Terraform version or version constraint: %s\n\n%s", tfversion, suggestion) - description = strings.TrimSpace(description) - return tfdiags.Sourceless(severity, "Terraform version error", description) + description := strings.TrimSpace(fmt.Sprintf("%s\n\n%s", message, suggestion)) + return tfdiags.Sourceless(severity, "Incompatible Terraform version", description) } From 3fedd6898cac09451bc1e877bad0471c69404c3f Mon Sep 17 00:00:00 2001 From: Omar Ismail Date: Sat, 9 Oct 2021 08:47:12 -0400 Subject: [PATCH 43/78] Backend State Migration to `cloud`: Multiple Workspaces * Handle when there are multiple workspaces migrating to cloud, using both the cloud name strategy and cloud tags strategy. * Add e2e tests --- internal/cloud/e2e/apply_auto_approve_test.go | 52 +-- internal/cloud/e2e/helper_test.go | 12 +- .../e2e/migrate_state_multi_to_tfc_test.go | 436 +++++++++++++++++- ...igrate_state_remote_backend_to_tfc_test.go | 34 +- .../e2e/migrate_state_single_to_tfc_test.go | 75 +-- .../e2e/migrate_state_tfc_to_other_test.go | 38 +- internal/command/meta_backend_migrate.go | 187 +++++++- internal/command/meta_backend_migrate_test.go | 62 +++ 8 files changed, 784 insertions(+), 112 deletions(-) create mode 100644 internal/command/meta_backend_migrate_test.go diff --git a/internal/cloud/e2e/apply_auto_approve_test.go b/internal/cloud/e2e/apply_auto_approve_test.go index 4e5a65a02..1614e4f1e 100644 --- a/internal/cloud/e2e/apply_auto_approve_test.go +++ b/internal/cloud/e2e/apply_auto_approve_test.go @@ -42,14 +42,14 @@ func Test_terraform_apply_autoApprove(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init"}, - expectedOutput: "Terraform has been successfully initialized", - expectedErr: "", + command: []string{"init"}, + expectedCmdOutput: "Terraform has been successfully initialized", + expectedErr: "", }, { - command: []string{"apply"}, - expectedOutput: "Do you want to perform these actions in workspace", - expectedErr: "Error asking approve", + command: []string{"apply"}, + expectedCmdOutput: "Do you want to perform these actions in workspace", + expectedErr: "Error asking approve", }, }, validations: func(t *testing.T, orgName, wsName string) { @@ -86,14 +86,14 @@ func Test_terraform_apply_autoApprove(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init"}, - expectedOutput: "Terraform has been successfully initialized", - expectedErr: "", + command: []string{"init"}, + expectedCmdOutput: "Terraform has been successfully initialized", + expectedErr: "", }, { - command: []string{"apply"}, - expectedOutput: "Do you want to perform these actions in workspace", - expectedErr: "Error asking approve", + command: []string{"apply"}, + expectedCmdOutput: "Do you want to perform these actions in workspace", + expectedErr: "Error asking approve", }, }, validations: func(t *testing.T, orgName, wsName string) { @@ -130,14 +130,14 @@ func Test_terraform_apply_autoApprove(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init"}, - expectedOutput: "Terraform has been successfully initialized", - expectedErr: "", + command: []string{"init"}, + expectedCmdOutput: "Terraform has been successfully initialized", + expectedErr: "", }, { - command: []string{"apply", "-auto-approve"}, - expectedOutput: "Apply complete! Resources: 1 added, 0 changed, 0 destroyed.", - expectedErr: "", + command: []string{"apply", "-auto-approve"}, + expectedCmdOutput: "Apply complete! Resources: 1 added, 0 changed, 0 destroyed.", + expectedErr: "", }, }, validations: func(t *testing.T, orgName, wsName string) { @@ -175,14 +175,14 @@ func Test_terraform_apply_autoApprove(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init"}, - expectedOutput: "Terraform has been successfully initialized", - expectedErr: "", + command: []string{"init"}, + expectedCmdOutput: "Terraform has been successfully initialized", + expectedErr: "", }, { - command: []string{"apply", "-auto-approve"}, - expectedOutput: "Apply complete! Resources: 1 added, 0 changed, 0 destroyed.", - expectedErr: "", + command: []string{"apply", "-auto-approve"}, + expectedCmdOutput: "Apply complete! Resources: 1 added, 0 changed, 0 destroyed.", + expectedErr: "", }, }, validations: func(t *testing.T, orgName, wsName string) { @@ -228,8 +228,8 @@ func Test_terraform_apply_autoApprove(t *testing.T) { } } - if cmd.expectedOutput != "" && !strings.Contains(stdout, cmd.expectedOutput) { - t.Fatalf("Expected to find output %s, but did not find in\n%s", cmd.expectedOutput, stdout) + if cmd.expectedCmdOutput != "" && !strings.Contains(stdout, cmd.expectedCmdOutput) { + t.Fatalf("Expected to find output %s, but did not find in\n%s", cmd.expectedCmdOutput, stdout) } } diff --git a/internal/cloud/e2e/helper_test.go b/internal/cloud/e2e/helper_test.go index 5af66bf1c..f8dad3a79 100644 --- a/internal/cloud/e2e/helper_test.go +++ b/internal/cloud/e2e/helper_test.go @@ -19,12 +19,12 @@ const ( ) type tfCommand struct { - command []string - expectedOutput string - expectedErr string - expectError bool - userInput []string - postInputOutput string + command []string + expectedCmdOutput string + expectedErr string + expectError bool + userInput []string + postInputOutput []string } type operationSets struct { diff --git a/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go index a6c4f8190..f4160a61a 100644 --- a/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go @@ -1,18 +1,434 @@ +//go:build e2e +// +build e2e + package main import ( + "context" + "fmt" + "io/ioutil" + "os" "testing" + + expect "github.com/Netflix/go-expect" + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/e2e" ) -/* - "multi" == multi-backend, multiple workspaces - -- when cloud config == name -> - ---- prompt -> do you want to ONLY migrate the current workspace +func Test_migrate_multi_to_tfc_cloud_name_strategy(t *testing.T) { + ctx := context.Background() + + cases := map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "migrating multiple workspaces to cloud using name strategy; current workspace is 'default'": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tfBlock := terraformConfigLocalBackend() + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "local"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "new", "prod"}, + expectedCmdOutput: `Created and switched to workspace "prod"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "select", "default"}, + expectedCmdOutput: `Switched to workspace "default".`, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "new-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `Do you want to copy only your current workspace?`, + userInput: []string{"yes", "yes"}, + postInputOutput: []string{ + `Do you want to copy existing state to the new backend?`, + `Successfully configured the backend "cloud"!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `new-workspace`, // this comes from the `prep` function + }, + { + command: []string{"output"}, + expectedCmdOutput: `val = "default"`, // this was the output of the current workspace selected before migration + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(wsList.Items) != 1 { + t.Fatalf("Expected the number of workspaces to be 1, but got %d", len(wsList.Items)) + } + ws := wsList.Items[0] + // this workspace name is what exists in the cloud backend configuration block + if ws.Name != "new-workspace" { + t.Fatalf("Expected workspace to be `new-workspace`, but is %s", ws.Name) + } + }, + }, + "migrating multiple workspaces to cloud using name strategy; current workspace is 'prod'": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tfBlock := terraformConfigLocalBackend() + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "local"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "new", "prod"}, + expectedCmdOutput: `Created and switched to workspace "prod"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "new-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `Do you want to copy only your current workspace?`, + userInput: []string{"yes", "yes"}, + postInputOutput: []string{ + `Do you want to copy existing state to the new backend?`, + `Successfully configured the backend "cloud"!`}, + }, + { + command: []string{"workspace", "list"}, + expectedCmdOutput: `new-workspace`, // this comes from the `prep` function + }, + { + command: []string{"output"}, + expectedCmdOutput: `val = "prod"`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{}) + if err != nil { + t.Fatal(err) + } + ws := wsList.Items[0] + // this workspace name is what exists in the cloud backend configuration block + if ws.Name != "new-workspace" { + t.Fatalf("Expected workspace to be `new-workspace`, but is %s", ws.Name) + } + }, + }, + } + + for name, tc := range cases { + fmt.Println("Test: ", name) + organization, cleanup := createOrganization(t) + defer cleanup() + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + defer tf.Close() + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + t.Log("Running commands: ", tfCmd.command) + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } + } - -- when cloud config == tags - -- If Default present, prompt to rename default. - -- Then -> Prompt with * -*/ -func Test_migrate_multi_to_tfc(t *testing.T) { - t.Skip("todo: see comments") +} + +func Test_migrate_multi_to_tfc_cloud_tags_strategy(t *testing.T) { + ctx := context.Background() + + cases := map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "migrating multiple workspaces to cloud using tags strategy; pattern is using prefix `app-*`": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tfBlock := terraformConfigLocalBackend() + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "local"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "new", "prod"}, + expectedCmdOutput: `Created and switched to workspace "prod"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "select", "default"}, + expectedCmdOutput: `Switched to workspace "default".`, + }, + { + command: []string{"output"}, + expectedCmdOutput: `val = "default"`, + }, + { + command: []string{"workspace", "select", "prod"}, + expectedCmdOutput: `Switched to workspace "prod".`, + }, + { + command: []string{"output"}, + expectedCmdOutput: `val = "prod"`, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `The "cloud" backend configuration only allows named workspaces!`, + userInput: []string{"dev", "1", "app-*", "1"}, + postInputOutput: []string{ + `Would you like to rename your workspaces?`, + "What pattern would you like to add to all your workspaces?", + "The currently selected workspace (prod) does not exist.", + "Terraform has been successfully initialized!"}, + }, + { + command: []string{"workspace", "select", "app-prod"}, + expectedCmdOutput: `Switched to workspace "app-prod".`, + }, + { + command: []string{"output"}, + expectedCmdOutput: `val = "prod"`, + }, + { + command: []string{"workspace", "select", "app-dev"}, + expectedCmdOutput: `Switched to workspace "app-dev".`, + }, + { + command: []string{"output"}, + expectedCmdOutput: `val = "default"`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{ + Tags: tfe.String("app"), + }) + if err != nil { + t.Fatal(err) + } + if len(wsList.Items) != 2 { + t.Fatalf("Expected the number of workspaecs to be 2, but got %d", len(wsList.Items)) + } + expectedWorkspaceNames := []string{"app-prod", "app-dev"} + for _, ws := range wsList.Items { + hasName := false + for _, expectedNames := range expectedWorkspaceNames { + if expectedNames == ws.Name { + hasName = true + } + } + if !hasName { + t.Fatalf("Worksapce %s is not in the expected list of workspaces", ws.Name) + } + } + }, + }, + } + + for name, tc := range cases { + fmt.Println("Test: ", name) + organization, cleanup := createOrganization(t) + t.Log(organization.Name) + defer cleanup() + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + defer tf.Close() + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + t.Log("running commands: ", tfCmd.command) + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + if output == "" { + continue + } + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } + } } diff --git a/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go b/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go index 6819ea7aa..0381216a6 100644 --- a/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go @@ -1,3 +1,6 @@ +//go:build e2e +// +build e2e + package main import ( @@ -23,9 +26,34 @@ import ( */ func Test_migrate_remote_backend_name_to_tfc(t *testing.T) { - t.Skip("todo: see comments") + t.Skip("TODO: see comments") + _ = map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "single workspace with backend name strategy, to cloud with name strategy": {}, + "single workspace with backend name strategy, to cloud with tags strategy": {}, + } } -func Test_migrate_remote_backend_prefix_to_tfc(t *testing.T) { - t.Skip("todo: see comments") +func Test_migrate_remote_backend_prefix_to_tfc_name(t *testing.T) { + t.Skip("TODO: see comments") + _ = map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "single workspace with backend prefix strategy, to cloud with name strategy": {}, + "multiple workspaces with backend prefix strategy, to cloud with name strategy": {}, + } +} + +func Test_migrate_remote_backend_prefix_to_tfc_tags(t *testing.T) { + t.Skip("TODO: see comments") + _ = map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "single workspace with backend prefix strategy, to cloud with tags strategy": {}, + "multiple workspaces with backend prefix strategy, to cloud with tags strategy": {}, + } } diff --git a/internal/cloud/e2e/migrate_state_single_to_tfc_test.go b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go index 28292419c..d251207fe 100644 --- a/internal/cloud/e2e/migrate_state_single_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go @@ -31,14 +31,14 @@ func Test_migrate_single_to_tfc(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init"}, - expectedOutput: `Successfully configured the backend "local"!`, + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "local"!`, }, { - command: []string{"apply"}, - userInput: []string{"yes"}, - expectedOutput: `Do you want to perform these actions?`, - postInputOutput: `Apply complete!`, + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, }, }, }, @@ -50,14 +50,14 @@ func Test_migrate_single_to_tfc(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init", "-migrate-state"}, - expectedOutput: `Do you want to copy existing state to the new backend?`, - userInput: []string{"yes"}, - postInputOutput: `Successfully configured the backend "cloud"!`, + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `Do you want to copy existing state to the new backend?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Successfully configured the backend "cloud"!`}, }, { - command: []string{"workspace", "list"}, - expectedOutput: `new-workspace`, + command: []string{"workspace", "list"}, + expectedCmdOutput: `new-workspace`, }, }, }, @@ -82,14 +82,14 @@ func Test_migrate_single_to_tfc(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init"}, - expectedOutput: `Successfully configured the backend "local"!`, + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "local"!`, }, { - command: []string{"apply"}, - userInput: []string{"yes"}, - expectedOutput: `Do you want to perform these actions?`, - postInputOutput: `Apply complete!`, + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, }, }, }, @@ -101,14 +101,14 @@ func Test_migrate_single_to_tfc(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init", "-migrate-state"}, - expectedOutput: `The "cloud" backend configuration only allows named workspaces!`, - userInput: []string{"new-workspace", "yes"}, - postInputOutput: `Successfully configured the backend "cloud"!`, + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `The "cloud" backend configuration only allows named workspaces!`, + userInput: []string{"new-workspace", "yes"}, + postInputOutput: []string{`Successfully configured the backend "cloud"!`}, }, { - command: []string{"workspace", "list"}, - expectedOutput: `new-workspace`, + command: []string{"workspace", "list"}, + expectedCmdOutput: `new-workspace`, }, }, }, @@ -162,23 +162,28 @@ func Test_migrate_single_to_tfc(t *testing.T) { t.Fatal(err) } - if tfCmd.expectedOutput != "" { - _, err := exp.ExpectString(tfCmd.expectedOutput) + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) if err != nil { t.Fatal(err) } } - if len(tfCmd.userInput) > 0 { - for _, input := range tfCmd.userInput { + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i <= lenInput; i++ { + input := tfCmd.userInput[i] exp.SendLine(input) - } - } - - if tfCmd.postInputOutput != "" { - _, err := exp.ExpectString(tfCmd.postInputOutput) - if err != nil { - t.Fatal(err) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } } } diff --git a/internal/cloud/e2e/migrate_state_tfc_to_other_test.go b/internal/cloud/e2e/migrate_state_tfc_to_other_test.go index 750a315b8..5c4173d01 100644 --- a/internal/cloud/e2e/migrate_state_tfc_to_other_test.go +++ b/internal/cloud/e2e/migrate_state_tfc_to_other_test.go @@ -27,8 +27,8 @@ func Test_migrate_tfc_to_other(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init"}, - expectedOutput: `Successfully configured the backend "cloud"!`, + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "cloud"!`, }, }, }, @@ -39,9 +39,9 @@ func Test_migrate_tfc_to_other(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init", "-migrate-state"}, - expectedOutput: `Migrating state from Terraform Cloud to another backend is not yet implemented.`, - expectError: true, + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `Migrating state from Terraform Cloud to another backend is not yet implemented.`, + expectError: true, }, }, }, @@ -83,26 +83,30 @@ func Test_migrate_tfc_to_other(t *testing.T) { t.Fatal(err) } - if tfCmd.expectedOutput != "" { - _, err := exp.ExpectString(tfCmd.expectedOutput) + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) if err != nil { t.Fatal(err) } } - if len(tfCmd.userInput) > 0 { - for _, input := range tfCmd.userInput { + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i <= lenInput; i++ { + input := tfCmd.userInput[i] exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } } } - - if tfCmd.postInputOutput != "" { - _, err := exp.ExpectString(tfCmd.postInputOutput) - if err != nil { - t.Fatal(err) - } - } - err = cmd.Wait() if err != nil && !tfCmd.expectError { t.Fatal(err) diff --git a/internal/command/meta_backend_migrate.go b/internal/command/meta_backend_migrate.go index 132bd96ef..17dcd930c 100644 --- a/internal/command/meta_backend_migrate.go +++ b/internal/command/meta_backend_migrate.go @@ -275,16 +275,9 @@ func (m *Meta) backendMigrateState_s_s(opts *backendMigrateOpts) error { // for a new name and migrate the default state to the given named state. destinationState, err = func() (statemgr.Full, error) { log.Print("[TRACE] backendMigrateState: destination doesn't support a default workspace, so we must prompt for a new name") - name, err := m.UIInput().Input(context.Background(), &terraform.InputOpts{ - Id: "new-state-name", - Query: fmt.Sprintf( - "[reset][bold][yellow]The %q backend configuration only allows "+ - "named workspaces![reset]", - opts.DestinationType), - Description: strings.TrimSpace(inputBackendNewWorkspaceName), - }) + name, err := m.promptNewWorkspaceName(opts.DestinationType) if err != nil { - return nil, fmt.Errorf("Error asking for new state name: %s", err) + return nil, err } // Update the name of the destination state. @@ -562,23 +555,156 @@ func (m *Meta) backendMigrateTFC(opts *backendMigrateOpts) error { multiSource := !sourceSingleState && len(sourceWorkspaces) > 1 if multiSource && destinationNameStrategy { - // we have to take the current workspace from the source and migrate that - // over to destination. Since there is multiple sources, and we are using a - // name strategy, we will only migrate the current workspace. - panic("not yet implemented") + if err := m.promptMultiToSingleCloudMigration(opts); err != nil { + return err + } + + currentEnv, err := m.Workspace() + if err != nil { + return err + } + + opts.sourceWorkspace = currentEnv + opts.destinationWorkspace = cloudBackendDestination.WorkspaceMapping.Name + + return m.backendMigrateState_s_s(opts) } // Multiple sources, and using tags strategy. So migrate every source // workspace over to new one, prompt for workspace name pattern (*), // and start migrating, and create tags for each workspace. if multiSource && destinationTagsStrategy { - // TODO: see internal/cloud/e2e/migrate_state_multi_to_tfc_test.go for notes - panic("not yet implemented") + return m.backendMigrateState_S_TFC(opts, sourceWorkspaces) } return nil } +// migrates a multi-state backend to Terraform Cloud +func (m *Meta) backendMigrateState_S_TFC(opts *backendMigrateOpts, sourceWorkspaces []string) error { + log.Print("[TRACE] backendMigrateState: migrating all named workspaces") + + // This map is used later when doing the migration per source/destination. + // If a source has 'default', then we ask what the new name should be. + // And further down when we actually run state migration for each + // sourc/destination workspce, we use this new name (where source is 'default') + // and set as destinationWorkspace. + defaultNewName := map[string]string{} + for i := 0; i < len(sourceWorkspaces); i++ { + if sourceWorkspaces[i] == backend.DefaultStateName { + newName, err := m.promptNewWorkspaceName(opts.DestinationType) + if err != nil { + return err + } + defaultNewName[sourceWorkspaces[i]] = newName + } + } + pattern, err := m.promptMultiStateMigrationPattern(opts.SourceType) + if err != nil { + return err + } + + // Go through each and migrate + for _, name := range sourceWorkspaces { + + // Copy the same names + opts.sourceWorkspace = name + if newName, ok := defaultNewName[name]; ok { + // this has to be done before setting destinationWorkspace + name = newName + } + opts.destinationWorkspace = strings.Replace(pattern, "*", name, -1) + + // Force it, we confirmed above + opts.force = true + + // Perform the migration + if err := m.backendMigrateState_s_s(opts); err != nil { + return fmt.Errorf(strings.TrimSpace( + errMigrateMulti), name, opts.SourceType, opts.DestinationType, err) + } + } + + return nil +} + +// Multi-state to single state. +func (m *Meta) promptMultiToSingleCloudMigration(opts *backendMigrateOpts) error { + migrate := opts.force + if !migrate { + var err error + // Ask the user if they want to migrate their existing remote state + migrate, err = m.confirm(&terraform.InputOpts{ + Id: "backend-migrate-multistate-to-single", + Query: "Do you want to copy only your current workspace?", + Description: strings.TrimSpace(tfcInputBackendMigrateMultiToSingle), + }) + if err != nil { + return fmt.Errorf("Error asking for state migration action: %s", err) + } + } + + if !migrate { + return fmt.Errorf("Migration aborted by user.") + } + + return nil +} + +func (m *Meta) promptNewWorkspaceName(destinationType string) (string, error) { + name, err := m.UIInput().Input(context.Background(), &terraform.InputOpts{ + Id: "new-state-name", + Query: fmt.Sprintf( + "[reset][bold][yellow]The %q backend configuration only allows "+ + "named workspaces![reset]", + destinationType), + Description: strings.TrimSpace(inputBackendNewWorkspaceName), + }) + if err != nil { + return "", fmt.Errorf("Error asking for new state name: %s", err) + } + + return name, nil +} + +func (m *Meta) promptMultiStateMigrationPattern(sourceType string) (string, error) { + renameWorkspaces, err := m.UIInput().Input(context.Background(), &terraform.InputOpts{ + Id: "backend-migrate-multistate-to-tfc", + Query: fmt.Sprintf("[reset][bold][yellow]%s[reset]", "Would you like to rename your workspaces?"), + Description: fmt.Sprintf(strings.TrimSpace(tfcInputBackendMigrateMultiToMulti), sourceType), + }) + if err != nil { + return "", fmt.Errorf("Error asking for state migration action: %s", err) + } + if renameWorkspaces != "2" && renameWorkspaces != "1" { + return "", fmt.Errorf("Please select 1 or 2 as part of this option.") + } + if renameWorkspaces == "2" { + // this means they did not want to rename their workspaces, and we are + // returning a generic '*' that means use the same workspace name during + // migration. + return "*", nil + } + + pattern, err := m.UIInput().Input(context.Background(), &terraform.InputOpts{ + Id: "backend-migrate-multistate-to-tfc-pattern", + Query: fmt.Sprintf("[reset][bold][yellow]%s[reset]", "What pattern would you like to add to all your workspaces?"), + Description: fmt.Sprintf(strings.TrimSpace(tfcInputBackendMigrateMultiToMultiPattern), sourceType), + }) + if err != nil { + return "", fmt.Errorf("Error asking for state migration action: %s", err) + } + if !strings.Contains(pattern, "*") { + return "", fmt.Errorf("The pattern must have an '*'") + } + + if count := strings.Count(pattern, "*"); count > 1 { + return "", fmt.Errorf("The pattern '*' cannot be used more than once.") + } + + return pattern, nil +} + const errMigrateLoadStates = ` Error inspecting states in the %q backend: %s @@ -629,6 +755,37 @@ Migrating state from Terraform Cloud to another backend is not yet implemented. Please use the API to do this: https://www.terraform.io/docs/cloud/api/state-versions.html ` +const tfcInputBackendMigrateMultiToMultiPattern = ` +If you choose to NOT rename your workspaces, just input "*". + +The asterisk "*" represents your workspace name. Here are a few examples +if a workspace was named 'prod': +* input: 'app-*'; output: 'app-prod' +* input: '*-app', output: 'prod-app' +* input: 'app-*-service', output: 'app-prod-service' +* input: '*'; output: 'prod' +` + +const tfcInputBackendMigrateMultiToMulti = ` +When migrating existing workspaces from the backend %[1]q to Terraform Cloud, would you like to +rename your workspaces? + +Unlike typical Terraform workspaces representing an environment associated with a particular +configuration (e.g. production, staging, development), Terraform Cloud workspaces are named uniquely +across all configurations used within an organization. A typical strategy to start with is +-- (e.g. networking-prod-us-east, networking-staging-us-east). + +For more information on workspace naming, see https://www.terraform.io/docs/cloud/workspaces/naming.html + +1. Yes, rename workspaces according to a pattern. +2. No, I would not like to rename my workspaces. Migrate them as currently named. +` + +const tfcInputBackendMigrateMultiToSingle = ` +The cloud configuration has one workspace declared, and you are attemtping to migrate multiple workspaces +to a single workspace. By continuing, you will only migrate your current workspace. +` + const inputBackendMigrateEmpty = ` Pre-existing state was found while migrating the previous %q backend to the newly configured %q backend. No existing state was found in the newly diff --git a/internal/command/meta_backend_migrate_test.go b/internal/command/meta_backend_migrate_test.go new file mode 100644 index 000000000..d45cbdc0d --- /dev/null +++ b/internal/command/meta_backend_migrate_test.go @@ -0,0 +1,62 @@ +package command + +import ( + "fmt" + "testing" +) + +func TestBackendMigrate_promptMultiStatePattern(t *testing.T) { + // Setup the meta + + cases := map[string]struct { + renamePrompt string + patternPrompt string + expectedErr string + }{ + "valid pattern": { + renamePrompt: "1", + patternPrompt: "hello-*", + expectedErr: "", + }, + "invalid pattern, only one asterisk allowed": { + renamePrompt: "1", + patternPrompt: "hello-*-world-*", + expectedErr: "The pattern '*' cannot be used more than once.", + }, + "invalid pattern, missing asterisk": { + renamePrompt: "1", + patternPrompt: "hello-world", + expectedErr: "The pattern must have an '*'", + }, + "invalid rename": { + renamePrompt: "3", + expectedErr: "Please select 1 or 2 as part of this option.", + }, + "no rename": { + renamePrompt: "2", + }, + } + for name, tc := range cases { + fmt.Println("Test: ", name) + m := testMetaBackend(t, nil) + input := map[string]string{} + cleanup := testInputMap(t, input) + if tc.renamePrompt != "" { + input["backend-migrate-multistate-to-tfc"] = tc.renamePrompt + } + if tc.patternPrompt != "" { + input["backend-migrate-multistate-to-tfc-pattern"] = tc.patternPrompt + } + + sourceType := "cloud" + _, err := m.promptMultiStateMigrationPattern(sourceType) + if tc.expectedErr == "" && err != nil { + t.Fatalf("expected error to be nil, but was %s", err.Error()) + } + if tc.expectedErr != "" && tc.expectedErr != err.Error() { + t.Fatalf("expected error to eq %s but got %s", tc.expectedErr, err.Error()) + } + + cleanup() + } +} From dc76bbee73bd232a47762a0797fbe3b9f59b6ff4 Mon Sep 17 00:00:00 2001 From: Omar Ismail Date: Sat, 9 Oct 2021 08:47:12 -0400 Subject: [PATCH 44/78] Backend State Migration: Add remote backend test --- internal/cloud/e2e/apply_auto_approve_test.go | 8 +- internal/cloud/e2e/helper_test.go | 51 +- .../e2e/migrate_state_multi_to_tfc_test.go | 6 +- ...igrate_state_remote_backend_to_tfc_test.go | 918 +++++++++++++++++- internal/cloud/e2e/run_variables_test.go | 39 +- internal/command/meta_backend_migrate.go | 34 +- 6 files changed, 989 insertions(+), 67 deletions(-) diff --git a/internal/cloud/e2e/apply_auto_approve_test.go b/internal/cloud/e2e/apply_auto_approve_test.go index 1614e4f1e..bcdbaeb53 100644 --- a/internal/cloud/e2e/apply_auto_approve_test.go +++ b/internal/cloud/e2e/apply_auto_approve_test.go @@ -29,7 +29,7 @@ func Test_terraform_apply_autoApprove(t *testing.T) { TerraformVersion: tfe.String(terraformVersion), AutoApply: tfe.Bool(false), } - workspace := createWorkspace(t, org, wOpts) + workspace := createWorkspace(t, org.Name, wOpts) cleanup := func() { defer orgCleanup() } @@ -73,7 +73,7 @@ func Test_terraform_apply_autoApprove(t *testing.T) { TerraformVersion: tfe.String(terraformVersion), AutoApply: tfe.Bool(true), } - workspace := createWorkspace(t, org, wOpts) + workspace := createWorkspace(t, org.Name, wOpts) cleanup := func() { defer orgCleanup() } @@ -117,7 +117,7 @@ func Test_terraform_apply_autoApprove(t *testing.T) { TerraformVersion: tfe.String(terraformVersion), AutoApply: tfe.Bool(false), } - workspace := createWorkspace(t, org, wOpts) + workspace := createWorkspace(t, org.Name, wOpts) cleanup := func() { defer orgCleanup() } @@ -162,7 +162,7 @@ func Test_terraform_apply_autoApprove(t *testing.T) { TerraformVersion: tfe.String(terraformVersion), AutoApply: tfe.Bool(true), } - workspace := createWorkspace(t, org, wOpts) + workspace := createWorkspace(t, org.Name, wOpts) cleanup := func() { defer orgCleanup() } diff --git a/internal/cloud/e2e/helper_test.go b/internal/cloud/e2e/helper_test.go index f8dad3a79..844797f2d 100644 --- a/internal/cloud/e2e/helper_test.go +++ b/internal/cloud/e2e/helper_test.go @@ -56,9 +56,9 @@ func createOrganization(t *testing.T) (*tfe.Organization, func()) { } } -func createWorkspace(t *testing.T, org *tfe.Organization, wOpts tfe.WorkspaceCreateOptions) *tfe.Workspace { +func createWorkspace(t *testing.T, orgName string, wOpts tfe.WorkspaceCreateOptions) *tfe.Workspace { ctx := context.Background() - w, err := tfeClient.Workspaces.Create(ctx, org.Name, wOpts) + w, err := tfeClient.Workspaces.Create(ctx, orgName, wOpts) if err != nil { t.Fatal(err) } @@ -66,6 +66,15 @@ func createWorkspace(t *testing.T, org *tfe.Organization, wOpts tfe.WorkspaceCre return w } +func getWorkspace(workspaces []*tfe.Workspace, workspace string) (*tfe.Workspace, bool) { + for _, ws := range workspaces { + if ws.Name == workspace { + return ws, false + } + } + return nil, true +} + func randomString(t *testing.T) string { v, err := uuid.GenerateUUID() if err != nil { @@ -87,6 +96,44 @@ output "val" { `) } +func terraformConfigRemoteBackendName(org, name string) string { + return fmt.Sprintf(` +terraform { + backend "remote" { + hostname = "%s" + organization = "%s" + + workspaces { + name = "%s" + } + } +} + +output "val" { + value = "${terraform.workspace}" +} +`, tfeHostname, org, name) +} + +func terraformConfigRemoteBackendPrefix(org, prefix string) string { + return fmt.Sprintf(` +terraform { + backend "remote" { + hostname = "%s" + organization = "%s" + + workspaces { + prefix = "%s" + } + } +} + +output "val" { + value = "${terraform.workspace}" +} +`, tfeHostname, org, prefix) +} + func terraformConfigCloudBackendTags(org, tag string) string { return fmt.Sprintf(` terraform { diff --git a/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go index f4160a61a..dd3ae2c26 100644 --- a/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go @@ -5,7 +5,6 @@ package main import ( "context" - "fmt" "io/ioutil" "os" "testing" @@ -168,7 +167,7 @@ func Test_migrate_multi_to_tfc_cloud_name_strategy(t *testing.T) { } for name, tc := range cases { - fmt.Println("Test: ", name) + t.Log("Test: ", name) organization, cleanup := createOrganization(t) defer cleanup() exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) @@ -192,6 +191,7 @@ func Test_migrate_multi_to_tfc_cloud_name_strategy(t *testing.T) { op.prep(t, organization.Name, tf.WorkDir()) for _, tfCmd := range op.commands { t.Log("Running commands: ", tfCmd.command) + tfCmd.command = append(tfCmd.command, "-ignore-remote-version") cmd := tf.Cmd(tfCmd.command...) cmd.Stdin = exp.Tty() cmd.Stdout = exp.Tty() @@ -357,7 +357,7 @@ func Test_migrate_multi_to_tfc_cloud_tags_strategy(t *testing.T) { } for name, tc := range cases { - fmt.Println("Test: ", name) + t.Log("Test: ", name) organization, cleanup := createOrganization(t) t.Log(organization.Name) defer cleanup() diff --git a/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go b/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go index 0381216a6..33c1686c8 100644 --- a/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go @@ -4,56 +4,912 @@ package main import ( + "context" + "io/ioutil" + "os" "testing" + + expect "github.com/Netflix/go-expect" + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/e2e" ) -// REMOTE BACKEND -/* - - RB name -> TFC name - -- straight copy if only if different name, or same WS name in diff org - -- other - -- ensure that the local workspace, after migration, is the new name (in the tfc config block) - - RB name -> TFC tags - -- just add tag, if in same org - -- If new org, if WS exists, just add tag - -- If new org, if WS not exists, create and add tag - - RB prefix -> TFC name - -- create if not exists - -- migrate the current worksapce state to ws name - - RB prefix -> TFC tags - -- update previous workspaces (prefix + local) with cloud config tag - -- Rename the local workspaces to match the TFC workspaces (prefix + former local, ie app-prod). inform user - -*/ -func Test_migrate_remote_backend_name_to_tfc(t *testing.T) { - t.Skip("TODO: see comments") - _ = map[string]struct { +func Test_migrate_remote_backend_name_to_tfc_name(t *testing.T) { + ctx := context.Background() + cases := map[string]struct { operations []operationSets validations func(t *testing.T, orgName string) }{ - "single workspace with backend name strategy, to cloud with name strategy": {}, - "single workspace with backend name strategy, to cloud with tags strategy": {}, + "backend name strategy, to cloud with name strategy": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + remoteWorkspace := "remote-workspace" + tfBlock := terraformConfigRemoteBackendName(orgName, remoteWorkspace) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "remote"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "remote-workspace"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "cloud-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Do you want to copy existing state to the new backend?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Successfully configured the backend "cloud"!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `cloud-workspace`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + expectedName := "cloud-workspace" + ws, err := tfeClient.Workspaces.Read(ctx, orgName, expectedName) + if err != nil { + t.Fatal(err) + } + if ws == nil { + t.Fatalf("Expected workspace %s to be present, but is not.", expectedName) + } + }, + }, + "backend name strategy, to cloud name strategy, using the same name": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + remoteWorkspace := "remote-workspace" + tfBlock := terraformConfigRemoteBackendName(orgName, remoteWorkspace) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "remote"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "remote-workspace"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "remote-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Terraform has been successfully initialized!`, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `remote-workspace`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + expectedName := "remote-workspace" + ws, err := tfeClient.Workspaces.Read(ctx, orgName, expectedName) + if err != nil { + t.Fatal(err) + } + if ws == nil { + t.Fatalf("Expected workspace %s to be present, but is not.", expectedName) + } + }, + }, + } + + for name, tc := range cases { + t.Log("Test: ", name) + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + organization, cleanup := createOrganization(t) + defer cleanup() + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } + } +} + +func Test_migrate_remote_backend_name_to_tfc_name_different_org(t *testing.T) { + ctx := context.Background() + cases := map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "backend name strategy, to cloud name strategy, using the same name, different organization": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + remoteWorkspace := "remote-workspace" + tfBlock := terraformConfigRemoteBackendName(orgName, remoteWorkspace) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "remote"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "remote-workspace"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "remote-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Do you want to copy existing state to the new backend?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Successfully configured the backend "cloud"!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `remote-workspace`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + expectedName := "remote-workspace" + ws, err := tfeClient.Workspaces.Read(ctx, orgName, expectedName) + if err != nil { + t.Fatal(err) + } + if ws == nil { + t.Fatalf("Expected workspace %s to be present, but is not.", expectedName) + } + }, + }, + } + + for name, tc := range cases { + t.Log("Test: ", name) + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + orgOne, cleanupOne := createOrganization(t) + orgTwo, cleanupTwo := createOrganization(t) + defer cleanupOne() + defer cleanupTwo() + orgs := []string{orgOne.Name, orgTwo.Name} + var orgName string + for index, op := range tc.operations { + orgName = orgs[index] + op.prep(t, orgName, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, orgName) + } + } +} + +func Test_migrate_remote_backend_name_to_tfc_tags(t *testing.T) { + ctx := context.Background() + cases := map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "single workspace with backend name strategy, to cloud with tags strategy": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + remoteWorkspace := "remote-workspace" + tfBlock := terraformConfigRemoteBackendName(orgName, remoteWorkspace) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "remote"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "remote-workspace"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `default`, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `The "cloud" backend configuration only allows named workspaces!`, + userInput: []string{"cloud-workspace", "yes"}, + postInputOutput: []string{ + `Do you want to copy existing state to the new backend?`, + `Successfully configured the backend "cloud"!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `cloud-workspace`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{ + Tags: tfe.String("app"), + }) + if err != nil { + t.Fatal(err) + } + if len(wsList.Items) != 1 { + t.Fatalf("Expected number of workspaces to be 1, but got %d", len(wsList.Items)) + } + ws := wsList.Items[0] + if ws.Name != "cloud-workspace" { + t.Fatalf("Expected workspace to be `cloud-workspace`, but is %s", ws.Name) + } + }, + }, + } + + for name, tc := range cases { + t.Log("Test: ", name) + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + organization, cleanup := createOrganization(t) + defer cleanup() + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } } } func Test_migrate_remote_backend_prefix_to_tfc_name(t *testing.T) { - t.Skip("TODO: see comments") - _ = map[string]struct { + ctx := context.Background() + cases := map[string]struct { operations []operationSets validations func(t *testing.T, orgName string) }{ - "single workspace with backend prefix strategy, to cloud with name strategy": {}, - "multiple workspaces with backend prefix strategy, to cloud with name strategy": {}, + "single workspace with backend prefix strategy, to cloud with name strategy": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{Name: tfe.String("app-one")}) + prefix := "app-" + tfBlock := terraformConfigRemoteBackendPrefix(orgName, prefix) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Terraform has been successfully initialized!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "app-one"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "cloud-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Do you want to copy existing state to the new backend?`, + userInput: []string{"yes"}, + postInputOutput: []string{ + `Successfully configured the backend "cloud"!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `cloud-workspace`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + expectedName := "cloud-workspace" + ws, err := tfeClient.Workspaces.Read(ctx, orgName, expectedName) + if err != nil { + t.Fatal(err) + } + if ws == nil { + t.Fatalf("Expected workspace %s to be present, but is not.", expectedName) + } + }, + }, + "multiple workspaces with backend prefix strategy, to cloud with name strategy": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{Name: tfe.String("app-one")}) + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{Name: tfe.String("app-two")}) + prefix := "app-" + tfBlock := terraformConfigRemoteBackendPrefix(orgName, prefix) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `The currently selected workspace (default) does not exist.`, + userInput: []string{"1"}, + postInputOutput: []string{`Terraform has been successfully initialized!`}, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "app-one"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "list"}, + expectedCmdOutput: "* one", // app name retrieved via prefix + }, + { + command: []string{"workspace", "select", "two"}, + expectedCmdOutput: `Switched to workspace "two".`, // app name retrieved via prefix + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "cloud-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Do you want to copy only your current workspace?`, + userInput: []string{"yes"}, + postInputOutput: []string{ + `Successfully configured the backend "cloud"!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `cloud-workspace`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + expectedName := "cloud-workspace" + ws, err := tfeClient.Workspaces.Read(ctx, orgName, expectedName) + if err != nil { + t.Fatal(err) + } + if ws == nil { + t.Fatalf("Expected workspace %s to be present, but is not.", expectedName) + } + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(wsList.Items) != 3 { + t.Fatalf("expected number of workspaces in this org to be 3, but got %d", len(wsList.Items)) + } + ws, empty := getWorkspace(wsList.Items, "cloud-workspace") + if empty { + t.Fatalf("expected workspaces to include 'cloud-workspace' but didn't.") + } + ws, empty = getWorkspace(wsList.Items, "app-one") + if empty { + t.Fatalf("expected workspaces to include 'app-one' but didn't.") + } + ws, empty = getWorkspace(wsList.Items, "app-two") + if empty { + t.Fatalf("expected workspaces to include 'app-two' but didn't.") + } + }, + }, + } + + for name, tc := range cases { + t.Log("Test: ", name) + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + organization, cleanup := createOrganization(t) + defer cleanup() + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } } } func Test_migrate_remote_backend_prefix_to_tfc_tags(t *testing.T) { - t.Skip("TODO: see comments") - _ = map[string]struct { + ctx := context.Background() + cases := map[string]struct { operations []operationSets validations func(t *testing.T, orgName string) }{ - "single workspace with backend prefix strategy, to cloud with tags strategy": {}, - "multiple workspaces with backend prefix strategy, to cloud with tags strategy": {}, + "single workspace with backend prefix strategy, to cloud with tags strategy": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{Name: tfe.String("app-one")}) + prefix := "app-" + tfBlock := terraformConfigRemoteBackendPrefix(orgName, prefix) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Terraform has been successfully initialized!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "app-one"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `The "cloud" backend configuration only allows named workspaces!`, + userInput: []string{"cloud-workspace", "yes"}, + postInputOutput: []string{ + `Do you want to copy existing state to the new backend?`, + `Successfully configured the backend "cloud"!`}, + }, + { + command: []string{"workspace", "list"}, + expectedCmdOutput: `cloud-workspace`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + expectedName := "cloud-workspace" + ws, err := tfeClient.Workspaces.Read(ctx, orgName, expectedName) + if err != nil { + t.Fatal(err) + } + if ws == nil { + t.Fatalf("Expected workspace %s to be present, but is not.", expectedName) + } + }, + }, + "multiple workspaces with backend prefix strategy, to cloud with tags strategy": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{Name: tfe.String("app-one")}) + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{Name: tfe.String("app-two")}) + prefix := "app-" + tfBlock := terraformConfigRemoteBackendPrefix(orgName, prefix) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `The currently selected workspace (default) does not exist.`, + userInput: []string{"1"}, + postInputOutput: []string{`Terraform has been successfully initialized!`}, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "app-one"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "select", "two"}, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "app-two"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Would you like to rename your workspaces?`, + userInput: []string{"1", "*"}, + postInputOutput: []string{`What pattern would you like to add to all your workspaces?`, + `Successfully configured the backend "cloud"!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: "two", // this comes from the original workspace name from the previous backend. + }, + { + command: []string{"workspace", "select", "one"}, + expectedCmdOutput: `Switched to workspace "one".`, // this comes from the original workspace name from the previous backend. + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{ + Tags: tfe.String("app"), + }) + if err != nil { + t.Fatal(err) + } + if len(wsList.Items) != 2 { + t.Logf("Expected the number of workspaces to be 2, but got %d", len(wsList.Items)) + } + ws, empty := getWorkspace(wsList.Items, "one") + if empty { + t.Fatalf("expected workspaces to include 'one' but didn't.") + } + if len(ws.TagNames) == 0 { + t.Fatalf("expected workspaces 'one' to have tags.") + } + ws, empty = getWorkspace(wsList.Items, "two") + if empty { + t.Fatalf("expected workspaces to include 'two' but didn't.") + } + if len(ws.TagNames) == 0 { + t.Fatalf("expected workspaces 'two' to have tags.") + } + }, + }, + } + + for name, tc := range cases { + t.Log("Test: ", name) + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + organization, cleanup := createOrganization(t) + defer cleanup() + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } } } diff --git a/internal/cloud/e2e/run_variables_test.go b/internal/cloud/e2e/run_variables_test.go index ba20077a1..6029066b7 100644 --- a/internal/cloud/e2e/run_variables_test.go +++ b/internal/cloud/e2e/run_variables_test.go @@ -57,16 +57,16 @@ func Test_cloud_run_variables(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init"}, - expectedOutput: `Successfully configured the backend "cloud"!`, + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "cloud"!`, }, { - command: []string{"plan", "-var", "foo=bar"}, - expectedOutput: ` + test_cli = "bar"`, + command: []string{"plan", "-var", "foo=bar"}, + expectedCmdOutput: ` + test_cli = "bar"`, }, { - command: []string{"plan", "-var", "foo=bar"}, - expectedOutput: ` + test_env = "qux"`, + command: []string{"plan", "-var", "foo=bar"}, + expectedCmdOutput: ` + test_env = "qux"`, }, }, }, @@ -110,23 +110,28 @@ func Test_cloud_run_variables(t *testing.T) { t.Fatal(err) } - if tfCmd.expectedOutput != "" { - _, err := exp.ExpectString(tfCmd.expectedOutput) + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) if err != nil { t.Fatal(err) } } - if len(tfCmd.userInput) > 0 { - for _, input := range tfCmd.userInput { + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] exp.SendLine(input) - } - } - - if tfCmd.postInputOutput != "" { - _, err := exp.ExpectString(tfCmd.postInputOutput) - if err != nil { - t.Fatal(err) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } } } diff --git a/internal/command/meta_backend_migrate.go b/internal/command/meta_backend_migrate.go index 17dcd930c..fd4c176dd 100644 --- a/internal/command/meta_backend_migrate.go +++ b/internal/command/meta_backend_migrate.go @@ -44,7 +44,7 @@ type backendMigrateOpts struct { // // This will attempt to lock both states for the migration. func (m *Meta) backendMigrateState(opts *backendMigrateOpts) error { - log.Printf("[TRACE] backendMigrateState: need to migrate from %q to %q backend config", opts.SourceType, opts.DestinationType) + log.Printf("[INFO] backendMigrateState: need to migrate from %q to %q backend config", opts.SourceType, opts.DestinationType) // We need to check what the named state status is. If we're converting // from multi-state to single-state for example, we need to handle that. var sourceSingleState, destinationSingleState, sourceTFC, destinationTFC bool @@ -154,7 +154,7 @@ func (m *Meta) backendMigrateState(opts *backendMigrateOpts) error { // Multi-state to multi-state. func (m *Meta) backendMigrateState_S_S(opts *backendMigrateOpts) error { - log.Print("[TRACE] backendMigrateState: migrating all named workspaces") + log.Print("[INFO] backendMigrateState: migrating all named workspaces") migrate := opts.force if !migrate { @@ -209,9 +209,9 @@ func (m *Meta) backendMigrateState_S_S(opts *backendMigrateOpts) error { // Multi-state to single state. func (m *Meta) backendMigrateState_S_s(opts *backendMigrateOpts) error { - log.Printf("[TRACE] backendMigrateState: destination backend type %q does not support named workspaces", opts.DestinationType) + log.Printf("[INFO] backendMigrateState: destination backend type %q does not support named workspaces", opts.DestinationType) - currentEnv, err := m.Workspace() + currentWorkspace, err := m.Workspace() if err != nil { return err } @@ -228,7 +228,7 @@ func (m *Meta) backendMigrateState_S_s(opts *backendMigrateOpts) error { opts.DestinationType), Description: fmt.Sprintf( strings.TrimSpace(inputBackendMigrateMultiToSingle), - opts.SourceType, opts.DestinationType, currentEnv), + opts.SourceType, opts.DestinationType, currentWorkspace), }) if err != nil { return fmt.Errorf( @@ -241,7 +241,7 @@ func (m *Meta) backendMigrateState_S_s(opts *backendMigrateOpts) error { } // Copy the default state - opts.sourceWorkspace = currentEnv + opts.sourceWorkspace = currentWorkspace // now switch back to the default env so we can acccess the new backend m.SetWorkspace(backend.DefaultStateName) @@ -251,7 +251,7 @@ func (m *Meta) backendMigrateState_S_s(opts *backendMigrateOpts) error { // Single state to single state, assumed default state name. func (m *Meta) backendMigrateState_s_s(opts *backendMigrateOpts) error { - log.Printf("[TRACE] backendMigrateState: migrating %q workspace to %q workspace", opts.sourceWorkspace, opts.destinationWorkspace) + log.Printf("[INFO] backendMigrateState: single-to-single migrating %q workspace to %q workspace", opts.sourceWorkspace, opts.destinationWorkspace) sourceState, err := opts.Source.StateMgr(opts.sourceWorkspace) if err != nil { @@ -534,7 +534,7 @@ func (m *Meta) backendMigrateTFC(opts *backendMigrateOpts) error { // Everything below, by the above two conditionals, now assumes that the // destination is always Terraform Cloud (TFC). - sourceSingle := sourceSingleState || (len(sourceWorkspaces) == 1 && sourceWorkspaces[0] == backend.DefaultStateName) + sourceSingle := sourceSingleState || (len(sourceWorkspaces) == 1) if sourceSingle { if cloudBackendDestination.WorkspaceMapping.Strategy() == cloud.WorkspaceNameStrategy { // If we know the name via WorkspaceNameStrategy, then set the @@ -543,6 +543,14 @@ func (m *Meta) backendMigrateTFC(opts *backendMigrateOpts) error { // in TFC if it does not exist. opts.destinationWorkspace = cloudBackendDestination.WorkspaceMapping.Name } + + currentWorkspace, err := m.Workspace() + if err != nil { + return err + } + opts.sourceWorkspace = currentWorkspace + + log.Printf("[INFO] backendMigrateTFC: single-to-single migration from source %s to destination %q", opts.sourceWorkspace, opts.destinationWorkspace) // Run normal single-to-single state migration // This will handle both situations where the new cloud backend // configuration is using a workspace.name strategy or workspace.tags @@ -559,13 +567,14 @@ func (m *Meta) backendMigrateTFC(opts *backendMigrateOpts) error { return err } - currentEnv, err := m.Workspace() + currentWorkspace, err := m.Workspace() if err != nil { return err } - opts.sourceWorkspace = currentEnv + opts.sourceWorkspace = currentWorkspace opts.destinationWorkspace = cloudBackendDestination.WorkspaceMapping.Name + log.Printf("[INFO] backendMigrateTFC: multi-to-single migration from source %s to destination %q", opts.sourceWorkspace, opts.destinationWorkspace) return m.backendMigrateState_s_s(opts) } @@ -574,9 +583,13 @@ func (m *Meta) backendMigrateTFC(opts *backendMigrateOpts) error { // workspace over to new one, prompt for workspace name pattern (*), // and start migrating, and create tags for each workspace. if multiSource && destinationTagsStrategy { + log.Printf("[INFO] backendMigrateTFC: multi-to-multi migration from source workspaces %q", sourceWorkspaces) return m.backendMigrateState_S_TFC(opts, sourceWorkspaces) } + // TODO(omar): after the check for sourceSingle is done, everything following + // it has to be multi. So rework the code to not need to check for multi, adn + // return m.backendMigrateState_S_TFC here. return nil } @@ -619,6 +632,7 @@ func (m *Meta) backendMigrateState_S_TFC(opts *backendMigrateOpts, sourceWorkspa opts.force = true // Perform the migration + log.Printf("[INFO] backendMigrateTFC: multi-to-multi migration, source workspace %q to destination workspace %q", opts.sourceWorkspace, opts.destinationWorkspace) if err := m.backendMigrateState_s_s(opts); err != nil { return fmt.Errorf(strings.TrimSpace( errMigrateMulti), name, opts.SourceType, opts.DestinationType, err) From f8256f6634c8de055669bac3e06cd507146f895a Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Wed, 13 Oct 2021 10:22:12 -0500 Subject: [PATCH 45/78] Update ux for "terraform init [-reconfigure]" --- internal/command/init.go | 30 ++++++++++++++++++++++++++++-- internal/command/meta_backend.go | 10 +++++++--- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/internal/command/init.go b/internal/command/init.go index d88ec181e..3de1d4101 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/backend" backendInit "github.com/hashicorp/terraform/internal/backend/init" + "github.com/hashicorp/terraform/internal/cloud" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/getproviders" @@ -304,12 +305,25 @@ func (c *InitCommand) Run(args []string) int { // by errors then we'll output them here so that the success message is // still the final thing shown. c.showDiagnostics(diags) - c.Ui.Output(c.Colorize().Color(strings.TrimSpace(outputInitSuccess))) + _, cloud := back.(*cloud.Cloud) + output := "" + if cloud { + output = outputInitSuccessCloud + } else { + output = outputInitSuccess + } + + c.Ui.Output(c.Colorize().Color(strings.TrimSpace(output))) + if !c.RunningInAutomation { // If we're not running in an automation wrapper, give the user // some more detailed next steps that are appropriate for interactive // shell usage. - c.Ui.Output(c.Colorize().Color(strings.TrimSpace(outputInitSuccessCLI))) + output = outputInitSuccessCLI + if cloud { + output = outputInitSuccessCLICloud + } + c.Ui.Output(c.Colorize().Color(strings.TrimSpace(output))) } return 0 } @@ -1087,6 +1101,10 @@ const outputInitSuccess = ` [reset][bold][green]Terraform has been successfully initialized![reset][green] ` +const outputInitSuccessCloud = ` +[reset][bold][green]Terraform Cloud has been successfully initialized![reset][green] +` + const outputInitSuccessCLI = `[reset][green] You may now begin working with Terraform. Try running "terraform plan" to see any changes that are required for your infrastructure. All Terraform commands @@ -1097,6 +1115,14 @@ rerun this command to reinitialize your working directory. If you forget, other commands will detect it and remind you to do so if necessary. ` +const outputInitSuccessCLICloud = `[reset][green] +You may now begin working with Terraform Cloud. Try running "terraform plan" to +see any changes that are required for your infrastructure. + +If you ever set or change modules or configuration for Terraform, run +"terraform init" again to reinitialize your working directory. +` + // providerProtocolTooOld is a message sent to the CLI UI if the provider's // supported protocol versions are too old for the user's version of terraform, // but a newer version of the provider is compatible. diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index 58cafdd4f..f3a2c11e2 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hcldec" "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/cloud" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" @@ -921,9 +922,12 @@ func (m *Meta) backend_C_r_s(c *configs.Backend, cHash int, sMgr *clistate.Local return nil, diags } - // By now the backend is successfully configured. - m.Ui.Output(m.Colorize().Color(fmt.Sprintf( - "[reset][green]\n"+strings.TrimSpace(successBackendSet), s.Backend.Type))) + // By now the backend is successfully configured. If using Terraform Cloud, the success + // message is handled as part of the final init message + if _, ok := b.(*cloud.Cloud); !ok { + m.Ui.Output(m.Colorize().Color(fmt.Sprintf( + "[reset][green]\n"+strings.TrimSpace(successBackendSet), s.Backend.Type))) + } return b, diags } From 0cd94908388cb2a586c6d29d2dc6fc2ec5d1f216 Mon Sep 17 00:00:00 2001 From: Barrett Clark Date: Mon, 18 Oct 2021 09:03:38 -0500 Subject: [PATCH 46/78] PR Feedback: cleanup variable setter --- internal/command/init.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/command/init.go b/internal/command/init.go index 3de1d4101..9065b1359 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -306,11 +306,9 @@ func (c *InitCommand) Run(args []string) int { // still the final thing shown. c.showDiagnostics(diags) _, cloud := back.(*cloud.Cloud) - output := "" + output := outputInitSuccess if cloud { output = outputInitSuccessCloud - } else { - output = outputInitSuccess } c.Ui.Output(c.Colorize().Color(strings.TrimSpace(output))) From d04c01573c51c2fa09d79b8401bc10d341a8aea1 Mon Sep 17 00:00:00 2001 From: Barrett Clark Date: Mon, 18 Oct 2021 11:11:56 -0500 Subject: [PATCH 47/78] Fix iterator in a couple of e2e tests --- internal/cloud/e2e/migrate_state_single_to_tfc_test.go | 2 +- internal/cloud/e2e/migrate_state_tfc_to_other_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cloud/e2e/migrate_state_single_to_tfc_test.go b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go index d251207fe..ebe85d1b5 100644 --- a/internal/cloud/e2e/migrate_state_single_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go @@ -172,7 +172,7 @@ func Test_migrate_single_to_tfc(t *testing.T) { lenInput := len(tfCmd.userInput) lenInputOutput := len(tfCmd.postInputOutput) if lenInput > 0 { - for i := 0; i <= lenInput; i++ { + for i := 0; i < lenInput; i++ { input := tfCmd.userInput[i] exp.SendLine(input) // use the index to find the corresponding diff --git a/internal/cloud/e2e/migrate_state_tfc_to_other_test.go b/internal/cloud/e2e/migrate_state_tfc_to_other_test.go index 5c4173d01..08a5e703a 100644 --- a/internal/cloud/e2e/migrate_state_tfc_to_other_test.go +++ b/internal/cloud/e2e/migrate_state_tfc_to_other_test.go @@ -93,7 +93,7 @@ func Test_migrate_tfc_to_other(t *testing.T) { lenInput := len(tfCmd.userInput) lenInputOutput := len(tfCmd.postInputOutput) if lenInput > 0 { - for i := 0; i <= lenInput; i++ { + for i := 0; i < lenInput; i++ { input := tfCmd.userInput[i] exp.SendLine(input) // use the index to find the corresponding From 261a2b49d37c5aa14a10597bb04836cfaa062b1b Mon Sep 17 00:00:00 2001 From: Barrett Clark Date: Fri, 15 Oct 2021 15:33:08 -0500 Subject: [PATCH 48/78] UX for terraform init that copies state to TFC --- internal/command/meta_backend_migrate.go | 63 +++++++++++++++++++----- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/internal/command/meta_backend_migrate.go b/internal/command/meta_backend_migrate.go index fd4c176dd..08ec3b8e7 100644 --- a/internal/command/meta_backend_migrate.go +++ b/internal/command/meta_backend_migrate.go @@ -434,12 +434,21 @@ func (m *Meta) backendMigrateState_s_s(opts *backendMigrateOpts) error { } func (m *Meta) backendMigrateEmptyConfirm(source, destination statemgr.Full, opts *backendMigrateOpts) (bool, error) { - inputOpts := &terraform.InputOpts{ - Id: "backend-migrate-copy-to-empty", - Query: "Do you want to copy existing state to the new backend?", - Description: fmt.Sprintf( - strings.TrimSpace(inputBackendMigrateEmpty), - opts.SourceType, opts.DestinationType), + var inputOpts *terraform.InputOpts + if opts.DestinationType == "cloud" { + inputOpts = &terraform.InputOpts{ + Id: "backend-migrate-copy-to-empty-cloud", + Query: "Do you want to copy existing state to Terraform Cloud?", + Description: fmt.Sprintf(strings.TrimSpace(inputBackendMigrateEmptyCloud), opts.SourceType), + } + } else { + inputOpts = &terraform.InputOpts{ + Id: "backend-migrate-copy-to-empty", + Query: "Do you want to copy existing state to the new backend?", + Description: fmt.Sprintf( + strings.TrimSpace(inputBackendMigrateEmpty), + opts.SourceType, opts.DestinationType), + } } return m.confirm(inputOpts) @@ -475,12 +484,23 @@ func (m *Meta) backendMigrateNonEmptyConfirm( } // Ask for confirmation - inputOpts := &terraform.InputOpts{ - Id: "backend-migrate-to-backend", - Query: "Do you want to copy existing state to the new backend?", - Description: fmt.Sprintf( - strings.TrimSpace(inputBackendMigrateNonEmpty), - opts.SourceType, opts.DestinationType, sourcePath, destinationPath), + var inputOpts *terraform.InputOpts + if opts.DestinationType == "cloud" { + inputOpts = &terraform.InputOpts{ + Id: "backend-migrate-to-tfc", + Query: "Do you want to copy existing state to Terraform Cloud?", + Description: fmt.Sprintf( + strings.TrimSpace(inputBackendMigrateNonEmptyCloud), + opts.SourceType, sourcePath, destinationPath), + } + } else { + inputOpts = &terraform.InputOpts{ + Id: "backend-migrate-to-backend", + Query: "Do you want to copy existing state to the new backend?", + Description: fmt.Sprintf( + strings.TrimSpace(inputBackendMigrateNonEmpty), + opts.SourceType, opts.DestinationType, sourcePath, destinationPath), + } } // Confirm with the user that the copy should occur @@ -807,6 +827,12 @@ configured %[2]q backend. Do you want to copy this state to the new %[2]q backend? Enter "yes" to copy and "no" to start with an empty state. ` +const inputBackendMigrateEmptyCloud = ` +Pre-existing state was found while migrating the previous %q backend to Terraform Cloud. +No existing state was found in Terraform Cloud. Do you want to copy this state to Terraform Cloud? +Enter "yes" to copy and "no" to start with an empty state. +` + const inputBackendMigrateNonEmpty = ` Pre-existing state was found while migrating the previous %q backend to the newly configured %q backend. An existing non-empty state already exists in @@ -821,6 +847,19 @@ Enter "yes" to copy and "no" to start with the existing state in the newly configured %[2]q backend. ` +const inputBackendMigrateNonEmptyCloud = ` +Pre-existing state was found while migrating the previous %q backend to +Terraform Cloud. An existing non-empty state already exists in Terraform Cloud. +The two states have been saved to temporary files that will be removed after +responding to this query. + +Previous (type %[1]q): %[2]s +New (Terraform Cloud): %[3]s + +Do you want to overwrite the state in Terraform Cloud with the previous state? +Enter "yes" to copy and "no" to start with the existing state in Terraform Cloud. +` + const inputBackendMigrateMultiToSingle = ` The existing %[1]q backend supports workspaces and you currently are using more than one. The newly configured %[2]q backend doesn't support From 93bfcff61af0ad5af41569e478929f0ed5bf4f05 Mon Sep 17 00:00:00 2001 From: Barrett Clark Date: Mon, 18 Oct 2021 16:02:45 -0500 Subject: [PATCH 49/78] More Terraform Cloud UX changes * Update e2e tests to specify Terraform Cloud * Update more of the terraform command flow to specify Terraform Cloud --- .../e2e/migrate_state_multi_to_tfc_test.go | 8 ++++---- ...migrate_state_remote_backend_to_tfc_test.go | 8 ++++---- .../e2e/migrate_state_single_to_tfc_test.go | 8 ++++---- internal/command/meta_backend.go | 18 +++++++++++++++--- internal/command/meta_backend_migrate.go | 13 ++++++++----- 5 files changed, 35 insertions(+), 20 deletions(-) diff --git a/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go index dd3ae2c26..e72169186 100644 --- a/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go @@ -68,7 +68,7 @@ func Test_migrate_multi_to_tfc_cloud_name_strategy(t *testing.T) { userInput: []string{"yes", "yes"}, postInputOutput: []string{ `Do you want to copy existing state to the new backend?`, - `Successfully configured the backend "cloud"!`}, + `Successfully configured Terraform Cloud!`}, }, { command: []string{"workspace", "show"}, @@ -139,7 +139,7 @@ func Test_migrate_multi_to_tfc_cloud_name_strategy(t *testing.T) { userInput: []string{"yes", "yes"}, postInputOutput: []string{ `Do you want to copy existing state to the new backend?`, - `Successfully configured the backend "cloud"!`}, + `Successfully configured Terraform Cloud!`}, }, { command: []string{"workspace", "list"}, @@ -191,7 +191,7 @@ func Test_migrate_multi_to_tfc_cloud_name_strategy(t *testing.T) { op.prep(t, organization.Name, tf.WorkDir()) for _, tfCmd := range op.commands { t.Log("Running commands: ", tfCmd.command) - tfCmd.command = append(tfCmd.command, "-ignore-remote-version") + tfCmd.command = append(tfCmd.command) cmd := tf.Cmd(tfCmd.command...) cmd.Stdin = exp.Tty() cmd.Stdout = exp.Tty() @@ -303,7 +303,7 @@ func Test_migrate_multi_to_tfc_cloud_tags_strategy(t *testing.T) { commands: []tfCommand{ { command: []string{"init", "-migrate-state"}, - expectedCmdOutput: `The "cloud" backend configuration only allows named workspaces!`, + expectedCmdOutput: `Terraform Cloud configuration only allows named workspaces!`, userInput: []string{"dev", "1", "app-*", "1"}, postInputOutput: []string{ `Would you like to rename your workspaces?`, diff --git a/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go b/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go index 33c1686c8..0cb7d5efc 100644 --- a/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go @@ -52,7 +52,7 @@ func Test_migrate_remote_backend_name_to_tfc_name(t *testing.T) { command: []string{"init", "-migrate-state", "-ignore-remote-version"}, expectedCmdOutput: `Do you want to copy existing state to the new backend?`, userInput: []string{"yes"}, - postInputOutput: []string{`Successfully configured the backend "cloud"!`}, + postInputOutput: []string{`Successfully configured Terraform Cloud!`}, }, { command: []string{"workspace", "show"}, @@ -234,7 +234,7 @@ func Test_migrate_remote_backend_name_to_tfc_name_different_org(t *testing.T) { command: []string{"init", "-migrate-state", "-ignore-remote-version"}, expectedCmdOutput: `Do you want to copy existing state to the new backend?`, userInput: []string{"yes"}, - postInputOutput: []string{`Successfully configured the backend "cloud"!`}, + postInputOutput: []string{`Successfully configured Terraform Cloud!`}, }, { command: []string{"workspace", "show"}, @@ -373,7 +373,7 @@ func Test_migrate_remote_backend_name_to_tfc_tags(t *testing.T) { commands: []tfCommand{ { command: []string{"init", "-migrate-state", "-ignore-remote-version"}, - expectedCmdOutput: `The "cloud" backend configuration only allows named workspaces!`, + expectedCmdOutput: `Terraform Cloud configuration only allows named workspaces!`, userInput: []string{"cloud-workspace", "yes"}, postInputOutput: []string{ `Do you want to copy existing state to the new backend?`, @@ -516,7 +516,7 @@ func Test_migrate_remote_backend_prefix_to_tfc_name(t *testing.T) { expectedCmdOutput: `Do you want to copy existing state to the new backend?`, userInput: []string{"yes"}, postInputOutput: []string{ - `Successfully configured the backend "cloud"!`}, + `Successfully configured Terraform Cloud!`}, }, { command: []string{"workspace", "show"}, diff --git a/internal/cloud/e2e/migrate_state_single_to_tfc_test.go b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go index ebe85d1b5..805a5766d 100644 --- a/internal/cloud/e2e/migrate_state_single_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go @@ -51,9 +51,9 @@ func Test_migrate_single_to_tfc(t *testing.T) { commands: []tfCommand{ { command: []string{"init", "-migrate-state"}, - expectedCmdOutput: `Do you want to copy existing state to the new backend?`, + expectedCmdOutput: `Do you want to copy existing state to the Terraform Cloud?`, userInput: []string{"yes"}, - postInputOutput: []string{`Successfully configured the backend "cloud"!`}, + postInputOutput: []string{`Successfully configured Terraform Cloud!`}, }, { command: []string{"workspace", "list"}, @@ -102,9 +102,9 @@ func Test_migrate_single_to_tfc(t *testing.T) { commands: []tfCommand{ { command: []string{"init", "-migrate-state"}, - expectedCmdOutput: `The "cloud" backend configuration only allows named workspaces!`, + expectedCmdOutput: `Terraform Cloud configuration only allows named workspaces!`, userInput: []string{"new-workspace", "yes"}, - postInputOutput: []string{`Successfully configured the backend "cloud"!`}, + postInputOutput: []string{`Successfully configured Terraform Cloud!`}, }, { command: []string{"workspace", "list"}, diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index f3a2c11e2..94b9e5e5c 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -952,7 +952,11 @@ func (m *Meta) backend_C_r_S_changed(c *configs.Backend, cHash int, sMgr *clista // no need to confuse the user if the backend types are the same if s.Backend.Type != c.Type { - m.Ui.Output(strings.TrimSpace(fmt.Sprintf(outputBackendMigrateChange, s.Backend.Type, c.Type))) + output := fmt.Sprintf(outputBackendMigrateChange, s.Backend.Type, c.Type) + if c.Type == "cloud" { + output = fmt.Sprintf(outputBackendMigrateChangeCloud, s.Backend.Type) + } + m.Ui.Output(strings.TrimSpace(output)) } // Grab the existing backend @@ -1011,8 +1015,12 @@ func (m *Meta) backend_C_r_S_changed(c *configs.Backend, cHash int, sMgr *clista } if output { - m.Ui.Output(m.Colorize().Color(fmt.Sprintf( - "[reset][green]\n"+strings.TrimSpace(successBackendSet), s.Backend.Type))) + // By now the backend is successfully configured. If using Terraform Cloud, the success + // message is handled as part of the final init message + if _, ok := b.(*cloud.Cloud); !ok { + m.Ui.Output(m.Colorize().Color(fmt.Sprintf( + "[reset][green]\n"+strings.TrimSpace(successBackendSet), s.Backend.Type))) + } } return b, diags @@ -1306,6 +1314,10 @@ const outputBackendMigrateChange = ` Terraform detected that the backend type changed from %q to %q. ` +const outputBackendMigrateChangeCloud = ` +Terraform detected that the backend type changed from %q to Terraform Cloud. +` + const outputBackendMigrateLocal = ` Terraform has detected you're unconfiguring your previously set %q backend. ` diff --git a/internal/command/meta_backend_migrate.go b/internal/command/meta_backend_migrate.go index 08ec3b8e7..92eadfa03 100644 --- a/internal/command/meta_backend_migrate.go +++ b/internal/command/meta_backend_migrate.go @@ -686,12 +686,15 @@ func (m *Meta) promptMultiToSingleCloudMigration(opts *backendMigrateOpts) error } func (m *Meta) promptNewWorkspaceName(destinationType string) (string, error) { + message := fmt.Sprintf("[reset][bold][yellow]The %q backend configuration only allows "+ + "named workspaces![reset]", destinationType) + if destinationType == "cloud" { + message = fmt.Sprintf("[reset][bold][yellow]The Terraform Cloud configuration only allows " + + "named workspaces![reset]") + } name, err := m.UIInput().Input(context.Background(), &terraform.InputOpts{ - Id: "new-state-name", - Query: fmt.Sprintf( - "[reset][bold][yellow]The %q backend configuration only allows "+ - "named workspaces![reset]", - destinationType), + Id: "new-state-name", + Query: message, Description: strings.TrimSpace(inputBackendNewWorkspaceName), }) if err != nil { From dfb4609be2b251fb43124d446c272e2d73ed4ebc Mon Sep 17 00:00:00 2001 From: Omar Ismail Date: Mon, 11 Oct 2021 17:44:44 -0400 Subject: [PATCH 50/78] Backend State Migration from cloud to cloud. * Add test for tfc to tfc mgiration * Fix old tests, and remove unused code. --- go.mod | 2 +- go.sum | 4 +- internal/cloud/e2e/apply_auto_approve_test.go | 345 ++++++------ internal/cloud/e2e/helper_test.go | 67 ++- internal/cloud/e2e/main_test.go | 27 - .../e2e/migrate_state_multi_to_tfc_test.go | 6 +- .../e2e/migrate_state_single_to_tfc_test.go | 7 +- .../e2e/migrate_state_tfc_to_tfc_test.go | 506 +++++++++++++++++- internal/command/meta_backend_migrate.go | 13 +- 9 files changed, 747 insertions(+), 230 deletions(-) diff --git a/go.mod b/go.mod index 66522fce6..26967a745 100644 --- a/go.mod +++ b/go.mod @@ -41,7 +41,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-plugin v1.4.3 github.com/hashicorp/go-retryablehttp v0.7.0 - github.com/hashicorp/go-tfe v0.19.1-0.20211012181137-3666eed9e8e9 + github.com/hashicorp/go-tfe v0.19.1-0.20211015143223-e7e0a0182bbd github.com/hashicorp/go-uuid v1.0.2 github.com/hashicorp/go-version v1.2.1 github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f diff --git a/go.sum b/go.sum index c5635216d..f1f6be181 100644 --- a/go.sum +++ b/go.sum @@ -379,8 +379,8 @@ github.com/hashicorp/go-slug v0.7.0/go.mod h1:Ib+IWBYfEfJGI1ZyXMGNbu2BU+aa3Dzu41 github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-tfe v0.19.1-0.20211012181137-3666eed9e8e9 h1:97QOrhJha4EmU+mUC1ubf15B40CjdcObULUop49+u8c= -github.com/hashicorp/go-tfe v0.19.1-0.20211012181137-3666eed9e8e9/go.mod h1:gyXLXbpBVxA2F/6opah8XBsOkZJxHYQmghl0OWi8keI= +github.com/hashicorp/go-tfe v0.19.1-0.20211015143223-e7e0a0182bbd h1:mn11v5DDNXkZq32QM8JSLNoUSbW2Ud4jMxm8IMpfS2w= +github.com/hashicorp/go-tfe v0.19.1-0.20211015143223-e7e0a0182bbd/go.mod h1:gyXLXbpBVxA2F/6opah8XBsOkZJxHYQmghl0OWi8keI= 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.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= diff --git a/internal/cloud/e2e/apply_auto_approve_test.go b/internal/cloud/e2e/apply_auto_approve_test.go index bcdbaeb53..8e10feb08 100644 --- a/internal/cloud/e2e/apply_auto_approve_test.go +++ b/internal/cloud/e2e/apply_auto_approve_test.go @@ -7,191 +7,175 @@ import ( "context" "io/ioutil" "log" - "strings" + "os" "testing" + expect "github.com/Netflix/go-expect" tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/terraform/internal/e2e" ) func Test_terraform_apply_autoApprove(t *testing.T) { ctx := context.Background() - cases := map[string]struct { - setup func(t *testing.T) (map[string]string, func()) - commands []tfCommand - validations func(t *testing.T, orgName, wsName string) - }{ - "workspace manual apply, terraform apply without auto-approve": { - setup: func(t *testing.T) (map[string]string, func()) { - org, orgCleanup := createOrganization(t) - wOpts := tfe.WorkspaceCreateOptions{ - Name: tfe.String(randomString(t)), - TerraformVersion: tfe.String(terraformVersion), - AutoApply: tfe.Bool(false), - } - workspace := createWorkspace(t, org.Name, wOpts) - cleanup := func() { - defer orgCleanup() - } - names := map[string]string{ - "organization": org.Name, - "workspace": workspace.Name, - } + tfVersion := "1.1.0-tfc-integration" + if !hasTerraformVersion(version) { + t.Skip("Skipping test because TFC does not have current terraform version.") + } - return names, cleanup - }, - commands: []tfCommand{ + cases := map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "workspace manual apply, terraform apply without auto-approve, expect prompt": { + operations: []operationSets{ { - command: []string{"init"}, - expectedCmdOutput: "Terraform has been successfully initialized", - expectedErr: "", - }, - { - command: []string{"apply"}, - expectedCmdOutput: "Do you want to perform these actions in workspace", - expectedErr: "Error asking approve", + prep: func(t *testing.T, orgName, dir string) { + wsName := "app" + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String(wsName), + TerraformVersion: tfe.String(tfVersion), + AutoApply: tfe.Bool(false), + }) + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "cloud"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "app"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, }, }, - validations: func(t *testing.T, orgName, wsName string) { - workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, wsName, &tfe.WorkspaceReadOptions{Include: "current_run"}) + validations: func(t *testing.T, orgName string) { + workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, "app", &tfe.WorkspaceReadOptions{Include: "current_run"}) if err != nil { t.Fatal(err) } if workspace.CurrentRun == nil { t.Fatal("Expected workspace to have run, but got nil") } - if workspace.CurrentRun.Status != tfe.RunPlanned { - t.Fatalf("Expected run status to be `planned`, but is %s", workspace.CurrentRun.Status) + if workspace.CurrentRun.Status != tfe.RunApplied { + t.Fatalf("Expected run status to be `applied`, but is %s", workspace.CurrentRun.Status) } }, }, - "workspace auto apply, terraform apply without auto-approve": { - setup: func(t *testing.T) (map[string]string, func()) { - org, orgCleanup := createOrganization(t) - wOpts := tfe.WorkspaceCreateOptions{ - Name: tfe.String(randomString(t)), - TerraformVersion: tfe.String(terraformVersion), - AutoApply: tfe.Bool(true), - } - workspace := createWorkspace(t, org.Name, wOpts) - cleanup := func() { - defer orgCleanup() - } - names := map[string]string{ - "organization": org.Name, - "workspace": workspace.Name, - } - - return names, cleanup - }, - commands: []tfCommand{ + "workspace auto apply, terraform apply without auto-approve, expect prompt": { + operations: []operationSets{ { - command: []string{"init"}, - expectedCmdOutput: "Terraform has been successfully initialized", - expectedErr: "", - }, - { - command: []string{"apply"}, - expectedCmdOutput: "Do you want to perform these actions in workspace", - expectedErr: "Error asking approve", + prep: func(t *testing.T, orgName, dir string) { + wsName := "app" + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String(wsName), + TerraformVersion: tfe.String(tfVersion), + AutoApply: tfe.Bool(true), + }) + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "cloud"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "app"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, }, }, - validations: func(t *testing.T, orgName, wsName string) { - workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, wsName, &tfe.WorkspaceReadOptions{Include: "current_run"}) + validations: func(t *testing.T, orgName string) { + workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, "app", &tfe.WorkspaceReadOptions{Include: "current_run"}) if err != nil { t.Fatal(err) } if workspace.CurrentRun == nil { - t.Fatalf("Expected workspace to have run, but got nil") - } - if workspace.CurrentRun.Status != tfe.RunPlanned { - t.Fatalf("Expected run status to be `planned`, but is %s", workspace.CurrentRun.Status) - } - }, - }, - "workspace manual apply, terraform apply auto-approve": { - setup: func(t *testing.T) (map[string]string, func()) { - org, orgCleanup := createOrganization(t) - wOpts := tfe.WorkspaceCreateOptions{ - Name: tfe.String(randomString(t)), - TerraformVersion: tfe.String(terraformVersion), - AutoApply: tfe.Bool(false), - } - workspace := createWorkspace(t, org.Name, wOpts) - cleanup := func() { - defer orgCleanup() - } - names := map[string]string{ - "organization": org.Name, - "workspace": workspace.Name, - } - - return names, cleanup - }, - commands: []tfCommand{ - { - command: []string{"init"}, - expectedCmdOutput: "Terraform has been successfully initialized", - expectedErr: "", - }, - { - command: []string{"apply", "-auto-approve"}, - expectedCmdOutput: "Apply complete! Resources: 1 added, 0 changed, 0 destroyed.", - expectedErr: "", - }, - }, - validations: func(t *testing.T, orgName, wsName string) { - workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, wsName, &tfe.WorkspaceReadOptions{Include: "current_run"}) - if err != nil { - t.Fatal(err) - } - if workspace.CurrentRun == nil { - t.Fatalf("Expected workspace to have run, but got nil") + t.Fatal("Expected workspace to have run, but got nil") } if workspace.CurrentRun.Status != tfe.RunApplied { t.Fatalf("Expected run status to be `applied`, but is %s", workspace.CurrentRun.Status) } }, }, - "workspace auto apply, terraform apply auto-approve": { - setup: func(t *testing.T) (map[string]string, func()) { - org, orgCleanup := createOrganization(t) - - wOpts := tfe.WorkspaceCreateOptions{ - Name: tfe.String(randomString(t)), - TerraformVersion: tfe.String(terraformVersion), - AutoApply: tfe.Bool(true), - } - workspace := createWorkspace(t, org.Name, wOpts) - cleanup := func() { - defer orgCleanup() - } - names := map[string]string{ - "organization": org.Name, - "workspace": workspace.Name, - } - - return names, cleanup - }, - commands: []tfCommand{ + "workspace manual apply, terraform apply with auto-approve, no prompt": { + operations: []operationSets{ { - command: []string{"init"}, - expectedCmdOutput: "Terraform has been successfully initialized", - expectedErr: "", - }, - { - command: []string{"apply", "-auto-approve"}, - expectedCmdOutput: "Apply complete! Resources: 1 added, 0 changed, 0 destroyed.", - expectedErr: "", + prep: func(t *testing.T, orgName, dir string) { + wsName := "app" + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String(wsName), + TerraformVersion: tfe.String(tfVersion), + AutoApply: tfe.Bool(false), + }) + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "cloud"!`, + }, + { + command: []string{"apply", "-auto-approve"}, + expectedCmdOutput: `Apply complete!`, + }, + }, }, }, - validations: func(t *testing.T, orgName, wsName string) { - workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, wsName, &tfe.WorkspaceReadOptions{Include: "current_run"}) + validations: func(t *testing.T, orgName string) { + workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, "app", &tfe.WorkspaceReadOptions{Include: "current_run"}) if err != nil { t.Fatal(err) } if workspace.CurrentRun == nil { - t.Fatalf("Expected workspace to have run, but got nil") + t.Fatal("Expected workspace to have run, but got nil") + } + if workspace.CurrentRun.Status != tfe.RunApplied { + t.Fatalf("Expected run status to be `applied`, but is %s", workspace.CurrentRun.Status) + } + }, + }, + "workspace auto apply, terraform apply with auto-approve, no prompt": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "app" + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String(wsName), + TerraformVersion: tfe.String(tfVersion), + AutoApply: tfe.Bool(true), + }) + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "cloud"!`, + }, + { + command: []string{"apply", "-auto-approve"}, + expectedCmdOutput: `Apply complete!`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, "app", &tfe.WorkspaceReadOptions{Include: "current_run"}) + if err != nil { + t.Fatal(err) + } + if workspace.CurrentRun == nil { + t.Fatal("Expected workspace to have run, but got nil") } if workspace.CurrentRun.Status != tfe.RunApplied { t.Fatalf("Expected run status to be `applied`, but is %s", workspace.CurrentRun.Status) @@ -201,38 +185,73 @@ func Test_terraform_apply_autoApprove(t *testing.T) { } for name, tc := range cases { log.Println("Test: ", name) - resourceData, cleanup := tc.setup(t) + + organization, cleanup := createOrganization(t) defer cleanup() + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() tmpDir, err := ioutil.TempDir("", "terraform-test") if err != nil { t.Fatal(err) } - orgName := resourceData["organization"] - wsName := resourceData["workspace"] - tfBlock := terraformConfigCloudBackendName(orgName, wsName) - writeMainTF(t, tfBlock, tmpDir) + defer os.RemoveAll(tmpDir) + tf := e2e.NewBinary(terraformBin, tmpDir) - defer tf.Close() - tf.AddEnv("TF_LOG=debug") + tf.AddEnv("TF_LOG=info") tf.AddEnv(cliConfigFileEnv) + defer tf.Close() - for _, cmd := range tc.commands { - stdout, stderr, err := tf.Run(cmd.command...) - if cmd.expectedErr == "" && err != nil { - t.Fatalf("Expected no error, but got %v. stderr\n: %s", err, stderr) - } - if cmd.expectedErr != "" { - if !strings.Contains(stderr, cmd.expectedErr) { - t.Fatalf("Expected to find error %s, but got %s", cmd.expectedErr, stderr) + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) } - } - if cmd.expectedCmdOutput != "" && !strings.Contains(stdout, cmd.expectedCmdOutput) { - t.Fatalf("Expected to find output %s, but did not find in\n%s", cmd.expectedCmdOutput, stdout) + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } } } - tc.validations(t, orgName, wsName) + if tc.validations != nil { + tc.validations(t, organization.Name) + } } } diff --git a/internal/cloud/e2e/helper_test.go b/internal/cloud/e2e/helper_test.go index 844797f2d..af585004b 100644 --- a/internal/cloud/e2e/helper_test.go +++ b/internal/cloud/e2e/helper_test.go @@ -47,6 +47,18 @@ func createOrganization(t *testing.T) (*tfe.Organization, func()) { t.Fatal(err) } + // TODO: remove this when we are ready to release. This should not need beta + // or internal access as the release will be. Also, we won't be able to access + // admin in production. + opts := tfe.AdminOrganizationUpdateOptions{ + AccessBetaTools: tfe.Bool(true), + AccessInternalTools: tfe.Bool(true), + } + _, err = tfeClient.Admin.Organizations.Update(ctx, org.Name, opts) + if err != nil { + t.Fatal(err) + } + return org, func() { if err := tfeClient.Organizations.Delete(ctx, org.Name); err != nil { t.Errorf("Error destroying organization! WARNING: Dangling resources\n"+ @@ -147,14 +159,10 @@ terraform { } } -resource "random_pet" "server" { - keepers = { - uuid = uuid() - } - - length = 3 +output "tag_val" { + value = "%s" } -`, tfeHostname, org, tag) +`, tfeHostname, org, tag, tag) } func terraformConfigCloudBackendName(org, name string) string { @@ -170,12 +178,8 @@ terraform { } } -resource "random_pet" "server" { - keepers = { - uuid = uuid() - } - - length = 3 +output "val" { + value = "${terraform.workspace}" } `, tfeHostname, org, name) } @@ -191,3 +195,40 @@ func writeMainTF(t *testing.T, block string, dir string) { } f.Close() } + +// Ensure that TFC/E has a particular terraform version. +func hasTerraformVersion(version string) bool { + opts := tfe.AdminTerraformVersionsListOptions{ + ListOptions: tfe.ListOptions{ + PageNumber: 1, + PageSize: 100, + }, + } + hasVersion := false + +findTfVersion: + for { + // TODO: update go-tfe Read() to retrieve a terraform version by name. + // Currently you can only retrieve by ID. + tfVersionList, err := tfeClient.Admin.TerraformVersions.List(context.Background(), opts) + if err != nil { + t.Fatalf("Could not retrieve list of terraform versions: %v", err) + } + for _, item := range tfVersionList.Items { + if item.Version == version { + hasVersion = true + break findTfVersion + } + } + + // Exit the loop when we've seen all pages. + if tfVersionList.CurrentPage >= tfVersionList.TotalPages { + break + } + + // Update the page number to get the next page. + opts.PageNumber = tfVersionList.NextPage + } + + return hasVersion +} diff --git a/internal/cloud/e2e/main_test.go b/internal/cloud/e2e/main_test.go index 58653e002..c13151320 100644 --- a/internal/cloud/e2e/main_test.go +++ b/internal/cloud/e2e/main_test.go @@ -4,7 +4,6 @@ package main import ( - "encoding/json" "fmt" "io/ioutil" "log" @@ -16,7 +15,6 @@ import ( tfe "github.com/hashicorp/go-tfe" ) -var terraformVersion string var terraformBin string var cliConfigFileEnv string @@ -46,7 +44,6 @@ func accTest() bool { func setup() func() { setTfeClient() teardown := setupBinary() - setVersion() return func() { teardown() @@ -117,30 +114,6 @@ func setupBinary() func() { } } -func setVersion() { - log.Println("Retrieving version") - cmd := exec.Command(terraformBin, "version", "-json") - out, err := cmd.Output() - if err != nil { - log.Fatal(fmt.Sprintf("Could not output terraform version: %v", err)) - } - var data map[string]interface{} - if err := json.Unmarshal(out, &data); err != nil { - log.Fatal(fmt.Sprintf("Could not unmarshal version output: %v", err)) - } - - out, err = exec.Command("git", "rev-parse", "HEAD").Output() - if err != nil { - log.Fatal(fmt.Sprintf("Could not execute go build command: %v", err)) - } - - hash := string(out)[0:8] - - fullVersion := data["terraform_version"].(string) - version := strings.Split(fullVersion, "-")[0] - terraformVersion = fmt.Sprintf("%s-%s", version, hash) -} - func writeCredRC(file string) { creds := credentialBlock() f, err := os.Create(file) diff --git a/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go index e72169186..d7916a046 100644 --- a/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go @@ -67,7 +67,7 @@ func Test_migrate_multi_to_tfc_cloud_name_strategy(t *testing.T) { expectedCmdOutput: `Do you want to copy only your current workspace?`, userInput: []string{"yes", "yes"}, postInputOutput: []string{ - `Do you want to copy existing state to the new backend?`, + `Do you want to copy existing state to Terraform Cloud?`, `Successfully configured Terraform Cloud!`}, }, { @@ -138,8 +138,8 @@ func Test_migrate_multi_to_tfc_cloud_name_strategy(t *testing.T) { expectedCmdOutput: `Do you want to copy only your current workspace?`, userInput: []string{"yes", "yes"}, postInputOutput: []string{ - `Do you want to copy existing state to the new backend?`, - `Successfully configured Terraform Cloud!`}, + `Do you want to copy existing state to Terraform Cloud?`, + `Terraform Cloud has been successfully initialized!`}, }, { command: []string{"workspace", "list"}, diff --git a/internal/cloud/e2e/migrate_state_single_to_tfc_test.go b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go index 805a5766d..d89396f4c 100644 --- a/internal/cloud/e2e/migrate_state_single_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go @@ -5,7 +5,6 @@ package main import ( "context" - "fmt" "io/ioutil" "os" "testing" @@ -104,7 +103,9 @@ func Test_migrate_single_to_tfc(t *testing.T) { command: []string{"init", "-migrate-state"}, expectedCmdOutput: `Terraform Cloud configuration only allows named workspaces!`, userInput: []string{"new-workspace", "yes"}, - postInputOutput: []string{`Successfully configured Terraform Cloud!`}, + postInputOutput: []string{ + `Do you want to copy existing state to the new backend?`, + `Successfully configured Terraform Cloud!`}, }, { command: []string{"workspace", "list"}, @@ -129,7 +130,7 @@ func Test_migrate_single_to_tfc(t *testing.T) { } for name, tc := range cases { - fmt.Println("Test: ", name) + t.Log("Test: ", name) organization, cleanup := createOrganization(t) defer cleanup() exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) diff --git a/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go b/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go index 17bbc0506..39ae98003 100644 --- a/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go @@ -1,21 +1,503 @@ +//go:build e2e +// +build e2e + package main import ( + "context" + "io/ioutil" + "os" "testing" + + expect "github.com/Netflix/go-expect" + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/e2e" ) -/* +func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { + ctx := context.Background() + tfVersion := "1.1.0-tfc-integration" + if !hasTerraformVersion(version) { + t.Skip("Skipping test because TFC does not have current terraform version.") + } - If org to org, treat it like a new backend. Then go through the multi/single logic + cases := map[string]struct { + setup func(t *testing.T) (string, func()) + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "migrating from name to name": { + setup: func(t *testing.T) (string, func()) { + organization, cleanup := createOrganization(t) + return organization.Name, cleanup + }, + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "prod" + // Creating the workspace here instead of it being created + // dynamically in the Cloud StateMgr because we want to ensure that + // the terraform version selected for the workspace matches the + // terraform version of this current branch. + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String("prod"), + TerraformVersion: tfe.String(tfVersion), + }) + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "cloud"!`, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `prod`, // this comes from the `prep` function + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "prod"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "dev" + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String(wsName), + TerraformVersion: tfe.String(tfVersion), + }) + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Do you want to copy existing state to the new backend?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Successfully configured the backend "cloud"!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `dev`, // this comes from the `prep` function + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{}) + if err != nil { + t.Fatal(err) + } + // this workspace name is what exists in the cloud backend configuration block + if len(wsList.Items) != 2 { + t.Fatal("Expected number of workspaces to be 2") + } + }, + }, + "migrating from name to tags": { + setup: func(t *testing.T) (string, func()) { + organization, cleanup := createOrganization(t) + return organization.Name, cleanup + }, + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "prod" + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String("prod"), + TerraformVersion: tfe.String(tfVersion), + }) + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "cloud"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "prod"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `The "cloud" backend configuration only allows named workspaces!`, + userInput: []string{"new-workspace", "yes"}, + postInputOutput: []string{ + `Do you want to copy existing state to the new backend?`, + `Successfully configured the backend "cloud"!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `new-workspace`, // this comes from the `prep` function + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{ + Tags: tfe.String("app"), + }) + if err != nil { + t.Fatal(err) + } + // this workspace name is what exists in the cloud backend configuration block + if len(wsList.Items) != 1 { + t.Fatal("Expected number of workspaces to be 1") + } + }, + }, + } - If same org, but name/tag changes - config name -> config name - -- straight copy - config name -> config tags - -- jsut add tag to workspace. - config tags -> config name - -- straight copy -*/ -func Test_migrate_tfc_to_tfc(t *testing.T) { - t.Skip("todo: see comments") + for name, tc := range cases { + t.Log("Test: ", name) + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + defer tf.Close() + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + + orgName, cleanup := tc.setup(t) + defer cleanup() + for _, op := range tc.operations { + op.prep(t, orgName, tf.WorkDir()) + for _, tfCmd := range op.commands { + t.Log("Running commands: ", tfCmd.command) + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err.Error()) + } + } + } + + if tc.validations != nil { + tc.validations(t, orgName) + } + } +} + +func Test_migrate_tfc_to_tfc_multiple_workspace(t *testing.T) { + ctx := context.Background() + tfVersion := "1.1.0-tfc-integration" + if !hasTerraformVersion(version) { + t.Skip("Skipping test because TFC does not have current terraform version.") + } + + cases := map[string]struct { + setup func(t *testing.T) (string, func()) + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "migrating from multiple workspaces via tags to name": { + setup: func(t *testing.T) (string, func()) { + organization, cleanup := createOrganization(t) + return organization.Name, cleanup + }, + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String("app-prod"), + Tags: []*tfe.Tag{{Name: tag}}, + TerraformVersion: tfe.String(tfVersion), + }) + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String("app-staging"), + Tags: []*tfe.Tag{{Name: tag}}, + TerraformVersion: tfe.String(tfVersion), + }) + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `The currently selected workspace (default) does not exist.`, + userInput: []string{"1"}, + postInputOutput: []string{`Terraform has been successfully initialized!`}, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "app-prod"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "select", "app-staging"}, + expectedCmdOutput: `Switched to workspace "app-staging".`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "app-staging"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"output"}, + expectedCmdOutput: `tag_val = "app"`, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + name := "service" + // Doing this here instead of relying on dynamic workspace creation + // because we want to set the terraform version here so that it is + // using the right version for post init operations. + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String(name), + TerraformVersion: tfe.String(tfVersion), + }) + tfBlock := terraformConfigCloudBackendName(orgName, name) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Do you want to copy only your current workspace?`, + userInput: []string{"yes", "yes"}, + postInputOutput: []string{ + `Do you want to copy existing state to the new backend?`, + `Terraform has been successfully initialized!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `service`, // this comes from the `prep` function + }, + { + command: []string{"output"}, + expectedCmdOutput: `tag_val = "app"`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + ws, err := tfeClient.Workspaces.Read(ctx, orgName, "service") + if err != nil { + t.Fatal(err) + } + if ws == nil { + t.Fatal("Expected to workspace not be empty") + } + }, + }, + "migrating from multiple workspaces via tags to other tags": { + setup: func(t *testing.T) (string, func()) { + organization, cleanup := createOrganization(t) + return organization.Name, cleanup + }, + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String("app-prod"), + Tags: []*tfe.Tag{{Name: tag}}, + TerraformVersion: tfe.String(tfVersion), + }) + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String("app-staging"), + Tags: []*tfe.Tag{{Name: tag}}, + TerraformVersion: tfe.String(tfVersion), + }) + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `The currently selected workspace (default) does not exist.`, + userInput: []string{"1"}, + postInputOutput: []string{`Terraform has been successfully initialized!`}, + }, + { + command: []string{"apply", "-auto-approve"}, + expectedCmdOutput: `Apply complete!`, + }, + { + command: []string{"workspace", "select", "app-staging"}, + expectedCmdOutput: `Switched to workspace "app-staging".`, + }, + { + command: []string{"apply", "-auto-approve"}, + expectedCmdOutput: `Apply complete!`, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tag := "billing" + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + t.Log(orgName) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state", "-ignore-remote-version"}, + expectedCmdOutput: `Would you like to rename your workspaces?`, + userInput: []string{"1", "new-*", "1"}, + postInputOutput: []string{ + `What pattern would you like to add to all your workspaces?`, + `The currently selected workspace (app-staging) does not exist.`, + `Successfully configured the backend "cloud"!`}, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{ + Tags: tfe.String("billing"), + }) + if err != nil { + t.Fatal(err) + } + if len(wsList.Items) != 2 { + t.Logf("Expected the number of workspaces to be 2, but got %d", len(wsList.Items)) + } + _, empty := getWorkspace(wsList.Items, "new-app-prod") + if empty { + t.Fatalf("expected workspaces to include 'new-app-prod' but didn't.") + } + _, empty = getWorkspace(wsList.Items, "new-app-staging") + if empty { + t.Fatalf("expected workspaces to include 'new-app-staging' but didn't.") + } + }, + }, + } + + for name, tc := range cases { + t.Log("Test: ", name) + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + defer tf.Close() + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + + orgName, cleanup := tc.setup(t) + defer cleanup() + for _, op := range tc.operations { + op.prep(t, orgName, tf.WorkDir()) + for _, tfCmd := range op.commands { + t.Log("Running commands: ", tfCmd.command) + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + t.Log(cmd.Stderr) + err = cmd.Wait() + if err != nil { + t.Fatal(err.Error()) + } + } + } + + if tc.validations != nil { + tc.validations(t, orgName) + } + } } diff --git a/internal/command/meta_backend_migrate.go b/internal/command/meta_backend_migrate.go index 92eadfa03..77d2ef05b 100644 --- a/internal/command/meta_backend_migrate.go +++ b/internal/command/meta_backend_migrate.go @@ -545,12 +545,6 @@ func (m *Meta) backendMigrateTFC(opts *backendMigrateOpts) error { return fmt.Errorf(strings.TrimSpace(errTFCMigrateNotYetImplemented)) } - // from TFC to TFC - if sourceTFC && destinationTFC { - // TODO: see internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go for notes - panic("not yet implemented") - } - // Everything below, by the above two conditionals, now assumes that the // destination is always Terraform Cloud (TFC). @@ -659,6 +653,13 @@ func (m *Meta) backendMigrateState_S_TFC(opts *backendMigrateOpts, sourceWorkspa } } + // After migrating multiple workspaces, we want to ensure that a workspace is + // set or we prompt the user to set a workspace. + err = m.selectWorkspace(opts.Destination) + if err != nil { + return err + } + return nil } From 09d5b70b7a2bcd3634bad10cec0aec959f8c6d73 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Tue, 19 Oct 2021 16:25:24 -0500 Subject: [PATCH 51/78] command/meta_backend_migrate:Tweak multi-to-single To TFC copy --- internal/command/meta_backend_migrate.go | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/internal/command/meta_backend_migrate.go b/internal/command/meta_backend_migrate.go index 77d2ef05b..0ba769b0c 100644 --- a/internal/command/meta_backend_migrate.go +++ b/internal/command/meta_backend_migrate.go @@ -577,10 +577,6 @@ func (m *Meta) backendMigrateTFC(opts *backendMigrateOpts) error { multiSource := !sourceSingleState && len(sourceWorkspaces) > 1 if multiSource && destinationNameStrategy { - if err := m.promptMultiToSingleCloudMigration(opts); err != nil { - return err - } - currentWorkspace, err := m.Workspace() if err != nil { return err @@ -588,6 +584,10 @@ func (m *Meta) backendMigrateTFC(opts *backendMigrateOpts) error { opts.sourceWorkspace = currentWorkspace opts.destinationWorkspace = cloudBackendDestination.WorkspaceMapping.Name + if err := m.promptMultiToSingleCloudMigration(opts); err != nil { + return err + } + log.Printf("[INFO] backendMigrateTFC: multi-to-single migration from source %s to destination %q", opts.sourceWorkspace, opts.destinationWorkspace) return m.backendMigrateState_s_s(opts) @@ -670,9 +670,11 @@ func (m *Meta) promptMultiToSingleCloudMigration(opts *backendMigrateOpts) error var err error // Ask the user if they want to migrate their existing remote state migrate, err = m.confirm(&terraform.InputOpts{ - Id: "backend-migrate-multistate-to-single", - Query: "Do you want to copy only your current workspace?", - Description: strings.TrimSpace(tfcInputBackendMigrateMultiToSingle), + Id: "backend-migrate-multistate-to-single", + Query: "Do you want to copy only your current workspace?", + Description: fmt.Sprintf( + strings.TrimSpace(tfcInputBackendMigrateMultiToSingle), + opts.SourceType, opts.destinationWorkspace), }) if err != nil { return fmt.Errorf("Error asking for state migration action: %s", err) @@ -820,8 +822,11 @@ For more information on workspace naming, see https://www.terraform.io/docs/clou ` const tfcInputBackendMigrateMultiToSingle = ` -The cloud configuration has one workspace declared, and you are attemtping to migrate multiple workspaces -to a single workspace. By continuing, you will only migrate your current workspace. +The previous backend %[1]q has multiple workspaces, but Terraform Cloud has been +configured to use a single workspace (%[2]q). By continuing, you will only +migrate your current workspace. If you wish to migrate all workspaces from the +previous backend, use the 'tags' strategy in your workspace configuration block +instead. ` const inputBackendMigrateEmpty = ` From 86f93a2cbc4ab5dc6cef7a3adfb08a497f2f97ce Mon Sep 17 00:00:00 2001 From: Omar Ismail Date: Tue, 19 Oct 2021 17:06:41 -0400 Subject: [PATCH 52/78] Cloud UX test fixes --- internal/cloud/e2e/apply_auto_approve_test.go | 10 +++---- internal/cloud/e2e/helper_test.go | 2 +- .../e2e/migrate_state_multi_to_tfc_test.go | 4 +-- ...igrate_state_remote_backend_to_tfc_test.go | 28 +++++++++---------- .../e2e/migrate_state_single_to_tfc_test.go | 8 +++--- .../e2e/migrate_state_tfc_to_other_test.go | 2 +- .../e2e/migrate_state_tfc_to_tfc_test.go | 28 +++++++++---------- 7 files changed, 41 insertions(+), 41 deletions(-) diff --git a/internal/cloud/e2e/apply_auto_approve_test.go b/internal/cloud/e2e/apply_auto_approve_test.go index 8e10feb08..626f4c272 100644 --- a/internal/cloud/e2e/apply_auto_approve_test.go +++ b/internal/cloud/e2e/apply_auto_approve_test.go @@ -18,7 +18,7 @@ import ( func Test_terraform_apply_autoApprove(t *testing.T) { ctx := context.Background() tfVersion := "1.1.0-tfc-integration" - if !hasTerraformVersion(version) { + if !hasTerraformVersion(t, tfVersion) { t.Skip("Skipping test because TFC does not have current terraform version.") } @@ -42,7 +42,7 @@ func Test_terraform_apply_autoApprove(t *testing.T) { commands: []tfCommand{ { command: []string{"init"}, - expectedCmdOutput: `Successfully configured the backend "cloud"!`, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, }, { command: []string{"apply"}, @@ -82,7 +82,7 @@ func Test_terraform_apply_autoApprove(t *testing.T) { commands: []tfCommand{ { command: []string{"init"}, - expectedCmdOutput: `Successfully configured the backend "cloud"!`, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, }, { command: []string{"apply"}, @@ -122,7 +122,7 @@ func Test_terraform_apply_autoApprove(t *testing.T) { commands: []tfCommand{ { command: []string{"init"}, - expectedCmdOutput: `Successfully configured the backend "cloud"!`, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, }, { command: []string{"apply", "-auto-approve"}, @@ -160,7 +160,7 @@ func Test_terraform_apply_autoApprove(t *testing.T) { commands: []tfCommand{ { command: []string{"init"}, - expectedCmdOutput: `Successfully configured the backend "cloud"!`, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, }, { command: []string{"apply", "-auto-approve"}, diff --git a/internal/cloud/e2e/helper_test.go b/internal/cloud/e2e/helper_test.go index af585004b..a9f4313ff 100644 --- a/internal/cloud/e2e/helper_test.go +++ b/internal/cloud/e2e/helper_test.go @@ -197,7 +197,7 @@ func writeMainTF(t *testing.T, block string, dir string) { } // Ensure that TFC/E has a particular terraform version. -func hasTerraformVersion(version string) bool { +func hasTerraformVersion(t *testing.T, version string) bool { opts := tfe.AdminTerraformVersionsListOptions{ ListOptions: tfe.ListOptions{ PageNumber: 1, diff --git a/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go index d7916a046..c9b837019 100644 --- a/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go @@ -68,7 +68,7 @@ func Test_migrate_multi_to_tfc_cloud_name_strategy(t *testing.T) { userInput: []string{"yes", "yes"}, postInputOutput: []string{ `Do you want to copy existing state to Terraform Cloud?`, - `Successfully configured Terraform Cloud!`}, + `Terraform Cloud has been successfully initialized!`}, }, { command: []string{"workspace", "show"}, @@ -309,7 +309,7 @@ func Test_migrate_multi_to_tfc_cloud_tags_strategy(t *testing.T) { `Would you like to rename your workspaces?`, "What pattern would you like to add to all your workspaces?", "The currently selected workspace (prod) does not exist.", - "Terraform has been successfully initialized!"}, + "Terraform Cloud has been successfully initialized!"}, }, { command: []string{"workspace", "select", "app-prod"}, diff --git a/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go b/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go index 0cb7d5efc..e6402e876 100644 --- a/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go @@ -50,9 +50,9 @@ func Test_migrate_remote_backend_name_to_tfc_name(t *testing.T) { commands: []tfCommand{ { command: []string{"init", "-migrate-state", "-ignore-remote-version"}, - expectedCmdOutput: `Do you want to copy existing state to the new backend?`, + expectedCmdOutput: `Do you want to copy existing state to Terraform Cloud?`, userInput: []string{"yes"}, - postInputOutput: []string{`Successfully configured Terraform Cloud!`}, + postInputOutput: []string{`Terraform Cloud has been successfully initialized!`}, }, { command: []string{"workspace", "show"}, @@ -102,7 +102,7 @@ func Test_migrate_remote_backend_name_to_tfc_name(t *testing.T) { commands: []tfCommand{ { command: []string{"init", "-migrate-state", "-ignore-remote-version"}, - expectedCmdOutput: `Terraform has been successfully initialized!`, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, }, { command: []string{"workspace", "show"}, @@ -232,9 +232,9 @@ func Test_migrate_remote_backend_name_to_tfc_name_different_org(t *testing.T) { commands: []tfCommand{ { command: []string{"init", "-migrate-state", "-ignore-remote-version"}, - expectedCmdOutput: `Do you want to copy existing state to the new backend?`, + expectedCmdOutput: `Do you want to copy existing state to Terraform Cloud?`, userInput: []string{"yes"}, - postInputOutput: []string{`Successfully configured Terraform Cloud!`}, + postInputOutput: []string{`Terraform Cloud has been successfully initialized!`}, }, { command: []string{"workspace", "show"}, @@ -376,8 +376,8 @@ func Test_migrate_remote_backend_name_to_tfc_tags(t *testing.T) { expectedCmdOutput: `Terraform Cloud configuration only allows named workspaces!`, userInput: []string{"cloud-workspace", "yes"}, postInputOutput: []string{ - `Do you want to copy existing state to the new backend?`, - `Successfully configured the backend "cloud"!`}, + `Do you want to copy existing state to Terraform Cloud?`, + `Terraform Cloud has been successfully initialized!`}, }, { command: []string{"workspace", "show"}, @@ -513,10 +513,10 @@ func Test_migrate_remote_backend_prefix_to_tfc_name(t *testing.T) { commands: []tfCommand{ { command: []string{"init", "-migrate-state", "-ignore-remote-version"}, - expectedCmdOutput: `Do you want to copy existing state to the new backend?`, + expectedCmdOutput: `Do you want to copy existing state to Terraform Cloud?`, userInput: []string{"yes"}, postInputOutput: []string{ - `Successfully configured Terraform Cloud!`}, + `Terraform Cloud has been successfully initialized!`}, }, { command: []string{"workspace", "show"}, @@ -581,7 +581,7 @@ func Test_migrate_remote_backend_prefix_to_tfc_name(t *testing.T) { expectedCmdOutput: `Do you want to copy only your current workspace?`, userInput: []string{"yes"}, postInputOutput: []string{ - `Successfully configured the backend "cloud"!`}, + `Terraform Cloud has been successfully initialized!`}, }, { command: []string{"workspace", "show"}, @@ -731,11 +731,11 @@ func Test_migrate_remote_backend_prefix_to_tfc_tags(t *testing.T) { commands: []tfCommand{ { command: []string{"init", "-migrate-state", "-ignore-remote-version"}, - expectedCmdOutput: `The "cloud" backend configuration only allows named workspaces!`, + expectedCmdOutput: `The Terraform Cloud configuration only allows named workspaces!`, userInput: []string{"cloud-workspace", "yes"}, postInputOutput: []string{ - `Do you want to copy existing state to the new backend?`, - `Successfully configured the backend "cloud"!`}, + `Do you want to copy existing state to Terraform Cloud?`, + `Terraform Cloud has been successfully initialized!`}, }, { command: []string{"workspace", "list"}, @@ -801,7 +801,7 @@ func Test_migrate_remote_backend_prefix_to_tfc_tags(t *testing.T) { expectedCmdOutput: `Would you like to rename your workspaces?`, userInput: []string{"1", "*"}, postInputOutput: []string{`What pattern would you like to add to all your workspaces?`, - `Successfully configured the backend "cloud"!`}, + `Terraform Cloud has been successfully initialized!`}, }, { command: []string{"workspace", "show"}, diff --git a/internal/cloud/e2e/migrate_state_single_to_tfc_test.go b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go index d89396f4c..d8423f428 100644 --- a/internal/cloud/e2e/migrate_state_single_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go @@ -50,9 +50,9 @@ func Test_migrate_single_to_tfc(t *testing.T) { commands: []tfCommand{ { command: []string{"init", "-migrate-state"}, - expectedCmdOutput: `Do you want to copy existing state to the Terraform Cloud?`, + expectedCmdOutput: `Do you want to copy existing state to Terraform Cloud?`, userInput: []string{"yes"}, - postInputOutput: []string{`Successfully configured Terraform Cloud!`}, + postInputOutput: []string{`Terraform Cloud has been successfully initialized!`}, }, { command: []string{"workspace", "list"}, @@ -104,8 +104,8 @@ func Test_migrate_single_to_tfc(t *testing.T) { expectedCmdOutput: `Terraform Cloud configuration only allows named workspaces!`, userInput: []string{"new-workspace", "yes"}, postInputOutput: []string{ - `Do you want to copy existing state to the new backend?`, - `Successfully configured Terraform Cloud!`}, + `Do you want to copy existing state to Terraform Cloud?`, + `Terraform Cloud has been successfully initialized!`}, }, { command: []string{"workspace", "list"}, diff --git a/internal/cloud/e2e/migrate_state_tfc_to_other_test.go b/internal/cloud/e2e/migrate_state_tfc_to_other_test.go index 08a5e703a..a7b807191 100644 --- a/internal/cloud/e2e/migrate_state_tfc_to_other_test.go +++ b/internal/cloud/e2e/migrate_state_tfc_to_other_test.go @@ -28,7 +28,7 @@ func Test_migrate_tfc_to_other(t *testing.T) { commands: []tfCommand{ { command: []string{"init"}, - expectedCmdOutput: `Successfully configured the backend "cloud"!`, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, }, }, }, diff --git a/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go b/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go index 39ae98003..ab5d908ed 100644 --- a/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go @@ -17,7 +17,7 @@ import ( func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { ctx := context.Background() tfVersion := "1.1.0-tfc-integration" - if !hasTerraformVersion(version) { + if !hasTerraformVersion(t, tfVersion) { t.Skip("Skipping test because TFC does not have current terraform version.") } @@ -49,7 +49,7 @@ func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { commands: []tfCommand{ { command: []string{"init"}, - expectedCmdOutput: `Successfully configured the backend "cloud"!`, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, }, { command: []string{"workspace", "show"}, @@ -76,9 +76,9 @@ func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { commands: []tfCommand{ { command: []string{"init", "-migrate-state", "-ignore-remote-version"}, - expectedCmdOutput: `Do you want to copy existing state to the new backend?`, + expectedCmdOutput: `Do you want to copy existing state to Terraform Cloud?`, userInput: []string{"yes"}, - postInputOutput: []string{`Successfully configured the backend "cloud"!`}, + postInputOutput: []string{`Terraform Cloud has been successfully initialized!`}, }, { command: []string{"workspace", "show"}, @@ -117,7 +117,7 @@ func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { commands: []tfCommand{ { command: []string{"init"}, - expectedCmdOutput: `Successfully configured the backend "cloud"!`, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, }, { command: []string{"apply"}, @@ -136,11 +136,11 @@ func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { commands: []tfCommand{ { command: []string{"init", "-migrate-state", "-ignore-remote-version"}, - expectedCmdOutput: `The "cloud" backend configuration only allows named workspaces!`, + expectedCmdOutput: `The Terraform Cloud configuration only allows named workspaces!`, userInput: []string{"new-workspace", "yes"}, postInputOutput: []string{ - `Do you want to copy existing state to the new backend?`, - `Successfully configured the backend "cloud"!`}, + `Do you want to copy existing state to Terraform Cloud?`, + `Terraform Cloud has been successfully initialized!`}, }, { command: []string{"workspace", "show"}, @@ -240,7 +240,7 @@ func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { func Test_migrate_tfc_to_tfc_multiple_workspace(t *testing.T) { ctx := context.Background() tfVersion := "1.1.0-tfc-integration" - if !hasTerraformVersion(version) { + if !hasTerraformVersion(t, tfVersion) { t.Skip("Skipping test because TFC does not have current terraform version.") } @@ -276,7 +276,7 @@ func Test_migrate_tfc_to_tfc_multiple_workspace(t *testing.T) { command: []string{"init"}, expectedCmdOutput: `The currently selected workspace (default) does not exist.`, userInput: []string{"1"}, - postInputOutput: []string{`Terraform has been successfully initialized!`}, + postInputOutput: []string{`Terraform Cloud has been successfully initialized!`}, }, { command: []string{"apply"}, @@ -319,8 +319,8 @@ func Test_migrate_tfc_to_tfc_multiple_workspace(t *testing.T) { expectedCmdOutput: `Do you want to copy only your current workspace?`, userInput: []string{"yes", "yes"}, postInputOutput: []string{ - `Do you want to copy existing state to the new backend?`, - `Terraform has been successfully initialized!`}, + `Do you want to copy existing state to Terraform Cloud?`, + `Terraform Cloud has been successfully initialized!`}, }, { command: []string{"workspace", "show"}, @@ -370,7 +370,7 @@ func Test_migrate_tfc_to_tfc_multiple_workspace(t *testing.T) { command: []string{"init"}, expectedCmdOutput: `The currently selected workspace (default) does not exist.`, userInput: []string{"1"}, - postInputOutput: []string{`Terraform has been successfully initialized!`}, + postInputOutput: []string{`Terraform Cloud has been successfully initialized!`}, }, { command: []string{"apply", "-auto-approve"}, @@ -401,7 +401,7 @@ func Test_migrate_tfc_to_tfc_multiple_workspace(t *testing.T) { postInputOutput: []string{ `What pattern would you like to add to all your workspaces?`, `The currently selected workspace (app-staging) does not exist.`, - `Successfully configured the backend "cloud"!`}, + `Terraform Cloud has been successfully initialized!`}, }, }, }, From 5ff2495ffaeb58b045815e47beffa2cb33b08b76 Mon Sep 17 00:00:00 2001 From: Omar Ismail Date: Thu, 21 Oct 2021 09:55:27 -0400 Subject: [PATCH 53/78] Update HasResource to HasManagedResourceInstanceObjects --- internal/cloud/backend_apply_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index fc5e99337..d741662cd 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -890,7 +890,7 @@ func TestCloud_applyForceLocal(t *testing.T) { if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { t.Fatalf("expected plan summary in output: %s", output) } - if !run.State.HasResources() { + if !run.State.HasManagedResourceInstanceObjects() { t.Fatalf("expected resources in state") } } @@ -953,7 +953,7 @@ func TestCloud_applyWorkspaceWithoutOperations(t *testing.T) { if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { t.Fatalf("expected plan summary in output: %s", output) } - if !run.State.HasResources() { + if !run.State.HasManagedResourceInstanceObjects() { t.Fatalf("expected resources in state") } } @@ -1565,7 +1565,7 @@ func TestCloud_applyVersionCheck(t *testing.T) { output := b.CLI.(*cli.MockUi).OutputWriter.String() hasRemote := strings.Contains(output, "Running apply in Terraform Cloud") hasSummary := strings.Contains(output, "1 added, 0 changed, 0 destroyed") - hasResources := run.State.HasResources() + hasResources := run.State.HasManagedResourceInstanceObjects() if !tc.forceLocal && tc.hasOperations { if !hasRemote { t.Errorf("missing TFC header in output: %s", output) From e49f271eb5d84b668529f576bae6816ca5e676c0 Mon Sep 17 00:00:00 2001 From: Barrett Clark Date: Thu, 21 Oct 2021 08:40:09 -0500 Subject: [PATCH 54/78] Update copy for terraform init with cloud block --- internal/command/init.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/command/init.go b/internal/command/init.go index 9065b1359..39a902c96 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -1117,8 +1117,8 @@ const outputInitSuccessCLICloud = `[reset][green] You may now begin working with Terraform Cloud. Try running "terraform plan" to see any changes that are required for your infrastructure. -If you ever set or change modules or configuration for Terraform, run -"terraform init" again to reinitialize your working directory. +If you ever set or change modules or Terraform Settings, run "terraform init" +again to reinitialize your working directory. ` // providerProtocolTooOld is a message sent to the CLI UI if the provider's From d29532cfeb64f19da70a2e8c015273d824b6349c Mon Sep 17 00:00:00 2001 From: Barrett Clark Date: Tue, 19 Oct 2021 10:20:11 -0500 Subject: [PATCH 55/78] tfc apply before init https://app.asana.com/0/1199201948575144/1201019450474843/f --- internal/command/meta_backend.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index 94b9e5e5c..5a26c71b0 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -592,6 +592,20 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di return m.backend_c_r_S(c, cHash, sMgr, true) + // Configuring Terraform Cloud for the first time. + case c != nil && c.Type == "cloud" && s.Backend.Empty(): + log.Printf("[TRACE] Meta.Backend: moving from default local state only to Terraform Cloud") + if !opts.Init { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Terraform Cloud has been configured but needs to be initialized.", + "Run \"terraform init\" to initialize Terraform Cloud.", + )) + return nil, diags + } + + return m.backend_C_r_s(c, cHash, sMgr) + // Configuring a backend for the first time. case c != nil && s.Backend.Empty(): log.Printf("[TRACE] Meta.Backend: moving from default local state only to %q backend", c.Type) From ab304d831f61e15e7bd7c6b5e867c236b80b6153 Mon Sep 17 00:00:00 2001 From: Barrett Clark Date: Thu, 21 Oct 2021 06:19:33 -0500 Subject: [PATCH 56/78] Found another path to backend init error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There are actually a few different ways to get to this message. 1. Blank state — no previous terraform applied. Start with a cloud block. 1. Implicit local — start with no backend specified. This actually goes through the same code execution path as the first scenario. 1. Explicit local — start with a backend local block that has been applied, then change from the local backend to a cloud block. This will recognize the state, and is a different path through the code in the meta backend. This commit handles the last case. The messaging has also been tweaked. End to end test included as well. --- .../e2e/backend_apply_before_init_test.go | 141 ++++++++++++++++++ internal/command/meta_backend.go | 48 +++++- 2 files changed, 181 insertions(+), 8 deletions(-) create mode 100644 internal/cloud/e2e/backend_apply_before_init_test.go diff --git a/internal/cloud/e2e/backend_apply_before_init_test.go b/internal/cloud/e2e/backend_apply_before_init_test.go new file mode 100644 index 000000000..86f2f515c --- /dev/null +++ b/internal/cloud/e2e/backend_apply_before_init_test.go @@ -0,0 +1,141 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "fmt" + "io/ioutil" + "os" + "testing" + + expect "github.com/Netflix/go-expect" + "github.com/hashicorp/terraform/internal/e2e" +) + +func Test_backend_apply_before_init(t *testing.T) { + cases := map[string]struct { + operations []operationSets + }{ + "terraform apply with cloud block - blank state": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "new-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"apply"}, + expectedCmdOutput: `Terraform Cloud has been configured but needs to be initialized`, + expectError: true, + }, + }, + }, + }, + }, + "terraform apply with cloud block - local state": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tfBlock := terraformConfigLocalBackend() + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "local"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "new-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"apply"}, + expectedCmdOutput: `Terraform Cloud has been configured but needs to be initialized`, + expectError: true, + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + fmt.Println("Test: ", name) + organization, cleanup := createOrganization(t) + defer cleanup() + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + tf.AddEnv("TF_LOG=info") + tf.AddEnv(cliConfigFileEnv) + defer tf.Close() + + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + err = cmd.Wait() + if err != nil && !tfCmd.expectError { + t.Fatal(err) + } + } + } + } +} diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index 5a26c71b0..a521289b5 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -593,13 +593,14 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di return m.backend_c_r_S(c, cHash, sMgr, true) // Configuring Terraform Cloud for the first time. + // NOTE: There may be an implicit local backend with state that is not visible to this block. case c != nil && c.Type == "cloud" && s.Backend.Empty(): log.Printf("[TRACE] Meta.Backend: moving from default local state only to Terraform Cloud") if !opts.Init { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Terraform Cloud has been configured but needs to be initialized.", - "Run \"terraform init\" to initialize Terraform Cloud.", + strings.TrimSpace(errBackendInitCloud), )) return nil, diags } @@ -640,17 +641,30 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di } log.Printf("[TRACE] Meta.Backend: backend configuration has changed (from type %q to type %q)", s.Backend.Type, c.Type) - initReason := fmt.Sprintf("Backend configuration changed for %q", c.Type) - if s.Backend.Type != c.Type { + initReason := "" + switch { + case c.Type == "cloud": + initReason = fmt.Sprintf("Backend configuration changed from %q to Terraform Cloud", s.Backend.Type) + case s.Backend.Type != c.Type: initReason = fmt.Sprintf("Backend configuration changed from %q to %q", s.Backend.Type, c.Type) + default: + initReason = fmt.Sprintf("Backend configuration changed for %q", c.Type) } if !opts.Init { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Backend initialization required, please run \"terraform init\"", - fmt.Sprintf(strings.TrimSpace(errBackendInit), initReason), - )) + if c.Type == "cloud" { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Terraform Cloud has been configured but needs to be initialized.", + strings.TrimSpace(errBackendInitCloud), + )) + } else { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Backend initialization required, please run \"terraform init\"", + fmt.Sprintf(strings.TrimSpace(errBackendInit), initReason), + )) + } return nil, diags } @@ -1315,6 +1329,24 @@ hasn't changed and try again. At this point, no changes to your existing configuration or state have been made. ` +const errBackendInitCloud = ` +Changes to the Terraform Cloud configuration block require reinitialization. +This allows Terraform to set up the new configuration, copy existing state, +etc. Learn more about Terraform Settings: +https://www.terraform.io/docs/language/settings/index.html + +Please run "terraform init" with either the "-reconfigure" or "-migrate-state" +flags. The "-reconfigure" option disregards any existing configuration, +preventing migration of any existing state. The "-migrate-state" option +will attempt to copy existing state to Terraform Cloud. Learn more about +using "terraform init": +https://www.terraform.io/docs/cli/commands/init.html#backend-initialization + +If the change reason above is incorrect, please verify your configuration +hasn't changed and try again. At this point, no changes to your existing +configuration or state have been made. +` + const errBackendWriteSaved = ` Error saving the backend configuration: %s From e16c53b561d3e524e5e53c6a40ac86f76e8bca8e Mon Sep 17 00:00:00 2001 From: Barrett Clark Date: Thu, 21 Oct 2021 14:29:12 -0500 Subject: [PATCH 57/78] Simplify/Consolidate the cloud conditionals --- internal/command/meta_backend.go | 36 +++++++++++++------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index a521289b5..d72a56c1d 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -592,31 +592,25 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di return m.backend_c_r_S(c, cHash, sMgr, true) - // Configuring Terraform Cloud for the first time. - // NOTE: There may be an implicit local backend with state that is not visible to this block. - case c != nil && c.Type == "cloud" && s.Backend.Empty(): - log.Printf("[TRACE] Meta.Backend: moving from default local state only to Terraform Cloud") - if !opts.Init { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Terraform Cloud has been configured but needs to be initialized.", - strings.TrimSpace(errBackendInitCloud), - )) - return nil, diags - } - - return m.backend_C_r_s(c, cHash, sMgr) - // Configuring a backend for the first time. case c != nil && s.Backend.Empty(): log.Printf("[TRACE] Meta.Backend: moving from default local state only to %q backend", c.Type) if !opts.Init { - initReason := fmt.Sprintf("Initial configuration of the requested backend %q", c.Type) - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Backend initialization required, please run \"terraform init\"", - fmt.Sprintf(strings.TrimSpace(errBackendInit), initReason), - )) + if c.Type == "cloud" { + // NOTE: There may be an implicit local backend with state that is not visible to this block. + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Terraform Cloud has been configured but needs to be initialized.", + strings.TrimSpace(errBackendInitCloud), + )) + } else { + initReason := fmt.Sprintf("Initial configuration of the requested backend %q", c.Type) + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Backend initialization required, please run \"terraform init\"", + fmt.Sprintf(strings.TrimSpace(errBackendInit), initReason), + )) + } return nil, diags } From 12a4af3e72416b59f2a0c4b5afb52ddc668d23e4 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Thu, 21 Oct 2021 22:25:00 -0500 Subject: [PATCH 58/78] Temporarily skip API version checking --- internal/cloud/backend.go | 18 ++++++++++-------- internal/cloud/backend_test.go | 1 + internal/cloud/versioning.go | 6 +++--- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 08dafb275..e6b0ba98a 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -295,14 +295,16 @@ func (b *Cloud) Configure(obj cty.Value) tfdiags.Diagnostics { desiredAPIVersion, _ := version.NewVersion("2.5") if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Unsupported Terraform Enterprise version", - fmt.Sprintf( - `The 'cloud' option requires Terraform Enterprise %s or later.`, - apiToMinimumTFEVersion["2.5"], - ), - )) + log.Printf("[TRACE] API version check failed; want: >= %s, got: %s", desiredAPIVersion.Original(), currentAPIVersion) + // FIXME: Skip version checking temporarily. + // diags = diags.Append(tfdiags.Sourceless( + // tfdiags.Error, + // "Unsupported Terraform Enterprise version", + // fmt.Sprintf( + // `The 'cloud' option requires Terraform Enterprise %s or later.`, + // apiToMinimumTFEVersion["2.5"], + // ), + // )) } // Configure a local backend for when we need to run operations locally. diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index cd2995887..d3b5d7c5f 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -269,6 +269,7 @@ func TestCloud_config(t *testing.T) { } func TestCloud_configVerifyMinimumTFEVersion(t *testing.T) { + t.Skip("skipping, as TFE version checking has been temporarily disabled") config := cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), "organization": cty.StringVal("hashicorp"), diff --git a/internal/cloud/versioning.go b/internal/cloud/versioning.go index b07fd74aa..4b9ee52e1 100644 --- a/internal/cloud/versioning.go +++ b/internal/cloud/versioning.go @@ -7,6 +7,6 @@ package cloud // The cloud package here, introduced in Terraform 1.1.0, requires a minimum of 2.5 (v202201-1) // The TFP-API-Version header that this refers to was introduced in 2.3 (v202006-1), so an absent // header can be considered < 2.3. -var apiToMinimumTFEVersion = map[string]string{ - "2.5": "v202201-1", -} +// var apiToMinimumTFEVersion = map[string]string{ +// "2.5": "v202201-1", +// } From 85f0bc6198b021b39e8844fd0760ccfcc68d492f Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Thu, 21 Oct 2021 22:26:05 -0500 Subject: [PATCH 59/78] Update go-tfe to e52963e --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 26967a745..be6b904fe 100644 --- a/go.mod +++ b/go.mod @@ -41,7 +41,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-plugin v1.4.3 github.com/hashicorp/go-retryablehttp v0.7.0 - github.com/hashicorp/go-tfe v0.19.1-0.20211015143223-e7e0a0182bbd + github.com/hashicorp/go-tfe v0.19.1-0.20211020175229-e52963e079d0 github.com/hashicorp/go-uuid v1.0.2 github.com/hashicorp/go-version v1.2.1 github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f diff --git a/go.sum b/go.sum index f1f6be181..da6b2940b 100644 --- a/go.sum +++ b/go.sum @@ -379,8 +379,8 @@ github.com/hashicorp/go-slug v0.7.0/go.mod h1:Ib+IWBYfEfJGI1ZyXMGNbu2BU+aa3Dzu41 github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-tfe v0.19.1-0.20211015143223-e7e0a0182bbd h1:mn11v5DDNXkZq32QM8JSLNoUSbW2Ud4jMxm8IMpfS2w= -github.com/hashicorp/go-tfe v0.19.1-0.20211015143223-e7e0a0182bbd/go.mod h1:gyXLXbpBVxA2F/6opah8XBsOkZJxHYQmghl0OWi8keI= +github.com/hashicorp/go-tfe v0.19.1-0.20211020175229-e52963e079d0 h1:64o1fy8p4a9J/5Gooy1S9iuEJQqXTVGYk5ZrXwFDZ8w= +github.com/hashicorp/go-tfe v0.19.1-0.20211020175229-e52963e079d0/go.mod h1:gyXLXbpBVxA2F/6opah8XBsOkZJxHYQmghl0OWi8keI= 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.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= From cc6de251d8275ce00fb49205f4750dcbc0f830a2 Mon Sep 17 00:00:00 2001 From: Barrett Clark Date: Thu, 21 Oct 2021 15:54:41 -0500 Subject: [PATCH 60/78] Update init reconfigure error message If you move from the remote backend to the cloud block you will see this error message. --- internal/command/meta_backend.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index d72a56c1d..552ee6300 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -663,7 +663,11 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di } if !m.migrateState { - diags = diags.Append(migrateOrReconfigDiag) + if c.Type == "cloud" { + diags = diags.Append(migrateOrReconfigDiagCloud) + } else { + diags = diags.Append(migrateOrReconfigDiag) + } return nil, diags } @@ -1384,3 +1388,10 @@ var migrateOrReconfigDiag = tfdiags.Sourceless( "A change in the backend configuration has been detected, which may require migrating existing state.\n\n"+ "If you wish to attempt automatic migration of the state, use \"terraform init -migrate-state\".\n"+ `If you wish to store the current configuration with no changes to the state, use "terraform init -reconfigure".`) + +var migrateOrReconfigDiagCloud = tfdiags.Sourceless( + tfdiags.Error, + "Terraform Cloud configuration changed", + "A change in the Terraform Cloud configuration has been detected, which may require migrating existing state.\n\n"+ + "If you wish to attempt automatic migration of the state, use \"terraform init -migrate-state\".\n"+ + `If you wish to store the current configuration with no changes to the state, use "terraform init -reconfigure".`) From 1ff782741624e168cba7dd606f620ca29efcc28b Mon Sep 17 00:00:00 2001 From: Omar Ismail Date: Fri, 22 Oct 2021 10:42:58 -0400 Subject: [PATCH 61/78] Multi-state migration prompt fix --- internal/command/meta_backend_migrate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/command/meta_backend_migrate.go b/internal/command/meta_backend_migrate.go index 0ba769b0c..6cfb69be1 100644 --- a/internal/command/meta_backend_migrate.go +++ b/internal/command/meta_backend_migrate.go @@ -729,7 +729,7 @@ func (m *Meta) promptMultiStateMigrationPattern(sourceType string) (string, erro pattern, err := m.UIInput().Input(context.Background(), &terraform.InputOpts{ Id: "backend-migrate-multistate-to-tfc-pattern", Query: fmt.Sprintf("[reset][bold][yellow]%s[reset]", "What pattern would you like to add to all your workspaces?"), - Description: fmt.Sprintf(strings.TrimSpace(tfcInputBackendMigrateMultiToMultiPattern), sourceType), + Description: strings.TrimSpace(tfcInputBackendMigrateMultiToMultiPattern), }) if err != nil { return "", fmt.Errorf("Error asking for state migration action: %s", err) From 64fbb0b6137384151662fca37636d2b83d41df7e Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Fri, 22 Oct 2021 13:42:31 -0600 Subject: [PATCH 62/78] remove reference to AccessInternalTools --- internal/cloud/e2e/helper_test.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/internal/cloud/e2e/helper_test.go b/internal/cloud/e2e/helper_test.go index a9f4313ff..7af990f12 100644 --- a/internal/cloud/e2e/helper_test.go +++ b/internal/cloud/e2e/helper_test.go @@ -47,18 +47,6 @@ func createOrganization(t *testing.T) (*tfe.Organization, func()) { t.Fatal(err) } - // TODO: remove this when we are ready to release. This should not need beta - // or internal access as the release will be. Also, we won't be able to access - // admin in production. - opts := tfe.AdminOrganizationUpdateOptions{ - AccessBetaTools: tfe.Bool(true), - AccessInternalTools: tfe.Bool(true), - } - _, err = tfeClient.Admin.Organizations.Update(ctx, org.Name, opts) - if err != nil { - t.Fatal(err) - } - return org, func() { if err := tfeClient.Organizations.Delete(ctx, org.Name); err != nil { t.Errorf("Error destroying organization! WARNING: Dangling resources\n"+ From aace0015c2eb108d9bbb978a1a7618a11f943cee Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Sat, 23 Oct 2021 23:33:52 -0500 Subject: [PATCH 63/78] cloud: Fix E2E variables test --- internal/cloud/e2e/run_variables_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cloud/e2e/run_variables_test.go b/internal/cloud/e2e/run_variables_test.go index 6029066b7..f5a86c650 100644 --- a/internal/cloud/e2e/run_variables_test.go +++ b/internal/cloud/e2e/run_variables_test.go @@ -58,7 +58,7 @@ func Test_cloud_run_variables(t *testing.T) { commands: []tfCommand{ { command: []string{"init"}, - expectedCmdOutput: `Successfully configured the backend "cloud"!`, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, }, { command: []string{"plan", "-var", "foo=bar"}, From c99f5972dee6f62eaaf96ba74ab3cd4e71d19caa Mon Sep 17 00:00:00 2001 From: Nick Fagerlund Date: Fri, 22 Oct 2021 14:50:16 -0700 Subject: [PATCH 64/78] cloud.StateMgr(): Set terraform version AFTER creating workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, if the remote TFC/TFE instance doesn't happen to have a tool_version record whose name exactly matches the value of `tfversion.String()`, Terraform would be completely blocked from using the `terraform workspace new` command (when configured with the tags strategy) — the API would give a 422 to the whole create request. This commit changes the StateMgr() function to do the work in two passes; first create the workspace (which should work fine regardless), THEN update the Terraform version and print a warning to the terminal if it fails (which 99% of the time is a benign failure with little impact on your future CLI usage). --- internal/cloud/backend.go | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index e6b0ba98a..f043378f6 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -506,6 +506,7 @@ func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { } if err == tfe.ErrResourceNotFound { + // Create a workspace options := tfe.WorkspaceCreateOptions{ Name: tfe.String(name), } @@ -517,12 +518,27 @@ func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { } options.Tags = tags - options.TerraformVersion = tfe.String(tfversion.String()) - workspace, err = b.client.Workspaces.Create(context.Background(), b.organization, options) if err != nil { return nil, fmt.Errorf("Error creating workspace %s: %v", name, err) } + + // Attempt to set the new workspace to use this version of Terraform. This + // can fail if there's no enabled tool_version whose name matches our + // version string, but that's expected sometimes -- just warn and continue. + versionOptions := tfe.WorkspaceUpdateOptions{ + TerraformVersion: tfe.String(tfversion.String()), + } + _, err := b.client.Workspaces.UpdateByID(context.Background(), workspace.ID, versionOptions) + if err != nil { + // TODO: Ideally we could rely on the client to tell us what the actual + // problem was, but we currently can't get enough context from the error + // object to do a nicely formatted message, so we're just assuming the + // issue was that the version wasn't available since that's probably what + // happened. + versionUnavailable := fmt.Sprintf(unavailableTerraformVersion, tfversion.String(), workspace.TerraformVersion) + b.CLI.Output(b.Colorize().Color(versionUnavailable)) + } } // This is a fallback error check. Most code paths should use other @@ -948,6 +964,12 @@ const operationNotCanceled = ` const refreshToApplyRefresh = `[bold][yellow]Proceeding with 'terraform apply -refresh-only -auto-approve'.[reset]` +const unavailableTerraformVersion = ` +[reset][yellow]The local Terraform version (%s) is not available in Terraform Cloud, or your +organization does not have access to it. The new workspace will use %s. You can +change this later in the workspace settings.[reset] +` + var ( workspaceConfigurationHelp = fmt.Sprintf( `The 'workspaces' block configures how Terraform CLI maps its workspaces for this single From cb26cbba22737f7145559f5c36664818a9e4c9b6 Mon Sep 17 00:00:00 2001 From: Nick Fagerlund Date: Mon, 25 Oct 2021 19:32:46 -0700 Subject: [PATCH 65/78] Add tests for cloud.StateMgr() when TFC lacks local tf version Alas, there's not a very good way to test the message we're supposed to print to the console in this situation; we just don't appear to have a mock terminal that the test can read from. But we can at least test that the function returns without erroring under the exact conditions where it was erroring before. Note that the behaviors of mc.Workspaces.Update and UpdateByID were already starting to drift, so I consolidated their actual attribute update logic into a helper function before they drifted much further. --- internal/cloud/backend_test.go | 52 +++++++++++++++++++++++++++++++ internal/cloud/tfe_client_mock.go | 44 ++++++++++++++++---------- 2 files changed, 80 insertions(+), 16 deletions(-) diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index d3b5d7c5f..0b0bb316a 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -305,6 +305,58 @@ func TestCloud_configVerifyMinimumTFEVersion(t *testing.T) { } } +func TestCloud_setUnavailableTerraformVersion(t *testing.T) { + // go-tfe returns an error IRL if you try to set a Terraform version that's + // not available in your TFC instance. To test this, tfe_client_mock errors if + // you try to set any Terraform version for this specific workspace name. + workspaceName := "unavailable-terraform-version" + + config := cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("sometag"), + }, + ), + }), + }) + + b, bCleanup := testBackend(t, config) + defer bCleanup() + + // Make sure the workspace doesn't exist yet -- otherwise, we can't test what + // happens when a workspace gets created. This is why we can't use "name" in + // the backend config above, btw: if you do, testBackend() creates the default + // workspace before we get a chance to do anything. + _, err := b.client.Workspaces.Read(context.Background(), b.organization, workspaceName) + if err != tfe.ErrResourceNotFound { + t.Fatalf("the workspace we were about to try and create (%s/%s) already exists in the mocks somehow, so this test isn't trustworthy anymore", b.organization, workspaceName) + } + + _, err = b.StateMgr(workspaceName) + if err != nil { + t.Fatalf("expected no error from StateMgr, despite not being able to set remote Terraform version: %#v", err) + } + // Make sure the workspace was created: + workspace, err := b.client.Workspaces.Read(context.Background(), b.organization, workspaceName) + if err != nil { + t.Fatalf("b.StateMgr() didn't actually create the desired workspace") + } + // Make sure our mocks still error as expected, using the same update function b.StateMgr() would call: + _, err = b.client.Workspaces.UpdateByID( + context.Background(), + workspace.ID, + tfe.WorkspaceUpdateOptions{TerraformVersion: tfe.String("1.1.0")}, + ) + if err == nil { + t.Fatalf("the mocks aren't emulating a nonexistent remote Terraform version correctly, so this test isn't trustworthy anymore") + } +} + func TestCloud_setConfigurationFields(t *testing.T) { originalForceBackendEnv := os.Getenv("TF_FORCE_LOCAL_BACKEND") diff --git a/internal/cloud/tfe_client_mock.go b/internal/cloud/tfe_client_mock.go index 518602c3c..8ba6883c3 100644 --- a/internal/cloud/tfe_client_mock.go +++ b/internal/cloud/tfe_client_mock.go @@ -1159,6 +1159,10 @@ func (m *MockWorkspaces) List(ctx context.Context, organization string, options } func (m *MockWorkspaces) Create(ctx context.Context, organization string, options tfe.WorkspaceCreateOptions) (*tfe.Workspace, error) { + // for TestCloud_setUnavailableTerraformVersion + if *options.Name == "unavailable-terraform-version" && options.TerraformVersion != nil { + return nil, fmt.Errorf("requested Terraform version not available in this TFC instance") + } if strings.HasSuffix(*options.Name, "no-operations") { options.Operations = tfe.Bool(false) } else if options.Operations == nil { @@ -1234,17 +1238,9 @@ func (m *MockWorkspaces) Update(ctx context.Context, organization, workspace str return nil, tfe.ErrResourceNotFound } - if options.Operations != nil { - w.Operations = *options.Operations - } - if options.Name != nil { - w.Name = *options.Name - } - if options.TerraformVersion != nil { - w.TerraformVersion = *options.TerraformVersion - } - if options.WorkingDirectory != nil { - w.WorkingDirectory = *options.WorkingDirectory + err := updateMockWorkspaceAttributes(w, options) + if err != nil { + return nil, err } delete(m.workspaceNames, workspace) @@ -1259,6 +1255,26 @@ func (m *MockWorkspaces) UpdateByID(ctx context.Context, workspaceID string, opt return nil, tfe.ErrResourceNotFound } + err := updateMockWorkspaceAttributes(w, options) + if err != nil { + return nil, err + } + + delete(m.workspaceNames, w.Name) + m.workspaceNames[w.Name] = w + + return w, nil +} + +func updateMockWorkspaceAttributes(w *tfe.Workspace, options tfe.WorkspaceUpdateOptions) error { + // for TestCloud_setUnavailableTerraformVersion + if w.Name == "unavailable-terraform-version" && options.TerraformVersion != nil { + return fmt.Errorf("requested Terraform version not available in this TFC instance") + } + + if options.Operations != nil { + w.Operations = *options.Operations + } if options.Name != nil { w.Name = *options.Name } @@ -1268,11 +1284,7 @@ func (m *MockWorkspaces) UpdateByID(ctx context.Context, workspaceID string, opt if options.WorkingDirectory != nil { w.WorkingDirectory = *options.WorkingDirectory } - - delete(m.workspaceNames, w.Name) - m.workspaceNames[w.Name] = w - - return w, nil + return nil } func (m *MockWorkspaces) Delete(ctx context.Context, organization, workspace string) error { From 50997d9a32c40604b33bd56ca7abb2f62ffe1bb3 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Fri, 22 Oct 2021 15:08:22 -0500 Subject: [PATCH 66/78] Restore API version checking, with internal usage accounted for --- internal/cloud/backend.go | 39 +++++++++++++++++++++++--------- internal/cloud/backend_cli.go | 1 + internal/cloud/backend_test.go | 41 ++++++++++++++++++++++++++++++++-- internal/cloud/versioning.go | 12 ---------- 4 files changed, 68 insertions(+), 25 deletions(-) delete mode 100644 internal/cloud/versioning.go diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index f043378f6..786309fed 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -85,6 +85,8 @@ type Cloud struct { // version. This will also cause VerifyWorkspaceTerraformVersion to return // a warning diagnostic instead of an error. ignoreVersionConflict bool + + runningInAutomation bool } var _ backend.Backend = (*Cloud)(nil) @@ -296,15 +298,25 @@ func (b *Cloud) Configure(obj cty.Value) tfdiags.Diagnostics { if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { log.Printf("[TRACE] API version check failed; want: >= %s, got: %s", desiredAPIVersion.Original(), currentAPIVersion) - // FIXME: Skip version checking temporarily. - // diags = diags.Append(tfdiags.Sourceless( - // tfdiags.Error, - // "Unsupported Terraform Enterprise version", - // fmt.Sprintf( - // `The 'cloud' option requires Terraform Enterprise %s or later.`, - // apiToMinimumTFEVersion["2.5"], - // ), - // )) + if b.runningInAutomation { + // It should never be possible for this Terraform process to be mistakenly + // used internally within an unsupported Terraform Enterprise install - but + // just in case it happens, give an actionable error. + diags = diags.Append( + tfdiags.Sourceless( + tfdiags.Error, + "Unsupported Terraform Enterprise version", + cloudIntegrationUsedInUnsupportedTFE, + ), + ) + } else { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Unsupported Terraform Enterprise version", + `The 'cloud' option is not supported with this version of Terraform Enterprise.`, + ), + ) + } } // Configure a local backend for when we need to run operations locally. @@ -967,8 +979,13 @@ const refreshToApplyRefresh = `[bold][yellow]Proceeding with 'terraform apply -r const unavailableTerraformVersion = ` [reset][yellow]The local Terraform version (%s) is not available in Terraform Cloud, or your organization does not have access to it. The new workspace will use %s. You can -change this later in the workspace settings.[reset] -` +change this later in the workspace settings.[reset]` + +const cloudIntegrationUsedInUnsupportedTFE = ` +This version of Terraform Cloud/Enterprise does not support the state mechanism +attempting to be used by the platform. This should never happen. + +Please reach out to HashiCorp Support to resolve this issue.` var ( workspaceConfigurationHelp = fmt.Sprintf( diff --git a/internal/cloud/backend_cli.go b/internal/cloud/backend_cli.go index 8dac9bf3e..cd0549616 100644 --- a/internal/cloud/backend_cli.go +++ b/internal/cloud/backend_cli.go @@ -15,6 +15,7 @@ func (b *Cloud) CLIInit(opts *backend.CLIOpts) error { b.CLI = opts.CLI b.CLIColor = opts.CLIColor b.ContextOpts = opts.ContextOpts + b.runningInAutomation = opts.RunningInAutomation return nil } diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index 0b0bb316a..87f7a91f2 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -269,7 +269,6 @@ func TestCloud_config(t *testing.T) { } func TestCloud_configVerifyMinimumTFEVersion(t *testing.T) { - t.Skip("skipping, as TFE version checking has been temporarily disabled") config := cty.ObjectVal(map[string]cty.Value{ "hostname": cty.NullVal(cty.String), "organization": cty.StringVal("hashicorp"), @@ -299,7 +298,45 @@ func TestCloud_configVerifyMinimumTFEVersion(t *testing.T) { t.Fatalf("expected configure to error") } - expected := "The 'cloud' option requires Terraform Enterprise v202201-1 or later." + expected := `The 'cloud' option is not supported with this version of Terraform Enterprise.` + if !strings.Contains(confDiags.Err().Error(), expected) { + t.Fatalf("expected configure to error with %q, got %q", expected, confDiags.Err().Error()) + } +} + +func TestCloud_configVerifyMinimumTFEVersionInAutomation(t *testing.T) { + config := cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "tags": cty.SetVal( + []cty.Value{ + cty.StringVal("billing"), + }, + ), + }), + }) + + handlers := map[string]func(http.ResponseWriter, *http.Request){ + "/api/v2/ping": func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("TFP-API-Version", "2.4") + }, + } + s := testServerWithHandlers(handlers) + + b := New(testDisco(s)) + b.runningInAutomation = true + + confDiags := b.Configure(config) + if confDiags.Err() == nil { + t.Fatalf("expected configure to error") + } + + expected := `This version of Terraform Cloud/Enterprise does not support the state mechanism +attempting to be used by the platform. This should never happen.` if !strings.Contains(confDiags.Err().Error(), expected) { t.Fatalf("expected configure to error with %q, got %q", expected, confDiags.Err().Error()) } diff --git a/internal/cloud/versioning.go b/internal/cloud/versioning.go deleted file mode 100644 index 4b9ee52e1..000000000 --- a/internal/cloud/versioning.go +++ /dev/null @@ -1,12 +0,0 @@ -package cloud - -// This simple map exists to translate TFP-API-Version strings to the TFE release where it was -// introduced, to provide actionable feedback on features that may be unsupported by the TFE -// installation but present in this version of Terraform. -// -// The cloud package here, introduced in Terraform 1.1.0, requires a minimum of 2.5 (v202201-1) -// The TFP-API-Version header that this refers to was introduced in 2.3 (v202006-1), so an absent -// header can be considered < 2.3. -// var apiToMinimumTFEVersion = map[string]string{ -// "2.5": "v202201-1", -// } From fabd8eb0b63b1489e24c7be95636611e29fb8696 Mon Sep 17 00:00:00 2001 From: Nick Fagerlund Date: Tue, 26 Oct 2021 17:02:28 -0700 Subject: [PATCH 67/78] Fix broken rename behavior in tfe_client_mock.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delete + assign at the end of `Update` and `UpdateByID` are meant to handle renaming a workspace — (remove old name), (insert new name). However, `UpdateByID` was doing (remove new name), (insert new name) and leaving the old name in place. This commit changes it to match `Update` by grabbing the original name off the workspace object _before_ potentially renaming it. --- internal/cloud/tfe_client_mock.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/cloud/tfe_client_mock.go b/internal/cloud/tfe_client_mock.go index 8ba6883c3..ecc3b9add 100644 --- a/internal/cloud/tfe_client_mock.go +++ b/internal/cloud/tfe_client_mock.go @@ -1255,12 +1255,13 @@ func (m *MockWorkspaces) UpdateByID(ctx context.Context, workspaceID string, opt return nil, tfe.ErrResourceNotFound } + originalName := w.Name err := updateMockWorkspaceAttributes(w, options) if err != nil { return nil, err } - delete(m.workspaceNames, w.Name) + delete(m.workspaceNames, originalName) m.workspaceNames[w.Name] = w return w, nil From 2c0294c7e37fbe3896bb0fbae7b86292af721018 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Wed, 27 Oct 2021 17:05:36 -0500 Subject: [PATCH 68/78] Tweak configuration copy for TFC This aligns more with the existing copy for backends --- internal/command/meta_backend.go | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index 552ee6300..8746c4366 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -597,11 +597,11 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di log.Printf("[TRACE] Meta.Backend: moving from default local state only to %q backend", c.Type) if !opts.Init { if c.Type == "cloud" { - // NOTE: There may be an implicit local backend with state that is not visible to this block. + initReason := "Initial configuration of Terraform Cloud" diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, - "Terraform Cloud has been configured but needs to be initialized.", - strings.TrimSpace(errBackendInitCloud), + "Terraform Cloud initialization required, please run \"terraform init\"", + fmt.Sprintf(strings.TrimSpace(errBackendInitCloud), initReason), )) } else { initReason := fmt.Sprintf("Initial configuration of the requested backend %q", c.Type) @@ -649,8 +649,8 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di if c.Type == "cloud" { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, - "Terraform Cloud has been configured but needs to be initialized.", - strings.TrimSpace(errBackendInitCloud), + "Terraform Cloud initialization required, please run \"terraform init\"", + fmt.Sprintf(strings.TrimSpace(errBackendInitCloud), initReason), )) } else { diags = diags.Append(tfdiags.Sourceless( @@ -1328,17 +1328,12 @@ configuration or state have been made. ` const errBackendInitCloud = ` -Changes to the Terraform Cloud configuration block require reinitialization. -This allows Terraform to set up the new configuration, copy existing state, -etc. Learn more about Terraform Settings: -https://www.terraform.io/docs/language/settings/index.html +Reason: %s +Changes to the Terraform Cloud configuration block require reinitialization. +This allows Terraform to set up the new configuration, copy existing state, etc. Please run "terraform init" with either the "-reconfigure" or "-migrate-state" -flags. The "-reconfigure" option disregards any existing configuration, -preventing migration of any existing state. The "-migrate-state" option -will attempt to copy existing state to Terraform Cloud. Learn more about -using "terraform init": -https://www.terraform.io/docs/cli/commands/init.html#backend-initialization +flags to use the current configuration. If the change reason above is incorrect, please verify your configuration hasn't changed and try again. At this point, no changes to your existing From f5366468b4a201718d668834a54ff84eb38f643e Mon Sep 17 00:00:00 2001 From: Barrett Clark Date: Wed, 27 Oct 2021 16:27:12 -0500 Subject: [PATCH 69/78] Cloud Backend reference migrating away from TFC --- internal/command/meta_backend.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index 8746c4366..4167964a9 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -779,7 +779,11 @@ func (m *Meta) backend_c_r_S(c *configs.Backend, cHash int, sMgr *clistate.Local // Get the backend type for output backendType := s.Backend.Type - m.Ui.Output(fmt.Sprintf(strings.TrimSpace(outputBackendMigrateLocal), s.Backend.Type)) + if s.Backend.Type == "cloud" { + m.Ui.Output(strings.TrimSpace(outputBackendMigrateLocalFromCloud)) + } else { + m.Ui.Output(fmt.Sprintf(strings.TrimSpace(outputBackendMigrateLocal), s.Backend.Type)) + } // Grab a purely local backend to get the local state if it exists localB, diags := m.Backend(&BackendOpts{ForceLocal: true, Init: true}) @@ -1360,6 +1364,9 @@ Terraform detected that the backend type changed from %q to Terraform Cloud. const outputBackendMigrateLocal = ` Terraform has detected you're unconfiguring your previously set %q backend. ` +const outputBackendMigrateLocalFromCloud = ` +Terraform has detected you're unconfiguring Terraform Cloud. +` const outputBackendReconfigure = ` [reset][bold]Backend configuration changed![reset] From fc5863844c901ddaffbd6b1a4118662e9da910ce Mon Sep 17 00:00:00 2001 From: Omar Ismail Date: Wed, 27 Oct 2021 11:14:54 -0400 Subject: [PATCH 70/78] Cloud migration: ignore backend version check when empty worksapces --- internal/cloud/backend.go | 6 +- internal/cloud/e2e/helper_test.go | 7 ++ .../e2e/migrate_state_tfc_to_tfc_test.go | 76 ++++++++++++++++++- internal/command/meta_backend_migrate.go | 4 +- 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 786309fed..1a5607f58 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -548,8 +548,10 @@ func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { // object to do a nicely formatted message, so we're just assuming the // issue was that the version wasn't available since that's probably what // happened. - versionUnavailable := fmt.Sprintf(unavailableTerraformVersion, tfversion.String(), workspace.TerraformVersion) - b.CLI.Output(b.Colorize().Color(versionUnavailable)) + if b.CLI != nil { + versionUnavailable := fmt.Sprintf(unavailableTerraformVersion, tfversion.String(), workspace.TerraformVersion) + b.CLI.Output(b.Colorize().Color(versionUnavailable)) + } } } diff --git a/internal/cloud/e2e/helper_test.go b/internal/cloud/e2e/helper_test.go index 7af990f12..0b0a6be58 100644 --- a/internal/cloud/e2e/helper_test.go +++ b/internal/cloud/e2e/helper_test.go @@ -47,6 +47,13 @@ func createOrganization(t *testing.T) (*tfe.Organization, func()) { t.Fatal(err) } + _, err = tfeClient.Admin.Organizations.Update(ctx, org.Name, tfe.AdminOrganizationUpdateOptions{ + AccessBetaTools: tfe.Bool(true), + }) + if err != nil { + t.Fatal(err) + } + return org, func() { if err := tfeClient.Organizations.Delete(ctx, org.Name); err != nil { t.Errorf("Error destroying organization! WARNING: Dangling resources\n"+ diff --git a/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go b/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go index ab5d908ed..342ee2f3c 100644 --- a/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go @@ -5,6 +5,7 @@ package main import ( "context" + "fmt" "io/ioutil" "os" "testing" @@ -16,7 +17,7 @@ import ( func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { ctx := context.Background() - tfVersion := "1.1.0-tfc-integration" + tfVersion := "1.1.0-alpha-20211027-dev-e51508be" if !hasTerraformVersion(t, tfVersion) { t.Skip("Skipping test because TFC does not have current terraform version.") } @@ -162,6 +163,75 @@ func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { } }, }, + "migrating from name to tags without ignore-version flag": { + setup: func(t *testing.T) (string, func()) { + organization, cleanup := createOrganization(t) + return organization.Name, cleanup + }, + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "prod" + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String("prod"), + TerraformVersion: tfe.String(tfVersion), + }) + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Terraform Cloud has been successfully initialized!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions in workspace "prod"?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + // This is only here to ensure that the updated terraform version is + // present in the workspace, and it does not default to a lower + // version that does not support `cloud`. + _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ + Name: tfe.String("new-workspace"), + TerraformVersion: tfe.String(tfVersion), + }) + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `The Terraform Cloud configuration only allows named workspaces!`, + expectError: true, + userInput: []string{"new-workspace", "yes"}, + postInputOutput: []string{ + // this is a temporary measure till we resolve some of the + // version mismatching. + fmt.Sprintf(`Remote workspace Terraform version "%s" does not match local Terraform version`, tfVersion)}, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{ + Tags: tfe.String("app"), + }) + if err != nil { + t.Fatal(err) + } + // The migration never occured, so we have no workspaces with this tag. + if len(wsList.Items) != 0 { + t.Fatalf("Expected number of workspaces to be 0, but got %d", len(wsList.Items)) + } + }, + }, } for name, tc := range cases { @@ -225,7 +295,7 @@ func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { } err = cmd.Wait() - if err != nil { + if err != nil && !tfCmd.expectError { t.Fatal(err.Error()) } } @@ -239,7 +309,7 @@ func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { func Test_migrate_tfc_to_tfc_multiple_workspace(t *testing.T) { ctx := context.Background() - tfVersion := "1.1.0-tfc-integration" + tfVersion := "1.1.0-alpha-20211027-dev-e51508be" if !hasTerraformVersion(t, tfVersion) { t.Skip("Skipping test because TFC does not have current terraform version.") } diff --git a/internal/command/meta_backend_migrate.go b/internal/command/meta_backend_migrate.go index 6cfb69be1..a4c0578cc 100644 --- a/internal/command/meta_backend_migrate.go +++ b/internal/command/meta_backend_migrate.go @@ -86,7 +86,9 @@ func (m *Meta) backendMigrateState(opts *backendMigrateOpts) error { } // If there are no specified destination workspaces, perform a remote // backend version check with the default workspace. - if len(destinationWorkspaces) == 0 { + // Ensure that we are not dealing with Terraform Cloud migrations, as it + // does not support the default name. + if len(destinationWorkspaces) == 0 && !destinationTFC { diags := m.remoteVersionCheck(opts.Destination, backend.DefaultStateName) if diags.HasErrors() { return diags.Err() From e364ef2905579ff8e71e8a03ff89736e68e887c6 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Thu, 28 Oct 2021 00:35:58 -0500 Subject: [PATCH 71/78] cloud/e2e: Disable cost estimation E2E tests including cost estimation should indeed be added, but the default case should be disabled; lots of cycles lost to pointless cost estimates on null and random resources. --- internal/cloud/e2e/helper_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/cloud/e2e/helper_test.go b/internal/cloud/e2e/helper_test.go index 0b0a6be58..c8cccc0f1 100644 --- a/internal/cloud/e2e/helper_test.go +++ b/internal/cloud/e2e/helper_test.go @@ -40,8 +40,9 @@ type testCases map[string]struct { func createOrganization(t *testing.T) (*tfe.Organization, func()) { ctx := context.Background() org, err := tfeClient.Organizations.Create(ctx, tfe.OrganizationCreateOptions{ - Name: tfe.String("tst-" + randomString(t)), - Email: tfe.String(fmt.Sprintf("%s@tfe.local", randomString(t))), + Name: tfe.String("tst-" + randomString(t)), + Email: tfe.String(fmt.Sprintf("%s@tfe.local", randomString(t))), + CostEstimationEnabled: tfe.Bool(false), }) if err != nil { t.Fatal(err) From f881c2d7940e12d61cf270fa4f0b8e160c6962df Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Wed, 27 Oct 2021 18:07:12 -0500 Subject: [PATCH 72/78] cloud: Skip E2E tests without an exact version --- internal/cloud/e2e/apply_auto_approve_test.go | 15 ++++---- .../e2e/backend_apply_before_init_test.go | 2 ++ internal/cloud/e2e/helper_test.go | 8 +++-- internal/cloud/e2e/main_test.go | 8 ++++- .../e2e/migrate_state_multi_to_tfc_test.go | 4 +++ ...igrate_state_remote_backend_to_tfc_test.go | 10 ++++++ .../e2e/migrate_state_single_to_tfc_test.go | 2 ++ .../e2e/migrate_state_tfc_to_tfc_test.go | 34 ++++++++----------- internal/cloud/e2e/run_variables_test.go | 2 ++ 9 files changed, 55 insertions(+), 30 deletions(-) diff --git a/internal/cloud/e2e/apply_auto_approve_test.go b/internal/cloud/e2e/apply_auto_approve_test.go index 626f4c272..535be368a 100644 --- a/internal/cloud/e2e/apply_auto_approve_test.go +++ b/internal/cloud/e2e/apply_auto_approve_test.go @@ -13,14 +13,13 @@ import ( expect "github.com/Netflix/go-expect" tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/terraform/internal/e2e" + tfversion "github.com/hashicorp/terraform/version" ) func Test_terraform_apply_autoApprove(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + ctx := context.Background() - tfVersion := "1.1.0-tfc-integration" - if !hasTerraformVersion(t, tfVersion) { - t.Skip("Skipping test because TFC does not have current terraform version.") - } cases := map[string]struct { operations []operationSets @@ -33,7 +32,7 @@ func Test_terraform_apply_autoApprove(t *testing.T) { wsName := "app" _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ Name: tfe.String(wsName), - TerraformVersion: tfe.String(tfVersion), + TerraformVersion: tfe.String(tfversion.String()), AutoApply: tfe.Bool(false), }) tfBlock := terraformConfigCloudBackendName(orgName, wsName) @@ -73,7 +72,7 @@ func Test_terraform_apply_autoApprove(t *testing.T) { wsName := "app" _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ Name: tfe.String(wsName), - TerraformVersion: tfe.String(tfVersion), + TerraformVersion: tfe.String(tfversion.String()), AutoApply: tfe.Bool(true), }) tfBlock := terraformConfigCloudBackendName(orgName, wsName) @@ -113,7 +112,7 @@ func Test_terraform_apply_autoApprove(t *testing.T) { wsName := "app" _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ Name: tfe.String(wsName), - TerraformVersion: tfe.String(tfVersion), + TerraformVersion: tfe.String(tfversion.String()), AutoApply: tfe.Bool(false), }) tfBlock := terraformConfigCloudBackendName(orgName, wsName) @@ -151,7 +150,7 @@ func Test_terraform_apply_autoApprove(t *testing.T) { wsName := "app" _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ Name: tfe.String(wsName), - TerraformVersion: tfe.String(tfVersion), + TerraformVersion: tfe.String(tfversion.String()), AutoApply: tfe.Bool(true), }) tfBlock := terraformConfigCloudBackendName(orgName, wsName) diff --git a/internal/cloud/e2e/backend_apply_before_init_test.go b/internal/cloud/e2e/backend_apply_before_init_test.go index 86f2f515c..213a48f0f 100644 --- a/internal/cloud/e2e/backend_apply_before_init_test.go +++ b/internal/cloud/e2e/backend_apply_before_init_test.go @@ -14,6 +14,8 @@ import ( ) func Test_backend_apply_before_init(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + cases := map[string]struct { operations []operationSets }{ diff --git a/internal/cloud/e2e/helper_test.go b/internal/cloud/e2e/helper_test.go index c8cccc0f1..0f4480ddf 100644 --- a/internal/cloud/e2e/helper_test.go +++ b/internal/cloud/e2e/helper_test.go @@ -12,6 +12,7 @@ import ( tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/go-uuid" + tfversion "github.com/hashicorp/terraform/version" ) const ( @@ -193,7 +194,8 @@ func writeMainTF(t *testing.T, block string, dir string) { } // Ensure that TFC/E has a particular terraform version. -func hasTerraformVersion(t *testing.T, version string) bool { +func skipWithoutRemoteTerraformVersion(t *testing.T) { + version := tfversion.String() opts := tfe.AdminTerraformVersionsListOptions{ ListOptions: tfe.ListOptions{ PageNumber: 1, @@ -226,5 +228,7 @@ findTfVersion: opts.PageNumber = tfVersionList.NextPage } - return hasVersion + if !hasVersion { + t.Skip(fmt.Sprintf("Skipping test because TFC/E does not have current Terraform version to test with (%s)", version)) + } } diff --git a/internal/cloud/e2e/main_test.go b/internal/cloud/e2e/main_test.go index c13151320..48e40b81f 100644 --- a/internal/cloud/e2e/main_test.go +++ b/internal/cloud/e2e/main_test.go @@ -13,6 +13,7 @@ import ( "testing" tfe "github.com/hashicorp/go-tfe" + tfversion "github.com/hashicorp/terraform/version" ) var terraformBin string @@ -97,7 +98,12 @@ func setupBinary() func() { log.Fatal(err) } - cmd := exec.Command("go", "build", "-o", tmpTerraformBinaryDir) + cmd := exec.Command( + "go", + "build", + "-o", tmpTerraformBinaryDir, + "-ldflags", fmt.Sprintf("-X \"github.com/hashicorp/terraform/version.Prerelease=%s\"", tfversion.Prerelease), + ) err = cmd.Run() if err != nil { log.Fatal(err) diff --git a/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go index c9b837019..6fda558f3 100644 --- a/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go @@ -15,6 +15,8 @@ import ( ) func Test_migrate_multi_to_tfc_cloud_name_strategy(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + ctx := context.Background() cases := map[string]struct { @@ -242,6 +244,8 @@ func Test_migrate_multi_to_tfc_cloud_name_strategy(t *testing.T) { } func Test_migrate_multi_to_tfc_cloud_tags_strategy(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + ctx := context.Background() cases := map[string]struct { diff --git a/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go b/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go index e6402e876..c9fe01976 100644 --- a/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go @@ -15,6 +15,8 @@ import ( ) func Test_migrate_remote_backend_name_to_tfc_name(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + ctx := context.Background() cases := map[string]struct { operations []operationSets @@ -197,6 +199,8 @@ func Test_migrate_remote_backend_name_to_tfc_name(t *testing.T) { } func Test_migrate_remote_backend_name_to_tfc_name_different_org(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + ctx := context.Background() cases := map[string]struct { operations []operationSets @@ -334,6 +338,8 @@ func Test_migrate_remote_backend_name_to_tfc_name_different_org(t *testing.T) { } func Test_migrate_remote_backend_name_to_tfc_tags(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + ctx := context.Background() cases := map[string]struct { operations []operationSets @@ -477,6 +483,8 @@ func Test_migrate_remote_backend_name_to_tfc_tags(t *testing.T) { } func Test_migrate_remote_backend_prefix_to_tfc_name(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + ctx := context.Background() cases := map[string]struct { operations []operationSets @@ -695,6 +703,8 @@ func Test_migrate_remote_backend_prefix_to_tfc_name(t *testing.T) { } func Test_migrate_remote_backend_prefix_to_tfc_tags(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + ctx := context.Background() cases := map[string]struct { operations []operationSets diff --git a/internal/cloud/e2e/migrate_state_single_to_tfc_test.go b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go index d8423f428..c060ee63e 100644 --- a/internal/cloud/e2e/migrate_state_single_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go @@ -15,6 +15,8 @@ import ( ) func Test_migrate_single_to_tfc(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + ctx := context.Background() cases := map[string]struct { diff --git a/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go b/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go index 342ee2f3c..3c48e6f3d 100644 --- a/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go @@ -13,14 +13,12 @@ import ( expect "github.com/Netflix/go-expect" tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/terraform/internal/e2e" + tfversion "github.com/hashicorp/terraform/version" ) func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) ctx := context.Background() - tfVersion := "1.1.0-alpha-20211027-dev-e51508be" - if !hasTerraformVersion(t, tfVersion) { - t.Skip("Skipping test because TFC does not have current terraform version.") - } cases := map[string]struct { setup func(t *testing.T) (string, func()) @@ -42,7 +40,7 @@ func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { // terraform version of this current branch. _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ Name: tfe.String("prod"), - TerraformVersion: tfe.String(tfVersion), + TerraformVersion: tfe.String(tfversion.String()), }) tfBlock := terraformConfigCloudBackendName(orgName, wsName) writeMainTF(t, tfBlock, dir) @@ -69,7 +67,7 @@ func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { wsName := "dev" _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ Name: tfe.String(wsName), - TerraformVersion: tfe.String(tfVersion), + TerraformVersion: tfe.String(tfversion.String()), }) tfBlock := terraformConfigCloudBackendName(orgName, wsName) writeMainTF(t, tfBlock, dir) @@ -110,7 +108,7 @@ func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { wsName := "prod" _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ Name: tfe.String("prod"), - TerraformVersion: tfe.String(tfVersion), + TerraformVersion: tfe.String(tfversion.String()), }) tfBlock := terraformConfigCloudBackendName(orgName, wsName) writeMainTF(t, tfBlock, dir) @@ -174,7 +172,7 @@ func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { wsName := "prod" _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ Name: tfe.String("prod"), - TerraformVersion: tfe.String(tfVersion), + TerraformVersion: tfe.String(tfversion.String()), }) tfBlock := terraformConfigCloudBackendName(orgName, wsName) writeMainTF(t, tfBlock, dir) @@ -200,7 +198,7 @@ func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { // version that does not support `cloud`. _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ Name: tfe.String("new-workspace"), - TerraformVersion: tfe.String(tfVersion), + TerraformVersion: tfe.String(tfversion.String()), }) tfBlock := terraformConfigCloudBackendTags(orgName, tag) writeMainTF(t, tfBlock, dir) @@ -214,7 +212,7 @@ func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { postInputOutput: []string{ // this is a temporary measure till we resolve some of the // version mismatching. - fmt.Sprintf(`Remote workspace Terraform version "%s" does not match local Terraform version`, tfVersion)}, + fmt.Sprintf(`Remote workspace Terraform version "%s" does not match local Terraform version`, tfversion.String())}, }, }, }, @@ -308,11 +306,9 @@ func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { } func Test_migrate_tfc_to_tfc_multiple_workspace(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + ctx := context.Background() - tfVersion := "1.1.0-alpha-20211027-dev-e51508be" - if !hasTerraformVersion(t, tfVersion) { - t.Skip("Skipping test because TFC does not have current terraform version.") - } cases := map[string]struct { setup func(t *testing.T) (string, func()) @@ -331,12 +327,12 @@ func Test_migrate_tfc_to_tfc_multiple_workspace(t *testing.T) { _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ Name: tfe.String("app-prod"), Tags: []*tfe.Tag{{Name: tag}}, - TerraformVersion: tfe.String(tfVersion), + TerraformVersion: tfe.String(tfversion.String()), }) _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ Name: tfe.String("app-staging"), Tags: []*tfe.Tag{{Name: tag}}, - TerraformVersion: tfe.String(tfVersion), + TerraformVersion: tfe.String(tfversion.String()), }) tfBlock := terraformConfigCloudBackendTags(orgName, tag) writeMainTF(t, tfBlock, dir) @@ -378,7 +374,7 @@ func Test_migrate_tfc_to_tfc_multiple_workspace(t *testing.T) { // using the right version for post init operations. _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ Name: tfe.String(name), - TerraformVersion: tfe.String(tfVersion), + TerraformVersion: tfe.String(tfversion.String()), }) tfBlock := terraformConfigCloudBackendName(orgName, name) writeMainTF(t, tfBlock, dir) @@ -425,12 +421,12 @@ func Test_migrate_tfc_to_tfc_multiple_workspace(t *testing.T) { _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ Name: tfe.String("app-prod"), Tags: []*tfe.Tag{{Name: tag}}, - TerraformVersion: tfe.String(tfVersion), + TerraformVersion: tfe.String(tfversion.String()), }) _ = createWorkspace(t, orgName, tfe.WorkspaceCreateOptions{ Name: tfe.String("app-staging"), Tags: []*tfe.Tag{{Name: tag}}, - TerraformVersion: tfe.String(tfVersion), + TerraformVersion: tfe.String(tfversion.String()), }) tfBlock := terraformConfigCloudBackendTags(orgName, tag) writeMainTF(t, tfBlock, dir) diff --git a/internal/cloud/e2e/run_variables_test.go b/internal/cloud/e2e/run_variables_test.go index f5a86c650..c92f22e9b 100644 --- a/internal/cloud/e2e/run_variables_test.go +++ b/internal/cloud/e2e/run_variables_test.go @@ -46,6 +46,8 @@ output "test_env" { } func Test_cloud_run_variables(t *testing.T) { + skipWithoutRemoteTerraformVersion(t) + cases := testCases{ "run variables from CLI arg": { operations: []operationSets{ From 652a68715f4b78f7d157fc5ec58381dbaa2e9e11 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Thu, 28 Oct 2021 01:33:18 -0500 Subject: [PATCH 73/78] cloud/e2e: Misc test fixes --- internal/cloud/e2e/backend_apply_before_init_test.go | 4 ++-- internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go | 5 ----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/internal/cloud/e2e/backend_apply_before_init_test.go b/internal/cloud/e2e/backend_apply_before_init_test.go index 213a48f0f..702b79bbc 100644 --- a/internal/cloud/e2e/backend_apply_before_init_test.go +++ b/internal/cloud/e2e/backend_apply_before_init_test.go @@ -30,7 +30,7 @@ func Test_backend_apply_before_init(t *testing.T) { commands: []tfCommand{ { command: []string{"apply"}, - expectedCmdOutput: `Terraform Cloud has been configured but needs to be initialized`, + expectedCmdOutput: `Terraform Cloud initialization required, please run "terraform init"`, expectError: true, }, }, @@ -66,7 +66,7 @@ func Test_backend_apply_before_init(t *testing.T) { commands: []tfCommand{ { command: []string{"apply"}, - expectedCmdOutput: `Terraform Cloud has been configured but needs to be initialized`, + expectedCmdOutput: `Terraform Cloud initialization required, please run "terraform init"`, expectError: true, }, }, diff --git a/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go b/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go index 3c48e6f3d..6d086de5b 100644 --- a/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go @@ -5,7 +5,6 @@ package main import ( "context" - "fmt" "io/ioutil" "os" "testing" @@ -209,10 +208,6 @@ func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { expectedCmdOutput: `The Terraform Cloud configuration only allows named workspaces!`, expectError: true, userInput: []string{"new-workspace", "yes"}, - postInputOutput: []string{ - // this is a temporary measure till we resolve some of the - // version mismatching. - fmt.Sprintf(`Remote workspace Terraform version "%s" does not match local Terraform version`, tfversion.String())}, }, }, }, From 3d23a8e06299f49b9bde340e991446112a453e7c Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Wed, 27 Oct 2021 23:52:40 -0500 Subject: [PATCH 74/78] cloud: Fix version check regression When the 'select the exact version if possible' behavior was added, the version check below it was never updated to take the newly updated version in to account, resulting in a failed version check even as the remote workspace updated to the correct version necessary. --- internal/cloud/backend.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 1a5607f58..caf6ffe30 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -504,6 +504,8 @@ func (b *Cloud) DeleteWorkspace(name string) error { // StateMgr implements backend.Enhanced. func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { + var remoteTFVersion string + if name == backend.DefaultStateName { return nil, backend.ErrDefaultWorkspaceNotSupported } @@ -516,6 +518,9 @@ func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { if err != nil && err != tfe.ErrResourceNotFound { return nil, fmt.Errorf("Failed to retrieve workspace %s: %v", name, err) } + if workspace != nil { + remoteTFVersion = workspace.TerraformVersion + } if err == tfe.ErrResourceNotFound { // Create a workspace @@ -530,11 +535,14 @@ func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { } options.Tags = tags + log.Printf("[TRACE] cloud: Creating Terraform Cloud workspace %s/%s", b.organization, name) workspace, err = b.client.Workspaces.Create(context.Background(), b.organization, options) if err != nil { return nil, fmt.Errorf("Error creating workspace %s: %v", name, err) } + remoteTFVersion = workspace.TerraformVersion + // Attempt to set the new workspace to use this version of Terraform. This // can fail if there's no enabled tool_version whose name matches our // version string, but that's expected sometimes -- just warn and continue. @@ -542,12 +550,15 @@ func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { TerraformVersion: tfe.String(tfversion.String()), } _, err := b.client.Workspaces.UpdateByID(context.Background(), workspace.ID, versionOptions) - if err != nil { + if err == nil { + remoteTFVersion = tfversion.String() + } else { // TODO: Ideally we could rely on the client to tell us what the actual // problem was, but we currently can't get enough context from the error // object to do a nicely formatted message, so we're just assuming the // issue was that the version wasn't available since that's probably what // happened. + log.Printf("[TRACE] cloud: Attempted to select version %s for TFC workspace; unavailable, so %s will be used instead.", tfversion.String(), workspace.TerraformVersion) if b.CLI != nil { versionUnavailable := fmt.Sprintf(unavailableTerraformVersion, tfversion.String(), workspace.TerraformVersion) b.CLI.Output(b.Colorize().Color(versionUnavailable)) @@ -561,11 +572,10 @@ func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { // accidentally upgrade state with a new code path, and the version check // logic is coarser and simpler. if !b.ignoreVersionConflict { - wsv := workspace.TerraformVersion // Explicitly ignore the pseudo-version "latest" here, as it will cause // plan and apply to always fail. - if wsv != tfversion.String() && wsv != "latest" { - return nil, fmt.Errorf("Remote workspace Terraform version %q does not match local Terraform version %q", workspace.TerraformVersion, tfversion.String()) + if remoteTFVersion != tfversion.String() && remoteTFVersion != "latest" { + return nil, fmt.Errorf("Remote workspace Terraform version %q does not match local Terraform version %q", remoteTFVersion, tfversion.String()) } } From 6d9c919f55227a05264942b18a717cedbb65b8f2 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Wed, 27 Oct 2021 21:23:19 -0600 Subject: [PATCH 75/78] adds X-Terraform-Integration header This can help differentiate cloud integration API requests from normal remote backend requests --- internal/cloud/backend.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index caf6ffe30..7ae10b8cb 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -36,6 +36,8 @@ const ( defaultHostname = "app.terraform.io" defaultParallelism = 10 tfeServiceID = "tfe.v2" + headerSourceKey = "X-Terraform-Integration" + headerSourceValue = "cloud" ) // Cloud is an implementation of EnhancedBackend in service of the Terraform Cloud/Enterprise @@ -254,6 +256,7 @@ func (b *Cloud) Configure(obj cty.Value) tfdiags.Diagnostics { // Set the version header to the current version. cfg.Headers.Set(tfversion.Header, tfversion.Version) + cfg.Headers.Set(headerSourceKey, headerSourceValue) // Create the TFC/E API client. b.client, err = tfe.NewClient(cfg) From 14260a5b4cd293d4a0ffae947ebb13c7173803cf Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Thu, 28 Oct 2021 10:37:06 -0500 Subject: [PATCH 76/78] Adjust default workspace naming prompt The previous version is a little overdramatic and somewhat accusatory. Just lay it out how it is: TFC needs workspaces to be named. --- internal/cloud/e2e/migrate_state_multi_to_tfc_test.go | 2 +- .../cloud/e2e/migrate_state_remote_backend_to_tfc_test.go | 4 ++-- internal/cloud/e2e/migrate_state_single_to_tfc_test.go | 2 +- internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go | 4 ++-- internal/command/meta_backend_migrate.go | 5 ++--- 5 files changed, 8 insertions(+), 9 deletions(-) diff --git a/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go index 6fda558f3..a0a6b7430 100644 --- a/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go @@ -307,7 +307,7 @@ func Test_migrate_multi_to_tfc_cloud_tags_strategy(t *testing.T) { commands: []tfCommand{ { command: []string{"init", "-migrate-state"}, - expectedCmdOutput: `Terraform Cloud configuration only allows named workspaces!`, + expectedCmdOutput: `Terraform Cloud requires all workspaces to be given an explicit name.`, userInput: []string{"dev", "1", "app-*", "1"}, postInputOutput: []string{ `Would you like to rename your workspaces?`, diff --git a/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go b/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go index c9fe01976..7f0a6fbc8 100644 --- a/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go @@ -379,7 +379,7 @@ func Test_migrate_remote_backend_name_to_tfc_tags(t *testing.T) { commands: []tfCommand{ { command: []string{"init", "-migrate-state", "-ignore-remote-version"}, - expectedCmdOutput: `Terraform Cloud configuration only allows named workspaces!`, + expectedCmdOutput: `Terraform Cloud requires all workspaces to be given an explicit name.`, userInput: []string{"cloud-workspace", "yes"}, postInputOutput: []string{ `Do you want to copy existing state to Terraform Cloud?`, @@ -741,7 +741,7 @@ func Test_migrate_remote_backend_prefix_to_tfc_tags(t *testing.T) { commands: []tfCommand{ { command: []string{"init", "-migrate-state", "-ignore-remote-version"}, - expectedCmdOutput: `The Terraform Cloud configuration only allows named workspaces!`, + expectedCmdOutput: `Terraform Cloud requires all workspaces to be given an explicit name.`, userInput: []string{"cloud-workspace", "yes"}, postInputOutput: []string{ `Do you want to copy existing state to Terraform Cloud?`, diff --git a/internal/cloud/e2e/migrate_state_single_to_tfc_test.go b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go index c060ee63e..baf9126f4 100644 --- a/internal/cloud/e2e/migrate_state_single_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go @@ -103,7 +103,7 @@ func Test_migrate_single_to_tfc(t *testing.T) { commands: []tfCommand{ { command: []string{"init", "-migrate-state"}, - expectedCmdOutput: `Terraform Cloud configuration only allows named workspaces!`, + expectedCmdOutput: `Terraform Cloud requires all workspaces to be given an explicit name.`, userInput: []string{"new-workspace", "yes"}, postInputOutput: []string{ `Do you want to copy existing state to Terraform Cloud?`, diff --git a/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go b/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go index 6d086de5b..fff9b5416 100644 --- a/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go @@ -134,7 +134,7 @@ func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { commands: []tfCommand{ { command: []string{"init", "-migrate-state", "-ignore-remote-version"}, - expectedCmdOutput: `The Terraform Cloud configuration only allows named workspaces!`, + expectedCmdOutput: `Terraform Cloud requires all workspaces to be given an explicit name.`, userInput: []string{"new-workspace", "yes"}, postInputOutput: []string{ `Do you want to copy existing state to Terraform Cloud?`, @@ -205,7 +205,7 @@ func Test_migrate_tfc_to_tfc_single_workspace(t *testing.T) { commands: []tfCommand{ { command: []string{"init", "-migrate-state"}, - expectedCmdOutput: `The Terraform Cloud configuration only allows named workspaces!`, + expectedCmdOutput: `Terraform Cloud requires all workspaces to be given an explicit name.`, expectError: true, userInput: []string{"new-workspace", "yes"}, }, diff --git a/internal/command/meta_backend_migrate.go b/internal/command/meta_backend_migrate.go index a4c0578cc..449c8303f 100644 --- a/internal/command/meta_backend_migrate.go +++ b/internal/command/meta_backend_migrate.go @@ -694,8 +694,7 @@ func (m *Meta) promptNewWorkspaceName(destinationType string) (string, error) { message := fmt.Sprintf("[reset][bold][yellow]The %q backend configuration only allows "+ "named workspaces![reset]", destinationType) if destinationType == "cloud" { - message = fmt.Sprintf("[reset][bold][yellow]The Terraform Cloud configuration only allows " + - "named workspaces![reset]") + message = `[reset][bold][yellow]Terraform Cloud requires all workspaces to be given an explicit name.[reset]` } name, err := m.UIInput().Input(context.Background(), &terraform.InputOpts{ Id: "new-state-name", @@ -896,7 +895,7 @@ If you answer "yes", Terraform will migrate all states. If you answer const inputBackendNewWorkspaceName = ` Please provide a new workspace name (e.g. dev, test) that will be used -to migrate the existing default workspace. +to migrate the existing default workspace. ` const inputBackendSelectWorkspace = ` From 6dc1fed6d548a5bb34b755a35e3e6be9b89b8454 Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Thu, 28 Oct 2021 15:27:39 -0500 Subject: [PATCH 77/78] Clarify legacy Ui comments --- internal/command/apply.go | 5 +++-- internal/command/plan.go | 5 +++-- internal/command/refresh.go | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/internal/command/apply.go b/internal/command/apply.go index 9d481c06b..7405bddf0 100644 --- a/internal/command/apply.go +++ b/internal/command/apply.go @@ -29,8 +29,9 @@ func (c *ApplyCommand) Run(rawArgs []string) int { common, rawArgs := arguments.ParseView(rawArgs) c.View.Configure(common) - // Propagate -no-color for the remote backend's legacy use of Ui. This - // should be removed when the remote backend is migrated to views. + // Propagate -no-color for legacy use of Ui. The remote backend and + // cloud package use this; it should be removed when/if they are + // migrated to views. c.Meta.color = !common.NoColor c.Meta.Color = c.Meta.color diff --git a/internal/command/plan.go b/internal/command/plan.go index ec90d8371..a5ff74ac1 100644 --- a/internal/command/plan.go +++ b/internal/command/plan.go @@ -21,8 +21,9 @@ func (c *PlanCommand) Run(rawArgs []string) int { common, rawArgs := arguments.ParseView(rawArgs) c.View.Configure(common) - // Propagate -no-color for the remote backend's legacy use of Ui. This - // should be removed when the remote backend is migrated to views. + // Propagate -no-color for legacy use of Ui. The remote backend and + // cloud package use this; it should be removed when/if they are + // migrated to views. c.Meta.color = !common.NoColor c.Meta.Color = c.Meta.color diff --git a/internal/command/refresh.go b/internal/command/refresh.go index c7c041eb7..18891b414 100644 --- a/internal/command/refresh.go +++ b/internal/command/refresh.go @@ -23,8 +23,9 @@ func (c *RefreshCommand) Run(rawArgs []string) int { common, rawArgs := arguments.ParseView(rawArgs) c.View.Configure(common) - // Propagate -no-color for the remote backend's legacy use of Ui. This - // should be removed when the remote backend is migrated to views. + // Propagate -no-color for legacy use of Ui. The remote backend and + // cloud package use this; it should be removed when/if they are + // migrated to views. c.Meta.color = !common.NoColor c.Meta.Color = c.Meta.color From 369264865e7512b6c27940d3d9d42733f954a9ad Mon Sep 17 00:00:00 2001 From: Chris Arcand Date: Thu, 28 Oct 2021 20:16:26 -0500 Subject: [PATCH 78/78] internal/plugin[6]: Add generated mocks --- internal/plugin/grpc_provider_test.go | 3 +- internal/plugin/grpc_provisioner_test.go | 3 +- internal/plugin/mock_proto/mock.go | 155 ++++++++++++----------- internal/plugin6/grpc_provider_test.go | 3 +- internal/plugin6/mock_proto/mock.go | 59 ++++----- 5 files changed, 114 insertions(+), 109 deletions(-) diff --git a/internal/plugin/grpc_provider_test.go b/internal/plugin/grpc_provider_test.go index a78c6d318..fc6216cc0 100644 --- a/internal/plugin/grpc_provider_test.go +++ b/internal/plugin/grpc_provider_test.go @@ -228,7 +228,8 @@ func TestGRPCProvider_Configure(t *testing.T) { } func TestGRPCProvider_Stop(t *testing.T) { - client := mockProviderClient(t) + ctrl := gomock.NewController(t) + client := mockproto.NewMockProviderClient(ctrl) p := &GRPCProvider{ client: client, } diff --git a/internal/plugin/grpc_provisioner_test.go b/internal/plugin/grpc_provisioner_test.go index ab775cdbd..848c9460f 100644 --- a/internal/plugin/grpc_provisioner_test.go +++ b/internal/plugin/grpc_provisioner_test.go @@ -129,7 +129,8 @@ func (r *provisionRecorder) Output(s string) { } func TestGRPCProvisioner_Stop(t *testing.T) { - client := mockProvisionerClient(t) + ctrl := gomock.NewController(t) + client := mockproto.NewMockProvisionerClient(ctrl) p := &GRPCProvisioner{ client: client, } diff --git a/internal/plugin/mock_proto/mock.go b/internal/plugin/mock_proto/mock.go index 5f4a69d72..054fe1cd8 100644 --- a/internal/plugin/mock_proto/mock.go +++ b/internal/plugin/mock_proto/mock.go @@ -6,37 +6,38 @@ package mock_tfplugin5 import ( context "context" + reflect "reflect" + gomock "github.com/golang/mock/gomock" tfplugin5 "github.com/hashicorp/terraform/internal/tfplugin5" grpc "google.golang.org/grpc" metadata "google.golang.org/grpc/metadata" - reflect "reflect" ) -// MockProviderClient is a mock of ProviderClient interface +// MockProviderClient is a mock of ProviderClient interface. type MockProviderClient struct { ctrl *gomock.Controller recorder *MockProviderClientMockRecorder } -// MockProviderClientMockRecorder is the mock recorder for MockProviderClient +// MockProviderClientMockRecorder is the mock recorder for MockProviderClient. type MockProviderClientMockRecorder struct { mock *MockProviderClient } -// NewMockProviderClient creates a new mock instance +// NewMockProviderClient creates a new mock instance. func NewMockProviderClient(ctrl *gomock.Controller) *MockProviderClient { mock := &MockProviderClient{ctrl: ctrl} mock.recorder = &MockProviderClientMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockProviderClient) EXPECT() *MockProviderClientMockRecorder { return m.recorder } -// ApplyResourceChange mocks base method +// ApplyResourceChange mocks base method. func (m *MockProviderClient) ApplyResourceChange(arg0 context.Context, arg1 *tfplugin5.ApplyResourceChange_Request, arg2 ...grpc.CallOption) (*tfplugin5.ApplyResourceChange_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -49,14 +50,14 @@ func (m *MockProviderClient) ApplyResourceChange(arg0 context.Context, arg1 *tfp return ret0, ret1 } -// ApplyResourceChange indicates an expected call of ApplyResourceChange +// ApplyResourceChange indicates an expected call of ApplyResourceChange. func (mr *MockProviderClientMockRecorder) ApplyResourceChange(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyResourceChange", reflect.TypeOf((*MockProviderClient)(nil).ApplyResourceChange), varargs...) } -// Configure mocks base method +// Configure mocks base method. func (m *MockProviderClient) Configure(arg0 context.Context, arg1 *tfplugin5.Configure_Request, arg2 ...grpc.CallOption) (*tfplugin5.Configure_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -69,14 +70,14 @@ func (m *MockProviderClient) Configure(arg0 context.Context, arg1 *tfplugin5.Con return ret0, ret1 } -// Configure indicates an expected call of Configure +// Configure indicates an expected call of Configure. func (mr *MockProviderClientMockRecorder) Configure(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Configure", reflect.TypeOf((*MockProviderClient)(nil).Configure), varargs...) } -// GetSchema mocks base method +// GetSchema mocks base method. func (m *MockProviderClient) GetSchema(arg0 context.Context, arg1 *tfplugin5.GetProviderSchema_Request, arg2 ...grpc.CallOption) (*tfplugin5.GetProviderSchema_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -89,14 +90,14 @@ func (m *MockProviderClient) GetSchema(arg0 context.Context, arg1 *tfplugin5.Get return ret0, ret1 } -// GetSchema indicates an expected call of GetSchema +// GetSchema indicates an expected call of GetSchema. func (mr *MockProviderClientMockRecorder) GetSchema(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSchema", reflect.TypeOf((*MockProviderClient)(nil).GetSchema), varargs...) } -// ImportResourceState mocks base method +// ImportResourceState mocks base method. func (m *MockProviderClient) ImportResourceState(arg0 context.Context, arg1 *tfplugin5.ImportResourceState_Request, arg2 ...grpc.CallOption) (*tfplugin5.ImportResourceState_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -109,14 +110,14 @@ func (m *MockProviderClient) ImportResourceState(arg0 context.Context, arg1 *tfp return ret0, ret1 } -// ImportResourceState indicates an expected call of ImportResourceState +// ImportResourceState indicates an expected call of ImportResourceState. func (mr *MockProviderClientMockRecorder) ImportResourceState(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ImportResourceState", reflect.TypeOf((*MockProviderClient)(nil).ImportResourceState), varargs...) } -// PlanResourceChange mocks base method +// PlanResourceChange mocks base method. func (m *MockProviderClient) PlanResourceChange(arg0 context.Context, arg1 *tfplugin5.PlanResourceChange_Request, arg2 ...grpc.CallOption) (*tfplugin5.PlanResourceChange_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -129,14 +130,14 @@ func (m *MockProviderClient) PlanResourceChange(arg0 context.Context, arg1 *tfpl return ret0, ret1 } -// PlanResourceChange indicates an expected call of PlanResourceChange +// PlanResourceChange indicates an expected call of PlanResourceChange. func (mr *MockProviderClientMockRecorder) PlanResourceChange(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PlanResourceChange", reflect.TypeOf((*MockProviderClient)(nil).PlanResourceChange), varargs...) } -// PrepareProviderConfig mocks base method +// PrepareProviderConfig mocks base method. func (m *MockProviderClient) PrepareProviderConfig(arg0 context.Context, arg1 *tfplugin5.PrepareProviderConfig_Request, arg2 ...grpc.CallOption) (*tfplugin5.PrepareProviderConfig_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -149,14 +150,14 @@ func (m *MockProviderClient) PrepareProviderConfig(arg0 context.Context, arg1 *t return ret0, ret1 } -// PrepareProviderConfig indicates an expected call of PrepareProviderConfig +// PrepareProviderConfig indicates an expected call of PrepareProviderConfig. func (mr *MockProviderClientMockRecorder) PrepareProviderConfig(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrepareProviderConfig", reflect.TypeOf((*MockProviderClient)(nil).PrepareProviderConfig), varargs...) } -// ReadDataSource mocks base method +// ReadDataSource mocks base method. func (m *MockProviderClient) ReadDataSource(arg0 context.Context, arg1 *tfplugin5.ReadDataSource_Request, arg2 ...grpc.CallOption) (*tfplugin5.ReadDataSource_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -169,14 +170,14 @@ func (m *MockProviderClient) ReadDataSource(arg0 context.Context, arg1 *tfplugin return ret0, ret1 } -// ReadDataSource indicates an expected call of ReadDataSource +// ReadDataSource indicates an expected call of ReadDataSource. func (mr *MockProviderClientMockRecorder) ReadDataSource(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadDataSource", reflect.TypeOf((*MockProviderClient)(nil).ReadDataSource), varargs...) } -// ReadResource mocks base method +// ReadResource mocks base method. func (m *MockProviderClient) ReadResource(arg0 context.Context, arg1 *tfplugin5.ReadResource_Request, arg2 ...grpc.CallOption) (*tfplugin5.ReadResource_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -189,14 +190,14 @@ func (m *MockProviderClient) ReadResource(arg0 context.Context, arg1 *tfplugin5. return ret0, ret1 } -// ReadResource indicates an expected call of ReadResource +// ReadResource indicates an expected call of ReadResource. func (mr *MockProviderClientMockRecorder) ReadResource(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadResource", reflect.TypeOf((*MockProviderClient)(nil).ReadResource), varargs...) } -// Stop mocks base method +// Stop mocks base method. func (m *MockProviderClient) Stop(arg0 context.Context, arg1 *tfplugin5.Stop_Request, arg2 ...grpc.CallOption) (*tfplugin5.Stop_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -209,14 +210,14 @@ func (m *MockProviderClient) Stop(arg0 context.Context, arg1 *tfplugin5.Stop_Req return ret0, ret1 } -// Stop indicates an expected call of Stop +// Stop indicates an expected call of Stop. func (mr *MockProviderClientMockRecorder) Stop(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockProviderClient)(nil).Stop), varargs...) } -// UpgradeResourceState mocks base method +// UpgradeResourceState mocks base method. func (m *MockProviderClient) UpgradeResourceState(arg0 context.Context, arg1 *tfplugin5.UpgradeResourceState_Request, arg2 ...grpc.CallOption) (*tfplugin5.UpgradeResourceState_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -229,14 +230,14 @@ func (m *MockProviderClient) UpgradeResourceState(arg0 context.Context, arg1 *tf return ret0, ret1 } -// UpgradeResourceState indicates an expected call of UpgradeResourceState +// UpgradeResourceState indicates an expected call of UpgradeResourceState. func (mr *MockProviderClientMockRecorder) UpgradeResourceState(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpgradeResourceState", reflect.TypeOf((*MockProviderClient)(nil).UpgradeResourceState), varargs...) } -// ValidateDataSourceConfig mocks base method +// ValidateDataSourceConfig mocks base method. func (m *MockProviderClient) ValidateDataSourceConfig(arg0 context.Context, arg1 *tfplugin5.ValidateDataSourceConfig_Request, arg2 ...grpc.CallOption) (*tfplugin5.ValidateDataSourceConfig_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -249,14 +250,14 @@ func (m *MockProviderClient) ValidateDataSourceConfig(arg0 context.Context, arg1 return ret0, ret1 } -// ValidateDataSourceConfig indicates an expected call of ValidateDataSourceConfig +// ValidateDataSourceConfig indicates an expected call of ValidateDataSourceConfig. func (mr *MockProviderClientMockRecorder) ValidateDataSourceConfig(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateDataSourceConfig", reflect.TypeOf((*MockProviderClient)(nil).ValidateDataSourceConfig), varargs...) } -// ValidateResourceTypeConfig mocks base method +// ValidateResourceTypeConfig mocks base method. func (m *MockProviderClient) ValidateResourceTypeConfig(arg0 context.Context, arg1 *tfplugin5.ValidateResourceTypeConfig_Request, arg2 ...grpc.CallOption) (*tfplugin5.ValidateResourceTypeConfig_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -269,37 +270,37 @@ func (m *MockProviderClient) ValidateResourceTypeConfig(arg0 context.Context, ar return ret0, ret1 } -// ValidateResourceTypeConfig indicates an expected call of ValidateResourceTypeConfig +// ValidateResourceTypeConfig indicates an expected call of ValidateResourceTypeConfig. func (mr *MockProviderClientMockRecorder) ValidateResourceTypeConfig(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateResourceTypeConfig", reflect.TypeOf((*MockProviderClient)(nil).ValidateResourceTypeConfig), varargs...) } -// MockProvisionerClient is a mock of ProvisionerClient interface +// MockProvisionerClient is a mock of ProvisionerClient interface. type MockProvisionerClient struct { ctrl *gomock.Controller recorder *MockProvisionerClientMockRecorder } -// MockProvisionerClientMockRecorder is the mock recorder for MockProvisionerClient +// MockProvisionerClientMockRecorder is the mock recorder for MockProvisionerClient. type MockProvisionerClientMockRecorder struct { mock *MockProvisionerClient } -// NewMockProvisionerClient creates a new mock instance +// NewMockProvisionerClient creates a new mock instance. func NewMockProvisionerClient(ctrl *gomock.Controller) *MockProvisionerClient { mock := &MockProvisionerClient{ctrl: ctrl} mock.recorder = &MockProvisionerClientMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockProvisionerClient) EXPECT() *MockProvisionerClientMockRecorder { return m.recorder } -// GetSchema mocks base method +// GetSchema mocks base method. func (m *MockProvisionerClient) GetSchema(arg0 context.Context, arg1 *tfplugin5.GetProvisionerSchema_Request, arg2 ...grpc.CallOption) (*tfplugin5.GetProvisionerSchema_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -312,14 +313,14 @@ func (m *MockProvisionerClient) GetSchema(arg0 context.Context, arg1 *tfplugin5. return ret0, ret1 } -// GetSchema indicates an expected call of GetSchema +// GetSchema indicates an expected call of GetSchema. func (mr *MockProvisionerClientMockRecorder) GetSchema(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSchema", reflect.TypeOf((*MockProvisionerClient)(nil).GetSchema), varargs...) } -// ProvisionResource mocks base method +// ProvisionResource mocks base method. func (m *MockProvisionerClient) ProvisionResource(arg0 context.Context, arg1 *tfplugin5.ProvisionResource_Request, arg2 ...grpc.CallOption) (tfplugin5.Provisioner_ProvisionResourceClient, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -332,14 +333,14 @@ func (m *MockProvisionerClient) ProvisionResource(arg0 context.Context, arg1 *tf return ret0, ret1 } -// ProvisionResource indicates an expected call of ProvisionResource +// ProvisionResource indicates an expected call of ProvisionResource. func (mr *MockProvisionerClientMockRecorder) ProvisionResource(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProvisionResource", reflect.TypeOf((*MockProvisionerClient)(nil).ProvisionResource), varargs...) } -// Stop mocks base method +// Stop mocks base method. func (m *MockProvisionerClient) Stop(arg0 context.Context, arg1 *tfplugin5.Stop_Request, arg2 ...grpc.CallOption) (*tfplugin5.Stop_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -352,14 +353,14 @@ func (m *MockProvisionerClient) Stop(arg0 context.Context, arg1 *tfplugin5.Stop_ return ret0, ret1 } -// Stop indicates an expected call of Stop +// Stop indicates an expected call of Stop. func (mr *MockProvisionerClientMockRecorder) Stop(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockProvisionerClient)(nil).Stop), varargs...) } -// ValidateProvisionerConfig mocks base method +// ValidateProvisionerConfig mocks base method. func (m *MockProvisionerClient) ValidateProvisionerConfig(arg0 context.Context, arg1 *tfplugin5.ValidateProvisionerConfig_Request, arg2 ...grpc.CallOption) (*tfplugin5.ValidateProvisionerConfig_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -372,37 +373,37 @@ func (m *MockProvisionerClient) ValidateProvisionerConfig(arg0 context.Context, return ret0, ret1 } -// ValidateProvisionerConfig indicates an expected call of ValidateProvisionerConfig +// ValidateProvisionerConfig indicates an expected call of ValidateProvisionerConfig. func (mr *MockProvisionerClientMockRecorder) ValidateProvisionerConfig(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateProvisionerConfig", reflect.TypeOf((*MockProvisionerClient)(nil).ValidateProvisionerConfig), varargs...) } -// MockProvisioner_ProvisionResourceClient is a mock of Provisioner_ProvisionResourceClient interface +// MockProvisioner_ProvisionResourceClient is a mock of Provisioner_ProvisionResourceClient interface. type MockProvisioner_ProvisionResourceClient struct { ctrl *gomock.Controller recorder *MockProvisioner_ProvisionResourceClientMockRecorder } -// MockProvisioner_ProvisionResourceClientMockRecorder is the mock recorder for MockProvisioner_ProvisionResourceClient +// MockProvisioner_ProvisionResourceClientMockRecorder is the mock recorder for MockProvisioner_ProvisionResourceClient. type MockProvisioner_ProvisionResourceClientMockRecorder struct { mock *MockProvisioner_ProvisionResourceClient } -// NewMockProvisioner_ProvisionResourceClient creates a new mock instance +// NewMockProvisioner_ProvisionResourceClient creates a new mock instance. func NewMockProvisioner_ProvisionResourceClient(ctrl *gomock.Controller) *MockProvisioner_ProvisionResourceClient { mock := &MockProvisioner_ProvisionResourceClient{ctrl: ctrl} mock.recorder = &MockProvisioner_ProvisionResourceClientMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockProvisioner_ProvisionResourceClient) EXPECT() *MockProvisioner_ProvisionResourceClientMockRecorder { return m.recorder } -// CloseSend mocks base method +// CloseSend mocks base method. func (m *MockProvisioner_ProvisionResourceClient) CloseSend() error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CloseSend") @@ -410,13 +411,13 @@ func (m *MockProvisioner_ProvisionResourceClient) CloseSend() error { return ret0 } -// CloseSend indicates an expected call of CloseSend +// CloseSend indicates an expected call of CloseSend. func (mr *MockProvisioner_ProvisionResourceClientMockRecorder) CloseSend() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CloseSend", reflect.TypeOf((*MockProvisioner_ProvisionResourceClient)(nil).CloseSend)) } -// Context mocks base method +// Context mocks base method. func (m *MockProvisioner_ProvisionResourceClient) Context() context.Context { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Context") @@ -424,13 +425,13 @@ func (m *MockProvisioner_ProvisionResourceClient) Context() context.Context { return ret0 } -// Context indicates an expected call of Context +// Context indicates an expected call of Context. func (mr *MockProvisioner_ProvisionResourceClientMockRecorder) Context() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Context", reflect.TypeOf((*MockProvisioner_ProvisionResourceClient)(nil).Context)) } -// Header mocks base method +// Header mocks base method. func (m *MockProvisioner_ProvisionResourceClient) Header() (metadata.MD, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Header") @@ -439,13 +440,13 @@ func (m *MockProvisioner_ProvisionResourceClient) Header() (metadata.MD, error) return ret0, ret1 } -// Header indicates an expected call of Header +// Header indicates an expected call of Header. func (mr *MockProvisioner_ProvisionResourceClientMockRecorder) Header() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Header", reflect.TypeOf((*MockProvisioner_ProvisionResourceClient)(nil).Header)) } -// Recv mocks base method +// Recv mocks base method. func (m *MockProvisioner_ProvisionResourceClient) Recv() (*tfplugin5.ProvisionResource_Response, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Recv") @@ -454,13 +455,13 @@ func (m *MockProvisioner_ProvisionResourceClient) Recv() (*tfplugin5.ProvisionRe return ret0, ret1 } -// Recv indicates an expected call of Recv +// Recv indicates an expected call of Recv. func (mr *MockProvisioner_ProvisionResourceClientMockRecorder) Recv() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Recv", reflect.TypeOf((*MockProvisioner_ProvisionResourceClient)(nil).Recv)) } -// RecvMsg mocks base method +// RecvMsg mocks base method. func (m *MockProvisioner_ProvisionResourceClient) RecvMsg(arg0 interface{}) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RecvMsg", arg0) @@ -468,13 +469,13 @@ func (m *MockProvisioner_ProvisionResourceClient) RecvMsg(arg0 interface{}) erro return ret0 } -// RecvMsg indicates an expected call of RecvMsg +// RecvMsg indicates an expected call of RecvMsg. func (mr *MockProvisioner_ProvisionResourceClientMockRecorder) RecvMsg(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RecvMsg", reflect.TypeOf((*MockProvisioner_ProvisionResourceClient)(nil).RecvMsg), arg0) } -// SendMsg mocks base method +// SendMsg mocks base method. func (m *MockProvisioner_ProvisionResourceClient) SendMsg(arg0 interface{}) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SendMsg", arg0) @@ -482,13 +483,13 @@ func (m *MockProvisioner_ProvisionResourceClient) SendMsg(arg0 interface{}) erro return ret0 } -// SendMsg indicates an expected call of SendMsg +// SendMsg indicates an expected call of SendMsg. func (mr *MockProvisioner_ProvisionResourceClientMockRecorder) SendMsg(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendMsg", reflect.TypeOf((*MockProvisioner_ProvisionResourceClient)(nil).SendMsg), arg0) } -// Trailer mocks base method +// Trailer mocks base method. func (m *MockProvisioner_ProvisionResourceClient) Trailer() metadata.MD { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Trailer") @@ -496,36 +497,36 @@ func (m *MockProvisioner_ProvisionResourceClient) Trailer() metadata.MD { return ret0 } -// Trailer indicates an expected call of Trailer +// Trailer indicates an expected call of Trailer. func (mr *MockProvisioner_ProvisionResourceClientMockRecorder) Trailer() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Trailer", reflect.TypeOf((*MockProvisioner_ProvisionResourceClient)(nil).Trailer)) } -// MockProvisioner_ProvisionResourceServer is a mock of Provisioner_ProvisionResourceServer interface +// MockProvisioner_ProvisionResourceServer is a mock of Provisioner_ProvisionResourceServer interface. type MockProvisioner_ProvisionResourceServer struct { ctrl *gomock.Controller recorder *MockProvisioner_ProvisionResourceServerMockRecorder } -// MockProvisioner_ProvisionResourceServerMockRecorder is the mock recorder for MockProvisioner_ProvisionResourceServer +// MockProvisioner_ProvisionResourceServerMockRecorder is the mock recorder for MockProvisioner_ProvisionResourceServer. type MockProvisioner_ProvisionResourceServerMockRecorder struct { mock *MockProvisioner_ProvisionResourceServer } -// NewMockProvisioner_ProvisionResourceServer creates a new mock instance +// NewMockProvisioner_ProvisionResourceServer creates a new mock instance. func NewMockProvisioner_ProvisionResourceServer(ctrl *gomock.Controller) *MockProvisioner_ProvisionResourceServer { mock := &MockProvisioner_ProvisionResourceServer{ctrl: ctrl} mock.recorder = &MockProvisioner_ProvisionResourceServerMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockProvisioner_ProvisionResourceServer) EXPECT() *MockProvisioner_ProvisionResourceServerMockRecorder { return m.recorder } -// Context mocks base method +// Context mocks base method. func (m *MockProvisioner_ProvisionResourceServer) Context() context.Context { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Context") @@ -533,13 +534,13 @@ func (m *MockProvisioner_ProvisionResourceServer) Context() context.Context { return ret0 } -// Context indicates an expected call of Context +// Context indicates an expected call of Context. func (mr *MockProvisioner_ProvisionResourceServerMockRecorder) Context() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Context", reflect.TypeOf((*MockProvisioner_ProvisionResourceServer)(nil).Context)) } -// RecvMsg mocks base method +// RecvMsg mocks base method. func (m *MockProvisioner_ProvisionResourceServer) RecvMsg(arg0 interface{}) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RecvMsg", arg0) @@ -547,13 +548,13 @@ func (m *MockProvisioner_ProvisionResourceServer) RecvMsg(arg0 interface{}) erro return ret0 } -// RecvMsg indicates an expected call of RecvMsg +// RecvMsg indicates an expected call of RecvMsg. func (mr *MockProvisioner_ProvisionResourceServerMockRecorder) RecvMsg(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RecvMsg", reflect.TypeOf((*MockProvisioner_ProvisionResourceServer)(nil).RecvMsg), arg0) } -// Send mocks base method +// Send mocks base method. func (m *MockProvisioner_ProvisionResourceServer) Send(arg0 *tfplugin5.ProvisionResource_Response) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Send", arg0) @@ -561,13 +562,13 @@ func (m *MockProvisioner_ProvisionResourceServer) Send(arg0 *tfplugin5.Provision return ret0 } -// Send indicates an expected call of Send +// Send indicates an expected call of Send. func (mr *MockProvisioner_ProvisionResourceServerMockRecorder) Send(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Send", reflect.TypeOf((*MockProvisioner_ProvisionResourceServer)(nil).Send), arg0) } -// SendHeader mocks base method +// SendHeader mocks base method. func (m *MockProvisioner_ProvisionResourceServer) SendHeader(arg0 metadata.MD) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SendHeader", arg0) @@ -575,13 +576,13 @@ func (m *MockProvisioner_ProvisionResourceServer) SendHeader(arg0 metadata.MD) e return ret0 } -// SendHeader indicates an expected call of SendHeader +// SendHeader indicates an expected call of SendHeader. func (mr *MockProvisioner_ProvisionResourceServerMockRecorder) SendHeader(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendHeader", reflect.TypeOf((*MockProvisioner_ProvisionResourceServer)(nil).SendHeader), arg0) } -// SendMsg mocks base method +// SendMsg mocks base method. func (m *MockProvisioner_ProvisionResourceServer) SendMsg(arg0 interface{}) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SendMsg", arg0) @@ -589,13 +590,13 @@ func (m *MockProvisioner_ProvisionResourceServer) SendMsg(arg0 interface{}) erro return ret0 } -// SendMsg indicates an expected call of SendMsg +// SendMsg indicates an expected call of SendMsg. func (mr *MockProvisioner_ProvisionResourceServerMockRecorder) SendMsg(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendMsg", reflect.TypeOf((*MockProvisioner_ProvisionResourceServer)(nil).SendMsg), arg0) } -// SetHeader mocks base method +// SetHeader mocks base method. func (m *MockProvisioner_ProvisionResourceServer) SetHeader(arg0 metadata.MD) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetHeader", arg0) @@ -603,19 +604,19 @@ func (m *MockProvisioner_ProvisionResourceServer) SetHeader(arg0 metadata.MD) er return ret0 } -// SetHeader indicates an expected call of SetHeader +// SetHeader indicates an expected call of SetHeader. func (mr *MockProvisioner_ProvisionResourceServerMockRecorder) SetHeader(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetHeader", reflect.TypeOf((*MockProvisioner_ProvisionResourceServer)(nil).SetHeader), arg0) } -// SetTrailer mocks base method +// SetTrailer mocks base method. func (m *MockProvisioner_ProvisionResourceServer) SetTrailer(arg0 metadata.MD) { m.ctrl.T.Helper() m.ctrl.Call(m, "SetTrailer", arg0) } -// SetTrailer indicates an expected call of SetTrailer +// SetTrailer indicates an expected call of SetTrailer. func (mr *MockProvisioner_ProvisionResourceServerMockRecorder) SetTrailer(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTrailer", reflect.TypeOf((*MockProvisioner_ProvisionResourceServer)(nil).SetTrailer), arg0) diff --git a/internal/plugin6/grpc_provider_test.go b/internal/plugin6/grpc_provider_test.go index ee58f5779..cca0b5820 100644 --- a/internal/plugin6/grpc_provider_test.go +++ b/internal/plugin6/grpc_provider_test.go @@ -235,7 +235,8 @@ func TestGRPCProvider_Configure(t *testing.T) { } func TestGRPCProvider_Stop(t *testing.T) { - client := mockProviderClient(t) + ctrl := gomock.NewController(t) + client := mockproto.NewMockProviderClient(ctrl) p := &GRPCProvider{ client: client, } diff --git a/internal/plugin6/mock_proto/mock.go b/internal/plugin6/mock_proto/mock.go index 506fd7bc6..448008ef7 100644 --- a/internal/plugin6/mock_proto/mock.go +++ b/internal/plugin6/mock_proto/mock.go @@ -6,36 +6,37 @@ package mock_tfplugin6 import ( context "context" + reflect "reflect" + gomock "github.com/golang/mock/gomock" tfplugin6 "github.com/hashicorp/terraform/internal/tfplugin6" grpc "google.golang.org/grpc" - reflect "reflect" ) -// MockProviderClient is a mock of ProviderClient interface +// MockProviderClient is a mock of ProviderClient interface. type MockProviderClient struct { ctrl *gomock.Controller recorder *MockProviderClientMockRecorder } -// MockProviderClientMockRecorder is the mock recorder for MockProviderClient +// MockProviderClientMockRecorder is the mock recorder for MockProviderClient. type MockProviderClientMockRecorder struct { mock *MockProviderClient } -// NewMockProviderClient creates a new mock instance +// NewMockProviderClient creates a new mock instance. func NewMockProviderClient(ctrl *gomock.Controller) *MockProviderClient { mock := &MockProviderClient{ctrl: ctrl} mock.recorder = &MockProviderClientMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockProviderClient) EXPECT() *MockProviderClientMockRecorder { return m.recorder } -// ApplyResourceChange mocks base method +// ApplyResourceChange mocks base method. func (m *MockProviderClient) ApplyResourceChange(arg0 context.Context, arg1 *tfplugin6.ApplyResourceChange_Request, arg2 ...grpc.CallOption) (*tfplugin6.ApplyResourceChange_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -48,14 +49,14 @@ func (m *MockProviderClient) ApplyResourceChange(arg0 context.Context, arg1 *tfp return ret0, ret1 } -// ApplyResourceChange indicates an expected call of ApplyResourceChange +// ApplyResourceChange indicates an expected call of ApplyResourceChange. func (mr *MockProviderClientMockRecorder) ApplyResourceChange(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyResourceChange", reflect.TypeOf((*MockProviderClient)(nil).ApplyResourceChange), varargs...) } -// ConfigureProvider mocks base method +// ConfigureProvider mocks base method. func (m *MockProviderClient) ConfigureProvider(arg0 context.Context, arg1 *tfplugin6.ConfigureProvider_Request, arg2 ...grpc.CallOption) (*tfplugin6.ConfigureProvider_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -68,14 +69,14 @@ func (m *MockProviderClient) ConfigureProvider(arg0 context.Context, arg1 *tfplu return ret0, ret1 } -// ConfigureProvider indicates an expected call of ConfigureProvider +// ConfigureProvider indicates an expected call of ConfigureProvider. func (mr *MockProviderClientMockRecorder) ConfigureProvider(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConfigureProvider", reflect.TypeOf((*MockProviderClient)(nil).ConfigureProvider), varargs...) } -// GetProviderSchema mocks base method +// GetProviderSchema mocks base method. func (m *MockProviderClient) GetProviderSchema(arg0 context.Context, arg1 *tfplugin6.GetProviderSchema_Request, arg2 ...grpc.CallOption) (*tfplugin6.GetProviderSchema_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -88,14 +89,14 @@ func (m *MockProviderClient) GetProviderSchema(arg0 context.Context, arg1 *tfplu return ret0, ret1 } -// GetProviderSchema indicates an expected call of GetProviderSchema +// GetProviderSchema indicates an expected call of GetProviderSchema. func (mr *MockProviderClientMockRecorder) GetProviderSchema(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProviderSchema", reflect.TypeOf((*MockProviderClient)(nil).GetProviderSchema), varargs...) } -// ImportResourceState mocks base method +// ImportResourceState mocks base method. func (m *MockProviderClient) ImportResourceState(arg0 context.Context, arg1 *tfplugin6.ImportResourceState_Request, arg2 ...grpc.CallOption) (*tfplugin6.ImportResourceState_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -108,14 +109,14 @@ func (m *MockProviderClient) ImportResourceState(arg0 context.Context, arg1 *tfp return ret0, ret1 } -// ImportResourceState indicates an expected call of ImportResourceState +// ImportResourceState indicates an expected call of ImportResourceState. func (mr *MockProviderClientMockRecorder) ImportResourceState(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ImportResourceState", reflect.TypeOf((*MockProviderClient)(nil).ImportResourceState), varargs...) } -// PlanResourceChange mocks base method +// PlanResourceChange mocks base method. func (m *MockProviderClient) PlanResourceChange(arg0 context.Context, arg1 *tfplugin6.PlanResourceChange_Request, arg2 ...grpc.CallOption) (*tfplugin6.PlanResourceChange_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -128,14 +129,14 @@ func (m *MockProviderClient) PlanResourceChange(arg0 context.Context, arg1 *tfpl return ret0, ret1 } -// PlanResourceChange indicates an expected call of PlanResourceChange +// PlanResourceChange indicates an expected call of PlanResourceChange. func (mr *MockProviderClientMockRecorder) PlanResourceChange(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PlanResourceChange", reflect.TypeOf((*MockProviderClient)(nil).PlanResourceChange), varargs...) } -// ReadDataSource mocks base method +// ReadDataSource mocks base method. func (m *MockProviderClient) ReadDataSource(arg0 context.Context, arg1 *tfplugin6.ReadDataSource_Request, arg2 ...grpc.CallOption) (*tfplugin6.ReadDataSource_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -148,14 +149,14 @@ func (m *MockProviderClient) ReadDataSource(arg0 context.Context, arg1 *tfplugin return ret0, ret1 } -// ReadDataSource indicates an expected call of ReadDataSource +// ReadDataSource indicates an expected call of ReadDataSource. func (mr *MockProviderClientMockRecorder) ReadDataSource(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadDataSource", reflect.TypeOf((*MockProviderClient)(nil).ReadDataSource), varargs...) } -// ReadResource mocks base method +// ReadResource mocks base method. func (m *MockProviderClient) ReadResource(arg0 context.Context, arg1 *tfplugin6.ReadResource_Request, arg2 ...grpc.CallOption) (*tfplugin6.ReadResource_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -168,14 +169,14 @@ func (m *MockProviderClient) ReadResource(arg0 context.Context, arg1 *tfplugin6. return ret0, ret1 } -// ReadResource indicates an expected call of ReadResource +// ReadResource indicates an expected call of ReadResource. func (mr *MockProviderClientMockRecorder) ReadResource(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadResource", reflect.TypeOf((*MockProviderClient)(nil).ReadResource), varargs...) } -// StopProvider mocks base method +// StopProvider mocks base method. func (m *MockProviderClient) StopProvider(arg0 context.Context, arg1 *tfplugin6.StopProvider_Request, arg2 ...grpc.CallOption) (*tfplugin6.StopProvider_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -188,14 +189,14 @@ func (m *MockProviderClient) StopProvider(arg0 context.Context, arg1 *tfplugin6. return ret0, ret1 } -// StopProvider indicates an expected call of StopProvider +// StopProvider indicates an expected call of StopProvider. func (mr *MockProviderClientMockRecorder) StopProvider(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopProvider", reflect.TypeOf((*MockProviderClient)(nil).StopProvider), varargs...) } -// UpgradeResourceState mocks base method +// UpgradeResourceState mocks base method. func (m *MockProviderClient) UpgradeResourceState(arg0 context.Context, arg1 *tfplugin6.UpgradeResourceState_Request, arg2 ...grpc.CallOption) (*tfplugin6.UpgradeResourceState_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -208,14 +209,14 @@ func (m *MockProviderClient) UpgradeResourceState(arg0 context.Context, arg1 *tf return ret0, ret1 } -// UpgradeResourceState indicates an expected call of UpgradeResourceState +// UpgradeResourceState indicates an expected call of UpgradeResourceState. func (mr *MockProviderClientMockRecorder) UpgradeResourceState(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpgradeResourceState", reflect.TypeOf((*MockProviderClient)(nil).UpgradeResourceState), varargs...) } -// ValidateDataResourceConfig mocks base method +// ValidateDataResourceConfig mocks base method. func (m *MockProviderClient) ValidateDataResourceConfig(arg0 context.Context, arg1 *tfplugin6.ValidateDataResourceConfig_Request, arg2 ...grpc.CallOption) (*tfplugin6.ValidateDataResourceConfig_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -228,14 +229,14 @@ func (m *MockProviderClient) ValidateDataResourceConfig(arg0 context.Context, ar return ret0, ret1 } -// ValidateDataResourceConfig indicates an expected call of ValidateDataResourceConfig +// ValidateDataResourceConfig indicates an expected call of ValidateDataResourceConfig. func (mr *MockProviderClientMockRecorder) ValidateDataResourceConfig(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateDataResourceConfig", reflect.TypeOf((*MockProviderClient)(nil).ValidateDataResourceConfig), varargs...) } -// ValidateProviderConfig mocks base method +// ValidateProviderConfig mocks base method. func (m *MockProviderClient) ValidateProviderConfig(arg0 context.Context, arg1 *tfplugin6.ValidateProviderConfig_Request, arg2 ...grpc.CallOption) (*tfplugin6.ValidateProviderConfig_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -248,14 +249,14 @@ func (m *MockProviderClient) ValidateProviderConfig(arg0 context.Context, arg1 * return ret0, ret1 } -// ValidateProviderConfig indicates an expected call of ValidateProviderConfig +// ValidateProviderConfig indicates an expected call of ValidateProviderConfig. func (mr *MockProviderClientMockRecorder) ValidateProviderConfig(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateProviderConfig", reflect.TypeOf((*MockProviderClient)(nil).ValidateProviderConfig), varargs...) } -// ValidateResourceConfig mocks base method +// ValidateResourceConfig mocks base method. func (m *MockProviderClient) ValidateResourceConfig(arg0 context.Context, arg1 *tfplugin6.ValidateResourceConfig_Request, arg2 ...grpc.CallOption) (*tfplugin6.ValidateResourceConfig_Response, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -268,7 +269,7 @@ func (m *MockProviderClient) ValidateResourceConfig(arg0 context.Context, arg1 * return ret0, ret1 } -// ValidateResourceConfig indicates an expected call of ValidateResourceConfig +// ValidateResourceConfig indicates an expected call of ValidateResourceConfig. func (mr *MockProviderClientMockRecorder) ValidateResourceConfig(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...)