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 + } + }) } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy