backend/remote: add support for the apply operation

This commit is contained in:
Sander van Harmelen 2018-09-19 21:01:40 +02:00
parent 27b720113e
commit 621d589189
5 changed files with 277 additions and 71 deletions

View File

@ -166,7 +166,7 @@ type Operation struct {
// RunningOperation is the result of starting an operation. // RunningOperation is the result of starting an operation.
type RunningOperation struct { type RunningOperation struct {
// For implementers of a backend, this context should not wrap the // For implementers of a backend, this context should not wrap the
// passed in context. Otherwise, canceling the parent context will // passed in context. Otherwise, cancelling the parent context will
// immediately mark this context as "done" but those aren't the semantics // immediately mark this context as "done" but those aren't the semantics
// we want: we want this context to be done only when the operation itself // we want: we want this context to be done only when the operation itself
// is fully done. // is fully done.

View File

@ -241,8 +241,8 @@ No configuration files found!
Apply requires configuration to be present. Applying without a configuration Apply requires configuration to be present. Applying without a configuration
would mark everything for destruction, which is normally not what is desired. would mark everything for destruction, which is normally not what is desired.
If you would like to destroy everything, please run 'terraform destroy' instead If you would like to destroy everything, please run 'terraform destroy' which
which does not require any configuration files. does not require any configuration files.
` `
const stateWriteBackedUpError = `Failed to persist state to backend. const stateWriteBackedUpError = `Failed to persist state to backend.
@ -285,7 +285,7 @@ This is a serious bug in Terraform and should be reported.
const earlyStateWriteErrorFmt = `Error saving current state: %s const earlyStateWriteErrorFmt = `Error saving current state: %s
Terraform encountered an error attempting to save the state before canceling Terraform encountered an error attempting to save the state before cancelling
the current operation. Once the operation is complete another attempt will be the current operation. Once the operation is complete another attempt will be
made to save the final state. made to save the final state.
` `

View File

@ -394,15 +394,17 @@ func (b *Remote) Operation(ctx context.Context, op *backend.Operation) (*backend
} }
// Determine the function to call for our operation // Determine the function to call for our operation
var f func(context.Context, context.Context, *backend.Operation, *backend.RunningOperation) var f func(context.Context, context.Context, *backend.Operation) error
switch op.Type { switch op.Type {
case backend.OperationTypePlan: case backend.OperationTypePlan:
f = b.opPlan f = b.opPlan
case backend.OperationTypeApply:
f = b.opApply
default: default:
return nil, fmt.Errorf( return nil, fmt.Errorf(
"\n\nThe \"remote\" backend currently only supports the \"plan\" operation.\n"+ "\n\nThe \"remote\" backend does not support the %q operation.\n"+
"Please use the remote backend web UI for all other operations:\n"+ "Please use the remote backend web UI for running this operation:\n"+
"https://%s/app/%s/%s", b.hostname, b.organization, op.Workspace) "https://%s/app/%s/%s", op.Type, b.hostname, b.organization, op.Workspace)
// return nil, backend.ErrOperationNotSupported // return nil, backend.ErrOperationNotSupported
} }
@ -432,7 +434,11 @@ func (b *Remote) Operation(ctx context.Context, op *backend.Operation) (*backend
defer cancel() defer cancel()
defer b.opLock.Unlock() defer b.opLock.Unlock()
f(stopCtx, cancelCtx, op, runningOp)
err := f(stopCtx, cancelCtx, op)
if err != nil && err != context.Canceled {
runningOp.Err = err
}
}() }()
// Return // Return
@ -453,6 +459,13 @@ func (b *Remote) Colorize() *colorstring.Colorize {
} }
} }
func generalError(msg string, err error) error {
if err != context.Canceled {
err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(generalErr, msg, err)))
}
return err
}
const generalErr = ` const generalErr = `
%s: %v %s: %v

View File

@ -0,0 +1,224 @@
package remote
import (
"bufio"
"context"
"errors"
"fmt"
"log"
"strings"
tfe "github.com/hashicorp/go-tfe"
"github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/terraform"
)
func (b *Remote) opApply(stopCtx, cancelCtx context.Context, op *backend.Operation) error {
log.Printf("[INFO] backend/remote: starting Apply operation")
// Retrieve the workspace used to run this operation in.
w, err := b.client.Workspaces.Read(stopCtx, b.organization, op.Workspace)
if err != nil {
return generalError("error retrieving workspace", err)
}
if w.VCSRepo != nil {
return fmt.Errorf(strings.TrimSpace(applyErrVCSNotSupported))
}
if op.Plan != nil {
return fmt.Errorf(strings.TrimSpace(applyErrPlanNotSupported))
}
if op.Targets != nil {
return fmt.Errorf(strings.TrimSpace(applyErrTargetsNotSupported))
}
if (op.Module == nil || op.Module.Config().Dir == "") && !op.Destroy {
return fmt.Errorf(strings.TrimSpace(planErrNoConfig))
}
r, err := b.plan(stopCtx, cancelCtx, op, w)
if err != nil {
return err
}
if len(r.PolicyChecks) > 0 {
err = b.checkPolicy(stopCtx, cancelCtx, op, r)
if err != nil {
return err
}
}
hasUI := op.UIOut != nil && op.UIIn != nil
mustConfirm := hasUI && (op.Destroy && (!op.DestroyForce && !op.AutoApprove))
if mustConfirm {
opts := &terraform.InputOpts{Id: "approve"}
if op.Destroy {
opts.Query = "Do 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 = "Do 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 = b.confirm(stopCtx, op, opts, r); err != nil {
return err
}
}
err = b.client.Runs.Apply(stopCtx, r.ID, tfe.RunApplyOptions{})
if err != nil {
return generalError("error approving the apply command", err)
}
logs, err := b.client.Applies.Logs(stopCtx, r.Apply.ID)
if err != nil {
return generalError("error retrieving logs", err)
}
scanner := bufio.NewScanner(logs)
for scanner.Scan() {
if b.CLI != nil {
b.CLI.Output(b.Colorize().Color(scanner.Text()))
}
}
if err := scanner.Err(); err != nil {
return generalError("error reading logs", err)
}
return nil
}
func (b *Remote) checkPolicy(stopCtx, cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error {
for _, pc := range r.PolicyChecks {
logs, err := b.client.PolicyChecks.Logs(stopCtx, pc.ID)
if err != nil {
return generalError("error retrieving policy check logs", err)
}
scanner := bufio.NewScanner(logs)
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("\n" + 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("error reading logs", err)
}
pc, err := b.client.PolicyChecks.Read(stopCtx, pc.ID)
if err != nil {
return generalError("error retrieving policy check", err)
}
switch pc.Status {
case tfe.PolicyPasses:
continue
case tfe.PolicyErrored:
return fmt.Errorf(msgPrefix + " errored.")
case tfe.PolicyHardFailed:
return fmt.Errorf(msgPrefix + " hard failed.")
case tfe.PolicySoftFailed:
if op.UIOut == nil || op.UIIn == nil ||
!pc.Actions.IsOverridable || !pc.Permissions.CanOverride {
return fmt.Errorf(msgPrefix + " soft failed.")
}
default:
return fmt.Errorf("Unknown or unexpected policy state: %s", pc.Status)
}
opts := &terraform.InputOpts{
Id: "override",
Query: "Do you want to override the failed policy check?",
Description: "Only 'yes' will be accepted to override.",
}
if err = b.confirm(stopCtx, op, opts, r); err != nil {
return err
}
}
return nil
}
func (b *Remote) confirm(stopCtx context.Context, op *backend.Operation, opts *terraform.InputOpts, r *tfe.Run) error {
v, err := op.UIIn.Input(opts)
if err != nil {
return fmt.Errorf("Error asking %s: %v", opts.Id, err)
}
if v != "yes" {
// Make sure we discard the run.
err = b.client.Runs.Discard(stopCtx, r.ID, tfe.RunDiscardOptions{})
if err != nil {
if op.Destroy {
return generalError("error disarding destroy", err)
}
return generalError("error disarding apply", err)
}
// Even if the run was disarding successfully, we still
// return an error as the apply command was cancelled.
if op.Destroy {
return errors.New("Destroy cancelled.")
}
return errors.New("Apply cancelled.")
}
return nil
}
const applyErrVCSNotSupported = `
Apply not allowed for workspaces with a VCS connection!
A workspace that is connected to a VCS requires the VCS based workflow
to ensure that the VCS remains the single source of truth.
`
const applyErrPlanNotSupported = `
Applying a saved plan is currently not supported!
The "remote" backend currently requires configuration to be present
and does not accept an existing saved plan as an argument at this time.
`
const applyErrTargetsNotSupported = `
Resource targeting is currently not supported!
The "remote" backend does not support resource targeting at this time.
`
const applyErrNoConfig = `
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.
`
const applyDefaultHeader = `
[reset][yellow]Running apply in the remote backend. Output will stream here. Pressing Ctrl-C
will stop streaming the logs, but will not stop the apply running remotely.
To view this run in a browser, visit:
https://%s/app/%s/%s/runs/%s[reset]
Waiting for the apply to start...
`

View File

@ -3,8 +3,8 @@ package remote
import ( import (
"bufio" "bufio"
"context" "context"
"errors"
"fmt" "fmt"
"io"
"io/ioutil" "io/ioutil"
"log" "log"
"os" "os"
@ -16,51 +16,45 @@ import (
"github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/backend"
) )
func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation, runningOp *backend.RunningOperation) { func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation) error {
log.Printf("[INFO] backend/remote: starting Plan operation") log.Printf("[INFO] backend/remote: starting Plan operation")
if op.Plan != nil { if op.Plan != nil {
runningOp.Err = fmt.Errorf(strings.TrimSpace(planErrPlanNotSupported)) return fmt.Errorf(strings.TrimSpace(planErrPlanNotSupported))
return
} }
if op.PlanOutPath != "" { if op.PlanOutPath != "" {
runningOp.Err = fmt.Errorf(strings.TrimSpace(planErrOutPathNotSupported)) return fmt.Errorf(strings.TrimSpace(planErrOutPathNotSupported))
return
} }
if op.Targets != nil { if op.Targets != nil {
runningOp.Err = fmt.Errorf(strings.TrimSpace(planErrTargetsNotSupported)) return fmt.Errorf(strings.TrimSpace(planErrTargetsNotSupported))
return
} }
if (op.Module == nil || op.Module.Config().Dir == "") && !op.Destroy { if (op.Module == nil || op.Module.Config().Dir == "") && !op.Destroy {
runningOp.Err = fmt.Errorf(strings.TrimSpace(planErrNoConfig)) return fmt.Errorf(strings.TrimSpace(planErrNoConfig))
return
} }
// Retrieve the workspace used to run this operation in. // Retrieve the workspace used to run this operation in.
w, err := b.client.Workspaces.Read(stopCtx, b.organization, op.Workspace) w, err := b.client.Workspaces.Read(stopCtx, b.organization, op.Workspace)
if err != nil { if err != nil {
if err != context.Canceled { return generalError("error retrieving workspace", err)
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
generalErr, "error retrieving workspace", err)))
}
return
} }
_, err = b.plan(stopCtx, cancelCtx, op, w)
return err
}
func (b *Remote) plan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) {
configOptions := tfe.ConfigurationVersionCreateOptions{ configOptions := tfe.ConfigurationVersionCreateOptions{
AutoQueueRuns: tfe.Bool(false), AutoQueueRuns: tfe.Bool(false),
Speculative: tfe.Bool(true), Speculative: tfe.Bool(op.Type == backend.OperationTypePlan),
} }
cv, err := b.client.ConfigurationVersions.Create(stopCtx, w.ID, configOptions) cv, err := b.client.ConfigurationVersions.Create(stopCtx, w.ID, configOptions)
if err != nil { if err != nil {
if err != context.Canceled { return nil, generalError("error creating configuration version", err)
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
generalErr, "error creating configuration version", err)))
}
return
} }
var configDir string var configDir string
@ -78,45 +72,34 @@ func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operatio
// be executed when we are destroying and doesn't need the config. // be executed when we are destroying and doesn't need the config.
configDir, err = ioutil.TempDir("", "tf") configDir, err = ioutil.TempDir("", "tf")
if err != nil { if err != nil {
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf( return nil, generalError("error creating temporary directory", err)
generalErr, "error creating temporary directory", err)))
return
} }
defer os.RemoveAll(configDir) defer os.RemoveAll(configDir)
// Make sure the configured working directory exists. // Make sure the configured working directory exists.
err = os.MkdirAll(filepath.Join(configDir, w.WorkingDirectory), 0700) err = os.MkdirAll(filepath.Join(configDir, w.WorkingDirectory), 0700)
if err != nil { if err != nil {
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf( return nil, generalError(
generalErr, "error creating temporary working directory", err))) "error creating temporary working directory", err)
return
} }
} }
err = b.client.ConfigurationVersions.Upload(stopCtx, cv.UploadURL, configDir) err = b.client.ConfigurationVersions.Upload(stopCtx, cv.UploadURL, configDir)
if err != nil { if err != nil {
if err != context.Canceled { return nil, generalError("error uploading configuration files", err)
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
generalErr, "error uploading configuration files", err)))
}
return
} }
uploaded := false uploaded := false
for i := 0; i < 60 && !uploaded; i++ { for i := 0; i < 60 && !uploaded; i++ {
select { select {
case <-stopCtx.Done(): case <-stopCtx.Done():
return return nil, context.Canceled
case <-cancelCtx.Done(): case <-cancelCtx.Done():
return return nil, context.Canceled
case <-time.After(500 * time.Millisecond): case <-time.After(500 * time.Millisecond):
cv, err = b.client.ConfigurationVersions.Read(stopCtx, cv.ID) cv, err = b.client.ConfigurationVersions.Read(stopCtx, cv.ID)
if err != nil { if err != nil {
if err != context.Canceled { return nil, generalError("error retrieving configuration version", err)
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
generalErr, "error retrieving configuration version", err)))
}
return
} }
if cv.Status == tfe.ConfigurationUploaded { if cv.Status == tfe.ConfigurationUploaded {
@ -126,9 +109,8 @@ func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operatio
} }
if !uploaded { if !uploaded {
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf( return nil, generalError(
generalErr, "error uploading configuration files", "operation timed out"))) "error uploading configuration files", errors.New("operation timed out"))
return
} }
runOptions := tfe.RunCreateOptions{ runOptions := tfe.RunCreateOptions{
@ -140,20 +122,12 @@ func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operatio
r, err := b.client.Runs.Create(stopCtx, runOptions) r, err := b.client.Runs.Create(stopCtx, runOptions)
if err != nil { if err != nil {
if err != context.Canceled { return nil, generalError("error creating run", err)
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
generalErr, "error creating run", err)))
}
return
} }
r, err = b.client.Runs.Read(stopCtx, r.ID) r, err = b.client.Runs.Read(stopCtx, r.ID)
if err != nil { if err != nil {
if err != context.Canceled { return nil, generalError("error retrieving run", err)
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
generalErr, "error retrieving run", err)))
}
return
} }
if b.CLI != nil { if b.CLI != nil {
@ -163,11 +137,7 @@ func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operatio
logs, err := b.client.Plans.Logs(stopCtx, r.Plan.ID) logs, err := b.client.Plans.Logs(stopCtx, r.Plan.ID)
if err != nil { if err != nil {
if err != context.Canceled { return nil, generalError("error retrieving logs", err)
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
generalErr, "error retrieving logs", err)))
}
return
} }
scanner := bufio.NewScanner(logs) scanner := bufio.NewScanner(logs)
@ -177,11 +147,10 @@ func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operatio
} }
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
if err != context.Canceled && err != io.EOF { return nil, generalError("error reading logs", err)
runningOp.Err = fmt.Errorf("Error reading logs: %v", err)
}
return
} }
return r, nil
} }
const planErrPlanNotSupported = ` const planErrPlanNotSupported = `
@ -217,7 +186,7 @@ a Terraform configuration file in the path being executed and try again.
const planDefaultHeader = ` const planDefaultHeader = `
[reset][yellow]Running plan in the remote backend. Output will stream here. Pressing Ctrl-C [reset][yellow]Running plan in the remote backend. Output will stream here. Pressing Ctrl-C
will stop streaming the logs, but will not stop the plan running remotely. will stop streaming the logs, but will not stop the plan running remotely.
To view this plan in a browser, visit: To view this run in a browser, visit:
https://%s/app/%s/%s/runs/%s[reset] https://%s/app/%s/%s/runs/%s[reset]
Waiting for the plan to start... Waiting for the plan to start...