[WIP] Show plan on bricks bundle deploy

This commit is contained in:
Shreyas Goenka 2023-04-19 15:14:26 +02:00
parent 598ad62688
commit c5497d39d6
No known key found for this signature in database
GPG Key ID: 92A07DF49CCB0622
12 changed files with 298 additions and 17 deletions

View File

@ -16,18 +16,21 @@ func (m *delete) Name() string {
return "files.Delete"
}
// TODO: autoapprove and tty detection for destroy. Don't allow destroy without auto-approve otherwise. Note this is a breaking change
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
}
cmdio.LogString(ctx, "Starting deletion of remote bundle files")
cmdio.LogString(ctx, "\nStarting 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.Ask(ctx, fmt.Sprintf("\n%s and all files in it will be %s Proceed?: ", b.Config.Workspace.RootPath, red("deleted permanently!")))
cmdio.LogString(ctx, fmt.Sprintf("\n%s and all files in it will be %s.", b.Config.Workspace.RootPath, red("deleted permanently!")))
proceed, err := cmdio.Ask(ctx, "Proceed with deletion?: ")
if err != nil {
return nil, err
}
@ -54,7 +57,7 @@ func (m *delete) Apply(ctx context.Context, b *bundle.Bundle) ([]bundle.Mutator,
return nil, err
}
cmdio.LogString(ctx, fmt.Sprintf("Deleted snapshot file at %s", sync.SnapshotPath()))
cmdio.LogString(ctx, fmt.Sprintf("\n Deleted snapshot file at %s", sync.SnapshotPath()))
cmdio.LogString(ctx, "Successfully deleted files!")
return nil, nil
}

View File

@ -26,7 +26,7 @@ func (m *upload) Apply(ctx context.Context, b *bundle.Bundle) ([]bundle.Mutator,
return nil, err
}
cmdio.LogString(ctx, fmt.Sprintf("Uploaded bundle files at %s!\n", b.Config.Workspace.FilesPath))
cmdio.LogString(ctx, fmt.Sprintf("Uploaded bundle files at %s\n", b.Config.Workspace.FilesPath))
return nil, nil
}

View File

@ -6,39 +6,93 @@ import (
"github.com/databricks/bricks/bundle"
"github.com/databricks/bricks/libs/cmdio"
"github.com/fatih/color"
"github.com/hashicorp/terraform-exec/tfexec"
)
type apply struct{}
type ApplyGoal string
var (
ApplyDeploy = ApplyGoal("deploy")
ApplyDestroy = ApplyGoal("destroy")
)
type apply struct {
goal ApplyGoal
}
func (w *apply) Name() string {
return "terraform.Apply"
}
func (w *apply) Apply(ctx context.Context, b *bundle.Bundle) ([]bundle.Mutator, error) {
// return early if plan is empty
if b.Plan.IsEmpty {
if w.goal == ApplyDeploy {
cmdio.LogString(ctx, "No resource changes to apply. Skipping deploy!")
}
if w.goal == ApplyDestroy {
cmdio.LogString(ctx, "No resources to destroy in plan. Skipping destroy!")
}
return nil, nil
}
tf := b.Terraform
if tf == nil {
return nil, fmt.Errorf("terraform not initialized")
}
cmdio.LogString(ctx, "Starting resource deployment")
err := tf.Init(ctx, tfexec.Upgrade(true))
if err != nil {
return nil, fmt.Errorf("terraform init: %w", err)
}
err = tf.Apply(ctx)
// Ask for confirmation, if needed
if !b.Plan.ConfirmApply {
red := color.New(color.FgRed).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
if b.Plan.IsReplacingResource {
cmdio.LogString(ctx, fmt.Sprintf("One or more resources will be %s. Any previous metadata associated might be lost.", yellow("replaced")))
}
if b.Plan.IsDeletingResource {
cmdio.LogString(ctx, fmt.Sprintf("One or more resources will be permanently %s", red("destroyed")))
}
b.Plan.ConfirmApply, err = cmdio.Ask(ctx, "Proceed with apply? [y/n]: ")
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")
}
cmdio.LogString(ctx, "\nStarting resource deployment")
// 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 apply: %w", err)
}
cmdio.LogString(ctx, "Resource deployment completed!")
if w.goal == ApplyDeploy {
cmdio.LogString(ctx, "Successfully deployed resources!")
}
if w.goal == ApplyDestroy {
cmdio.LogString(ctx, "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 Apply() bundle.Mutator {
return &apply{}
func Apply(goal ApplyGoal) bundle.Mutator {
return &apply{
goal: goal,
}
}

View File

@ -0,0 +1,70 @@
package terraform
import (
"context"
"fmt"
"github.com/databricks/bricks/bundle"
"github.com/databricks/bricks/libs/cmdio"
"github.com/hashicorp/terraform-exec/tfexec"
)
type showPlan struct{}
func (m *showPlan) Name() string {
return "terraform.ShowPlan"
}
func (m *showPlan) 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)
}
// read plan file
plan, err := tf.ShowPlanFile(ctx, b.Plan.Path)
if err != nil {
return nil, err
}
// compute bundle specific change events
changeEvents := make([]*ResourceChangeEvent, 0)
for _, change := range plan.ResourceChanges {
if change.Change == nil {
continue
}
if change.Change.Actions.Replace() {
b.Plan.IsReplacingResource = true
}
if change.Change.Actions.Delete() {
b.Plan.IsDeletingResource = true
}
event := toResourceChangeEvent(change)
if event == nil {
continue
}
changeEvents = append(changeEvents, event)
}
// return without logging anything if no relevant change events in computed plan
if len(changeEvents) == 0 {
return nil, nil
}
// log resource changes
cmdio.LogString(ctx, "The following resource changes will be applied:")
for _, event := range changeEvents {
cmdio.Log(ctx, event)
}
cmdio.LogNewline(ctx)
return nil, nil
}
func ShowPlan() bundle.Mutator {
return &showPlan{}
}

View File

@ -0,0 +1,97 @@
package terraform
import (
"os"
"strings"
"github.com/fatih/color"
tfjson "github.com/hashicorp/terraform-json"
"golang.org/x/term"
)
type ResourceChangeEvent struct {
Name string `json:"name"`
ResourceType string `json:"resource_type"`
Action string `json:"action"`
}
func toAction(actions tfjson.Actions) string {
action := "no-op"
switch {
case actions.Create():
action = "create"
case actions.Read():
action = "read"
case actions.Update():
action = "update"
case actions.Delete():
action = "delete"
case actions.Replace():
action = "replace"
}
red := color.New(color.FgRed).SprintFunc()
green := color.New(color.FgGreen).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
isTty := term.IsTerminal(int(os.Stderr.Fd()))
if isTty && action == "create" {
action = green(action)
}
if isTty && action == "delete" {
action = red(action)
}
if isTty && action == "replace" {
action = yellow(action)
}
return action
}
func toResourceType(terraformType string) string {
switch terraformType {
case "databricks_job":
return "job"
case "databricks_pipeline":
return "pipeline"
case "databricks_mlflow_model":
return "mlflow_model"
case "databricks_mlflow_experiment":
return "mlflow_experiment"
default:
return ""
}
}
func toResourceChangeEvent(change *tfjson.ResourceChange) *ResourceChangeEvent {
if change.Change == nil {
return nil
}
actions := change.Change.Actions
if actions.Read() || actions.NoOp() {
return nil
}
action := toAction(actions)
resourceType := toResourceType(change.Type)
if resourceType == "" {
return nil
}
name := change.Name
if name == "" {
return nil
}
return &ResourceChangeEvent{
Name: name,
Action: action,
ResourceType: resourceType,
}
}
func (event *ResourceChangeEvent) String() string {
return strings.Join([]string{" ", string(event.Action), event.ResourceType, event.Name}, " ")
}
func (event *ResourceChangeEvent) IsInplaceSupported() bool {
return false
}

View File

@ -19,7 +19,9 @@ func Deploy() bundle.Mutator {
terraform.Interpolate(),
terraform.Write(),
terraform.StatePull(),
terraform.Apply(),
terraform.Plan("deploy"),
terraform.ShowPlan(),
terraform.Apply("deploy"),
terraform.StatePush(),
lock.Release(),
},

View File

@ -14,8 +14,9 @@ func Destroy() bundle.Mutator {
[]bundle.Mutator{
lock.Acquire(),
terraform.StatePull(),
terraform.Plan(terraform.PlanGoal("destroy")),
terraform.Destroy(),
terraform.Plan("destroy"),
terraform.ShowPlan(),
terraform.Apply("destroy"),
terraform.StatePush(),
lock.Release(),
files.Delete(),

View File

@ -1,10 +1,16 @@
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"
)
var deployCmd = &cobra.Command{
@ -13,11 +19,30 @@ var deployCmd = &cobra.Command{
PreRunE: root.MustConfigureBundle,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
b := bundle.Get(cmd.Context())
// If `--force` is specified, force acquisition of the deployment lock.
b.Config.Bundle.Lock.Force = force
// 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")
}
// Check auto-approve is selected for json logging
logger, ok := cmdio.FromContext(ctx)
if !ok {
return fmt.Errorf("progress logger not found")
}
if logger.Mode == flags.ModeJson && !autoApprove {
return fmt.Errorf("please specify --auto-approve since selected logging format is json")
}
return bundle.Apply(cmd.Context(), b, []bundle.Mutator{
phases.Initialize(),
phases.Build(),
@ -28,7 +53,10 @@ var deployCmd = &cobra.Command{
var force bool
var autoApprove bool
func init() {
AddCommand(deployCmd)
deployCmd.Flags().BoolVar(&force, "force", false, "Force acquisition of deployment lock.")
deployCmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "Skip interactive approvals")
}

View File

@ -13,6 +13,8 @@ import (
"golang.org/x/term"
)
// TODO: note possibility of aliases
var destroyCmd = &cobra.Command{
Use: "destroy",
Short: "Destroy deployed bundle resources",
@ -51,9 +53,8 @@ var destroyCmd = &cobra.Command{
},
}
var autoApprove bool
func init() {
AddCommand(destroyCmd)
destroyCmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "Skip interactive approvals for deleting resources and files")
destroyCmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "Skip interactive approvals")
destroyCmd.Flags().BoolVar(&force, "force", false, "Force acquisition of deployment lock.")
}

View File

@ -63,6 +63,14 @@ func LogString(ctx context.Context, message string) {
})
}
func LogNewline(ctx context.Context) {
logger, ok := FromContext(ctx)
if !ok {
logger = Default()
}
logger.Log(&NewlineEvent{})
}
func Ask(ctx context.Context, question string) (bool, error) {
logger, ok := FromContext(ctx)
if !ok {

View File

@ -0,0 +1,11 @@
package cmdio
type NewlineEvent struct{}
func (event *NewlineEvent) String() string {
return ""
}
func (event *NewlineEvent) IsInplaceSupported() bool {
return false
}

View File

@ -10,4 +10,10 @@ type Plan struct {
// If true, the plan is empty and applying it will not do anything
IsEmpty bool
// If true, there are one or more resources in plan that will be re-created
IsReplacingResource bool
// if true, there are one or more resources in plan that will be destroyed
IsDeletingResource bool
}