mirror of
https://github.com/coder/coder.git
synced 2025-07-09 11:45:56 +00:00
feat: skip terraform destroy if there is no state when deleting (#1594)
This commit is contained in:
@ -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"`
|
||||
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
Reference in New Issue
Block a user