From 4966c14d1fc3dfa0a17547730892acfab4154dea Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Mon, 3 Apr 2023 22:12:20 +0200 Subject: [PATCH 1/6] Add bundle destroy command --- bundle/bundle.go | 3 + bundle/deploy/terraform/destroy.go | 46 ++++++++++++++ bundle/deploy/terraform/plan.go | 98 ++++++++++++++++++++++++++++++ bundle/phases/destroy.go | 23 +++++++ cmd/bundle/destroy.go | 47 ++++++++++++++ libs/terraform/plan.go | 68 +++++++++++++++++++++ 6 files changed, 285 insertions(+) create mode 100644 bundle/deploy/terraform/destroy.go create mode 100644 bundle/deploy/terraform/plan.go create mode 100644 bundle/phases/destroy.go create mode 100644 cmd/bundle/destroy.go create mode 100644 libs/terraform/plan.go diff --git a/bundle/bundle.go b/bundle/bundle.go index e9ec55cc66..7814fe88c4 100644 --- a/bundle/bundle.go +++ b/bundle/bundle.go @@ -34,6 +34,9 @@ type Bundle struct { // Stores the locker responsible for acquiring/releasing a deployment lock. Locker *locker.Locker + + // User has manually confirmed to destroy the deployed resources + ConfirmDestroy bool } func Load(path string) (*Bundle, error) { diff --git a/bundle/deploy/terraform/destroy.go b/bundle/deploy/terraform/destroy.go new file mode 100644 index 0000000000..ddfdffdd06 --- /dev/null +++ b/bundle/deploy/terraform/destroy.go @@ -0,0 +1,46 @@ +package terraform + +import ( + "context" + "fmt" + "os" + + "github.com/databricks/bricks/bundle" + "github.com/hashicorp/terraform-exec/tfexec" +) + +type destroy struct{} + +func (w *destroy) Name() string { + return "terraform.Destroy" +} + +func (w *destroy) Apply(ctx context.Context, b *bundle.Bundle) ([]bundle.Mutator, error) { + if !b.ConfirmDestroy { + return nil, nil + } + + tf := b.Terraform + if tf == nil { + return nil, fmt.Errorf("terraform not initialized") + } + + err := tf.Init(ctx, tfexec.Upgrade(true)) + if err != nil { + return nil, fmt.Errorf("terraform init: %w", err) + } + + err = tf.Destroy(ctx) + if err != nil { + return nil, fmt.Errorf("terraform destroy: %w", err) + } + + fmt.Fprintln(os.Stderr, "Successfully destroyed resources!") + return nil, nil +} + +// Apply returns a [bundle.Mutator] that runs the equivalent of `terraform apply` +// from the bundle's ephemeral working directory for Terraform. +func Destroy() bundle.Mutator { + return &destroy{} +} diff --git a/bundle/deploy/terraform/plan.go b/bundle/deploy/terraform/plan.go new file mode 100644 index 0000000000..931c54b601 --- /dev/null +++ b/bundle/deploy/terraform/plan.go @@ -0,0 +1,98 @@ +package terraform + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/databricks/bricks/bundle" + "github.com/databricks/bricks/libs/progress" + "github.com/databricks/bricks/libs/terraform" + "github.com/fatih/color" + "github.com/hashicorp/terraform-exec/tfexec" + "golang.org/x/term" +) + +type plan struct { + destroy bool +} + +func (p *plan) Name() string { + return "terraform.Plan" +} + +func (p *plan) Apply(ctx context.Context, b *bundle.Bundle) ([]bundle.Mutator, error) { + tf := b.Terraform + if tf == nil { + return nil, fmt.Errorf("terraform not initialized") + } + + err := tf.Init(ctx, tfexec.Upgrade(true)) + if err != nil { + return nil, fmt.Errorf("terraform init: %w", err) + } + + // compute plan and get output in buf + buf := &strings.Builder{} + isDiff, err := tf.PlanJSON(ctx, buf, tfexec.Destroy(p.destroy)) + if err != nil { + return nil, fmt.Errorf("terraform apply: %w", err) + } + if !isDiff { + fmt.Fprintln(os.Stderr, "No resources to destroy!") + return nil, nil + } + + tfPlan := terraform.NewPlan() + + // store all events in struct + for _, log := range strings.Split(buf.String(), "\n") { + err := tfPlan.AddEvent(log) + if err != nil { + return nil, err + } + } + + // Log plan + progressLogger, ok := progress.FromContext(ctx) + if !ok { + return nil, fmt.Errorf("no progress logger found") + } + if tfPlan.ChangeSummary != nil && tfPlan.ChangeSummary.Summary != nil { + fmt.Fprintf(os.Stderr, "Will destroy %d resources: \n\n", tfPlan.ChangeSummary.Summary.Remove) + } + for _, c := range tfPlan.PlannedChanges { + progressLogger.Log(c.Change) + } + if !term.IsTerminal(int(os.Stderr.Fd())) { + // TODO: enforce forced flag here? + b.ConfirmDestroy = true + return nil, nil + } + + red := color.New(color.FgRed).SprintFunc() + fmt.Fprintf(os.Stderr, "\nThis will permanently %s resources! Proceed? [y/n]: ", red("destroy")) + + reader := bufio.NewReader(os.Stdin) + ans, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + if ans == "y\n" { + fmt.Fprintln(os.Stderr, "Destroying resources!") + b.ConfirmDestroy = true + } else { + fmt.Fprintln(os.Stderr, "Skipped!") + } + return nil, nil +} + +// Apply returns a [bundle.Mutator] that runs the equivalent of `terraform apply` +// from the bundle's ephemeral working directory for Terraform. +func Plan(destroy bool) bundle.Mutator { + return &plan{ + destroy: destroy, + } +} diff --git a/bundle/phases/destroy.go b/bundle/phases/destroy.go new file mode 100644 index 0000000000..0409a35c0f --- /dev/null +++ b/bundle/phases/destroy.go @@ -0,0 +1,23 @@ +package phases + +import ( + "github.com/databricks/bricks/bundle" + "github.com/databricks/bricks/bundle/deploy/lock" + "github.com/databricks/bricks/bundle/deploy/terraform" +) + +// The destroy phase deletes artifacts and resources. +// TODO: force lock workaround. Error message on lock acquisition is misleading +func Destroy() bundle.Mutator { + return newPhase( + "destroy", + []bundle.Mutator{ + lock.Acquire(), + terraform.StatePull(), + terraform.Plan(true), + terraform.Destroy(), + terraform.StatePush(), + lock.Release(), + }, + ) +} diff --git a/cmd/bundle/destroy.go b/cmd/bundle/destroy.go new file mode 100644 index 0000000000..1338780787 --- /dev/null +++ b/cmd/bundle/destroy.go @@ -0,0 +1,47 @@ +package bundle + +import ( + "github.com/databricks/bricks/bundle" + "github.com/databricks/bricks/bundle/phases" + "github.com/databricks/bricks/cmd/root" + "github.com/databricks/bricks/libs/flags" + "github.com/databricks/bricks/libs/progress" + "github.com/spf13/cobra" +) + +// TODO: do we need ci/cd for non-tty + +// TODO: +// 1. Delete resources +// 2. Delete files - need tracking dirs for this +// 3. Delete artifacts +// 4. What about running resources + +// TODO: json logs? + +var destroyCmd = &cobra.Command{ + Use: "destroy", + Short: "Destroy deployed bundle resources", + + PreRunE: root.MustConfigureBundle, + RunE: func(cmd *cobra.Command, args []string) error { + b := bundle.Get(cmd.Context()) + + // If `--force` is specified, force acquisition of the deployment lock. + b.Config.Bundle.Lock.Force = force + + ctx := progress.NewContext(cmd.Context(), progress.NewLogger(flags.ModeAppend)) + return bundle.Apply(ctx, b, []bundle.Mutator{ + phases.Initialize(), + phases.Build(), + phases.Destroy(), + }) + }, +} + +var skipConfirmation bool + +func init() { + AddCommand(destroyCmd) + deployCmd.Flags().BoolVar(&skipConfirmation, "skip-confirmation", false, "skip confirmation before destroy") +} diff --git a/libs/terraform/plan.go b/libs/terraform/plan.go new file mode 100644 index 0000000000..8909e47998 --- /dev/null +++ b/libs/terraform/plan.go @@ -0,0 +1,68 @@ +package terraform + +import ( + "encoding/json" + "strings" +) + +// TODO: can be used during deploy time too + +type Resource struct { + Name string `json:"resource_name"` + Type string `json:"resource_type"` +} + +type Change struct { + Resource *Resource `json:"resource"` + Action string `json:"action"` +} + +type Summary struct { + Add int `json:"add"` + Change int `json:"change"` + Remove int `json:"remove"` +} + +type Event struct { + Change *Change `json:"change"` + Summary *Summary `json:"changes"` + Type string `json:"type"` +} + +type Plan struct { + ChangeSummary *Event + PlannedChanges []Event +} + +func NewPlan() *Plan { + return &Plan{ + PlannedChanges: make([]Event, 0), + } +} + +func (p *Plan) AddEvent(s string) error { + event := &Event{} + if len(s) == 0 { + return nil + } + err := json.Unmarshal([]byte(s), event) + if err != nil { + return err + } + switch event.Type { + case "planned_change": + p.PlannedChanges = append(p.PlannedChanges, *event) + case "change_summary": + p.ChangeSummary = event + } + return nil +} + +func (c *Change) String() string { + result := strings.Builder{} + result.WriteString(c.Action + " ") + result.WriteString(c.Resource.Type + " ") + result.WriteString(c.Resource.Name) + + return result.String() +} From 431306b7a3e5e08f77f24616aa5f456dacbf753b Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Wed, 5 Apr 2023 01:50:02 +0200 Subject: [PATCH 2/6] wip, but better --- bundle/bundle.go | 4 +- bundle/deploy/terraform/destroy.go | 92 +++++++++++++++++++++++++++-- bundle/deploy/terraform/plan.go | 65 ++++---------------- bundle/run/job.go | 6 +- bundle/run/pipeline.go | 4 +- cmd/bundle/destroy.go | 7 ++- cmd/root/progress_logger.go | 6 +- cmd/root/progress_logger_test.go | 4 +- libs/{progress => cmdio}/context.go | 2 +- libs/{progress => cmdio}/event.go | 2 +- libs/{progress => cmdio}/logger.go | 23 +++++++- libs/terraform/plan.go | 69 +++------------------- 12 files changed, 147 insertions(+), 137 deletions(-) rename libs/{progress => cmdio}/context.go (96%) rename libs/{progress => cmdio}/event.go (71%) rename libs/{progress => cmdio}/logger.go (74%) diff --git a/bundle/bundle.go b/bundle/bundle.go index 7814fe88c4..3c47689416 100644 --- a/bundle/bundle.go +++ b/bundle/bundle.go @@ -16,6 +16,7 @@ import ( "github.com/databricks/bricks/folders" "github.com/databricks/bricks/libs/git" "github.com/databricks/bricks/libs/locker" + "github.com/databricks/bricks/libs/terraform" "github.com/databricks/databricks-sdk-go" sdkconfig "github.com/databricks/databricks-sdk-go/config" "github.com/hashicorp/terraform-exec/tfexec" @@ -35,8 +36,7 @@ type Bundle struct { // Stores the locker responsible for acquiring/releasing a deployment lock. Locker *locker.Locker - // User has manually confirmed to destroy the deployed resources - ConfirmDestroy bool + Plan *terraform.Plan } func Load(path string) (*Bundle, error) { diff --git a/bundle/deploy/terraform/destroy.go b/bundle/deploy/terraform/destroy.go index ddfdffdd06..4bed6517eb 100644 --- a/bundle/deploy/terraform/destroy.go +++ b/bundle/deploy/terraform/destroy.go @@ -4,11 +4,58 @@ import ( "context" "fmt" "os" + "strings" "github.com/databricks/bricks/bundle" + "github.com/databricks/bricks/libs/cmdio" + "github.com/fatih/color" "github.com/hashicorp/terraform-exec/tfexec" + tfjson "github.com/hashicorp/terraform-json" ) +// TODO: This is temperory. Come up with a robust way to log mutator progress and +// status events +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 logDestroyPlan(l *cmdio.Logger, changes []*tfjson.ResourceChange) error { + // TODO: remove once we have mutator logging in place + fmt.Fprintln(os.Stderr, "The following resources will be removed: ") + for _, c := range changes { + if c.Change.Actions.Delete() { + l.Log(&PlanResourceChange{ + ResourceType: c.Type, + Action: "delete", + ResourceName: c.Name, + }) + } + } + return nil +} + type destroy struct{} func (w *destroy) Name() string { @@ -16,7 +63,14 @@ func (w *destroy) Name() string { } func (w *destroy) Apply(ctx context.Context, b *bundle.Bundle) ([]bundle.Mutator, error) { - if !b.ConfirmDestroy { + // interface to io with the user + logger, ok := cmdio.FromContext(ctx) + if !ok { + return nil, fmt.Errorf("no logger found") + } + + if b.Plan.IsEmpty { + fmt.Fprintln(os.Stderr, "No resources to destroy, skipping destroy!") return nil, nil } @@ -25,12 +79,40 @@ func (w *destroy) Apply(ctx context.Context, b *bundle.Bundle) ([]bundle.Mutator return nil, fmt.Errorf("terraform not initialized") } - err := tf.Init(ctx, tfexec.Upgrade(true)) + // read plan file + plan, err := tf.ShowPlanFile(ctx, b.Plan.Path) if err != nil { - return nil, fmt.Errorf("terraform init: %w", err) + return nil, err + } + + // print the resources that will be destroyed + err = logDestroyPlan(logger, plan.ResourceChanges) + if err != nil { + return nil, err + } + + // TODO: Add is term detection + + // Ask for confirmation, if needed + if !b.Plan.ConfirmApply { + red := color.New(color.FgRed).SprintFunc() + b.Plan.ConfirmApply, err = logger.Ask(fmt.Sprintf("\nThis will permanently %s resources! Proceed? [y/n]: ", red("destroy"))) + if err != nil { + return nil, err + } + } + + // return if confirmation was not provided + if !b.Plan.ConfirmApply { + return nil, nil + } + + if b.Plan.Path == "" { + return nil, fmt.Errorf("no plan found") } - err = tf.Destroy(ctx) + // Apply terraform according to the computed destroy plan + err = tf.Apply(ctx, tfexec.DirOrPlan(b.Plan.Path)) if err != nil { return nil, fmt.Errorf("terraform destroy: %w", err) } @@ -39,7 +121,7 @@ func (w *destroy) Apply(ctx context.Context, b *bundle.Bundle) ([]bundle.Mutator return nil, nil } -// Apply returns a [bundle.Mutator] that runs the equivalent of `terraform apply` +// Destroy returns a [bundle.Mutator] that runs the equivalent of `terraform destroy` // from the bundle's ephemeral working directory for Terraform. func Destroy() bundle.Mutator { return &destroy{} diff --git a/bundle/deploy/terraform/plan.go b/bundle/deploy/terraform/plan.go index 931c54b601..41293a6b88 100644 --- a/bundle/deploy/terraform/plan.go +++ b/bundle/deploy/terraform/plan.go @@ -1,18 +1,13 @@ package terraform import ( - "bufio" "context" "fmt" - "os" - "strings" + "path/filepath" "github.com/databricks/bricks/bundle" - "github.com/databricks/bricks/libs/progress" "github.com/databricks/bricks/libs/terraform" - "github.com/fatih/color" "github.com/hashicorp/terraform-exec/tfexec" - "golang.org/x/term" ) type plan struct { @@ -34,57 +29,23 @@ func (p *plan) Apply(ctx context.Context, b *bundle.Bundle) ([]bundle.Mutator, e return nil, fmt.Errorf("terraform init: %w", err) } - // compute plan and get output in buf - buf := &strings.Builder{} - isDiff, err := tf.PlanJSON(ctx, buf, tfexec.Destroy(p.destroy)) + // Persist computed plan + tfDir, err := Dir(b) if err != nil { - return nil, fmt.Errorf("terraform apply: %w", err) - } - if !isDiff { - fmt.Fprintln(os.Stderr, "No resources to destroy!") - return nil, nil - } - - tfPlan := terraform.NewPlan() - - // store all events in struct - for _, log := range strings.Split(buf.String(), "\n") { - err := tfPlan.AddEvent(log) - if err != nil { - return nil, err - } - } - - // Log plan - progressLogger, ok := progress.FromContext(ctx) - if !ok { - return nil, fmt.Errorf("no progress logger found") - } - if tfPlan.ChangeSummary != nil && tfPlan.ChangeSummary.Summary != nil { - fmt.Fprintf(os.Stderr, "Will destroy %d resources: \n\n", tfPlan.ChangeSummary.Summary.Remove) - } - for _, c := range tfPlan.PlannedChanges { - progressLogger.Log(c.Change) - } - if !term.IsTerminal(int(os.Stderr.Fd())) { - // TODO: enforce forced flag here? - b.ConfirmDestroy = true - return nil, nil + return nil, err } - - red := color.New(color.FgRed).SprintFunc() - fmt.Fprintf(os.Stderr, "\nThis will permanently %s resources! Proceed? [y/n]: ", red("destroy")) - - reader := bufio.NewReader(os.Stdin) - ans, err := reader.ReadString('\n') + planPath := filepath.Join(tfDir, "plan") + notEmpty, err := tf.Plan(ctx, tfexec.Destroy(p.destroy), tfexec.Out(planPath)) if err != nil { return nil, err } - if ans == "y\n" { - fmt.Fprintln(os.Stderr, "Destroying resources!") - b.ConfirmDestroy = true - } else { - fmt.Fprintln(os.Stderr, "Skipped!") + + // Set plan in main bundle struct for downstream mutators + // TODO: allow bypass using cmd line flag + b.Plan = &terraform.Plan{ + Path: planPath, + ConfirmApply: false, + IsEmpty: !notEmpty, } return nil, nil } diff --git a/bundle/run/job.go b/bundle/run/job.go index 94f28fcb8a..dcefeb8b4d 100644 --- a/bundle/run/job.go +++ b/bundle/run/job.go @@ -8,8 +8,8 @@ import ( "github.com/databricks/bricks/bundle" "github.com/databricks/bricks/bundle/config/resources" + "github.com/databricks/bricks/libs/cmdio" "github.com/databricks/bricks/libs/log" - "github.com/databricks/bricks/libs/progress" "github.com/databricks/databricks-sdk-go/retries" "github.com/databricks/databricks-sdk-go/service/jobs" "github.com/fatih/color" @@ -177,7 +177,7 @@ func logDebugCallback(ctx context.Context, runId *int64) func(info *retries.Info } } -func logProgressCallback(ctx context.Context, progressLogger *progress.Logger) func(info *retries.Info[jobs.Run]) { +func logProgressCallback(ctx context.Context, progressLogger *cmdio.Logger) func(info *retries.Info[jobs.Run]) { var prevState *jobs.RunState return func(info *retries.Info[jobs.Run]) { i := info.Info @@ -241,7 +241,7 @@ func (r *jobRunner) Run(ctx context.Context, opts *Options) (RunOutput, error) { logDebug := logDebugCallback(ctx, runId) // callback to log progress events. Called on every poll request - progressLogger, ok := progress.FromContext(ctx) + progressLogger, ok := cmdio.FromContext(ctx) if !ok { return nil, fmt.Errorf("no progress logger found") } diff --git a/bundle/run/pipeline.go b/bundle/run/pipeline.go index 901e0a0989..070666a1f7 100644 --- a/bundle/run/pipeline.go +++ b/bundle/run/pipeline.go @@ -9,9 +9,9 @@ import ( "github.com/databricks/bricks/bundle" "github.com/databricks/bricks/bundle/config/resources" "github.com/databricks/bricks/bundle/run/pipeline" + "github.com/databricks/bricks/libs/cmdio" "github.com/databricks/bricks/libs/flags" "github.com/databricks/bricks/libs/log" - "github.com/databricks/bricks/libs/progress" "github.com/databricks/databricks-sdk-go/service/pipelines" flag "github.com/spf13/pflag" ) @@ -162,7 +162,7 @@ func (r *pipelineRunner) Run(ctx context.Context, opts *Options) (RunOutput, err // setup progress logger and tracker to query events updateTracker := pipeline.NewUpdateTracker(pipelineID, updateID, w) - progressLogger, ok := progress.FromContext(ctx) + progressLogger, ok := cmdio.FromContext(ctx) if !ok { return nil, fmt.Errorf("no progress logger found") } diff --git a/cmd/bundle/destroy.go b/cmd/bundle/destroy.go index 1338780787..0e21b953ec 100644 --- a/cmd/bundle/destroy.go +++ b/cmd/bundle/destroy.go @@ -4,8 +4,8 @@ import ( "github.com/databricks/bricks/bundle" "github.com/databricks/bricks/bundle/phases" "github.com/databricks/bricks/cmd/root" + "github.com/databricks/bricks/libs/cmdio" "github.com/databricks/bricks/libs/flags" - "github.com/databricks/bricks/libs/progress" "github.com/spf13/cobra" ) @@ -30,7 +30,10 @@ var destroyCmd = &cobra.Command{ // If `--force` is specified, force acquisition of the deployment lock. b.Config.Bundle.Lock.Force = force - ctx := progress.NewContext(cmd.Context(), progress.NewLogger(flags.ModeAppend)) + // TODO: add tty check here + // TODO: json logs + + ctx := cmdio.NewContext(cmd.Context(), cmdio.NewLogger(flags.ModeAppend)) return bundle.Apply(ctx, b, []bundle.Mutator{ phases.Initialize(), phases.Build(), diff --git a/cmd/root/progress_logger.go b/cmd/root/progress_logger.go index cfa7404271..12e05c9dad 100644 --- a/cmd/root/progress_logger.go +++ b/cmd/root/progress_logger.go @@ -5,8 +5,8 @@ import ( "fmt" "os" + "github.com/databricks/bricks/libs/cmdio" "github.com/databricks/bricks/libs/flags" - "github.com/databricks/bricks/libs/progress" "golang.org/x/term" ) @@ -31,8 +31,8 @@ func initializeProgressLogger(ctx context.Context) (context.Context, error) { format = resolveModeDefault(format) } - progressLogger := progress.NewLogger(format) - return progress.NewContext(ctx, progressLogger), nil + progressLogger := cmdio.NewLogger(format) + return cmdio.NewContext(ctx, progressLogger), nil } var progressFormat = flags.NewProgressLogFormat() diff --git a/cmd/root/progress_logger_test.go b/cmd/root/progress_logger_test.go index f962b6604e..09311dec8a 100644 --- a/cmd/root/progress_logger_test.go +++ b/cmd/root/progress_logger_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" + "github.com/databricks/bricks/libs/cmdio" "github.com/databricks/bricks/libs/flags" - "github.com/databricks/bricks/libs/progress" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -39,7 +39,7 @@ func TestDefaultLoggerModeResolution(t *testing.T) { require.Equal(t, progressFormat, flags.ModeDefault) ctx, err := initializeProgressLogger(context.Background()) require.NoError(t, err) - logger, ok := progress.FromContext(ctx) + logger, ok := cmdio.FromContext(ctx) assert.True(t, ok) assert.Equal(t, logger.Mode, flags.ModeAppend) } diff --git a/libs/progress/context.go b/libs/cmdio/context.go similarity index 96% rename from libs/progress/context.go rename to libs/cmdio/context.go index 4fd60194cf..0ab2513b20 100644 --- a/libs/progress/context.go +++ b/libs/cmdio/context.go @@ -1,4 +1,4 @@ -package progress +package cmdio import ( "context" diff --git a/libs/progress/event.go b/libs/cmdio/event.go similarity index 71% rename from libs/progress/event.go rename to libs/cmdio/event.go index a082c299b4..1ce686e34e 100644 --- a/libs/progress/event.go +++ b/libs/cmdio/event.go @@ -1,4 +1,4 @@ -package progress +package cmdio type Event interface { String() string diff --git a/libs/progress/logger.go b/libs/cmdio/logger.go similarity index 74% rename from libs/progress/logger.go rename to libs/cmdio/logger.go index d09f0e3dc0..f2d19f0325 100644 --- a/libs/progress/logger.go +++ b/libs/cmdio/logger.go @@ -1,6 +1,7 @@ -package progress +package cmdio import ( + "bufio" "encoding/json" "io" "os" @@ -9,7 +10,9 @@ import ( ) type Logger struct { - Mode flags.ProgressLogFormat + Mode flags.ProgressLogFormat + + Reader bufio.Reader Writer io.Writer isFirstEvent bool @@ -19,10 +22,26 @@ func NewLogger(mode flags.ProgressLogFormat) *Logger { return &Logger{ Mode: mode, Writer: os.Stderr, + Reader: *bufio.NewReader(os.Stdin), isFirstEvent: true, } } +func (l *Logger) Ask(question string) (bool, error) { + l.Writer.Write([]byte(question)) + ans, err := l.Reader.ReadString('\n') + + if err != nil { + return false, err + } + + if ans == "y\n" { + return true, nil + } else { + return false, nil + } +} + func (l *Logger) Log(event Event) { switch l.Mode { case flags.ModeInplace: diff --git a/libs/terraform/plan.go b/libs/terraform/plan.go index 8909e47998..22fea62063 100644 --- a/libs/terraform/plan.go +++ b/libs/terraform/plan.go @@ -1,68 +1,13 @@ package terraform -import ( - "encoding/json" - "strings" -) - -// TODO: can be used during deploy time too - -type Resource struct { - Name string `json:"resource_name"` - Type string `json:"resource_type"` -} - -type Change struct { - Resource *Resource `json:"resource"` - Action string `json:"action"` -} - -type Summary struct { - Add int `json:"add"` - Change int `json:"change"` - Remove int `json:"remove"` -} - -type Event struct { - Change *Change `json:"change"` - Summary *Summary `json:"changes"` - Type string `json:"type"` -} - type Plan struct { - ChangeSummary *Event - PlannedChanges []Event -} - -func NewPlan() *Plan { - return &Plan{ - PlannedChanges: make([]Event, 0), - } -} - -func (p *Plan) AddEvent(s string) error { - event := &Event{} - if len(s) == 0 { - return nil - } - err := json.Unmarshal([]byte(s), event) - if err != nil { - return err - } - switch event.Type { - case "planned_change": - p.PlannedChanges = append(p.PlannedChanges, *event) - case "change_summary": - p.ChangeSummary = event - } - return nil -} + // Path to the plan + Path string -func (c *Change) String() string { - result := strings.Builder{} - result.WriteString(c.Action + " ") - result.WriteString(c.Resource.Type + " ") - result.WriteString(c.Resource.Name) + // Holds whether the user can consented to destruction. Either by interactive + // confirmation or by passing a command line flag + ConfirmApply bool - return result.String() + // If true, the plan is empty and applying it will not do anything + IsEmpty bool } From 1c111b83a66a550372a8a8af813c99b99f49ddba Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Wed, 5 Apr 2023 16:41:47 +0200 Subject: [PATCH 3/6] Added file deletion --- bundle/deploy/files/delete.go | 53 ++++++++++++++++++++++++++++++ bundle/deploy/terraform/destroy.go | 2 +- bundle/phases/destroy.go | 2 ++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 bundle/deploy/files/delete.go diff --git a/bundle/deploy/files/delete.go b/bundle/deploy/files/delete.go new file mode 100644 index 0000000000..e644a66cf6 --- /dev/null +++ b/bundle/deploy/files/delete.go @@ -0,0 +1,53 @@ +package files + +import ( + "context" + "fmt" + + "github.com/databricks/bricks/bundle" + "github.com/databricks/bricks/libs/cmdio" + "github.com/databricks/databricks-sdk-go/service/workspace" + "github.com/fatih/color" +) + +type delete struct{} + +func (m *delete) Name() string { + return "files.Delete" +} + +func (m *delete) Apply(ctx context.Context, b *bundle.Bundle) ([]bundle.Mutator, error) { + // Do not delete files if terraform destroy was not consented + if !b.Plan.IsEmpty && !b.Plan.ConfirmApply { + return nil, nil + } + + // interface to io with the user + logger, ok := cmdio.FromContext(ctx) + if !ok { + return nil, fmt.Errorf("no logger found") + } + red := color.New(color.FgRed).SprintFunc() + proceed, err := logger.Ask(fmt.Sprintf("\nDirectory %s and all files in it will be %s Proceed?: ", b.Config.Workspace.Root, red("deleted permanently!"))) + if err != nil { + return nil, err + } + if !proceed { + return nil, nil + } + + err = b.WorkspaceClient().Workspace.Delete(ctx, workspace.Delete{ + Path: b.Config.Workspace.Root, + Recursive: true, + }) + if err != nil { + return nil, err + } + + fmt.Println("Successfully deleted files!") + return nil, nil +} + +func Delete() bundle.Mutator { + return &delete{} +} diff --git a/bundle/deploy/terraform/destroy.go b/bundle/deploy/terraform/destroy.go index 4bed6517eb..48edbc681a 100644 --- a/bundle/deploy/terraform/destroy.go +++ b/bundle/deploy/terraform/destroy.go @@ -13,7 +13,7 @@ import ( tfjson "github.com/hashicorp/terraform-json" ) -// TODO: This is temperory. Come up with a robust way to log mutator progress and +// TODO: This is temporary. Come up with a robust way to log mutator progress and // status events type PlanResourceChange struct { ResourceType string `json:"resource_type"` diff --git a/bundle/phases/destroy.go b/bundle/phases/destroy.go index 0409a35c0f..c88868f576 100644 --- a/bundle/phases/destroy.go +++ b/bundle/phases/destroy.go @@ -2,6 +2,7 @@ package phases import ( "github.com/databricks/bricks/bundle" + "github.com/databricks/bricks/bundle/deploy/files" "github.com/databricks/bricks/bundle/deploy/lock" "github.com/databricks/bricks/bundle/deploy/terraform" ) @@ -18,6 +19,7 @@ func Destroy() bundle.Mutator { terraform.Destroy(), terraform.StatePush(), lock.Release(), + files.Delete(), }, ) } From dd65065258ed5f0ea36f2d333c9ff485d1cd4942 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Wed, 5 Apr 2023 17:45:03 +0200 Subject: [PATCH 4/6] refine file delete --- bundle/bundle.go | 4 ++++ bundle/deploy/files/delete.go | 19 ++++++++++++------- bundle/deploy/terraform/destroy.go | 2 +- bundle/deploy/terraform/plan.go | 19 +++++++++++++------ bundle/phases/destroy.go | 3 +-- cmd/bundle/destroy.go | 28 ++++++++++++++-------------- 6 files changed, 45 insertions(+), 30 deletions(-) diff --git a/bundle/bundle.go b/bundle/bundle.go index 3c47689416..f62026ed79 100644 --- a/bundle/bundle.go +++ b/bundle/bundle.go @@ -37,6 +37,10 @@ type Bundle struct { Locker *locker.Locker Plan *terraform.Plan + + // if true, we skip approval checks for deploy, destroy resources and delete + // files + AutoApprove bool } func Load(path string) (*Bundle, error) { diff --git a/bundle/deploy/files/delete.go b/bundle/deploy/files/delete.go index e644a66cf6..91bedc2a5c 100644 --- a/bundle/deploy/files/delete.go +++ b/bundle/deploy/files/delete.go @@ -3,6 +3,7 @@ package files import ( "context" "fmt" + "os" "github.com/databricks/bricks/bundle" "github.com/databricks/bricks/libs/cmdio" @@ -28,15 +29,19 @@ func (m *delete) Apply(ctx context.Context, b *bundle.Bundle) ([]bundle.Mutator, return nil, fmt.Errorf("no logger found") } red := color.New(color.FgRed).SprintFunc() - proceed, err := logger.Ask(fmt.Sprintf("\nDirectory %s and all files in it will be %s Proceed?: ", b.Config.Workspace.Root, red("deleted permanently!"))) - if err != nil { - return nil, err - } - if !proceed { - return nil, nil + + fmt.Fprintf(os.Stderr, "\nRemote directory %s will be deleted\n", b.Config.Workspace.Root) + if !b.AutoApprove { + proceed, err := logger.Ask(fmt.Sprintf("%s and all files in it will be %s Proceed?: ", b.Config.Workspace.Root, red("deleted permanently!"))) + if err != nil { + return nil, err + } + if !proceed { + return nil, nil + } } - err = b.WorkspaceClient().Workspace.Delete(ctx, workspace.Delete{ + err := b.WorkspaceClient().Workspace.Delete(ctx, workspace.Delete{ Path: b.Config.Workspace.Root, Recursive: true, }) diff --git a/bundle/deploy/terraform/destroy.go b/bundle/deploy/terraform/destroy.go index 48edbc681a..3c245d29c7 100644 --- a/bundle/deploy/terraform/destroy.go +++ b/bundle/deploy/terraform/destroy.go @@ -70,7 +70,7 @@ func (w *destroy) Apply(ctx context.Context, b *bundle.Bundle) ([]bundle.Mutator } if b.Plan.IsEmpty { - fmt.Fprintln(os.Stderr, "No resources to destroy, skipping destroy!") + fmt.Fprintln(os.Stderr, "No resources to destroy!") return nil, nil } diff --git a/bundle/deploy/terraform/plan.go b/bundle/deploy/terraform/plan.go index 41293a6b88..ed3c6be472 100644 --- a/bundle/deploy/terraform/plan.go +++ b/bundle/deploy/terraform/plan.go @@ -10,8 +10,15 @@ import ( "github.com/hashicorp/terraform-exec/tfexec" ) +type PlanGoal string + +var ( + PlanDeploy = PlanGoal("deploy") + PlanDestroy = PlanGoal("destroy") +) + type plan struct { - destroy bool + goal PlanGoal } func (p *plan) Name() string { @@ -35,16 +42,16 @@ func (p *plan) Apply(ctx context.Context, b *bundle.Bundle) ([]bundle.Mutator, e return nil, err } planPath := filepath.Join(tfDir, "plan") - notEmpty, err := tf.Plan(ctx, tfexec.Destroy(p.destroy), tfexec.Out(planPath)) + destroy := p.goal == PlanDestroy + notEmpty, err := tf.Plan(ctx, tfexec.Destroy(destroy), tfexec.Out(planPath)) if err != nil { return nil, err } // Set plan in main bundle struct for downstream mutators - // TODO: allow bypass using cmd line flag b.Plan = &terraform.Plan{ Path: planPath, - ConfirmApply: false, + ConfirmApply: b.AutoApprove, IsEmpty: !notEmpty, } return nil, nil @@ -52,8 +59,8 @@ func (p *plan) Apply(ctx context.Context, b *bundle.Bundle) ([]bundle.Mutator, e // Apply returns a [bundle.Mutator] that runs the equivalent of `terraform apply` // from the bundle's ephemeral working directory for Terraform. -func Plan(destroy bool) bundle.Mutator { +func Plan(goal PlanGoal) bundle.Mutator { return &plan{ - destroy: destroy, + goal: goal, } } diff --git a/bundle/phases/destroy.go b/bundle/phases/destroy.go index c88868f576..baec5c4fcb 100644 --- a/bundle/phases/destroy.go +++ b/bundle/phases/destroy.go @@ -8,14 +8,13 @@ import ( ) // The destroy phase deletes artifacts and resources. -// TODO: force lock workaround. Error message on lock acquisition is misleading func Destroy() bundle.Mutator { return newPhase( "destroy", []bundle.Mutator{ lock.Acquire(), terraform.StatePull(), - terraform.Plan(true), + terraform.Plan(terraform.PlanGoal("destroy")), terraform.Destroy(), terraform.StatePush(), lock.Release(), diff --git a/cmd/bundle/destroy.go b/cmd/bundle/destroy.go index 0e21b953ec..086768dd13 100644 --- a/cmd/bundle/destroy.go +++ b/cmd/bundle/destroy.go @@ -1,24 +1,18 @@ package bundle import ( + "fmt" + "os" + "github.com/databricks/bricks/bundle" "github.com/databricks/bricks/bundle/phases" "github.com/databricks/bricks/cmd/root" "github.com/databricks/bricks/libs/cmdio" "github.com/databricks/bricks/libs/flags" "github.com/spf13/cobra" + "golang.org/x/term" ) -// TODO: do we need ci/cd for non-tty - -// TODO: -// 1. Delete resources -// 2. Delete files - need tracking dirs for this -// 3. Delete artifacts -// 4. What about running resources - -// TODO: json logs? - var destroyCmd = &cobra.Command{ Use: "destroy", Short: "Destroy deployed bundle resources", @@ -30,8 +24,14 @@ var destroyCmd = &cobra.Command{ // If `--force` is specified, force acquisition of the deployment lock. b.Config.Bundle.Lock.Force = force - // TODO: add tty check here - // TODO: json logs + // If `--auto-approve`` is specified, we skip confirmation checks + b.AutoApprove = autoApprove + + // we require auto-approve for non tty terminals since interactive consent + // is not possible + if !term.IsTerminal(int(os.Stderr.Fd())) && !autoApprove { + return fmt.Errorf("please specify --auto-approve to skip interactive confirmation checks for non tty consoles") + } ctx := cmdio.NewContext(cmd.Context(), cmdio.NewLogger(flags.ModeAppend)) return bundle.Apply(ctx, b, []bundle.Mutator{ @@ -42,9 +42,9 @@ var destroyCmd = &cobra.Command{ }, } -var skipConfirmation bool +var autoApprove bool func init() { AddCommand(destroyCmd) - deployCmd.Flags().BoolVar(&skipConfirmation, "skip-confirmation", false, "skip confirmation before destroy") + destroyCmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "Skip interactive approvals for deleting resources and files") } From b370c979a6c5f33452ea7ce83dd64f445b9ab53f Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Wed, 5 Apr 2023 17:47:35 +0200 Subject: [PATCH 5/6] cleanup --- bundle/deploy/terraform/destroy.go | 2 -- bundle/deploy/terraform/plan.go | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/bundle/deploy/terraform/destroy.go b/bundle/deploy/terraform/destroy.go index 3c245d29c7..c7ae460fba 100644 --- a/bundle/deploy/terraform/destroy.go +++ b/bundle/deploy/terraform/destroy.go @@ -91,8 +91,6 @@ func (w *destroy) Apply(ctx context.Context, b *bundle.Bundle) ([]bundle.Mutator return nil, err } - // TODO: Add is term detection - // Ask for confirmation, if needed if !b.Plan.ConfirmApply { red := color.New(color.FgRed).SprintFunc() diff --git a/bundle/deploy/terraform/plan.go b/bundle/deploy/terraform/plan.go index ed3c6be472..070383ec9b 100644 --- a/bundle/deploy/terraform/plan.go +++ b/bundle/deploy/terraform/plan.go @@ -57,7 +57,7 @@ func (p *plan) Apply(ctx context.Context, b *bundle.Bundle) ([]bundle.Mutator, e return nil, nil } -// Apply returns a [bundle.Mutator] that runs the equivalent of `terraform apply` +// Plan returns a [bundle.Mutator] that runs the equivalent of `terraform plan -out ./plan` // from the bundle's ephemeral working directory for Terraform. func Plan(goal PlanGoal) bundle.Mutator { return &plan{ From f75f379ddd9d44020bc8b622ec797f212c4c8c8c Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Wed, 5 Apr 2023 17:53:03 +0200 Subject: [PATCH 6/6] nit --- bundle/deploy/terraform/destroy.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundle/deploy/terraform/destroy.go b/bundle/deploy/terraform/destroy.go index c7ae460fba..4235d35345 100644 --- a/bundle/deploy/terraform/destroy.go +++ b/bundle/deploy/terraform/destroy.go @@ -119,8 +119,8 @@ func (w *destroy) Apply(ctx context.Context, b *bundle.Bundle) ([]bundle.Mutator return nil, nil } -// Destroy returns a [bundle.Mutator] that runs the equivalent of `terraform destroy` -// from the bundle's ephemeral working directory for Terraform. +// Destroy returns a [bundle.Mutator] that runs the conceptual equivalent of +// `terraform destroy ./plan` from the bundle's ephemeral working directory for Terraform. func Destroy() bundle.Mutator { return &destroy{} }