Move to a single prompt during bundle destroy (#1583)

## Changes
Right now we ask users for two confirmations when destroying a bundle.
One to destroy the resources and one to delete the files. This PR
consolidates the two prompts into one.

## Tests
Manually

Destroying a bundle with no resources:
```
➜  bundle-playground git:(master) ✗ cli bundle destroy
All files and directories at the following location will be deleted: /Users/shreyas.goenka@databricks.com/.bundle/bundle-playground/default

Would you like to proceed? [y/n]: y
No resources to destroy
Updating deployment state...
Deleting files...
Destroy complete!
```

Destroying a bundle with no remote state:
```
➜  bundle-playground git:(master) ✗ cli bundle destroy
No active deployment found to destroy!
```

When a user cancells a deployment:
```
➜  bundle-playground git:(master) ✗ cli bundle destroy
The following resources will be deleted:
  delete job job_1
  delete job job_2
  delete pipeline foo

All files and directories at the following location will be deleted: /Users/shreyas.goenka@databricks.com/.bundle/bundle-playground/default

Would you like to proceed? [y/n]: n
Destroy cancelled!
```

When a user destroys resources:
```
➜  bundle-playground git:(master) ✗ cli bundle destroy
The following resources will be deleted:
  delete job job_1
  delete job job_2
  delete pipeline foo

All files and directories at the following location will be deleted: /Users/shreyas.goenka@databricks.com/.bundle/bundle-playground/default

Would you like to proceed? [y/n]: y
Updating deployment state...
Deleting files...
Destroy complete!
```
This commit is contained in:
shreyas-goenka 2024-07-24 18:32:19 +05:30 committed by GitHub
parent 39fc86e83b
commit e6241e196f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 108 additions and 117 deletions

View File

@ -12,7 +12,6 @@ import (
"github.com/databricks/cli/libs/diag" "github.com/databricks/cli/libs/diag"
"github.com/databricks/cli/libs/sync" "github.com/databricks/cli/libs/sync"
"github.com/databricks/databricks-sdk-go/service/workspace" "github.com/databricks/databricks-sdk-go/service/workspace"
"github.com/fatih/color"
) )
type delete struct{} type delete struct{}
@ -22,24 +21,7 @@ func (m *delete) Name() string {
} }
func (m *delete) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics { func (m *delete) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
// Do not delete files if terraform destroy was not consented cmdio.LogString(ctx, "Deleting files...")
if !b.Plan.IsEmpty && !b.Plan.ConfirmApply {
return nil
}
cmdio.LogString(ctx, "Starting deletion of remote bundle files")
cmdio.LogString(ctx, fmt.Sprintf("Bundle remote directory is %s", b.Config.Workspace.RootPath))
red := color.New(color.FgRed).SprintFunc()
if !b.AutoApprove {
proceed, err := cmdio.AskYesOrNo(ctx, fmt.Sprintf("\n%s and all files in it will be %s Proceed?", b.Config.Workspace.RootPath, red("deleted permanently!")))
if err != nil {
return diag.FromErr(err)
}
if !proceed {
return nil
}
}
err := b.WorkspaceClient().Workspace.Delete(ctx, workspace.Delete{ err := b.WorkspaceClient().Workspace.Delete(ctx, workspace.Delete{
Path: b.Config.Workspace.RootPath, Path: b.Config.Workspace.RootPath,
@ -54,8 +36,6 @@ func (m *delete) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
if err != nil { if err != nil {
return diag.FromErr(err) return diag.FromErr(err)
} }
cmdio.LogString(ctx, "Successfully deleted files!")
return nil return nil
} }

View File

@ -2,61 +2,13 @@ package terraform
import ( import (
"context" "context"
"fmt"
"strings"
"github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/diag" "github.com/databricks/cli/libs/diag"
"github.com/fatih/color" "github.com/databricks/cli/libs/log"
"github.com/hashicorp/terraform-exec/tfexec" "github.com/hashicorp/terraform-exec/tfexec"
tfjson "github.com/hashicorp/terraform-json"
) )
type PlanResourceChange struct {
ResourceType string `json:"resource_type"`
Action string `json:"action"`
ResourceName string `json:"resource_name"`
}
func (c *PlanResourceChange) String() string {
result := strings.Builder{}
switch c.Action {
case "delete":
result.WriteString(" delete ")
default:
result.WriteString(c.Action + " ")
}
switch c.ResourceType {
case "databricks_job":
result.WriteString("job ")
case "databricks_pipeline":
result.WriteString("pipeline ")
default:
result.WriteString(c.ResourceType + " ")
}
result.WriteString(c.ResourceName)
return result.String()
}
func (c *PlanResourceChange) IsInplaceSupported() bool {
return false
}
func logDestroyPlan(ctx context.Context, changes []*tfjson.ResourceChange) error {
cmdio.LogString(ctx, "The following resources will be removed:")
for _, c := range changes {
if c.Change.Actions.Delete() {
cmdio.Log(ctx, &PlanResourceChange{
ResourceType: c.Type,
Action: "delete",
ResourceName: c.Name,
})
}
}
return nil
}
type destroy struct{} type destroy struct{}
func (w *destroy) Name() string { func (w *destroy) Name() string {
@ -66,7 +18,7 @@ func (w *destroy) Name() string {
func (w *destroy) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics { func (w *destroy) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
// return early if plan is empty // return early if plan is empty
if b.Plan.IsEmpty { if b.Plan.IsEmpty {
cmdio.LogString(ctx, "No resources to destroy in plan. Skipping destroy!") log.Debugf(ctx, "No resources to destroy in plan. Skipping destroy.")
return nil return nil
} }
@ -75,45 +27,15 @@ func (w *destroy) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics
return diag.Errorf("terraform not initialized") return diag.Errorf("terraform not initialized")
} }
// read plan file
plan, err := tf.ShowPlanFile(ctx, b.Plan.Path)
if err != nil {
return diag.FromErr(err)
}
// print the resources that will be destroyed
err = logDestroyPlan(ctx, plan.ResourceChanges)
if err != nil {
return diag.FromErr(err)
}
// Ask for confirmation, if needed
if !b.Plan.ConfirmApply {
red := color.New(color.FgRed).SprintFunc()
b.Plan.ConfirmApply, err = cmdio.AskYesOrNo(ctx, fmt.Sprintf("\nThis will permanently %s resources! Proceed?", red("destroy")))
if err != nil {
return diag.FromErr(err)
}
}
// return if confirmation was not provided
if !b.Plan.ConfirmApply {
return nil
}
if b.Plan.Path == "" { if b.Plan.Path == "" {
return diag.Errorf("no plan found") return diag.Errorf("no plan found")
} }
cmdio.LogString(ctx, "Starting to destroy resources")
// Apply terraform according to the computed destroy plan // Apply terraform according to the computed destroy plan
err = tf.Apply(ctx, tfexec.DirOrPlan(b.Plan.Path)) err := tf.Apply(ctx, tfexec.DirOrPlan(b.Plan.Path))
if err != nil { if err != nil {
return diag.Errorf("terraform destroy: %v", err) return diag.Errorf("terraform destroy: %v", err)
} }
cmdio.LogString(ctx, "Successfully destroyed resources!")
return nil return nil
} }

View File

@ -6,8 +6,8 @@ import (
"path/filepath" "path/filepath"
"github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/diag" "github.com/databricks/cli/libs/diag"
"github.com/databricks/cli/libs/log"
"github.com/databricks/cli/libs/terraform" "github.com/databricks/cli/libs/terraform"
"github.com/hashicorp/terraform-exec/tfexec" "github.com/hashicorp/terraform-exec/tfexec"
) )
@ -33,8 +33,6 @@ func (p *plan) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
return diag.Errorf("terraform not initialized") return diag.Errorf("terraform not initialized")
} }
cmdio.LogString(ctx, "Starting plan computation")
err := tf.Init(ctx, tfexec.Upgrade(true)) err := tf.Init(ctx, tfexec.Upgrade(true))
if err != nil { if err != nil {
return diag.Errorf("terraform init: %v", err) return diag.Errorf("terraform init: %v", err)
@ -56,11 +54,10 @@ func (p *plan) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
// Set plan in main bundle struct for downstream mutators // Set plan in main bundle struct for downstream mutators
b.Plan = &terraform.Plan{ b.Plan = &terraform.Plan{
Path: planPath, Path: planPath,
ConfirmApply: b.AutoApprove,
IsEmpty: !notEmpty, IsEmpty: !notEmpty,
} }
cmdio.LogString(ctx, fmt.Sprintf("Planning complete and persisted at %s\n", planPath)) log.Debugf(ctx, fmt.Sprintf("Planning complete and persisted at %s\n", planPath))
return nil return nil
} }

View File

@ -3,13 +3,18 @@ package phases
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"net/http" "net/http"
"github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/deploy/files" "github.com/databricks/cli/bundle/deploy/files"
"github.com/databricks/cli/bundle/deploy/lock" "github.com/databricks/cli/bundle/deploy/lock"
"github.com/databricks/cli/bundle/deploy/terraform" "github.com/databricks/cli/bundle/deploy/terraform"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/log"
terraformlib "github.com/databricks/cli/libs/terraform"
"github.com/databricks/databricks-sdk-go/apierr" "github.com/databricks/databricks-sdk-go/apierr"
) )
@ -26,8 +31,63 @@ func assertRootPathExists(ctx context.Context, b *bundle.Bundle) (bool, error) {
return true, err return true, err
} }
func approvalForDestroy(ctx context.Context, b *bundle.Bundle) (bool, error) {
tf := b.Terraform
if tf == nil {
return false, fmt.Errorf("terraform not initialized")
}
// read plan file
plan, err := tf.ShowPlanFile(ctx, b.Plan.Path)
if err != nil {
return false, err
}
deleteActions := make([]terraformlib.Action, 0)
for _, rc := range plan.ResourceChanges {
if rc.Change.Actions.Delete() {
deleteActions = append(deleteActions, terraformlib.Action{
Action: terraformlib.ActionTypeDelete,
ResourceType: rc.Type,
ResourceName: rc.Name,
})
}
}
if len(deleteActions) > 0 {
cmdio.LogString(ctx, "The following resources will be deleted:")
for _, a := range deleteActions {
cmdio.Log(ctx, a)
}
cmdio.LogString(ctx, "")
}
cmdio.LogString(ctx, fmt.Sprintf("All files and directories at the following location will be deleted: %s", b.Config.Workspace.RootPath))
cmdio.LogString(ctx, "")
if b.AutoApprove {
return true, nil
}
approved, err := cmdio.AskYesOrNo(ctx, "Would you like to proceed?")
if err != nil {
return false, err
}
return approved, nil
}
// The destroy phase deletes artifacts and resources. // The destroy phase deletes artifacts and resources.
func Destroy() bundle.Mutator { func Destroy() bundle.Mutator {
// Core destructive mutators for destroy. These require informed user consent.
destroyCore := bundle.Seq(
terraform.Destroy(),
terraform.StatePush(),
files.Delete(),
bundle.LogString("Destroy complete!"),
)
destroyMutator := bundle.Seq( destroyMutator := bundle.Seq(
lock.Acquire(), lock.Acquire(),
bundle.Defer( bundle.Defer(
@ -36,13 +96,14 @@ func Destroy() bundle.Mutator {
terraform.Interpolate(), terraform.Interpolate(),
terraform.Write(), terraform.Write(),
terraform.Plan(terraform.PlanGoal("destroy")), terraform.Plan(terraform.PlanGoal("destroy")),
terraform.Destroy(), bundle.If(
terraform.StatePush(), approvalForDestroy,
files.Delete(), destroyCore,
bundle.LogString("Destroy cancelled!"),
),
), ),
lock.Release(lock.GoalDestroy), lock.Release(lock.GoalDestroy),
), ),
bundle.LogString("Destroy complete!"),
) )
return newPhase( return newPhase(

View File

@ -1,13 +1,44 @@
package terraform package terraform
import "strings"
type Plan struct { type Plan struct {
// Path to the plan // Path to the plan
Path string Path string
// Holds whether the user can consented to destruction. Either by interactive
// confirmation or by passing a command line flag
ConfirmApply bool
// If true, the plan is empty and applying it will not do anything // If true, the plan is empty and applying it will not do anything
IsEmpty bool IsEmpty bool
} }
type Action struct {
// Type and name of the resource
ResourceType string `json:"resource_type"`
ResourceName string `json:"resource_name"`
Action ActionType `json:"action"`
}
func (a Action) String() string {
// terraform resources have the databricks_ prefix, which is not needed.
rtype := strings.TrimPrefix(a.ResourceType, "databricks_")
return strings.Join([]string{" ", string(a.Action), rtype, a.ResourceName}, " ")
}
func (c Action) IsInplaceSupported() bool {
return false
}
// These enum values correspond to action types defined in the tfjson library.
// "recreate" maps to the tfjson.Actions.Replace() function.
// "update" maps to tfjson.Actions.Update() and so on. source:
// https://github.com/hashicorp/terraform-json/blob/0104004301ca8e7046d089cdc2e2db2179d225be/action.go#L14
type ActionType string
const (
ActionTypeCreate ActionType = "create"
ActionTypeDelete ActionType = "delete"
ActionTypeUpdate ActionType = "update"
ActionTypeNoOp ActionType = "no-op"
ActionTypeRead ActionType = "read"
ActionTypeRecreate ActionType = "recreate"
)