Skip to content

Commit

Permalink
Merge pull request hashicorp#22761 from hashicorp/pault/tfce-ga
Browse files Browse the repository at this point in the history
Remote Backend: Support latest cost-estimate API
  • Loading branch information
thrashr888 authored Sep 30, 2019
2 parents e7fa424 + f2ef8ef commit 6f313ab
Show file tree
Hide file tree
Showing 20 changed files with 751 additions and 152 deletions.
83 changes: 83 additions & 0 deletions backend/remote/backend_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"fmt"
"io"
"math"
"strconv"
"strings"
"time"

tfe "github.com/hashicorp/go-tfe"
Expand Down Expand Up @@ -227,6 +229,87 @@ func (b *Remote) parseVariableValues(op *backend.Operation) (terraform.InputValu
return result, diags
}

func (b *Remote) costEstimate(stopCtx, cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error {
if r.CostEstimate == nil {
return nil
}

if b.CLI != nil {
b.CLI.Output("\n------------------------------------------------------------------------\n")
}

msgPrefix := "Cost estimation"
if b.CLI != nil {
b.CLI.Output(b.Colorize().Color(msgPrefix + ":\n"))
}

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(1 * time.Second):
}

// 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)
}

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(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("Waiting for cost estimate to complete..." + elapsed + "\n"))
}
continue
case tfe.CostEstimateErrored:
return fmt.Errorf(msgPrefix + " errored.")
case tfe.CostEstimateCanceled:
return fmt.Errorf(msgPrefix + " canceled.")
default:
return fmt.Errorf("Unknown or unexpected cost estimate state: %s", ce.Status)
}
}
return nil
}

func (b *Remote) checkPolicy(stopCtx, cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error {
if b.CLI != nil {
b.CLI.Output("\n------------------------------------------------------------------------\n")
Expand Down
151 changes: 144 additions & 7 deletions backend/remote/backend_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
type mockClient struct {
Applies *mockApplies
ConfigurationVersions *mockConfigurationVersions
CostEstimates *mockCostEstimates
Organizations *mockOrganizations
Plans *mockPlans
PolicyChecks *mockPolicyChecks
Expand All @@ -33,6 +34,7 @@ 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)
Expand Down Expand Up @@ -212,6 +214,88 @@ func (m *mockConfigurationVersions) Upload(ctx context.Context, url, path string
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
Expand Down Expand Up @@ -647,6 +731,11 @@ func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t
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
Expand All @@ -658,13 +747,14 @@ func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t
}

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,
CostEstimate: ce,
HasChanges: false,
Permissions: &tfe.RunPermissions{},
Plan: p,
Status: tfe.RunPending,
}

if pc != nil {
Expand Down Expand Up @@ -960,6 +1050,14 @@ 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) {
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 {
Expand All @@ -982,6 +1080,28 @@ 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) {
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)
Expand All @@ -990,6 +1110,14 @@ func (m *mockWorkspaces) Delete(ctx context.Context, organization, workspace str
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 {
Expand All @@ -999,6 +1127,15 @@ func (m *mockWorkspaces) RemoveVCSConnection(ctx context.Context, organization,
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 {
Expand Down
8 changes: 8 additions & 0 deletions backend/remote/backend_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,14 @@ to capture the filesystem context the remote workspace expects:
return r, nil
}

// 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)
Expand Down
Loading

0 comments on commit 6f313ab

Please sign in to comment.