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.
type RunningOperation struct {
// 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
// we want: we want this context to be done only when the operation itself
// is fully done.

View File

@ -241,8 +241,8 @@ 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' instead
which does not require any configuration files.
If you would like to destroy everything, please run 'terraform destroy' which
does not require any configuration files.
`
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
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
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
var f func(context.Context, context.Context, *backend.Operation, *backend.RunningOperation)
var f func(context.Context, context.Context, *backend.Operation) error
switch op.Type {
case backend.OperationTypePlan:
f = b.opPlan
case backend.OperationTypeApply:
f = b.opApply
default:
return nil, fmt.Errorf(
"\n\nThe \"remote\" backend currently only supports the \"plan\" operation.\n"+
"Please use the remote backend web UI for all other operations:\n"+
"https://%s/app/%s/%s", b.hostname, b.organization, op.Workspace)
"\n\nThe \"remote\" backend does not support the %q operation.\n"+
"Please use the remote backend web UI for running this operation:\n"+
"https://%s/app/%s/%s", op.Type, b.hostname, b.organization, op.Workspace)
// return nil, backend.ErrOperationNotSupported
}
@ -432,7 +434,11 @@ func (b *Remote) Operation(ctx context.Context, op *backend.Operation) (*backend
defer cancel()
defer b.opLock.Unlock()
f(stopCtx, cancelCtx, op, runningOp)
err := f(stopCtx, cancelCtx, op)
if err != nil && err != context.Canceled {
runningOp.Err = err
}
}()
// 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 = `
%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 (
"bufio"
"context"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"os"
@ -16,51 +16,45 @@ import (
"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")
if op.Plan != nil {
runningOp.Err = fmt.Errorf(strings.TrimSpace(planErrPlanNotSupported))
return
return fmt.Errorf(strings.TrimSpace(planErrPlanNotSupported))
}
if op.PlanOutPath != "" {
runningOp.Err = fmt.Errorf(strings.TrimSpace(planErrOutPathNotSupported))
return
return fmt.Errorf(strings.TrimSpace(planErrOutPathNotSupported))
}
if op.Targets != nil {
runningOp.Err = fmt.Errorf(strings.TrimSpace(planErrTargetsNotSupported))
return
return fmt.Errorf(strings.TrimSpace(planErrTargetsNotSupported))
}
if (op.Module == nil || op.Module.Config().Dir == "") && !op.Destroy {
runningOp.Err = fmt.Errorf(strings.TrimSpace(planErrNoConfig))
return
return fmt.Errorf(strings.TrimSpace(planErrNoConfig))
}
// Retrieve the workspace used to run this operation in.
w, err := b.client.Workspaces.Read(stopCtx, b.organization, op.Workspace)
if err != nil {
if err != context.Canceled {
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
generalErr, "error retrieving workspace", err)))
}
return
return generalError("error retrieving workspace", err)
}
_, 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{
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)
if err != nil {
if err != context.Canceled {
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
generalErr, "error creating configuration version", err)))
}
return
return nil, generalError("error creating configuration version", err)
}
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.
configDir, err = ioutil.TempDir("", "tf")
if err != nil {
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
generalErr, "error creating temporary directory", err)))
return
return nil, generalError("error creating 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 {
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
generalErr, "error creating temporary working directory", err)))
return
return nil, generalError(
"error creating temporary working directory", err)
}
}
err = b.client.ConfigurationVersions.Upload(stopCtx, cv.UploadURL, configDir)
if err != nil {
if err != context.Canceled {
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
generalErr, "error uploading configuration files", err)))
}
return
return nil, generalError("error uploading configuration files", err)
}
uploaded := false
for i := 0; i < 60 && !uploaded; i++ {
select {
case <-stopCtx.Done():
return
return nil, context.Canceled
case <-cancelCtx.Done():
return
return nil, context.Canceled
case <-time.After(500 * time.Millisecond):
cv, err = b.client.ConfigurationVersions.Read(stopCtx, cv.ID)
if err != nil {
if err != context.Canceled {
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
generalErr, "error retrieving configuration version", err)))
}
return
return nil, generalError("error retrieving configuration version", err)
}
if cv.Status == tfe.ConfigurationUploaded {
@ -126,9 +109,8 @@ func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operatio
}
if !uploaded {
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
generalErr, "error uploading configuration files", "operation timed out")))
return
return nil, generalError(
"error uploading configuration files", errors.New("operation timed out"))
}
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)
if err != nil {
if err != context.Canceled {
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
generalErr, "error creating run", err)))
}
return
return nil, generalError("error creating run", err)
}
r, err = b.client.Runs.Read(stopCtx, r.ID)
if err != nil {
if err != context.Canceled {
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
generalErr, "error retrieving run", err)))
}
return
return nil, generalError("error retrieving run", err)
}
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)
if err != nil {
if err != context.Canceled {
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
generalErr, "error retrieving logs", err)))
}
return
return nil, generalError("error retrieving logs", err)
}
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 != context.Canceled && err != io.EOF {
runningOp.Err = fmt.Errorf("Error reading logs: %v", err)
}
return
return nil, generalError("error reading logs", err)
}
return r, nil
}
const planErrPlanNotSupported = `
@ -217,7 +186,7 @@ a Terraform configuration file in the path being executed and try again.
const planDefaultHeader = `
[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.
To view this plan in a browser, visit:
To view this run in a browser, visit:
https://%s/app/%s/%s/runs/%s[reset]
Waiting for the plan to start...