diff --git a/backend/remote/backend_common.go b/backend/remote/backend_common.go index 4e64830f2..15f59ac47 100644 --- a/backend/remote/backend_common.go +++ b/backend/remote/backend_common.go @@ -227,6 +227,57 @@ func (b *Remote) parseVariableValues(op *backend.Operation) (terraform.InputValu return result, diags } +func (b *Remote) costEstimation(stopCtx, cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error { + if r.CostEstimation == nil { + return nil + } + + if b.CLI != nil { + b.CLI.Output("\n------------------------------------------------------------------------\n") + } + + logs, err := b.client.CostEstimations.Logs(stopCtx, r.CostEstimation.ID) + if err != nil { + return generalError("Failed to retrieve cost estimation logs", err) + } + scanner := bufio.NewScanner(logs) + + // Retrieve the cost estimation to get its current status. + ce, err := b.client.CostEstimations.Read(stopCtx, r.CostEstimation.ID) + if err != nil { + return generalError("Failed to retrieve cost estimation", err) + } + + msgPrefix := "Cost estimation" + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(msgPrefix + ":\n")) + } + + for scanner.Scan() { + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(scanner.Text())) + } + } + + if err := scanner.Err(); err != nil { + return generalError("Failed to read logs", err) + } + + switch ce.Status { + case tfe.CostEstimationFinished: + if len(r.PolicyChecks) == 0 && r.HasChanges && op.Type == backend.OperationTypeApply && b.CLI != nil { + b.CLI.Output("\n------------------------------------------------------------------------") + } + return nil + case tfe.CostEstimationErrored: + return fmt.Errorf(msgPrefix + " errored.") + case tfe.CostEstimationCanceled: + return fmt.Errorf(msgPrefix + " canceled.") + default: + return fmt.Errorf("Unknown or unexpected cost estimation state: %s", ce.Status) + } +} + func (b *Remote) checkPolicy(stopCtx, cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error { if b.CLI != nil { b.CLI.Output("\n------------------------------------------------------------------------\n") diff --git a/backend/remote/backend_mock.go b/backend/remote/backend_mock.go index 8985dd0e3..31b63131b 100644 --- a/backend/remote/backend_mock.go +++ b/backend/remote/backend_mock.go @@ -21,6 +21,7 @@ import ( type mockClient struct { Applies *mockApplies ConfigurationVersions *mockConfigurationVersions + CostEstimations *mockCostEstimations Organizations *mockOrganizations Plans *mockPlans PolicyChecks *mockPolicyChecks @@ -33,6 +34,7 @@ func newMockClient() *mockClient { c := &mockClient{} c.Applies = newMockApplies(c) c.ConfigurationVersions = newMockConfigurationVersions(c) + c.CostEstimations = newMockCostEstimations(c) c.Organizations = newMockOrganizations(c) c.Plans = newMockPlans(c) c.PolicyChecks = newMockPolicyChecks(c) @@ -212,6 +214,84 @@ func (m *mockConfigurationVersions) Upload(ctx context.Context, url, path string return nil } +type mockCostEstimations struct { + client *mockClient + estimations map[string]*tfe.CostEstimation + logs map[string]string +} + +func newMockCostEstimations(client *mockClient) *mockCostEstimations { + return &mockCostEstimations{ + client: client, + estimations: make(map[string]*tfe.CostEstimation), + 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 *mockCostEstimations) create(cvID, workspaceID string) (*tfe.CostEstimation, error) { + id := generateID("ce-") + + ce := &tfe.CostEstimation{ + ID: id, + Status: tfe.CostEstimationQueued, + } + + w, ok := m.client.Workspaces.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + logfile := filepath.Join( + m.client.ConfigurationVersions.uploadPaths[cvID], + w.WorkingDirectory, + "ce.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 *mockCostEstimations) Read(ctx context.Context, costEstimationID string) (*tfe.CostEstimation, error) { + ce, ok := m.estimations[costEstimationID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + return ce, nil +} + +func (m *mockCostEstimations) Logs(ctx context.Context, costEstimationID string) (io.Reader, error) { + ce, ok := m.estimations[costEstimationID] + 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.CostEstimationFinished + + return bytes.NewBuffer(logs), nil +} + // mockInput is a mock implementation of terraform.UIInput. type mockInput struct { answers map[string]string @@ -652,19 +732,25 @@ func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t return nil, err } + ce, err := m.client.CostEstimations.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, - HasChanges: false, - Permissions: &tfe.RunPermissions{}, - Plan: p, - Status: tfe.RunPending, + ID: generateID("run-"), + Actions: &tfe.RunActions{IsCancelable: true}, + Apply: a, + CostEstimation: ce, + HasChanges: false, + Permissions: &tfe.RunPermissions{}, + Plan: p, + Status: tfe.RunPending, } if pc != nil { diff --git a/backend/remote/backend_plan.go b/backend/remote/backend_plan.go index 200f7fd8d..db5495b16 100644 --- a/backend/remote/backend_plan.go +++ b/backend/remote/backend_plan.go @@ -290,6 +290,14 @@ func (b *Remote) plan(stopCtx, cancelCtx context.Context, op *backend.Operation, return r, nil } + // Show any cost estimation output. + if r.CostEstimation != nil { + err = b.costEstimation(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) diff --git a/backend/remote/backend_plan_test.go b/backend/remote/backend_plan_test.go index 389444200..acf3c4abb 100644 --- a/backend/remote/backend_plan_test.go +++ b/backend/remote/backend_plan_test.go @@ -655,6 +655,40 @@ func TestRemote_planWithWorkingDirectory(t *testing.T) { } } +func TestRemote_costEstimation(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup := testOperationPlan(t, "./test-fixtures/plan-cost-estimation") + 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() + 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 the remote backend") { + t.Fatalf("expected remote backend header in output: %s", output) + } + if !strings.Contains(output, "SKU") { + t.Fatalf("expected cost estimation 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 TestRemote_planPolicyPass(t *testing.T) { b, bCleanup := testBackendDefault(t) defer bCleanup() @@ -681,12 +715,12 @@ func TestRemote_planPolicyPass(t *testing.T) { if !strings.Contains(output, "Running plan in the remote backend") { t.Fatalf("expected remote backend 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 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } } func TestRemote_planPolicyHardFail(t *testing.T) { @@ -720,12 +754,12 @@ func TestRemote_planPolicyHardFail(t *testing.T) { if !strings.Contains(output, "Running plan in the remote backend") { t.Fatalf("expected remote backend 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 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } } func TestRemote_planPolicySoftFail(t *testing.T) { @@ -759,12 +793,12 @@ func TestRemote_planPolicySoftFail(t *testing.T) { if !strings.Contains(output, "Running plan in the remote backend") { t.Fatalf("expected remote backend 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 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summery in output: %s", output) + } } func TestRemote_planWithRemoteError(t *testing.T) { diff --git a/backend/remote/test-fixtures/plan-cost-estimation/ce.log b/backend/remote/test-fixtures/plan-cost-estimation/ce.log new file mode 100644 index 000000000..e51fef1ed --- /dev/null +++ b/backend/remote/test-fixtures/plan-cost-estimation/ce.log @@ -0,0 +1,6 @@ ++---------+------+-----+-------------+----------------------+ +| PRODUCT | NAME | SKU | DESCRIPTION | DELTA | ++---------+------+-----+-------------+----------------------+ ++---------+------+-----+-------------+----------------------+ +| TOTAL | $0.000 USD / 720 HRS | ++---------+------+-----+-------------+----------------------+ diff --git a/backend/remote/test-fixtures/plan-cost-estimation/main.tf b/backend/remote/test-fixtures/plan-cost-estimation/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/backend/remote/test-fixtures/plan-cost-estimation/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/backend/remote/test-fixtures/plan-cost-estimation/plan.log b/backend/remote/test-fixtures/plan-cost-estimation/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/backend/remote/test-fixtures/plan-cost-estimation/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/backend/remote/testing.go b/backend/remote/testing.go index 09c541897..2213ba18c 100644 --- a/backend/remote/testing.go +++ b/backend/remote/testing.go @@ -115,6 +115,7 @@ func testBackend(t *testing.T, obj cty.Value) (*Remote, func()) { b.CLI = cli.NewMockUi() b.client.Applies = mc.Applies b.client.ConfigurationVersions = mc.ConfigurationVersions + b.client.CostEstimations = mc.CostEstimations b.client.Organizations = mc.Organizations b.client.Plans = mc.Plans b.client.PolicyChecks = mc.PolicyChecks diff --git a/go.mod b/go.mod index c4c37b4a4..5b19624d4 100644 --- a/go.mod +++ b/go.mod @@ -55,7 +55,7 @@ require ( github.com/hashicorp/go-retryablehttp v0.5.2 github.com/hashicorp/go-rootcerts v1.0.0 github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86 // indirect - github.com/hashicorp/go-tfe v0.3.14 + github.com/hashicorp/go-tfe v0.3.16 github.com/hashicorp/go-uuid v1.0.1 github.com/hashicorp/go-version v1.1.0 github.com/hashicorp/golang-lru v0.5.0 // indirect diff --git a/go.sum b/go.sum index a3d6a91e6..99cec1954 100644 --- a/go.sum +++ b/go.sum @@ -191,8 +191,8 @@ github.com/hashicorp/go-slug v0.3.0 h1:L0c+AvH/J64iMNF4VqRaRku2DMTEuHioPVS7kMjWI github.com/hashicorp/go-slug v0.3.0/go.mod h1:I5tq5Lv0E2xcNXNkmx7BSfzi1PsJ2cNjs3cC3LwyhK8= github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86 h1:7YOlAIO2YWnJZkQp7B5eFykaIY7C9JndqAFQyVV5BhM= github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-tfe v0.3.14 h1:1eWmq4RAICGufydNUWu7ahb0gtq24pN9jatD2FkdxdE= -github.com/hashicorp/go-tfe v0.3.14/go.mod h1:SuPHR+OcxvzBZNye7nGPfwZTEyd3rWPfLVbCgyZPezM= +github.com/hashicorp/go-tfe v0.3.16 h1:GS2yv580p0co4j3FBVaC6Zahd9mxdCGehhJ0qqzFMH0= +github.com/hashicorp/go-tfe v0.3.16/go.mod h1:SuPHR+OcxvzBZNye7nGPfwZTEyd3rWPfLVbCgyZPezM= github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM= 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= diff --git a/vendor/github.com/hashicorp/go-tfe/cost_estimation.go b/vendor/github.com/hashicorp/go-tfe/cost_estimation.go new file mode 100644 index 000000000..acb038554 --- /dev/null +++ b/vendor/github.com/hashicorp/go-tfe/cost_estimation.go @@ -0,0 +1,121 @@ +package tfe + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/url" + "time" +) + +// Compile-time proof of interface implementation. +var _ CostEstimations = (*costEstimations)(nil) + +// CostEstimations describes all the costEstimation related methods that +// the Terraform Enterprise API supports. +// +// TFE API docs: https://www.terraform.io/docs/enterprise/api/ (TBD) +type CostEstimations interface { + // Read a costEstimation by its ID. + Read(ctx context.Context, costEstimationID string) (*CostEstimation, error) + + // Logs retrieves the logs of a costEstimation. + Logs(ctx context.Context, costEstimationID string) (io.Reader, error) +} + +// costEstimations implements CostEstimations. +type costEstimations struct { + client *Client +} + +// CostEstimationStatus represents a costEstimation state. +type CostEstimationStatus string + +//List all available costEstimation statuses. +const ( + CostEstimationCanceled CostEstimationStatus = "canceled" + CostEstimationErrored CostEstimationStatus = "errored" + CostEstimationFinished CostEstimationStatus = "finished" + CostEstimationQueued CostEstimationStatus = "queued" +) + +// CostEstimation represents a Terraform Enterprise costEstimation. +type CostEstimation struct { + ID string `jsonapi:"primary,cost-estimations"` + ErrorMessage string `jsonapi:"attr,error-message"` + Status CostEstimationStatus `jsonapi:"attr,status"` + StatusTimestamps *CostEstimationStatusTimestamps `jsonapi:"attr,status-timestamps"` +} + +// CostEstimationStatusTimestamps holds the timestamps for individual costEstimation statuses. +type CostEstimationStatusTimestamps struct { + CanceledAt time.Time `json:"canceled-at"` + ErroredAt time.Time `json:"errored-at"` + FinishedAt time.Time `json:"finished-at"` + QueuedAt time.Time `json:"queued-at"` +} + +// Read a costEstimation by its ID. +func (s *costEstimations) Read(ctx context.Context, costEstimationID string) (*CostEstimation, error) { + if !validStringID(&costEstimationID) { + return nil, errors.New("invalid value for cost estimation ID") + } + + u := fmt.Sprintf("cost-estimations/%s", url.QueryEscape(costEstimationID)) + req, err := s.client.newRequest("GET", u, nil) + if err != nil { + return nil, err + } + + ce := &CostEstimation{} + err = s.client.do(ctx, req, ce) + if err != nil { + return nil, err + } + + return ce, nil +} + +// Logs retrieves the logs of a costEstimation. +func (s *costEstimations) Logs(ctx context.Context, costEstimationID string) (io.Reader, error) { + if !validStringID(&costEstimationID) { + return nil, errors.New("invalid value for cost estimation ID") + } + + // Loop until the context is canceled or the cost estimation is finished + // running. The cost estimation logs are not streamed and so only available + // once the estimation is finished. + for { + // Get the costEstimation to make sure it exists. + ce, err := s.Read(ctx, costEstimationID) + if err != nil { + return nil, err + } + + switch ce.Status { + case CostEstimationQueued: + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(500 * time.Millisecond): + continue + } + } + + u := fmt.Sprintf("cost-estimations/%s/output", url.QueryEscape(costEstimationID)) + req, err := s.client.newRequest("GET", u, nil) + if err != nil { + return nil, err + } + + logs := bytes.NewBuffer(nil) + err = s.client.do(ctx, req, logs) + if err != nil { + return nil, err + } + + return logs, nil + } +} diff --git a/vendor/github.com/hashicorp/go-tfe/organization.go b/vendor/github.com/hashicorp/go-tfe/organization.go index e3a14f697..23d3041f0 100644 --- a/vendor/github.com/hashicorp/go-tfe/organization.go +++ b/vendor/github.com/hashicorp/go-tfe/organization.go @@ -77,6 +77,7 @@ type OrganizationList struct { type Organization struct { Name string `jsonapi:"primary,organizations"` CollaboratorAuthPolicy AuthPolicyType `jsonapi:"attr,collaborator-auth-policy"` + CostEstimationEnabled bool `jsonapi:"attr,cost-estimation-enabled"` CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` Email string `jsonapi:"attr,email"` EnterprisePlan EnterprisePlanType `jsonapi:"attr,enterprise-plan"` @@ -167,6 +168,9 @@ type OrganizationCreateOptions struct { // Authentication policy. CollaboratorAuthPolicy *AuthPolicyType `jsonapi:"attr,collaborator-auth-policy,omitempty"` + // Enable Cost Estimation + CostEstimationEnabled *bool `jsonapi:"attr,cost-estimation-enabled,omitempty"` + // The name of the "owners" team OwnersTeamSAMLRoleID *string `jsonapi:"attr,owners-team-saml-role-id,omitempty"` } @@ -248,6 +252,9 @@ type OrganizationUpdateOptions struct { // Authentication policy. CollaboratorAuthPolicy *AuthPolicyType `jsonapi:"attr,collaborator-auth-policy,omitempty"` + // Enable Cost Estimation + CostEstimationEnabled *bool `jsonapi:"attr,cost-estimation-enabled,omitempty"` + // The name of the "owners" team OwnersTeamSAMLRoleID *string `jsonapi:"attr,owners-team-saml-role-id,omitempty"` } diff --git a/vendor/github.com/hashicorp/go-tfe/run.go b/vendor/github.com/hashicorp/go-tfe/run.go index 58528739b..702a3de81 100644 --- a/vendor/github.com/hashicorp/go-tfe/run.go +++ b/vendor/github.com/hashicorp/go-tfe/run.go @@ -49,12 +49,16 @@ type RunStatus string //List all available run statuses. const ( RunApplied RunStatus = "applied" + RunApplyQueued RunStatus = "apply_queued" RunApplying RunStatus = "applying" RunCanceled RunStatus = "canceled" RunConfirmed RunStatus = "confirmed" + RunCostEstimated RunStatus = "cost_estimated" + RunCostEstimating RunStatus = "cost_estimating" RunDiscarded RunStatus = "discarded" RunErrored RunStatus = "errored" RunPending RunStatus = "pending" + RunPlanQueued RunStatus = "plan_queued" RunPlanned RunStatus = "planned" RunPlannedAndFinished RunStatus = "planned_and_finished" RunPlanning RunStatus = "planning" @@ -98,6 +102,7 @@ type Run struct { // Relations Apply *Apply `jsonapi:"relation,apply"` ConfigurationVersion *ConfigurationVersion `jsonapi:"relation,configuration-version"` + CostEstimation *CostEstimation `jsonapi:"relation,cost-estimation"` Plan *Plan `jsonapi:"relation,plan"` PolicyChecks []*PolicyCheck `jsonapi:"relation,policy-checks"` Workspace *Workspace `jsonapi:"relation,workspace"` @@ -274,7 +279,7 @@ func (s *runs) Cancel(ctx context.Context, runID string, options RunCancelOption return s.client.do(ctx, req, nil) } -// RunCancelOptions represents the options for force-canceling a run. +// RunForceCancelOptions represents the options for force-canceling a run. type RunForceCancelOptions struct { // An optional comment explaining the reason for the force-cancel. Comment *string `json:"comment,omitempty"` diff --git a/vendor/github.com/hashicorp/go-tfe/tfe.go b/vendor/github.com/hashicorp/go-tfe/tfe.go index 00cda2ea2..c9d173b6a 100644 --- a/vendor/github.com/hashicorp/go-tfe/tfe.go +++ b/vendor/github.com/hashicorp/go-tfe/tfe.go @@ -106,6 +106,7 @@ type Client struct { Applies Applies ConfigurationVersions ConfigurationVersions + CostEstimations CostEstimations NotificationConfigurations NotificationConfigurations OAuthClients OAuthClients OAuthTokens OAuthTokens @@ -195,6 +196,7 @@ func NewClient(cfg *Config) (*Client, error) { // Create the services. client.Applies = &applies{client: client} client.ConfigurationVersions = &configurationVersions{client: client} + client.CostEstimations = &costEstimations{client: client} client.NotificationConfigurations = ¬ificationConfigurations{client: client} client.OAuthClients = &oAuthClients{client: client} client.OAuthTokens = &oAuthTokens{client: client} @@ -247,7 +249,7 @@ func (c *Client) retryHTTPBackoff(min, max time.Duration, attemptNum int, resp * } // Use the rate limit backoff function when we are rate limited. - if resp.StatusCode == 429 { + if resp != nil && resp.StatusCode == 429 { return rateLimitBackoff(min, max, attemptNum, resp) } diff --git a/vendor/modules.txt b/vendor/modules.txt index c81dee4ce..6b217a443 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -290,7 +290,7 @@ github.com/hashicorp/go-rootcerts github.com/hashicorp/go-safetemp # github.com/hashicorp/go-slug v0.3.0 github.com/hashicorp/go-slug -# github.com/hashicorp/go-tfe v0.3.14 +# github.com/hashicorp/go-tfe v0.3.16 github.com/hashicorp/go-tfe # github.com/hashicorp/go-uuid v1.0.1 github.com/hashicorp/go-uuid