github.com/databricks/cli@v0.203.0/bundle/config/mutator/process_environment_mode.go (about)

     1  package mutator
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"path"
     7  	"strings"
     8  
     9  	"github.com/databricks/cli/bundle"
    10  	"github.com/databricks/cli/bundle/config"
    11  	"github.com/databricks/databricks-sdk-go/service/iam"
    12  	"github.com/databricks/databricks-sdk-go/service/jobs"
    13  	"github.com/databricks/databricks-sdk-go/service/ml"
    14  )
    15  
    16  type processEnvironmentMode struct{}
    17  
    18  const developmentConcurrentRuns = 4
    19  
    20  func ProcessEnvironmentMode() bundle.Mutator {
    21  	return &processEnvironmentMode{}
    22  }
    23  
    24  func (m *processEnvironmentMode) Name() string {
    25  	return "ProcessEnvironmentMode"
    26  }
    27  
    28  // Mark all resources as being for 'development' purposes, i.e.
    29  // changing their their name, adding tags, and (in the future)
    30  // marking them as 'hidden' in the UI.
    31  func transformDevelopmentMode(b *bundle.Bundle) error {
    32  	r := b.Config.Resources
    33  
    34  	prefix := "[dev " + b.Config.Workspace.CurrentUser.ShortName + "] "
    35  
    36  	for i := range r.Jobs {
    37  		r.Jobs[i].Name = prefix + r.Jobs[i].Name
    38  		if r.Jobs[i].Tags == nil {
    39  			r.Jobs[i].Tags = make(map[string]string)
    40  		}
    41  		r.Jobs[i].Tags["dev"] = b.Config.Workspace.CurrentUser.DisplayName
    42  		if r.Jobs[i].MaxConcurrentRuns == 0 {
    43  			r.Jobs[i].MaxConcurrentRuns = developmentConcurrentRuns
    44  		}
    45  		if r.Jobs[i].Schedule != nil {
    46  			r.Jobs[i].Schedule.PauseStatus = jobs.PauseStatusPaused
    47  		}
    48  		if r.Jobs[i].Continuous != nil {
    49  			r.Jobs[i].Continuous.PauseStatus = jobs.PauseStatusPaused
    50  		}
    51  		if r.Jobs[i].Trigger != nil {
    52  			r.Jobs[i].Trigger.PauseStatus = jobs.PauseStatusPaused
    53  		}
    54  	}
    55  
    56  	for i := range r.Pipelines {
    57  		r.Pipelines[i].Name = prefix + r.Pipelines[i].Name
    58  		r.Pipelines[i].Development = true
    59  		// (pipelines don't yet support tags)
    60  	}
    61  
    62  	for i := range r.Models {
    63  		r.Models[i].Name = prefix + r.Models[i].Name
    64  		r.Models[i].Tags = append(r.Models[i].Tags, ml.ModelTag{Key: "dev", Value: ""})
    65  	}
    66  
    67  	for i := range r.Experiments {
    68  		filepath := r.Experiments[i].Name
    69  		dir := path.Dir(filepath)
    70  		base := path.Base(filepath)
    71  		if dir == "." {
    72  			r.Experiments[i].Name = prefix + base
    73  		} else {
    74  			r.Experiments[i].Name = dir + "/" + prefix + base
    75  		}
    76  		r.Experiments[i].Tags = append(r.Experiments[i].Tags, ml.ExperimentTag{Key: "dev", Value: b.Config.Workspace.CurrentUser.DisplayName})
    77  	}
    78  
    79  	return nil
    80  }
    81  
    82  func validateDevelopmentMode(b *bundle.Bundle) error {
    83  	if path := findIncorrectPath(b, config.Development); path != "" {
    84  		return fmt.Errorf("%s must start with '~/' or contain the current username when using 'mode: development'", path)
    85  	}
    86  	return nil
    87  }
    88  
    89  func findIncorrectPath(b *bundle.Bundle, mode config.Mode) string {
    90  	username := b.Config.Workspace.CurrentUser.UserName
    91  	containsExpected := true
    92  	if mode == config.Production {
    93  		containsExpected = false
    94  	}
    95  
    96  	if strings.Contains(b.Config.Workspace.RootPath, username) != containsExpected && b.Config.Workspace.RootPath != "" {
    97  		return "root_path"
    98  	}
    99  	if strings.Contains(b.Config.Workspace.StatePath, username) != containsExpected {
   100  		return "state_path"
   101  	}
   102  	if strings.Contains(b.Config.Workspace.FilesPath, username) != containsExpected {
   103  		return "files_path"
   104  	}
   105  	if strings.Contains(b.Config.Workspace.ArtifactsPath, username) != containsExpected {
   106  		return "artifacts_path"
   107  	}
   108  	return ""
   109  }
   110  
   111  func validateProductionMode(ctx context.Context, b *bundle.Bundle, isPrincipalUsed bool) error {
   112  	if b.Config.Bundle.Git.Inferred {
   113  		env := b.Config.Bundle.Environment
   114  		return fmt.Errorf("environment with 'mode: production' must specify an explicit 'environments.%s.git' configuration", env)
   115  	}
   116  
   117  	r := b.Config.Resources
   118  	for i := range r.Pipelines {
   119  		if r.Pipelines[i].Development {
   120  			return fmt.Errorf("environment with 'mode: production' cannot specify a pipeline with 'development: true'")
   121  		}
   122  	}
   123  
   124  	if !isPrincipalUsed {
   125  		if path := findIncorrectPath(b, config.Production); path != "" {
   126  			message := "%s must not contain the current username when using 'mode: production'"
   127  			if path == "root_path" {
   128  				return fmt.Errorf(message+"\n  tip: set workspace.root_path to a shared path such as /Shared/.bundle/${bundle.name}/${bundle.environment}", path)
   129  			} else {
   130  				return fmt.Errorf(message, path)
   131  			}
   132  		}
   133  
   134  		if !isRunAsSet(r) {
   135  			return fmt.Errorf("'run_as' must be set for all jobs when using 'mode: production'")
   136  		}
   137  	}
   138  	return nil
   139  }
   140  
   141  // Determines whether a service principal identity is used to run the CLI.
   142  func isServicePrincipalUsed(ctx context.Context, b *bundle.Bundle) (bool, error) {
   143  	ws := b.WorkspaceClient()
   144  
   145  	// Check if a principal with the current user's ID exists.
   146  	// We need to use the ListAll method since Get is only usable by admins.
   147  	matches, err := ws.ServicePrincipals.ListAll(ctx, iam.ListServicePrincipalsRequest{
   148  		Filter: "id eq " + b.Config.Workspace.CurrentUser.Id,
   149  	})
   150  	if err != nil {
   151  		return false, err
   152  	}
   153  	return len(matches) > 0, nil
   154  }
   155  
   156  // Determines whether run_as is explicitly set for all resources.
   157  // We do this in a best-effort fashion rather than check the top-level
   158  // 'run_as' field because the latter is not required to be set.
   159  func isRunAsSet(r config.Resources) bool {
   160  	for i := range r.Jobs {
   161  		if r.Jobs[i].RunAs == nil {
   162  			return false
   163  		}
   164  	}
   165  	return true
   166  }
   167  
   168  func (m *processEnvironmentMode) Apply(ctx context.Context, b *bundle.Bundle) error {
   169  	switch b.Config.Bundle.Mode {
   170  	case config.Development:
   171  		err := validateDevelopmentMode(b)
   172  		if err != nil {
   173  			return err
   174  		}
   175  		return transformDevelopmentMode(b)
   176  	case config.Production:
   177  		isPrincipal, err := isServicePrincipalUsed(ctx, b)
   178  		if err != nil {
   179  			return err
   180  		}
   181  		return validateProductionMode(ctx, b, isPrincipal)
   182  	case "":
   183  		// No action
   184  	default:
   185  		return fmt.Errorf("unsupported value specified for 'mode': %s", b.Config.Bundle.Mode)
   186  	}
   187  
   188  	return nil
   189  }