diff --git a/provisioner/terraform/provision.go b/provisioner/terraform/provision.go index ecc493b991c56..8c1c96d55e57a 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,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) - 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, `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"` 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 + } + }) }