From 85e3d06fc8f4a468c069c332a2cfaeb2c5e963cd Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 19 May 2022 16:53:15 +0000 Subject: [PATCH 1/2] feat: skip terraform destroy if there is no state when deleting --- provisioner/terraform/provision.go | 92 +++++++++++++++++++++---- provisioner/terraform/provision_test.go | 47 +++++++++++++ 2 files changed, 124 insertions(+), 15 deletions(-) diff --git a/provisioner/terraform/provision.go b/provisioner/terraform/provision.go index ecc493b991c56..4cb7523bcfc23 100644 --- a/provisioner/terraform/provision.go +++ b/provisioner/terraform/provision.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "runtime" "strings" @@ -22,6 +23,13 @@ import ( "github.com/coder/coder/provisionersdk/proto" ) +var ( + // noStateRegex is matched against the output from `terraform state show` + noStateRegex = regexp.MustCompile(`(?i)State read error.*no state`) + + noStateError = xerrors.New("no state") +) + // Provision executes `terraform apply`. func (t *terraform) Provision(stream proto.DRPCProvisioner_ProvisionStream) error { shutdown, shutdownFunc := context.WithCancel(stream.Context()) @@ -190,6 +198,41 @@ func (t *terraform) Provision(stream proto.DRPCProvisioner_ProvisionStream) erro } }() + // If we're destroying, exit early if there's no state. + if start.Metadata.WorkspaceTransition == proto.WorkspaceTransition_DESTROY { + _, err := getTerraformState(shutdown, terraform, statefilePath) + if xerrors.Is(err, noStateError) { + _ = stream.Send(&proto.Provision_Response{ + Type: &proto.Provision_Response_Log{ + Log: &proto.Log{ + Level: proto.LogLevel_INFO, + Output: "The terraform state does not exist, there is nothing to do", + }, + }, + }) + + return stream.Send(&proto.Provision_Response{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Error: "", + }, + }, + }) + } + if err != nil { + err = xerrors.Errorf("get terraform state: %w", err) + _ = stream.Send(&proto.Provision_Response{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Error: err.Error(), + }, + }, + }) + + return err + } + } + planfilePath := filepath.Join(start.Directory, "terraform.tfplan") var args []string if start.DryRun { @@ -378,23 +421,11 @@ func parseTerraformApply(ctx context.Context, terraform *tfexec.Terraform, state _, err := os.Stat(statefilePath) statefileExisted := err == nil - statefile, err := os.OpenFile(statefilePath, os.O_CREATE|os.O_RDWR, 0600) - if err != nil { - return nil, xerrors.Errorf("open statefile %q: %w", statefilePath, err) - } - defer statefile.Close() - // #nosec - cmd := exec.CommandContext(ctx, terraform.ExecPath(), "state", "pull") - cmd.Dir = terraform.WorkingDir() - cmd.Stdout = statefile - err = cmd.Run() - if err != nil { - return nil, xerrors.Errorf("pull terraform state: %w", err) - } - state, err := terraform.ShowStateFile(ctx, statefilePath) + state, err := getTerraformState(ctx, terraform, statefilePath) if err != nil { - return nil, xerrors.Errorf("show terraform state: %w", err) + return nil, xerrors.Errorf("get terraform state: %w", err) } + resources := make([]*proto.Resource, 0) if state.Values != nil { rawGraph, err := terraform.Graph(ctx) @@ -557,6 +588,37 @@ func parseTerraformApply(ctx context.Context, terraform *tfexec.Terraform, state }, nil } +// getTerraformState pulls and merges any remote terraform state into the given +// path and reads the merged state. If there is no state, `noStateError` will be +// returned. +func getTerraformState(ctx context.Context, terraform *tfexec.Terraform, statefilePath string) (*tfjson.State, error) { + statefile, err := os.OpenFile(statefilePath, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, xerrors.Errorf("open statefile %q: %w", statefilePath, err) + } + defer statefile.Close() + + // #nosec + cmd := exec.CommandContext(ctx, terraform.ExecPath(), "state", "pull") + cmd.Dir = terraform.WorkingDir() + cmd.Stdout = statefile + err = cmd.Run() + if err != nil { + return nil, xerrors.Errorf("pull terraform state: %w", err) + } + + state, err := terraform.ShowStateFile(ctx, statefilePath) + if err != nil { + if noStateRegex.MatchString(err.Error()) { + return nil, noStateError + } + + return nil, xerrors.Errorf("show terraform state: %w", err) + } + + return state, nil +} + type terraformProvisionLog struct { Level string `json:"@level"` Message string `json:"@message"` diff --git a/provisioner/terraform/provision_test.go b/provisioner/terraform/provision_test.go index 6169f8e5f0bc4..af5995cd68fda 100644 --- a/provisioner/terraform/provision_test.go +++ b/provisioner/terraform/provision_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "sort" + "strings" "testing" "github.com/stretchr/testify/require" @@ -509,4 +510,50 @@ provider "coder" { } }) } + + t.Run("DestroyNoState", func(t *testing.T) { + t.Parallel() + + const template = `resource "null_resource" "A" {}` + + directory := t.TempDir() + err := os.WriteFile(filepath.Join(directory, "main.tf"), []byte(template), 0600) + require.NoError(t, err) + + request := &proto.Provision_Request{ + Type: &proto.Provision_Request_Start{ + Start: &proto.Provision_Start{ + State: nil, + Directory: directory, + Metadata: &proto.Provision_Metadata{ + WorkspaceTransition: proto.WorkspaceTransition_DESTROY, + }, + }, + }, + } + + response, err := api.Provision(ctx) + require.NoError(t, err) + err = response.Send(request) + require.NoError(t, err) + + gotLog := false + for { + msg, err := response.Recv() + require.NoError(t, err) + require.NotNil(t, msg) + + if msg.GetLog() != nil && strings.Contains(msg.GetLog().Output, "nothing to do") { + gotLog = true + continue + } + if msg.GetComplete() == nil { + continue + } + + require.Empty(t, msg.GetComplete().Error) + require.True(t, gotLog, "never received 'nothing to do' log") + break + } + }) } From aecb3bedf7189f212eed35e587950dcf997c2a9f Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 19 May 2022 18:30:59 +0000 Subject: [PATCH 2/2] chore: code review --- provisioner/terraform/provision.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/provisioner/terraform/provision.go b/provisioner/terraform/provision.go index 4cb7523bcfc23..8c1c96d55e57a 100644 --- a/provisioner/terraform/provision.go +++ b/provisioner/terraform/provision.go @@ -26,8 +26,6 @@ import ( var ( // noStateRegex is matched against the output from `terraform state show` noStateRegex = regexp.MustCompile(`(?i)State read error.*no state`) - - noStateError = xerrors.New("no state") ) // Provision executes `terraform apply`. @@ -198,10 +196,14 @@ func (t *terraform) Provision(stream proto.DRPCProvisioner_ProvisionStream) erro } }() - // If we're destroying, exit early if there's no state. + // If we're destroying, exit early if there's no state. This is necessary to + // avoid any cases where a workspace is "locked out" of terraform due to + // e.g. bad template param values and cannot be deleted. This is just for + // contingency, in the future we will try harder to prevent workspaces being + // broken this hard. if start.Metadata.WorkspaceTransition == proto.WorkspaceTransition_DESTROY { _, err := getTerraformState(shutdown, terraform, statefilePath) - if xerrors.Is(err, noStateError) { + if xerrors.Is(err, os.ErrNotExist) { _ = stream.Send(&proto.Provision_Response{ Type: &proto.Provision_Response_Log{ Log: &proto.Log{ @@ -213,9 +215,7 @@ func (t *terraform) Provision(stream proto.DRPCProvisioner_ProvisionStream) erro return stream.Send(&proto.Provision_Response{ Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Error: "", - }, + Complete: &proto.Provision_Complete{}, }, }) } @@ -589,8 +589,8 @@ func parseTerraformApply(ctx context.Context, terraform *tfexec.Terraform, state } // getTerraformState pulls and merges any remote terraform state into the given -// path and reads the merged state. If there is no state, `noStateError` will be -// returned. +// path and reads the merged state. If there is no state, `os.ErrNotExist` will +// be returned. func getTerraformState(ctx context.Context, terraform *tfexec.Terraform, statefilePath string) (*tfjson.State, error) { statefile, err := os.OpenFile(statefilePath, os.O_CREATE|os.O_RDWR, 0600) if err != nil { @@ -610,7 +610,7 @@ func getTerraformState(ctx context.Context, terraform *tfexec.Terraform, statefi state, err := terraform.ShowStateFile(ctx, statefilePath) if err != nil { if noStateRegex.MatchString(err.Error()) { - return nil, noStateError + return nil, os.ErrNotExist } return nil, xerrors.Errorf("show terraform state: %w", err)