mirror of https://github.com/databricks/cli.git
Add a foundation for built-in templates (#685)
## Changes This pull request extends the templating support in preparation of a new, default template (WIP, https://github.com/databricks/cli/pull/686): * builtin templates that can be initialized using e.g. `databricks bundle init default-python` * builtin templates are embedded into the executable using go's `embed` functionality, making sure they're co-versioned with the CLI * new helpers to get the workspace name, current user name, etc. help craft a complete template * (not enabled yet) when the user types `databricks bundle init` they can interactively select the `default-python` template And makes two tangentially related changes: * IsServicePrincipal now uses the "users" API rather than the "principals" API, since the latter is too slow for our purposes. * mode: prod no longer requires the 'target.prod.git' setting. It's hard to set that from a template. (Pieter is planning an overhaul of warnings support; this would be one of the first warnings we show.) The actual `default-python` template is maintained in a separate PR: https://github.com/databricks/cli/pull/686 ## Tests Unit tests, manual testing
This commit is contained in:
parent
c5cd20de23
commit
a5b86093ec
|
@ -28,3 +28,6 @@ __pycache__
|
||||||
.terraform.lock.hcl
|
.terraform.lock.hcl
|
||||||
|
|
||||||
.vscode/launch.json
|
.vscode/launch.json
|
||||||
|
.vscode/tasks.json
|
||||||
|
|
||||||
|
.databricks
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Typings for Pylance in VS Code
|
||||||
|
# see https://github.com/microsoft/pyright/blob/main/docs/builtins.md
|
||||||
|
from databricks.sdk.runtime import *
|
|
@ -7,5 +7,6 @@
|
||||||
"files.insertFinalNewline": true,
|
"files.insertFinalNewline": true,
|
||||||
"files.trimFinalNewlines": true,
|
"files.trimFinalNewlines": true,
|
||||||
"python.envFile": "${workspaceFolder}/.databricks/.databricks.env",
|
"python.envFile": "${workspaceFolder}/.databricks/.databricks.env",
|
||||||
"databricks.python.envFile": "${workspaceFolder}/.env"
|
"databricks.python.envFile": "${workspaceFolder}/.env",
|
||||||
|
"python.analysis.stubPath": ".vscode"
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,8 @@ import (
|
||||||
|
|
||||||
"github.com/databricks/cli/bundle"
|
"github.com/databricks/cli/bundle"
|
||||||
"github.com/databricks/cli/bundle/config"
|
"github.com/databricks/cli/bundle/config"
|
||||||
"github.com/databricks/databricks-sdk-go/service/iam"
|
"github.com/databricks/cli/libs/auth"
|
||||||
|
"github.com/databricks/cli/libs/log"
|
||||||
"github.com/databricks/databricks-sdk-go/service/jobs"
|
"github.com/databricks/databricks-sdk-go/service/jobs"
|
||||||
"github.com/databricks/databricks-sdk-go/service/ml"
|
"github.com/databricks/databricks-sdk-go/service/ml"
|
||||||
)
|
)
|
||||||
|
@ -111,7 +112,7 @@ func findIncorrectPath(b *bundle.Bundle, mode config.Mode) string {
|
||||||
func validateProductionMode(ctx context.Context, b *bundle.Bundle, isPrincipalUsed bool) error {
|
func validateProductionMode(ctx context.Context, b *bundle.Bundle, isPrincipalUsed bool) error {
|
||||||
if b.Config.Bundle.Git.Inferred {
|
if b.Config.Bundle.Git.Inferred {
|
||||||
env := b.Config.Bundle.Target
|
env := b.Config.Bundle.Target
|
||||||
return fmt.Errorf("target with 'mode: production' must specify an explicit 'targets.%s.git' configuration", env)
|
log.Warnf(ctx, "target with 'mode: production' should specify an explicit 'targets.%s.git' configuration", env)
|
||||||
}
|
}
|
||||||
|
|
||||||
r := b.Config.Resources
|
r := b.Config.Resources
|
||||||
|
@ -138,21 +139,6 @@ func validateProductionMode(ctx context.Context, b *bundle.Bundle, isPrincipalUs
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determines whether a service principal identity is used to run the CLI.
|
|
||||||
func isServicePrincipalUsed(ctx context.Context, b *bundle.Bundle) (bool, error) {
|
|
||||||
ws := b.WorkspaceClient()
|
|
||||||
|
|
||||||
// Check if a principal with the current user's ID exists.
|
|
||||||
// We need to use the ListAll method since Get is only usable by admins.
|
|
||||||
matches, err := ws.ServicePrincipals.ListAll(ctx, iam.ListServicePrincipalsRequest{
|
|
||||||
Filter: "id eq " + b.Config.Workspace.CurrentUser.Id,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return len(matches) > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determines whether run_as is explicitly set for all resources.
|
// Determines whether run_as is explicitly set for all resources.
|
||||||
// We do this in a best-effort fashion rather than check the top-level
|
// We do this in a best-effort fashion rather than check the top-level
|
||||||
// 'run_as' field because the latter is not required to be set.
|
// 'run_as' field because the latter is not required to be set.
|
||||||
|
@ -174,10 +160,7 @@ func (m *processTargetMode) Apply(ctx context.Context, b *bundle.Bundle) error {
|
||||||
}
|
}
|
||||||
return transformDevelopmentMode(b)
|
return transformDevelopmentMode(b)
|
||||||
case config.Production:
|
case config.Production:
|
||||||
isPrincipal, err := isServicePrincipalUsed(ctx, b)
|
isPrincipal := auth.IsServicePrincipal(ctx, b.WorkspaceClient(), b.Config.Workspace.CurrentUser.Id)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return validateProductionMode(ctx, b, isPrincipal)
|
return validateProductionMode(ctx, b, isPrincipal)
|
||||||
case "":
|
case "":
|
||||||
// No action
|
// No action
|
||||||
|
|
|
@ -118,17 +118,6 @@ func TestProcessTargetModeProduction(t *testing.T) {
|
||||||
assert.False(t, bundle.Config.Resources.Pipelines["pipeline1"].PipelineSpec.Development)
|
assert.False(t, bundle.Config.Resources.Pipelines["pipeline1"].PipelineSpec.Development)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessTargetModeProductionGit(t *testing.T) {
|
|
||||||
bundle := mockBundle(config.Production)
|
|
||||||
|
|
||||||
// Pretend the user didn't set Git configuration explicitly
|
|
||||||
bundle.Config.Bundle.Git.Inferred = true
|
|
||||||
|
|
||||||
err := validateProductionMode(context.Background(), bundle, false)
|
|
||||||
require.ErrorContains(t, err, "git")
|
|
||||||
bundle.Config.Bundle.Git.Inferred = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessTargetModeProductionOkForPrincipal(t *testing.T) {
|
func TestProcessTargetModeProductionOkForPrincipal(t *testing.T) {
|
||||||
bundle := mockBundle(config.Production)
|
bundle := mockBundle(config.Production)
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
package bundle
|
package bundle
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/databricks/cli/cmd/root"
|
||||||
"github.com/databricks/cli/libs/git"
|
"github.com/databricks/cli/libs/git"
|
||||||
"github.com/databricks/cli/libs/template"
|
"github.com/databricks/cli/libs/template"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
@ -36,9 +38,9 @@ func repoName(url string) string {
|
||||||
|
|
||||||
func newInitCommand() *cobra.Command {
|
func newInitCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "init TEMPLATE_PATH",
|
Use: "init [TEMPLATE_PATH]",
|
||||||
Short: "Initialize Template",
|
Short: "Initialize Template",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
}
|
}
|
||||||
|
|
||||||
var configFile string
|
var configFile string
|
||||||
|
@ -48,9 +50,26 @@ func newInitCommand() *cobra.Command {
|
||||||
cmd.Flags().StringVar(&templateDir, "template-dir", "", "Directory within repository that holds the template specification.")
|
cmd.Flags().StringVar(&templateDir, "template-dir", "", "Directory within repository that holds the template specification.")
|
||||||
cmd.Flags().StringVar(&outputDir, "output-dir", "", "Directory to write the initialized template to.")
|
cmd.Flags().StringVar(&outputDir, "output-dir", "", "Directory to write the initialized template to.")
|
||||||
|
|
||||||
|
cmd.PreRunE = root.MustWorkspaceClient
|
||||||
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||||
templatePath := args[0]
|
|
||||||
ctx := cmd.Context()
|
ctx := cmd.Context()
|
||||||
|
var templatePath string
|
||||||
|
if len(args) > 0 {
|
||||||
|
templatePath = args[0]
|
||||||
|
} else {
|
||||||
|
return errors.New("please specify a template")
|
||||||
|
|
||||||
|
/* TODO: propose to use default-python (once template is ready)
|
||||||
|
var err error
|
||||||
|
if !cmdio.IsOutTTY(ctx) || !cmdio.IsInTTY(ctx) {
|
||||||
|
return errors.New("please specify a template")
|
||||||
|
}
|
||||||
|
templatePath, err = cmdio.Ask(ctx, "Template to use", "default-python")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
if !isRepoUrl(templatePath) {
|
if !isRepoUrl(templatePath) {
|
||||||
// skip downloading the repo because input arg is not a URL. We assume
|
// skip downloading the repo because input arg is not a URL. We assume
|
||||||
|
|
|
@ -113,6 +113,10 @@ TRY_AUTH: // or try picking a config profile dynamically
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SetWorkspaceClient(ctx context.Context, w *databricks.WorkspaceClient) context.Context {
|
||||||
|
return context.WithValue(ctx, &workspaceClient, w)
|
||||||
|
}
|
||||||
|
|
||||||
func transformLoadError(path string, err error) error {
|
func transformLoadError(path string, err error) error {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return fmt.Errorf("no configuration file found at %s; please create one first", path)
|
return fmt.Errorf("no configuration file found at %s; please create one first", path)
|
||||||
|
|
|
@ -43,7 +43,7 @@ func getTarget(cmd *cobra.Command) (value string) {
|
||||||
return target
|
return target
|
||||||
}
|
}
|
||||||
|
|
||||||
func getProfile(cmd *cobra.Command) (value string) {
|
func GetProfile(cmd *cobra.Command) (value string) {
|
||||||
// The command line flag takes precedence.
|
// The command line flag takes precedence.
|
||||||
flag := cmd.Flag("profile")
|
flag := cmd.Flag("profile")
|
||||||
if flag != nil {
|
if flag != nil {
|
||||||
|
@ -70,7 +70,7 @@ func loadBundle(cmd *cobra.Command, args []string, load func(ctx context.Context
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
profile := getProfile(cmd)
|
profile := GetProfile(cmd)
|
||||||
if profile != "" {
|
if profile != "" {
|
||||||
b.Config.Workspace.Profile = profile
|
b.Config.Workspace.Profile = profile
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/databricks/databricks-sdk-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Determines whether a given user id is a service principal.
|
||||||
|
// This function uses a heuristic: if no user exists with this id, we assume
|
||||||
|
// it's a service principal. Unfortunately, the standard service principal API is too
|
||||||
|
// slow for our purposes.
|
||||||
|
func IsServicePrincipal(ctx context.Context, ws *databricks.WorkspaceClient, userId string) bool {
|
||||||
|
_, err := ws.Users.GetById(ctx, userId)
|
||||||
|
return err != nil
|
||||||
|
}
|
|
@ -1,10 +1,16 @@
|
||||||
package template
|
package template
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/databricks/cli/cmd/root"
|
||||||
|
"github.com/databricks/cli/libs/auth"
|
||||||
|
"github.com/databricks/databricks-sdk-go/service/iam"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ErrFail struct {
|
type ErrFail struct {
|
||||||
|
@ -20,35 +26,87 @@ type pair struct {
|
||||||
v any
|
v any
|
||||||
}
|
}
|
||||||
|
|
||||||
var helperFuncs = template.FuncMap{
|
func loadHelpers(ctx context.Context) template.FuncMap {
|
||||||
"fail": func(format string, args ...any) (any, error) {
|
var user *iam.User
|
||||||
return nil, ErrFail{fmt.Sprintf(format, args...)}
|
var is_service_principal *bool
|
||||||
},
|
w := root.WorkspaceClient(ctx)
|
||||||
// Alias for https://pkg.go.dev/net/url#Parse. Allows usage of all methods of url.URL
|
return template.FuncMap{
|
||||||
"url": func(rawUrl string) (*url.URL, error) {
|
"fail": func(format string, args ...any) (any, error) {
|
||||||
return url.Parse(rawUrl)
|
return nil, ErrFail{fmt.Sprintf(format, args...)}
|
||||||
},
|
},
|
||||||
// Alias for https://pkg.go.dev/regexp#Compile. Allows usage of all methods of regexp.Regexp
|
// Alias for https://pkg.go.dev/net/url#Parse. Allows usage of all methods of url.URL
|
||||||
"regexp": func(expr string) (*regexp.Regexp, error) {
|
"url": func(rawUrl string) (*url.URL, error) {
|
||||||
return regexp.Compile(expr)
|
return url.Parse(rawUrl)
|
||||||
},
|
},
|
||||||
// A key value pair. This is used with the map function to generate maps
|
// Alias for https://pkg.go.dev/regexp#Compile. Allows usage of all methods of regexp.Regexp
|
||||||
// to use inside a template
|
"regexp": func(expr string) (*regexp.Regexp, error) {
|
||||||
"pair": func(k string, v any) pair {
|
return regexp.Compile(expr)
|
||||||
return pair{k, v}
|
},
|
||||||
},
|
// A key value pair. This is used with the map function to generate maps
|
||||||
// map converts a list of pairs to a map object. This is useful to pass multiple
|
// to use inside a template
|
||||||
// objects to templates defined in the library directory. Go text template
|
"pair": func(k string, v any) pair {
|
||||||
// syntax for invoking a template only allows specifying a single argument,
|
return pair{k, v}
|
||||||
// this function can be used to workaround that limitation.
|
},
|
||||||
//
|
// map converts a list of pairs to a map object. This is useful to pass multiple
|
||||||
// For example: {{template "my_template" (map (pair "foo" $arg1) (pair "bar" $arg2))}}
|
// objects to templates defined in the library directory. Go text template
|
||||||
// $arg1 and $arg2 can be referred from inside "my_template" as ".foo" and ".bar"
|
// syntax for invoking a template only allows specifying a single argument,
|
||||||
"map": func(pairs ...pair) map[string]any {
|
// this function can be used to workaround that limitation.
|
||||||
result := make(map[string]any, 0)
|
//
|
||||||
for _, p := range pairs {
|
// For example: {{template "my_template" (map (pair "foo" $arg1) (pair "bar" $arg2))}}
|
||||||
result[p.k] = p.v
|
// $arg1 and $arg2 can be referred from inside "my_template" as ".foo" and ".bar"
|
||||||
}
|
"map": func(pairs ...pair) map[string]any {
|
||||||
return result
|
result := make(map[string]any, 0)
|
||||||
},
|
for _, p := range pairs {
|
||||||
|
result[p.k] = p.v
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
// Get smallest node type (follows Terraform's GetSmallestNodeType)
|
||||||
|
"smallest_node_type": func() (string, error) {
|
||||||
|
if w.Config.Host == "" {
|
||||||
|
return "", errors.New("cannot determine target workspace, please first setup a configuration profile using 'databricks auth login'")
|
||||||
|
}
|
||||||
|
if w.Config.IsAzure() {
|
||||||
|
return "Standard_D3_v2", nil
|
||||||
|
} else if w.Config.IsGcp() {
|
||||||
|
return "n1-standard-4", nil
|
||||||
|
}
|
||||||
|
return "i3.xlarge", nil
|
||||||
|
},
|
||||||
|
"workspace_host": func() (string, error) {
|
||||||
|
if w.Config.Host == "" {
|
||||||
|
return "", errors.New("cannot determine target workspace, please first setup a configuration profile using 'databricks auth login'")
|
||||||
|
}
|
||||||
|
return w.Config.Host, nil
|
||||||
|
},
|
||||||
|
"user_name": func() (string, error) {
|
||||||
|
if user == nil {
|
||||||
|
var err error
|
||||||
|
user, err = w.CurrentUser.Me(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := user.UserName
|
||||||
|
if result == "" {
|
||||||
|
result = user.Id
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
},
|
||||||
|
"is_service_principal": func() (bool, error) {
|
||||||
|
if is_service_principal != nil {
|
||||||
|
return *is_service_principal, nil
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
var err error
|
||||||
|
user, err = w.CurrentUser.Me(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := auth.IsServicePrincipal(ctx, w, user.Id)
|
||||||
|
is_service_principal = &result
|
||||||
|
return result, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,9 +2,15 @@ package template
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/databricks/cli/cmd/root"
|
||||||
|
"github.com/databricks/cli/libs/cmdio"
|
||||||
|
"github.com/databricks/cli/libs/flags"
|
||||||
|
"github.com/databricks/databricks-sdk-go"
|
||||||
|
workspaceConfig "github.com/databricks/databricks-sdk-go/config"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
@ -13,7 +19,9 @@ func TestTemplatePrintStringWithoutProcessing(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
r, err := newRenderer(ctx, nil, "./testdata/print-without-processing/template", "./testdata/print-without-processing/library", tmpDir)
|
ctx = root.SetWorkspaceClient(ctx, nil)
|
||||||
|
helpers := loadHelpers(ctx)
|
||||||
|
r, err := newRenderer(ctx, nil, helpers, "./testdata/print-without-processing/template", "./testdata/print-without-processing/library", tmpDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = r.walk()
|
err = r.walk()
|
||||||
|
@ -28,7 +36,9 @@ func TestTemplateRegexpCompileFunction(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
r, err := newRenderer(ctx, nil, "./testdata/regexp-compile/template", "./testdata/regexp-compile/library", tmpDir)
|
ctx = root.SetWorkspaceClient(ctx, nil)
|
||||||
|
helpers := loadHelpers(ctx)
|
||||||
|
r, err := newRenderer(ctx, nil, helpers, "./testdata/regexp-compile/template", "./testdata/regexp-compile/library", tmpDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = r.walk()
|
err = r.walk()
|
||||||
|
@ -44,7 +54,9 @@ func TestTemplateUrlFunction(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
r, err := newRenderer(ctx, nil, "./testdata/urlparse-function/template", "./testdata/urlparse-function/library", tmpDir)
|
ctx = root.SetWorkspaceClient(ctx, nil)
|
||||||
|
helpers := loadHelpers(ctx)
|
||||||
|
r, err := newRenderer(ctx, nil, helpers, "./testdata/urlparse-function/template", "./testdata/urlparse-function/library", tmpDir)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
@ -59,7 +71,9 @@ func TestTemplateMapPairFunction(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
r, err := newRenderer(ctx, nil, "./testdata/map-pair/template", "./testdata/map-pair/library", tmpDir)
|
ctx = root.SetWorkspaceClient(ctx, nil)
|
||||||
|
helpers := loadHelpers(ctx)
|
||||||
|
r, err := newRenderer(ctx, nil, helpers, "./testdata/map-pair/template", "./testdata/map-pair/library", tmpDir)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
@ -69,3 +83,49 @@ func TestTemplateMapPairFunction(t *testing.T) {
|
||||||
assert.Len(t, r.files, 1)
|
assert.Len(t, r.files, 1)
|
||||||
assert.Equal(t, "false 123 hello 12.3", string(r.files[0].(*inMemoryFile).content))
|
assert.Equal(t, "false 123 hello 12.3", string(r.files[0].(*inMemoryFile).content))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceHost(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
w := &databricks.WorkspaceClient{
|
||||||
|
Config: &workspaceConfig.Config{
|
||||||
|
Host: "https://myhost.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx = root.SetWorkspaceClient(ctx, w)
|
||||||
|
|
||||||
|
helpers := loadHelpers(ctx)
|
||||||
|
r, err := newRenderer(ctx, nil, helpers, "./testdata/workspace-host/template", "./testdata/map-pair/library", tmpDir)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = r.walk()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, r.files, 1)
|
||||||
|
assert.Contains(t, string(r.files[0].(*inMemoryFile).content), "https://myhost.com")
|
||||||
|
assert.Contains(t, string(r.files[0].(*inMemoryFile).content), "i3.xlarge")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceHostNotConfigured(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cmd := cmdio.NewIO(flags.OutputJSON, strings.NewReader(""), os.Stdout, os.Stderr, "template")
|
||||||
|
ctx = cmdio.InContext(ctx, cmd)
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
w := &databricks.WorkspaceClient{
|
||||||
|
Config: &workspaceConfig.Config{},
|
||||||
|
}
|
||||||
|
ctx = root.SetWorkspaceClient(ctx, w)
|
||||||
|
|
||||||
|
helpers := loadHelpers(ctx)
|
||||||
|
r, err := newRenderer(ctx, nil, helpers, "./testdata/workspace-host/template", "./testdata/map-pair/library", tmpDir)
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = r.walk()
|
||||||
|
require.ErrorContains(t, err, "cannot determine target workspace")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -2,6 +2,10 @@ package template
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -9,6 +13,9 @@ const libraryDirName = "library"
|
||||||
const templateDirName = "template"
|
const templateDirName = "template"
|
||||||
const schemaFileName = "databricks_template_schema.json"
|
const schemaFileName = "databricks_template_schema.json"
|
||||||
|
|
||||||
|
//go:embed all:templates
|
||||||
|
var builtinTemplates embed.FS
|
||||||
|
|
||||||
// This function materializes the input templates as a project, using user defined
|
// This function materializes the input templates as a project, using user defined
|
||||||
// configurations.
|
// configurations.
|
||||||
// Parameters:
|
// Parameters:
|
||||||
|
@ -18,9 +25,21 @@ const schemaFileName = "databricks_template_schema.json"
|
||||||
// templateRoot: root of the template definition
|
// templateRoot: root of the template definition
|
||||||
// outputDir: root of directory where to initialize the template
|
// outputDir: root of directory where to initialize the template
|
||||||
func Materialize(ctx context.Context, configFilePath, templateRoot, outputDir string) error {
|
func Materialize(ctx context.Context, configFilePath, templateRoot, outputDir string) error {
|
||||||
|
// Use a temporary directory in case any builtin templates like default-python are used
|
||||||
|
tempDir, err := os.MkdirTemp("", "templates")
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
templateRoot, err = prepareBuiltinTemplates(templateRoot, tempDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
templatePath := filepath.Join(templateRoot, templateDirName)
|
templatePath := filepath.Join(templateRoot, templateDirName)
|
||||||
libraryPath := filepath.Join(templateRoot, libraryDirName)
|
libraryPath := filepath.Join(templateRoot, libraryDirName)
|
||||||
schemaPath := filepath.Join(templateRoot, schemaFileName)
|
schemaPath := filepath.Join(templateRoot, schemaFileName)
|
||||||
|
helpers := loadHelpers(ctx)
|
||||||
|
|
||||||
config, err := newConfig(ctx, schemaPath)
|
config, err := newConfig(ctx, schemaPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -48,7 +67,7 @@ func Materialize(ctx context.Context, configFilePath, templateRoot, outputDir st
|
||||||
}
|
}
|
||||||
|
|
||||||
// Walk and render the template, since input configuration is complete
|
// Walk and render the template, since input configuration is complete
|
||||||
r, err := newRenderer(ctx, config.values, templatePath, libraryPath, outputDir)
|
r, err := newRenderer(ctx, config.values, helpers, templatePath, libraryPath, outputDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -56,5 +75,46 @@ func Materialize(ctx context.Context, configFilePath, templateRoot, outputDir st
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return r.persistToDisk()
|
|
||||||
|
err = r.persistToDisk()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
println("✨ Successfully initialized template")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the given templateRoot matches
|
||||||
|
func prepareBuiltinTemplates(templateRoot string, tempDir string) (string, error) {
|
||||||
|
_, err := fs.Stat(builtinTemplates, path.Join("templates", templateRoot))
|
||||||
|
if err != nil {
|
||||||
|
// The given path doesn't appear to be using out built-in templates
|
||||||
|
return templateRoot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have a built-in template with the same name as templateRoot!
|
||||||
|
// Now we need to make a fully copy of the builtin templates to a real file system
|
||||||
|
// since template.Parse() doesn't support embed.FS.
|
||||||
|
err = fs.WalkDir(builtinTemplates, "templates", func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetPath := filepath.Join(tempDir, path)
|
||||||
|
if entry.IsDir() {
|
||||||
|
return os.Mkdir(targetPath, 0755)
|
||||||
|
} else {
|
||||||
|
content, err := fs.ReadFile(builtinTemplates, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(targetPath, content, 0644)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(tempDir, "templates", templateRoot), nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,9 +57,9 @@ type renderer struct {
|
||||||
instanceRoot string
|
instanceRoot string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRenderer(ctx context.Context, config map[string]any, templateRoot, libraryRoot, instanceRoot string) (*renderer, error) {
|
func newRenderer(ctx context.Context, config map[string]any, helpers template.FuncMap, templateRoot, libraryRoot, instanceRoot string) (*renderer, error) {
|
||||||
// Initialize new template, with helper functions loaded
|
// Initialize new template, with helper functions loaded
|
||||||
tmpl := template.New("").Funcs(helperFuncs)
|
tmpl := template.New("").Funcs(helpers)
|
||||||
|
|
||||||
// Load user defined associated templates from the library root
|
// Load user defined associated templates from the library root
|
||||||
libraryGlob := filepath.Join(libraryRoot, "*")
|
libraryGlob := filepath.Join(libraryRoot, "*")
|
||||||
|
@ -104,7 +104,7 @@ func (r *renderer) executeTemplate(templateDefinition string) (string, error) {
|
||||||
// Parse the template text
|
// Parse the template text
|
||||||
tmpl, err = tmpl.Parse(templateDefinition)
|
tmpl, err = tmpl.Parse(templateDefinition)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", fmt.Errorf("error in %s: %w", templateDefinition, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute template and get result
|
// Execute template and get result
|
||||||
|
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/databricks/cli/cmd/root"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
@ -31,7 +32,10 @@ func assertFilePermissions(t *testing.T, path string, perm fs.FileMode) {
|
||||||
func TestRendererWithAssociatedTemplateInLibrary(t *testing.T) {
|
func TestRendererWithAssociatedTemplateInLibrary(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
r, err := newRenderer(context.Background(), nil, "./testdata/email/template", "./testdata/email/library", tmpDir)
|
ctx := context.Background()
|
||||||
|
ctx = root.SetWorkspaceClient(ctx, nil)
|
||||||
|
helpers := loadHelpers(ctx)
|
||||||
|
r, err := newRenderer(ctx, nil, helpers, "./testdata/email/template", "./testdata/email/library", tmpDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = r.walk()
|
err = r.walk()
|
||||||
|
@ -202,9 +206,11 @@ func TestRendererPersistToDisk(t *testing.T) {
|
||||||
|
|
||||||
func TestRendererWalk(t *testing.T) {
|
func TestRendererWalk(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
ctx = root.SetWorkspaceClient(ctx, nil)
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
r, err := newRenderer(ctx, nil, "./testdata/walk/template", "./testdata/walk/library", tmpDir)
|
helpers := loadHelpers(ctx)
|
||||||
|
r, err := newRenderer(ctx, nil, helpers, "./testdata/walk/template", "./testdata/walk/library", tmpDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = r.walk()
|
err = r.walk()
|
||||||
|
@ -241,9 +247,11 @@ func TestRendererWalk(t *testing.T) {
|
||||||
|
|
||||||
func TestRendererFailFunction(t *testing.T) {
|
func TestRendererFailFunction(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
ctx = root.SetWorkspaceClient(ctx, nil)
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
r, err := newRenderer(ctx, nil, "./testdata/fail/template", "./testdata/fail/library", tmpDir)
|
helpers := loadHelpers(ctx)
|
||||||
|
r, err := newRenderer(ctx, nil, helpers, "./testdata/fail/template", "./testdata/fail/library", tmpDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = r.walk()
|
err = r.walk()
|
||||||
|
@ -252,9 +260,11 @@ func TestRendererFailFunction(t *testing.T) {
|
||||||
|
|
||||||
func TestRendererSkipsDirsEagerly(t *testing.T) {
|
func TestRendererSkipsDirsEagerly(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
ctx = root.SetWorkspaceClient(ctx, nil)
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
r, err := newRenderer(ctx, nil, "./testdata/skip-dir-eagerly/template", "./testdata/skip-dir-eagerly/library", tmpDir)
|
helpers := loadHelpers(ctx)
|
||||||
|
r, err := newRenderer(ctx, nil, helpers, "./testdata/skip-dir-eagerly/template", "./testdata/skip-dir-eagerly/library", tmpDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = r.walk()
|
err = r.walk()
|
||||||
|
@ -267,9 +277,11 @@ func TestRendererSkipsDirsEagerly(t *testing.T) {
|
||||||
|
|
||||||
func TestRendererSkipAllFilesInCurrentDirectory(t *testing.T) {
|
func TestRendererSkipAllFilesInCurrentDirectory(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
ctx = root.SetWorkspaceClient(ctx, nil)
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
r, err := newRenderer(ctx, nil, "./testdata/skip-all-files-in-cwd/template", "./testdata/skip-all-files-in-cwd/library", tmpDir)
|
helpers := loadHelpers(ctx)
|
||||||
|
r, err := newRenderer(ctx, nil, helpers, "./testdata/skip-all-files-in-cwd/template", "./testdata/skip-all-files-in-cwd/library", tmpDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = r.walk()
|
err = r.walk()
|
||||||
|
@ -288,9 +300,11 @@ func TestRendererSkipAllFilesInCurrentDirectory(t *testing.T) {
|
||||||
|
|
||||||
func TestRendererSkipPatternsAreRelativeToFileDirectory(t *testing.T) {
|
func TestRendererSkipPatternsAreRelativeToFileDirectory(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
ctx = root.SetWorkspaceClient(ctx, nil)
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
r, err := newRenderer(ctx, nil, "./testdata/skip-is-relative/template", "./testdata/skip-is-relative/library", tmpDir)
|
helpers := loadHelpers(ctx)
|
||||||
|
r, err := newRenderer(ctx, nil, helpers, "./testdata/skip-is-relative/template", "./testdata/skip-is-relative/library", tmpDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = r.walk()
|
err = r.walk()
|
||||||
|
@ -304,9 +318,11 @@ func TestRendererSkipPatternsAreRelativeToFileDirectory(t *testing.T) {
|
||||||
|
|
||||||
func TestRendererSkip(t *testing.T) {
|
func TestRendererSkip(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
ctx = root.SetWorkspaceClient(ctx, nil)
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
r, err := newRenderer(ctx, nil, "./testdata/skip/template", "./testdata/skip/library", tmpDir)
|
helpers := loadHelpers(ctx)
|
||||||
|
r, err := newRenderer(ctx, nil, helpers, "./testdata/skip/template", "./testdata/skip/library", tmpDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = r.walk()
|
err = r.walk()
|
||||||
|
@ -335,8 +351,10 @@ func TestRendererReadsPermissionsBits(t *testing.T) {
|
||||||
}
|
}
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
ctx = root.SetWorkspaceClient(ctx, nil)
|
||||||
|
|
||||||
r, err := newRenderer(ctx, nil, "./testdata/executable-bit-read/template", "./testdata/executable-bit-read/library", tmpDir)
|
helpers := loadHelpers(ctx)
|
||||||
|
r, err := newRenderer(ctx, nil, helpers, "./testdata/executable-bit-read/template", "./testdata/executable-bit-read/library", tmpDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = r.walk()
|
err = r.walk()
|
||||||
|
@ -422,9 +440,11 @@ func TestRendererNoErrorOnConflictingFileIfSkipped(t *testing.T) {
|
||||||
|
|
||||||
func TestRendererNonTemplatesAreCreatedAsCopyFiles(t *testing.T) {
|
func TestRendererNonTemplatesAreCreatedAsCopyFiles(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
ctx = root.SetWorkspaceClient(ctx, nil)
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
r, err := newRenderer(ctx, nil, "./testdata/copy-file-walk/template", "./testdata/copy-file-walk/library", tmpDir)
|
helpers := loadHelpers(ctx)
|
||||||
|
r, err := newRenderer(ctx, nil, helpers, "./testdata/copy-file-walk/template", "./testdata/copy-file-walk/library", tmpDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = r.walk()
|
err = r.walk()
|
||||||
|
@ -437,12 +457,14 @@ func TestRendererNonTemplatesAreCreatedAsCopyFiles(t *testing.T) {
|
||||||
|
|
||||||
func TestRendererFileTreeRendering(t *testing.T) {
|
func TestRendererFileTreeRendering(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
ctx = root.SetWorkspaceClient(ctx, nil)
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
helpers := loadHelpers(ctx)
|
||||||
r, err := newRenderer(ctx, map[string]any{
|
r, err := newRenderer(ctx, map[string]any{
|
||||||
"dir_name": "my_directory",
|
"dir_name": "my_directory",
|
||||||
"file_name": "my_file",
|
"file_name": "my_file",
|
||||||
}, "./testdata/file-tree-rendering/template", "./testdata/file-tree-rendering/library", tmpDir)
|
}, helpers, "./testdata/file-tree-rendering/template", "./testdata/file-tree-rendering/library", tmpDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = r.walk()
|
err = r.walk()
|
||||||
|
@ -462,9 +484,11 @@ func TestRendererFileTreeRendering(t *testing.T) {
|
||||||
|
|
||||||
func TestRendererSubTemplateInPath(t *testing.T) {
|
func TestRendererSubTemplateInPath(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
ctx = root.SetWorkspaceClient(ctx, nil)
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
r, err := newRenderer(ctx, nil, "./testdata/template-in-path/template", "./testdata/template-in-path/library", tmpDir)
|
helpers := loadHelpers(ctx)
|
||||||
|
r, err := newRenderer(ctx, nil, helpers, "./testdata/template-in-path/template", "./testdata/template-in-path/library", tmpDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = r.walk()
|
err = r.walk()
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"project_name": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "my_project",
|
||||||
|
"description": "Name of the directory"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"project_name": "my_project"
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
# {{.project_name}}
|
||||||
|
|
||||||
|
The '{{.project_name}}' bundle was generated using the default-python template.
|
|
@ -0,0 +1,2 @@
|
||||||
|
{{workspace_host}}
|
||||||
|
{{smallest_node_type}}
|
Loading…
Reference in New Issue