databricks-cli/libs/template/renderer.go

351 lines
10 KiB
Go
Raw Normal View History

package template
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"regexp"
Upgraded Go version to 1.21 (#664) ## Changes Upgraded Go version to 1.21 Upgraded to use `slices` and `slog` from core instead of experimental. Still use `exp/maps` as our code relies on `maps.Keys` which is not part of core package and therefore refactoring required. ### Tests Integration tests passed ``` [DEBUG] Test execution command: /opt/homebrew/opt/go@1.21/bin/go test ./... -json -timeout 1h -run ^TestAcc [DEBUG] Test execution directory: /Users/andrew.nester/cli 2023/08/15 13:20:51 [INFO] ✅ TestAccAlertsCreateErrWhenNoArguments (2.150s) 2023/08/15 13:20:52 [INFO] ✅ TestAccApiGet (0.580s) 2023/08/15 13:20:53 [INFO] ✅ TestAccClustersList (0.900s) 2023/08/15 13:20:54 [INFO] ✅ TestAccClustersGet (0.870s) 2023/08/15 13:21:06 [INFO] ✅ TestAccFilerWorkspaceFilesReadWrite (11.980s) 2023/08/15 13:21:13 [INFO] ✅ TestAccFilerWorkspaceFilesReadDir (7.060s) 2023/08/15 13:21:25 [INFO] ✅ TestAccFilerDbfsReadWrite (12.810s) 2023/08/15 13:21:33 [INFO] ✅ TestAccFilerDbfsReadDir (7.380s) 2023/08/15 13:21:41 [INFO] ✅ TestAccFilerWorkspaceNotebookConflict (7.760s) 2023/08/15 13:21:49 [INFO] ✅ TestAccFilerWorkspaceNotebookWithOverwriteFlag (8.660s) 2023/08/15 13:21:49 [INFO] ✅ TestAccFilerLocalReadWrite (0.020s) 2023/08/15 13:21:49 [INFO] ✅ TestAccFilerLocalReadDir (0.010s) 2023/08/15 13:21:52 [INFO] ✅ TestAccFsCatForDbfs (3.190s) 2023/08/15 13:21:53 [INFO] ✅ TestAccFsCatForDbfsOnNonExistentFile (0.890s) 2023/08/15 13:21:54 [INFO] ✅ TestAccFsCatForDbfsInvalidScheme (0.600s) 2023/08/15 13:21:57 [INFO] ✅ TestAccFsCatDoesNotSupportOutputModeJson (2.960s) 2023/08/15 13:22:28 [INFO] ✅ TestAccFsCpDir (31.480s) 2023/08/15 13:22:43 [INFO] ✅ TestAccFsCpFileToFile (14.530s) 2023/08/15 13:22:58 [INFO] ✅ TestAccFsCpFileToDir (14.610s) 2023/08/15 13:23:29 [INFO] ✅ TestAccFsCpDirToDirFileNotOverwritten (31.810s) 2023/08/15 13:23:47 [INFO] ✅ TestAccFsCpFileToDirFileNotOverwritten (17.500s) 2023/08/15 13:24:04 [INFO] ✅ TestAccFsCpFileToFileFileNotOverwritten (17.260s) 2023/08/15 13:24:37 [INFO] ✅ TestAccFsCpDirToDirWithOverwriteFlag (32.690s) 2023/08/15 13:24:56 [INFO] ✅ TestAccFsCpFileToFileWithOverwriteFlag (19.290s) 2023/08/15 13:25:15 [INFO] ✅ TestAccFsCpFileToDirWithOverwriteFlag (19.230s) 2023/08/15 13:25:17 [INFO] ✅ TestAccFsCpErrorsWhenSourceIsDirWithoutRecursiveFlag (2.010s) 2023/08/15 13:25:18 [INFO] ✅ TestAccFsCpErrorsOnInvalidScheme (0.610s) 2023/08/15 13:25:33 [INFO] ✅ TestAccFsCpSourceIsDirectoryButTargetIsFile (14.900s) 2023/08/15 13:25:37 [INFO] ✅ TestAccFsLsForDbfs (3.770s) 2023/08/15 13:25:41 [INFO] ✅ TestAccFsLsForDbfsWithAbsolutePaths (4.160s) 2023/08/15 13:25:44 [INFO] ✅ TestAccFsLsForDbfsOnFile (2.990s) 2023/08/15 13:25:46 [INFO] ✅ TestAccFsLsForDbfsOnEmptyDir (1.870s) 2023/08/15 13:25:46 [INFO] ✅ TestAccFsLsForDbfsForNonexistingDir (0.850s) 2023/08/15 13:25:47 [INFO] ✅ TestAccFsLsWithoutScheme (0.560s) 2023/08/15 13:25:49 [INFO] ✅ TestAccFsMkdirCreatesDirectory (2.310s) 2023/08/15 13:25:52 [INFO] ✅ TestAccFsMkdirCreatesMultipleDirectories (2.920s) 2023/08/15 13:25:55 [INFO] ✅ TestAccFsMkdirWhenDirectoryAlreadyExists (2.320s) 2023/08/15 13:25:57 [INFO] ✅ TestAccFsMkdirWhenFileExistsAtPath (2.820s) 2023/08/15 13:26:01 [INFO] ✅ TestAccFsRmForFile (4.030s) 2023/08/15 13:26:05 [INFO] ✅ TestAccFsRmForEmptyDirectory (3.530s) 2023/08/15 13:26:08 [INFO] ✅ TestAccFsRmForNonEmptyDirectory (3.190s) 2023/08/15 13:26:09 [INFO] ✅ TestAccFsRmForNonExistentFile (0.830s) 2023/08/15 13:26:13 [INFO] ✅ TestAccFsRmForNonEmptyDirectoryWithRecursiveFlag (3.580s) 2023/08/15 13:26:13 [INFO] ✅ TestAccGitClone (0.800s) 2023/08/15 13:26:14 [INFO] ✅ TestAccGitCloneWithOnlyRepoNameOnAlternateBranch (0.790s) 2023/08/15 13:26:15 [INFO] ✅ TestAccGitCloneErrorsWhenRepositoryDoesNotExist (0.540s) 2023/08/15 13:26:23 [INFO] ✅ TestAccLock (8.630s) 2023/08/15 13:26:27 [INFO] ✅ TestAccLockUnlockWithoutAllowsLockFileNotExist (3.490s) 2023/08/15 13:26:30 [INFO] ✅ TestAccLockUnlockWithAllowsLockFileNotExist (3.130s) 2023/08/15 13:26:39 [INFO] ✅ TestAccSyncFullFileSync (9.370s) 2023/08/15 13:26:50 [INFO] ✅ TestAccSyncIncrementalFileSync (10.390s) 2023/08/15 13:27:00 [INFO] ✅ TestAccSyncNestedFolderSync (10.680s) 2023/08/15 13:27:11 [INFO] ✅ TestAccSyncNestedFolderDoesntFailOnNonEmptyDirectory (10.970s) 2023/08/15 13:27:22 [INFO] ✅ TestAccSyncNestedSpacePlusAndHashAreEscapedSync (10.930s) 2023/08/15 13:27:29 [INFO] ✅ TestAccSyncIncrementalFileOverwritesFolder (7.020s) 2023/08/15 13:27:37 [INFO] ✅ TestAccSyncIncrementalSyncPythonNotebookToFile (7.380s) 2023/08/15 13:27:43 [INFO] ✅ TestAccSyncIncrementalSyncFileToPythonNotebook (6.050s) 2023/08/15 13:27:48 [INFO] ✅ TestAccSyncIncrementalSyncPythonNotebookDelete (5.390s) 2023/08/15 13:27:51 [INFO] ✅ TestAccSyncEnsureRemotePathIsUsableIfRepoDoesntExist (2.570s) 2023/08/15 13:27:56 [INFO] ✅ TestAccSyncEnsureRemotePathIsUsableIfRepoExists (5.540s) 2023/08/15 13:27:58 [INFO] ✅ TestAccSyncEnsureRemotePathIsUsableInWorkspace (1.840s) 2023/08/15 13:27:59 [INFO] ✅ TestAccWorkspaceList (0.790s) 2023/08/15 13:28:08 [INFO] ✅ TestAccExportDir (8.860s) 2023/08/15 13:28:11 [INFO] ✅ TestAccExportDirDoesNotOverwrite (3.090s) 2023/08/15 13:28:14 [INFO] ✅ TestAccExportDirWithOverwriteFlag (3.500s) 2023/08/15 13:28:23 [INFO] ✅ TestAccImportDir (8.330s) 2023/08/15 13:28:34 [INFO] ✅ TestAccImportDirDoesNotOverwrite (10.970s) 2023/08/15 13:28:44 [INFO] ✅ TestAccImportDirWithOverwriteFlag (10.130s) 2023/08/15 13:28:44 [INFO] ✅ 68/68 passed, 0 failed, 3 skipped ```
2023-08-15 13:50:40 +00:00
"slices"
"sort"
"strings"
"text/template"
"github.com/databricks/cli/libs/filer"
"github.com/databricks/cli/libs/log"
"github.com/databricks/databricks-sdk-go/logger"
)
const templateExtension = ".tmpl"
// 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
files []file
// 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
// Filer rooted at template root. The file tree from this root is walked to
// generate the project
templateFiler filer.Filer
// Root directory for the project instantiated from the template
instanceRoot string
}
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
tmpl := template.New("").Funcs(helpers)
// Load user defined associated templates from the library root
libraryGlob := filepath.Join(libraryRoot, "*")
matches, err := filepath.Glob(libraryGlob)
if err != nil {
return nil, err
}
if len(matches) != 0 {
tmpl, err = tmpl.ParseFiles(matches...)
if err != nil {
return nil, err
}
}
templateFiler, err := filer.NewLocalClient(templateRoot)
if err != nil {
return nil, err
}
ctx = log.NewContext(ctx, log.GetLogger(ctx).With("action", "initialize-template"))
return &renderer{
ctx: ctx,
config: config,
baseTemplate: tmpl,
files: make([]file, 0),
skipPatterns: make([]string, 0),
templateFiler: templateFiler,
instanceRoot: instanceRoot,
}, 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
}
// 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")
// Parse the template text
tmpl, err = tmpl.Parse(templateDefinition)
if err != nil {
return "", fmt.Errorf("error in %s: %w", templateDefinition, err)
}
// Execute template and get result
result := strings.Builder{}
err = tmpl.Execute(&result, r.config)
if err != nil {
// 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]),
}
}
return "", err
}
return result.String(), nil
}
func (r *renderer) computeFile(relPathTemplate string) (file, error) {
// read file permissions
info, err := r.templateFiler.Stat(r.ctx, relPathTemplate)
if err != nil {
return nil, err
}
perm := info.Mode().Perm()
// Execute relative path template to get destination path for the file
relPath, err := r.executeTemplate(relPathTemplate)
if err != nil {
return nil, err
}
2024-09-17 09:21:15 +00:00
// we need the absolute path in case we need to write notebooks using the REST API
2024-09-04 09:45:27 +00:00
rootPath, err := filepath.Abs(r.instanceRoot)
if err != nil {
return nil, err
}
// 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 &copyFile{
dstPath: &destinationPath{
2024-09-04 09:45:27 +00:00
root: rootPath,
relPath: relPath,
},
perm: perm,
ctx: r.ctx,
srcPath: relPathTemplate,
srcFiler: r.templateFiler,
}, nil
} else {
// Trim the .tmpl suffix from file name, if specified in the template
// path
relPath = strings.TrimSuffix(relPath, templateExtension)
}
// read template file's content
templateReader, err := r.templateFiler.Read(r.ctx, relPathTemplate)
if err != nil {
return nil, err
}
defer templateReader.Close()
// execute the contents of the file as a template
contentTemplate, err := io.ReadAll(templateReader)
if err != nil {
return nil, err
}
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{
2024-09-04 09:16:50 +00:00
ctx: r.ctx,
dstPath: &destinationPath{
2024-09-04 09:45:27 +00:00
root: rootPath,
relPath: relPath,
},
perm: perm,
content: []byte(content),
}, 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
}
match, err := isSkipped(instanceDirectory, r.skipPatterns)
if err != nil {
return err
}
if match {
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{
"skip": func(relPattern string) (string, error) {
// patterns are specified relative to current directory of the file
// the {{skip}} function is called from.
patternRaw := path.Join(currentDirectory, relPattern)
pattern, err := r.executeTemplate(patternRaw)
if err != nil {
return "", err
}
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
return "", nil
},
})
// 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
entries, err := r.templateFiler.ReadDir(r.ctx, currentDirectory)
if err != nil {
return err
}
// Sort by name to ensure deterministic ordering
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
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
}
logger.Infof(r.ctx, "added file to list of possible project files: %s", f.DstPath().relPath)
r.files = append(r.files, f)
}
}
return nil
}
func (r *renderer) persistToDisk() error {
// Accumulate files which we will persist, skipping files whose path matches
// any of the skip patterns
filesToPersist := make([]file, 0)
for _, file := range r.files {
match, err := isSkipped(file.DstPath().relPath, r.skipPatterns)
if err != nil {
return err
}
if match {
log.Infof(r.ctx, "skipping file: %s", file.DstPath())
continue
}
filesToPersist = append(filesToPersist, file)
}
// Assert no conflicting files exist
for _, file := range filesToPersist {
path := file.DstPath().absPath()
_, err := os.Stat(path)
if err == nil {
return fmt.Errorf("failed to initialize template, one or more files already exist: %s", path)
2024-09-04 11:23:26 +00:00
}
if !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("error while verifying file %s does not already exist: %w", path, err)
}
}
// Persist files to disk
for _, file := range filesToPersist {
2024-09-04 09:16:50 +00:00
err := file.PersistToDisk()
if err != nil {
return err
}
}
return nil
}
func isSkipped(filePath string, patterns []string) (bool, error) {
for _, pattern := range patterns {
isMatch, err := path.Match(pattern, filePath)
if err != nil {
return false, err
}
if isMatch {
return true, nil
}
}
return false, nil
}