Merge pull request #19014 from hashicorp/f-exit-code

backend/remote: allow enhanced backends to pass custom exit codes
This commit is contained in:
Sander van Harmelen 2018-10-09 09:42:17 +02:00 committed by GitHub
commit c03a89c788
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 205 additions and 27 deletions

View File

@ -188,6 +188,10 @@ type RunningOperation struct {
// the operation has completed.
Err error
// ExitCode can be used to set a custom exit code. This enables enhanced
// backends to set specific exit codes that miror any remote exit codes.
ExitCode int
// PlanEmpty is populated after a Plan operation completes without error
// to note whether a plan is empty or has changes.
PlanEmpty bool

View File

@ -416,7 +416,8 @@ func (b *Remote) Operation(ctx context.Context, op *backend.Operation) (*backend
// the runninCtx is only used to block until the operation returns.
runningCtx, done := context.WithCancel(context.Background())
runningOp := &backend.RunningOperation{
Context: runningCtx,
Context: runningCtx,
PlanEmpty: true,
}
// stopCtx wraps the context passed in, and is used to signal a graceful Stop.
@ -436,13 +437,30 @@ func (b *Remote) Operation(ctx context.Context, op *backend.Operation) (*backend
defer b.opLock.Unlock()
r, err := f(stopCtx, cancelCtx, op)
if err != nil && err != context.Canceled {
runningOp.Err = err
r, opErr := f(stopCtx, cancelCtx, op)
if opErr != nil && opErr != context.Canceled {
runningOp.Err = opErr
return
}
if r != nil && err == context.Canceled {
runningOp.Err = b.cancel(cancelCtx, op, r.ID)
if r != nil {
// Retrieve the run to get its current status.
r, err := b.client.Runs.Read(cancelCtx, r.ID)
if err != nil {
runningOp.Err = generalError("error retrieving run", err)
return
}
// Record if there are any changes.
runningOp.PlanEmpty = !r.HasChanges
if opErr == context.Canceled {
runningOp.Err = b.cancel(cancelCtx, op, r)
}
if runningOp.Err == nil && r.Status == tfe.RunErrored {
runningOp.ExitCode = 1
}
}
}()
@ -450,13 +468,7 @@ func (b *Remote) Operation(ctx context.Context, op *backend.Operation) (*backend
return runningOp, nil
}
func (b *Remote) cancel(cancelCtx context.Context, op *backend.Operation, runID string) error {
// Retrieve the run to get its current status.
r, err := b.client.Runs.Read(cancelCtx, runID)
if err != nil {
return generalError("error cancelling run", err)
}
func (b *Remote) cancel(cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error {
if r.Status == tfe.RunPending && r.Actions.IsCancelable {
// Only ask if the remote operation should be canceled
// if the auto approve flag is not set.
@ -483,7 +495,7 @@ func (b *Remote) cancel(cancelCtx context.Context, op *backend.Operation, runID
}
// Try to cancel the remote operation.
err = b.client.Runs.Cancel(cancelCtx, r.ID, tfe.RunCancelOptions{})
err := b.client.Runs.Cancel(cancelCtx, r.ID, tfe.RunCancelOptions{})
if err != nil {
return generalError("error cancelling run", err)
}

View File

@ -49,6 +49,9 @@ func TestRemote_applyBasic(t *testing.T) {
if run.Err != nil {
t.Fatalf("error running operation: %v", run.Err)
}
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)
@ -132,6 +135,9 @@ func TestRemote_applyWithVCS(t *testing.T) {
if run.Err == nil {
t.Fatalf("expected an apply error, got: %v", run.Err)
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
if !strings.Contains(run.Err.Error(), "not allowed for workspaces with a VCS") {
t.Fatalf("expected a VCS error, got: %v", run.Err)
}
@ -182,6 +188,9 @@ func TestRemote_applyWithPlan(t *testing.T) {
if run.Err == nil {
t.Fatalf("expected an apply error, got: %v", run.Err)
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
if !strings.Contains(run.Err.Error(), "saved plan is currently not supported") {
t.Fatalf("expected a saved plan error, got: %v", run.Err)
}
@ -232,6 +241,9 @@ func TestRemote_applyWithTarget(t *testing.T) {
if run.Err == nil {
t.Fatalf("expected an apply error, got: %v", run.Err)
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
if !strings.Contains(run.Err.Error(), "targeting is currently not supported") {
t.Fatalf("expected a targeting error, got: %v", run.Err)
}
@ -278,6 +290,9 @@ func TestRemote_applyNoConfig(t *testing.T) {
if run.Err == nil {
t.Fatalf("expected an apply error, got: %v", run.Err)
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
if !strings.Contains(run.Err.Error(), "configuration files found") {
t.Fatalf("expected configuration files error, got: %v", run.Err)
}
@ -302,6 +317,9 @@ func TestRemote_applyNoChanges(t *testing.T) {
if run.Err != nil {
t.Fatalf("error running operation: %v", run.Err)
}
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.") {
@ -334,6 +352,9 @@ func TestRemote_applyNoApprove(t *testing.T) {
if run.Err == nil {
t.Fatalf("expected an apply error, got: %v", run.Err)
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
if !strings.Contains(run.Err.Error(), "Apply discarded") {
t.Fatalf("expected an apply discarded error, got: %v", run.Err)
}
@ -368,6 +389,9 @@ func TestRemote_applyAutoApprove(t *testing.T) {
if run.Err != nil {
t.Fatalf("error running operation: %v", run.Err)
}
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)
@ -479,6 +503,9 @@ func TestRemote_applyDestroy(t *testing.T) {
if run.Err != nil {
t.Fatalf("error running operation: %v", run.Err)
}
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)
@ -516,6 +543,9 @@ func TestRemote_applyDestroyNoConfig(t *testing.T) {
if run.Err != nil {
t.Fatalf("unexpected apply error: %v", run.Err)
}
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)
@ -547,6 +577,9 @@ func TestRemote_applyPolicyPass(t *testing.T) {
if run.Err != nil {
t.Fatalf("error running operation: %v", run.Err)
}
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)
@ -589,6 +622,9 @@ func TestRemote_applyPolicyHardFail(t *testing.T) {
if run.Err == nil {
t.Fatalf("expected an apply error, got: %v", run.Err)
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
if !strings.Contains(run.Err.Error(), "hard failed") {
t.Fatalf("expected a policy check error, got: %v", run.Err)
}
@ -634,6 +670,9 @@ func TestRemote_applyPolicySoftFail(t *testing.T) {
if run.Err != nil {
t.Fatalf("error running operation: %v", run.Err)
}
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)
@ -677,6 +716,9 @@ func TestRemote_applyPolicySoftFailAutoApprove(t *testing.T) {
if run.Err != nil {
t.Fatalf("error running operation: %v", run.Err)
}
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)
@ -693,3 +735,32 @@ func TestRemote_applyPolicySoftFailAutoApprove(t *testing.T) {
t.Fatalf("missing apply summery in output: %s", output)
}
}
func TestRemote_applyWithRemoteError(t *testing.T) {
b := testBackendDefault(t)
mod, modCleanup := module.TestTree(t, "./test-fixtures/apply-with-error")
defer modCleanup()
op := testOperationApply()
op.Module = mod
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.Err != nil {
t.Fatalf("error running operation: %v", run.Err)
}
if run.ExitCode != 1 {
t.Fatalf("expected exit code 1, got %d", run.ExitCode)
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "null_resource.foo: 1 error") {
t.Fatalf("missing apply error in output: %s", output)
}
}

View File

@ -609,9 +609,9 @@ func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t
r := &tfe.Run{
ID: generateID("run-"),
Actions: &tfe.RunActions{},
Actions: &tfe.RunActions{IsCancelable: true},
Apply: a,
HasChanges: true,
HasChanges: false,
Permissions: &tfe.RunPermissions{},
Plan: p,
Status: tfe.RunPending,
@ -625,14 +625,6 @@ func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t
r.IsDestroy = *options.IsDestroy
}
logs, _ := ioutil.ReadFile(m.client.Plans.logs[p.LogReadURL])
if r.IsDestroy || !bytes.Contains(logs, []byte("No changes. Infrastructure is up-to-date.")) {
r.Actions.IsCancelable = true
r.Actions.IsConfirmable = true
r.HasChanges = true
r.Permissions.CanApply = true
}
m.runs[r.ID] = r
m.workspaces[options.Workspace.ID] = append(m.workspaces[options.Workspace.ID], r)
@ -653,12 +645,28 @@ func (m *mockRuns) Read(ctx context.Context, runID string) (*tfe.Run, error) {
}
}
if !pending {
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.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
}
}
return r, nil
}

View File

@ -44,6 +44,9 @@ func TestRemote_planBasic(t *testing.T) {
if run.Err != nil {
t.Fatalf("error running operation: %v", run.Err)
}
if run.PlanEmpty {
t.Fatal("expected a non-empty plan")
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
@ -158,6 +161,9 @@ func TestRemote_planWithPlan(t *testing.T) {
if run.Err == nil {
t.Fatalf("expected a plan error, got: %v", run.Err)
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
if !strings.Contains(run.Err.Error(), "saved plan is currently not supported") {
t.Fatalf("expected a saved plan error, got: %v", run.Err)
}
@ -183,6 +189,9 @@ func TestRemote_planWithPath(t *testing.T) {
if run.Err == nil {
t.Fatalf("expected a plan error, got: %v", run.Err)
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
if !strings.Contains(run.Err.Error(), "generated plan is currently not supported") {
t.Fatalf("expected a generated plan error, got: %v", run.Err)
}
@ -233,6 +242,9 @@ func TestRemote_planWithTarget(t *testing.T) {
if run.Err == nil {
t.Fatalf("expected a plan error, got: %v", run.Err)
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
if !strings.Contains(run.Err.Error(), "targeting is currently not supported") {
t.Fatalf("expected a targeting error, got: %v", run.Err)
}
@ -279,6 +291,9 @@ func TestRemote_planNoConfig(t *testing.T) {
if run.Err == nil {
t.Fatalf("expected a plan error, got: %v", run.Err)
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
if !strings.Contains(run.Err.Error(), "configuration files found") {
t.Fatalf("expected configuration files error, got: %v", run.Err)
}
@ -372,6 +387,9 @@ func TestRemote_planDestroy(t *testing.T) {
if run.Err != nil {
t.Fatalf("unexpected plan error: %v", run.Err)
}
if run.PlanEmpty {
t.Fatalf("expected a non-empty plan")
}
}
func TestRemote_planDestroyNoConfig(t *testing.T) {
@ -391,6 +409,9 @@ func TestRemote_planDestroyNoConfig(t *testing.T) {
if run.Err != nil {
t.Fatalf("unexpected plan error: %v", run.Err)
}
if run.PlanEmpty {
t.Fatalf("expected a non-empty plan")
}
}
func TestRemote_planWithWorkingDirectory(t *testing.T) {
@ -422,9 +443,41 @@ func TestRemote_planWithWorkingDirectory(t *testing.T) {
if run.Err != nil {
t.Fatalf("error running operation: %v", run.Err)
}
if run.PlanEmpty {
t.Fatalf("expected a non-empty plan")
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("missing plan summery in output: %s", output)
}
}
func TestRemote_planWithRemoteError(t *testing.T) {
b := testBackendDefault(t)
mod, modCleanup := module.TestTree(t, "./test-fixtures/plan-with-error")
defer modCleanup()
op := testOperationPlan()
op.Module = mod
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.Err != nil {
t.Fatalf("error running operation: %v", run.Err)
}
if run.ExitCode != 1 {
t.Fatalf("expected exit code 1, got %d", run.ExitCode)
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "null_resource.foo: 1 error") {
t.Fatalf("missing plan error in output: %s", output)
}
}

View File

@ -0,0 +1,5 @@
resource "null_resource" "foo" {
triggers {
random = "${guid()}"
}
}

View File

@ -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()}

View File

@ -0,0 +1,5 @@
resource "null_resource" "foo" {
triggers {
random = "${guid()}"
}
}

View File

@ -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()}

View File

@ -177,7 +177,7 @@ func (c *ApplyCommand) Run(args []string) int {
}
}
return 0
return op.ExitCode
}
func (c *ApplyCommand) Help() string {

View File

@ -121,7 +121,7 @@ func (c *PlanCommand) Run(args []string) int {
return 2
}
return 0
return op.ExitCode
}
func (c *PlanCommand) Help() string {