diff --git a/bundle/deploy/files/delete.go b/bundle/deploy/files/delete.go index 1339714495..7ae407e495 100644 --- a/bundle/deploy/files/delete.go +++ b/bundle/deploy/files/delete.go @@ -10,9 +10,9 @@ import ( "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/sync" "github.com/databricks/databricks-sdk-go/service/workspace" - "github.com/fatih/color" ) type delete struct{} @@ -22,24 +22,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, @@ -55,7 +38,7 @@ func (m *delete) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics { return diag.FromErr(err) } - cmdio.LogString(ctx, "Successfully deleted files!") + log.Debugf(ctx, "Successfully deleted files!") return nil } diff --git a/bundle/deploy/terraform/destroy.go b/bundle/deploy/terraform/destroy.go index 16f074a222..1eed99934e 100644 --- a/bundle/deploy/terraform/destroy.go +++ b/bundle/deploy/terraform/destroy.go @@ -2,61 +2,14 @@ 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 +19,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!") + cmdio.LogString(ctx, "No resources to destroy") return nil } @@ -75,45 +28,19 @@ 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") + log.Debugf(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!") + log.Debugf(ctx, "Successfully destroyed resources!") return nil } diff --git a/bundle/deploy/terraform/plan.go b/bundle/deploy/terraform/plan.go index 50e0f78ca2..d01c6abab5 100644 --- a/bundle/deploy/terraform/plan.go +++ b/bundle/deploy/terraform/plan.go @@ -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,7 +33,7 @@ func (p *plan) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics { return diag.Errorf("terraform not initialized") } - cmdio.LogString(ctx, "Starting plan computation") + log.Debugf(ctx, "Starting plan computation") err := tf.Init(ctx, tfexec.Upgrade(true)) if err != nil { @@ -55,12 +55,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 } diff --git a/bundle/phases/deploy.go b/bundle/phases/deploy.go index 46c3891895..e890230118 100644 --- a/bundle/phases/deploy.go +++ b/bundle/phases/deploy.go @@ -1,6 +1,9 @@ package phases import ( + "context" + "fmt" + "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/artifacts" "github.com/databricks/cli/bundle/config" @@ -14,10 +17,99 @@ import ( "github.com/databricks/cli/bundle/permissions" "github.com/databricks/cli/bundle/python" "github.com/databricks/cli/bundle/scripts" + "github.com/databricks/cli/libs/cmdio" + terraformlib "github.com/databricks/cli/libs/terraform" ) +func approvalForDeploy(ctx context.Context, b *bundle.Bundle) (bool, error) { + if b.AutoApprove { + return true, nil + } + + 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: "delete", + ResourceType: rc.Type, + ResourceName: rc.Name, + }) + } + } + + recreateActions := make([]terraformlib.Action, 0) + for _, rc := range plan.ResourceChanges { + if rc.Change.Actions.Replace() { + recreateActions = append(recreateActions, terraformlib.Action{ + Action: "recreate", + ResourceType: rc.Type, + ResourceName: rc.Name, + }) + } + } + + // No need for approval if the plan does not include any destructive actions. + if len(deleteActions) == 0 && len(recreateActions) == 0 { + return true, nil + } + + if len(deleteActions) > 0 { + cmdio.LogString(ctx, "") + cmdio.LogString(ctx, "The following resources will be deleted:") + for _, a := range deleteActions { + cmdio.Log(ctx, a) + } + } + + if len(recreateActions) > 0 { + cmdio.LogString(ctx, "") + cmdio.LogString(ctx, "The following resources will be recreated. Note that recreation can be lossy and may lead to lost metadata or data:") + for _, a := range recreateActions { + cmdio.Log(ctx, a) + } + cmdio.LogString(ctx, "") + } + + if !cmdio.IsPromptSupported(ctx) { + return false, fmt.Errorf("the deployment requires destructive actions, but current console does not support prompting. Please specify --auto-approve if you would like to skip prompts and proceed") + } + + cmdio.LogString(ctx, "") + approved, err := cmdio.AskYesOrNo(ctx, "Would you like to proceed?") + if err != nil { + return false, err + } + + return approved, nil +} + // The deploy phase deploys artifacts and resources. func Deploy() bundle.Mutator { + // Core mutators that CRUD resources and modify deployment state. These + // mutators need informed consent if they are potentially destructive. + deployCore := bundle.Defer( + terraform.Apply(), + bundle.Seq( + terraform.StatePush(), + terraform.Load(), + metadata.Compute(), + metadata.Upload(), + scripts.Execute(config.ScriptPostDeploy), + bundle.LogString("Deployment complete!"), + ), + ) + deployMutator := bundle.Seq( scripts.Execute(config.ScriptPreDeploy), lock.Acquire(), @@ -37,20 +129,15 @@ func Deploy() bundle.Mutator { terraform.Interpolate(), terraform.Write(), terraform.CheckRunningResource(), - bundle.Defer( - terraform.Apply(), - bundle.Seq( - terraform.StatePush(), - terraform.Load(), - metadata.Compute(), - metadata.Upload(), - ), + terraform.Plan(terraform.PlanGoal("deploy")), + bundle.If( + approvalForDeploy, + deployCore, + bundle.LogString("Deployment cancelled!"), ), ), lock.Release(lock.GoalDeploy), ), - scripts.Execute(config.ScriptPostDeploy), - bundle.LogString("Deployment complete!"), ) return newPhase( diff --git a/bundle/phases/destroy.go b/bundle/phases/destroy.go index f1beace848..b8960c6932 100644 --- a/bundle/phases/destroy.go +++ b/bundle/phases/destroy.go @@ -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: "delete", + 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( diff --git a/cmd/bundle/deploy.go b/cmd/bundle/deploy.go index 1232c8de51..f82c26d0cc 100644 --- a/cmd/bundle/deploy.go +++ b/cmd/bundle/deploy.go @@ -24,10 +24,12 @@ func newDeployCommand() *cobra.Command { var forceLock bool var failOnActiveRuns bool var computeID string + var autoApprove bool cmd.Flags().BoolVar(&force, "force", false, "Force-override Git branch validation.") cmd.Flags().BoolVar(&forceLock, "force-lock", false, "Force acquisition of deployment lock.") cmd.Flags().BoolVar(&failOnActiveRuns, "fail-on-active-runs", false, "Fail if there are running jobs or pipelines in the deployment.") cmd.Flags().StringVarP(&computeID, "compute-id", "c", "", "Override compute in the deployment with the given compute ID.") + cmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "Skip interactive approvals for deleting or recreating resources") cmd.RunE = func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -37,10 +39,11 @@ func newDeployCommand() *cobra.Command { bundle.ApplyFunc(ctx, b, func(context.Context, *bundle.Bundle) diag.Diagnostics { b.Config.Bundle.Force = force b.Config.Bundle.Deployment.Lock.Force = forceLock + b.AutoApprove = autoApprove + if cmd.Flag("compute-id").Changed { b.Config.Bundle.ComputeID = computeID } - if cmd.Flag("fail-on-active-runs").Changed { b.Config.Bundle.Deployment.FailOnActiveRuns = failOnActiveRuns } diff --git a/internal/bundle/helpers.go b/internal/bundle/helpers.go index a17964b167..f4fe38112f 100644 --- a/internal/bundle/helpers.go +++ b/internal/bundle/helpers.go @@ -60,7 +60,7 @@ func validateBundle(t *testing.T, ctx context.Context, path string) ([]byte, err func deployBundle(t *testing.T, ctx context.Context, path string) error { t.Setenv("BUNDLE_ROOT", path) - c := internal.NewCobraTestRunnerWithContext(t, ctx, "bundle", "deploy", "--force-lock") + c := internal.NewCobraTestRunnerWithContext(t, ctx, "bundle", "deploy", "--force-lock", "--auto-approve") _, _, err := c.Run() return err } diff --git a/libs/terraform/plan.go b/libs/terraform/plan.go index 22fea62063..476b8fd54b 100644 --- a/libs/terraform/plan.go +++ b/libs/terraform/plan.go @@ -1,13 +1,40 @@ 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 +} + +type ActionType string + +const ( + ActionTypeCreate ActionType = "create" + ActionTypeDelete ActionType = "delete" + ActionTypeUpdate ActionType = "update" + ActionTypeNoOp ActionType = "no-op" + ActionTypeRead ActionType = "read" + ActionTypeRecreate ActionType = "recreate" +)