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"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@ -22,6 +23,11 @@ import (
|
|||||||
"github.com/coder/coder/provisionersdk/proto"
|
"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`.
|
// Provision executes `terraform apply`.
|
||||||
func (t *terraform) Provision(stream proto.DRPCProvisioner_ProvisionStream) error {
|
func (t *terraform) Provision(stream proto.DRPCProvisioner_ProvisionStream) error {
|
||||||
shutdown, shutdownFunc := context.WithCancel(stream.Context())
|
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")
|
planfilePath := filepath.Join(start.Directory, "terraform.tfplan")
|
||||||
var args []string
|
var args []string
|
||||||
if start.DryRun {
|
if start.DryRun {
|
||||||
@ -378,23 +421,11 @@ func parseTerraformApply(ctx context.Context, terraform *tfexec.Terraform, state
|
|||||||
_, err := os.Stat(statefilePath)
|
_, err := os.Stat(statefilePath)
|
||||||
statefileExisted := err == nil
|
statefileExisted := err == nil
|
||||||
|
|
||||||
statefile, err := os.OpenFile(statefilePath, os.O_CREATE|os.O_RDWR, 0600)
|
state, err := getTerraformState(ctx, terraform, statefilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, xerrors.Errorf("open statefile %q: %w", statefilePath, err)
|
return nil, xerrors.Errorf("get terraform state: %w", 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resources := make([]*proto.Resource, 0)
|
resources := make([]*proto.Resource, 0)
|
||||||
if state.Values != nil {
|
if state.Values != nil {
|
||||||
rawGraph, err := terraform.Graph(ctx)
|
rawGraph, err := terraform.Graph(ctx)
|
||||||
@ -557,6 +588,37 @@ func parseTerraformApply(ctx context.Context, terraform *tfexec.Terraform, state
|
|||||||
}, nil
|
}, 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 {
|
type terraformProvisionLog struct {
|
||||||
Level string `json:"@level"`
|
Level string `json:"@level"`
|
||||||
Message string `json:"@message"`
|
Message string `json:"@message"`
|
||||||
|
@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"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