2023-07-21 08:59:02 +00:00
|
|
|
package template
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
2024-06-03 12:39:36 +00:00
|
|
|
"io/fs"
|
2023-07-21 08:59:02 +00:00
|
|
|
"path"
|
2023-09-14 15:53:20 +00:00
|
|
|
"regexp"
|
2023-08-15 13:50:40 +00:00
|
|
|
"slices"
|
2023-09-06 09:52:31 +00:00
|
|
|
"sort"
|
2023-07-21 08:59:02 +00:00
|
|
|
"strings"
|
|
|
|
"text/template"
|
|
|
|
|
2024-11-18 21:28:28 +00:00
|
|
|
"github.com/databricks/cli/libs/filer"
|
2023-07-21 08:59:02 +00:00
|
|
|
"github.com/databricks/cli/libs/log"
|
|
|
|
"github.com/databricks/databricks-sdk-go/logger"
|
|
|
|
)
|
|
|
|
|
2023-08-01 13:43:27 +00:00
|
|
|
const templateExtension = ".tmpl"
|
2023-07-21 08:59:02 +00:00
|
|
|
|
|
|
|
// Renders a databricks template as a project
|
|
|
|
type renderer struct {
|
|
|
|
ctx context.Context
|
|
|
|
|
|
|
|
// A config that is the "dot" value available to any template being rendered.
|
|
|
|
// Refer to https://pkg.go.dev/text/template for how templates can use
|
|
|
|
// this "dot" value
|
|
|
|
config map[string]any
|
|
|
|
|
|
|
|
// A base template with helper functions and user defined templates in the
|
|
|
|
// library directory loaded. This is cloned for each project template computation
|
|
|
|
// during file tree walk
|
|
|
|
baseTemplate *template.Template
|
|
|
|
|
|
|
|
// List of in memory files generated from template
|
2023-08-01 13:43:27 +00:00
|
|
|
files []file
|
2023-07-21 08:59:02 +00:00
|
|
|
|
|
|
|
// Glob patterns for files and directories to skip. There are three possible
|
|
|
|
// outcomes for skip:
|
|
|
|
//
|
|
|
|
// 1. File is not generated. This happens if one of the file's parent directories
|
|
|
|
// match a glob pattern
|
|
|
|
//
|
|
|
|
// 2. File is generated but not persisted to disk. This happens if the file itself
|
|
|
|
// matches a glob pattern, but none of it's parents match a glob pattern from the list
|
|
|
|
//
|
|
|
|
// 3. File is persisted to disk. This happens if the file and it's parent directories
|
|
|
|
// do not match any glob patterns from this list
|
|
|
|
skipPatterns []string
|
|
|
|
|
2024-11-18 19:43:12 +00:00
|
|
|
// [fs.FS] that holds the template's file tree.
|
|
|
|
srcFS fs.FS
|
2023-07-21 08:59:02 +00:00
|
|
|
}
|
|
|
|
|
2024-11-18 16:32:02 +00:00
|
|
|
func newRenderer(
|
|
|
|
ctx context.Context,
|
|
|
|
config map[string]any,
|
|
|
|
helpers template.FuncMap,
|
|
|
|
templateFS fs.FS,
|
|
|
|
templateDir string,
|
|
|
|
libraryDir string,
|
|
|
|
) (*renderer, error) {
|
2023-07-21 08:59:02 +00:00
|
|
|
// Initialize new template, with helper functions loaded
|
2023-08-25 09:03:42 +00:00
|
|
|
tmpl := template.New("").Funcs(helpers)
|
2023-07-21 08:59:02 +00:00
|
|
|
|
2024-11-18 16:32:02 +00:00
|
|
|
// Find user-defined templates in the library directory
|
|
|
|
matches, err := fs.Glob(templateFS, path.Join(libraryDir, "*"))
|
2023-07-21 08:59:02 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2024-11-18 16:32:02 +00:00
|
|
|
|
|
|
|
// Parse user-defined templates.
|
|
|
|
// Note: we do not call [ParseFS] with the glob directly because
|
|
|
|
// it returns an error if no files match the pattern.
|
2023-07-21 08:59:02 +00:00
|
|
|
if len(matches) != 0 {
|
2024-11-18 16:32:02 +00:00
|
|
|
tmpl, err = tmpl.ParseFS(templateFS, matches...)
|
2023-07-21 08:59:02 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-11-18 19:43:12 +00:00
|
|
|
srcFS, err := fs.Sub(templateFS, path.Clean(templateDir))
|
2023-07-21 08:59:02 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
ctx = log.NewContext(ctx, log.GetLogger(ctx).With("action", "initialize-template"))
|
|
|
|
|
|
|
|
return &renderer{
|
2024-11-18 16:32:02 +00:00
|
|
|
ctx: ctx,
|
|
|
|
config: config,
|
|
|
|
baseTemplate: tmpl,
|
|
|
|
files: make([]file, 0),
|
|
|
|
skipPatterns: make([]string, 0),
|
2024-11-18 19:43:12 +00:00
|
|
|
srcFS: srcFS,
|
2023-07-21 08:59:02 +00:00
|
|
|
}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Executes the template by applying config on it. Returns the materialized template
|
|
|
|
// as a string
|
|
|
|
func (r *renderer) executeTemplate(templateDefinition string) (string, error) {
|
|
|
|
// Create copy of base template so as to not overwrite it
|
|
|
|
tmpl, err := r.baseTemplate.Clone()
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
2023-09-14 15:53:20 +00:00
|
|
|
// The template execution will error instead of printing <no value> on unknown
|
|
|
|
// map keys if the "missingkey=error" option is set.
|
|
|
|
// We do this here instead of doing this once for r.baseTemplate because
|
|
|
|
// the Template.Clone() method does not clone options.
|
|
|
|
tmpl = tmpl.Option("missingkey=error")
|
|
|
|
|
2023-07-21 08:59:02 +00:00
|
|
|
// Parse the template text
|
|
|
|
tmpl, err = tmpl.Parse(templateDefinition)
|
|
|
|
if err != nil {
|
2023-08-25 09:03:42 +00:00
|
|
|
return "", fmt.Errorf("error in %s: %w", templateDefinition, err)
|
2023-07-21 08:59:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Execute template and get result
|
|
|
|
result := strings.Builder{}
|
|
|
|
err = tmpl.Execute(&result, r.config)
|
|
|
|
if err != nil {
|
2023-09-14 15:53:20 +00:00
|
|
|
// Parse and return a more readable error for missing values that are used
|
|
|
|
// by the template definition but are not provided in the passed config.
|
|
|
|
target := &template.ExecError{}
|
|
|
|
if errors.As(err, target) {
|
|
|
|
captureRegex := regexp.MustCompile(`map has no entry for key "(.*)"`)
|
|
|
|
matches := captureRegex.FindStringSubmatch(target.Err.Error())
|
|
|
|
if len(matches) != 2 {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
return "", template.ExecError{
|
|
|
|
Name: target.Name,
|
|
|
|
Err: fmt.Errorf("variable %q not defined", matches[1]),
|
|
|
|
}
|
|
|
|
}
|
2023-07-21 08:59:02 +00:00
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
return result.String(), nil
|
|
|
|
}
|
|
|
|
|
2023-08-01 13:43:27 +00:00
|
|
|
func (r *renderer) computeFile(relPathTemplate string) (file, error) {
|
|
|
|
// read file permissions
|
2024-11-18 19:43:12 +00:00
|
|
|
info, err := fs.Stat(r.srcFS, relPathTemplate)
|
2023-07-21 08:59:02 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2023-08-01 13:43:27 +00:00
|
|
|
perm := info.Mode().Perm()
|
|
|
|
|
2023-08-11 13:48:32 +00:00
|
|
|
// Execute relative path template to get destination path for the file
|
|
|
|
relPath, err := r.executeTemplate(relPathTemplate)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2023-08-01 13:43:27 +00:00
|
|
|
// If file name does not specify the `.tmpl` extension, then it is copied
|
|
|
|
// over as is, without treating it as a template
|
|
|
|
if !strings.HasSuffix(relPathTemplate, templateExtension) {
|
|
|
|
return ©File{
|
2024-11-18 16:32:02 +00:00
|
|
|
perm: perm,
|
2024-11-18 21:28:28 +00:00
|
|
|
relPath: relPath,
|
2024-11-18 19:43:12 +00:00
|
|
|
srcFS: r.srcFS,
|
2024-11-18 16:32:02 +00:00
|
|
|
srcPath: relPathTemplate,
|
2023-08-01 13:43:27 +00:00
|
|
|
}, nil
|
2023-08-11 13:48:32 +00:00
|
|
|
} else {
|
|
|
|
// Trim the .tmpl suffix from file name, if specified in the template
|
|
|
|
// path
|
|
|
|
relPath = strings.TrimSuffix(relPath, templateExtension)
|
2023-07-21 08:59:02 +00:00
|
|
|
}
|
|
|
|
|
2023-08-01 13:43:27 +00:00
|
|
|
// read template file's content
|
2024-11-18 19:43:12 +00:00
|
|
|
templateReader, err := r.srcFS.Open(relPathTemplate)
|
2023-07-21 08:59:02 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2023-08-01 13:43:27 +00:00
|
|
|
defer templateReader.Close()
|
2023-07-21 08:59:02 +00:00
|
|
|
|
|
|
|
// execute the contents of the file as a template
|
2023-08-01 13:43:27 +00:00
|
|
|
contentTemplate, err := io.ReadAll(templateReader)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2023-07-21 08:59:02 +00:00
|
|
|
content, err := r.executeTemplate(string(contentTemplate))
|
|
|
|
// Capture errors caused by the "fail" helper function
|
|
|
|
if target := (&ErrFail{}); errors.As(err, target) {
|
|
|
|
return nil, target
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("failed to compute file content for %s. %w", relPathTemplate, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return &inMemoryFile{
|
|
|
|
perm: perm,
|
2024-11-18 21:28:28 +00:00
|
|
|
relPath: relPath,
|
2023-08-01 13:43:27 +00:00
|
|
|
content: []byte(content),
|
2023-07-21 08:59:02 +00:00
|
|
|
}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// This function walks the template file tree to generate an in memory representation
|
|
|
|
// of a project.
|
|
|
|
//
|
|
|
|
// During file tree walk, in the current directory, we would like to determine
|
|
|
|
// all possible {{skip}} function calls before we process any of the directories
|
|
|
|
// so that we can skip them eagerly if needed. That is in the current working directory
|
|
|
|
// we would like to process all files before we process any of the directories.
|
|
|
|
//
|
|
|
|
// This is not possible using the std library WalkDir which processes the files in
|
|
|
|
// lexical order which is why this function implements BFS.
|
|
|
|
func (r *renderer) walk() error {
|
|
|
|
directories := []string{"."}
|
|
|
|
var currentDirectory string
|
|
|
|
|
|
|
|
for len(directories) > 0 {
|
|
|
|
currentDirectory, directories = directories[0], directories[1:]
|
|
|
|
|
|
|
|
// Skip current directory if it matches any of accumulated skip patterns
|
|
|
|
instanceDirectory, err := r.executeTemplate(currentDirectory)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2023-08-01 13:43:27 +00:00
|
|
|
match, err := isSkipped(instanceDirectory, r.skipPatterns)
|
2023-07-21 08:59:02 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2023-08-01 13:43:27 +00:00
|
|
|
if match {
|
2023-07-21 08:59:02 +00:00
|
|
|
logger.Infof(r.ctx, "skipping directory: %s", instanceDirectory)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
// Add skip function, which accumulates skip patterns relative to current
|
|
|
|
// directory
|
|
|
|
r.baseTemplate.Funcs(template.FuncMap{
|
2023-09-06 09:52:31 +00:00
|
|
|
"skip": func(relPattern string) (string, error) {
|
2023-07-21 08:59:02 +00:00
|
|
|
// patterns are specified relative to current directory of the file
|
|
|
|
// the {{skip}} function is called from.
|
2023-09-06 09:52:31 +00:00
|
|
|
patternRaw := path.Join(currentDirectory, relPattern)
|
|
|
|
pattern, err := r.executeTemplate(patternRaw)
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
2023-07-21 08:59:02 +00:00
|
|
|
if !slices.Contains(r.skipPatterns, pattern) {
|
|
|
|
logger.Infof(r.ctx, "adding skip pattern: %s", pattern)
|
|
|
|
r.skipPatterns = append(r.skipPatterns, pattern)
|
|
|
|
}
|
|
|
|
// return empty string will print nothing at function call site
|
|
|
|
// when executing the template
|
2023-09-06 09:52:31 +00:00
|
|
|
return "", nil
|
2023-07-21 08:59:02 +00:00
|
|
|
},
|
|
|
|
})
|
|
|
|
|
|
|
|
// Process all entries in current directory
|
|
|
|
//
|
|
|
|
// 1. For files: the templates in the file name and content are executed, and
|
|
|
|
// a in memory representation of the file is generated
|
|
|
|
//
|
|
|
|
// 2. For directories: They are appended to a slice, which acts as a queue
|
|
|
|
// allowing BFS traversal of the template file tree
|
2024-11-18 19:43:12 +00:00
|
|
|
entries, err := fs.ReadDir(r.srcFS, currentDirectory)
|
2023-07-21 08:59:02 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2023-09-06 09:52:31 +00:00
|
|
|
// Sort by name to ensure deterministic ordering
|
|
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
|
|
return entries[i].Name() < entries[j].Name()
|
|
|
|
})
|
2023-07-21 08:59:02 +00:00
|
|
|
for _, entry := range entries {
|
|
|
|
if entry.IsDir() {
|
|
|
|
// Add to slice, for BFS traversal
|
|
|
|
directories = append(directories, path.Join(currentDirectory, entry.Name()))
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
// Generate in memory representation of file
|
|
|
|
f, err := r.computeFile(path.Join(currentDirectory, entry.Name()))
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2024-11-18 21:28:28 +00:00
|
|
|
logger.Infof(r.ctx, "added file to list of possible project files: %s", f.RelPath())
|
2023-07-21 08:59:02 +00:00
|
|
|
r.files = append(r.files, f)
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2024-11-18 21:28:28 +00:00
|
|
|
func (r *renderer) persistToDisk(ctx context.Context, out filer.Filer) error {
|
2023-07-21 08:59:02 +00:00
|
|
|
// Accumulate files which we will persist, skipping files whose path matches
|
|
|
|
// any of the skip patterns
|
2023-08-01 13:43:27 +00:00
|
|
|
filesToPersist := make([]file, 0)
|
2023-07-21 08:59:02 +00:00
|
|
|
for _, file := range r.files {
|
2024-11-18 21:28:28 +00:00
|
|
|
match, err := isSkipped(file.RelPath(), r.skipPatterns)
|
2023-07-21 08:59:02 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2023-08-01 13:43:27 +00:00
|
|
|
if match {
|
2024-11-18 21:28:28 +00:00
|
|
|
log.Infof(r.ctx, "skipping file: %s", file.RelPath())
|
2023-07-21 08:59:02 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
filesToPersist = append(filesToPersist, file)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Assert no conflicting files exist
|
|
|
|
for _, file := range filesToPersist {
|
2024-11-18 21:28:28 +00:00
|
|
|
path := file.RelPath()
|
|
|
|
_, err := out.Stat(ctx, path)
|
2023-07-21 08:59:02 +00:00
|
|
|
if err == nil {
|
2023-10-19 07:08:36 +00:00
|
|
|
return fmt.Errorf("failed to initialize template, one or more files already exist: %s", path)
|
2023-07-21 08:59:02 +00:00
|
|
|
}
|
2024-06-03 12:39:36 +00:00
|
|
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
2023-07-21 08:59:02 +00:00
|
|
|
return fmt.Errorf("error while verifying file %s does not already exist: %w", path, err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Persist files to disk
|
|
|
|
for _, file := range filesToPersist {
|
2024-11-18 21:28:28 +00:00
|
|
|
err := file.Write(ctx, out)
|
2023-07-21 08:59:02 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2023-08-01 13:43:27 +00:00
|
|
|
func isSkipped(filePath string, patterns []string) (bool, error) {
|
|
|
|
for _, pattern := range patterns {
|
2023-07-21 08:59:02 +00:00
|
|
|
isMatch, err := path.Match(pattern, filePath)
|
|
|
|
if err != nil {
|
|
|
|
return false, err
|
|
|
|
}
|
|
|
|
if isMatch {
|
|
|
|
return true, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false, nil
|
|
|
|
}
|