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/sync"
"github.com/databricks/databricks-sdk-go/service/workspace"
"github.com/fatih/color"
)
type delete struct{}
@ -22,24 +21,7 @@ func (m *delete) Name() string {
}
func (m *delete) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
// Do not delete files if terraform destroy was not consented
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
}
}
cmdio.LogString(ctx, "Deleting files...")
err := b.WorkspaceClient().Workspace.Delete(ctx, workspace.Delete{
Path: b.Config.Workspace.RootPath,
@ -54,8 +36,6 @@ func (m *delete) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
if err != nil {
return diag.FromErr(err)
}
cmdio.LogString(ctx, "Successfully deleted files!")
return nil
}

View File

@ -2,61 +2,13 @@ package terraform
import (
"context"
"fmt"
"strings"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/diag"
"github.com/fatih/color"
"github.com/databricks/cli/libs/log"
"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{}
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 {
// return early if plan is empty
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
}
@ -75,45 +27,15 @@ func (w *destroy) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics
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 == "" {
return diag.Errorf("no plan found")
}
cmdio.LogString(ctx, "Starting to destroy resources")
// 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 {
return diag.Errorf("terraform destroy: %v", err)
}
cmdio.LogString(ctx, "Successfully destroyed resources!")
return nil
}

View File

@ -6,8 +6,8 @@ import (
"path/filepath"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/diag"
"github.com/databricks/cli/libs/log"
"github.com/databricks/cli/libs/terraform"
"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")
}
cmdio.LogString(ctx, "Starting plan computation")
err := tf.Init(ctx, tfexec.Upgrade(true))
if err != nil {
return diag.Errorf("terraform init: %v", err)
@ -55,12 +53,11 @@ func (p *plan) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
// Set plan in main bundle struct for downstream mutators
b.Plan = &terraform.Plan{
Path: planPath,
ConfirmApply: b.AutoApprove,
IsEmpty: !notEmpty,
Path: planPath,
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
}

View File

@ -3,13 +3,18 @@ package phases
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/deploy/files"
"github.com/databricks/cli/bundle/deploy/lock"
"github.com/databricks/cli/bundle/deploy/terraform"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/log"
terraformlib "github.com/databricks/cli/libs/terraform"
"github.com/databricks/databricks-sdk-go/apierr"
)
@ -26,8 +31,63 @@ func assertRootPathExists(ctx context.Context, b *bundle.Bundle) (bool, error) {
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.
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(
lock.Acquire(),
bundle.Defer(
@ -36,13 +96,14 @@ func Destroy() bundle.Mutator {
terraform.Interpolate(),
terraform.Write(),
terraform.Plan(terraform.PlanGoal("destroy")),
terraform.Destroy(),
terraform.StatePush(),
files.Delete(),
bundle.If(
approvalForDestroy,
destroyCore,
bundle.LogString("Destroy cancelled!"),
),
),
lock.Release(lock.GoalDestroy),
),
bundle.LogString("Destroy complete!"),
)
return newPhase(

View File

@ -1,13 +1,44 @@
package terraform
import "strings"
type Plan struct {
// Path to the plan
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
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"
)