feat: skip terraform destroy if there is no state when deleting (#1594)

This commit is contained in:
Dean Sheather
2022-05-20 14:07:23 +10:00
committed by GitHub
parent a03615a01f
commit adb7d20c16
2 changed files with 124 additions and 15 deletions

View File

@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
@ -22,6 +23,11 @@ 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`)
)
// Provision executes `terraform apply`.
func (t *terraform) Provision(stream proto.DRPCProvisioner_ProvisionStream) error {
shutdown, shutdownFunc := context.WithCancel(stream.Context())
@ -190,6 +196,43 @@ func (t *terraform) Provision(stream proto.DRPCProvisioner_ProvisionStream) erro
}
}()
// 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, os.ErrNotExist) {
_ = 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{},
},
})
}
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)
state, err := getTerraformState(ctx, terraform, statefilePath)
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 {
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, `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 {
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, os.ErrNotExist
}
return nil, xerrors.Errorf("show terraform state: %w", err)
}
return state, nil
}
type terraformProvisionLog struct {
Level string `json:"@level"`
Message string `json:"@message"`

View File

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