Make resource and artifact paths in bundle config relative to config folder (#708)

# Warning: breaking change

## Changes
Instead of having paths in bundle config files be relative to bundle
root even if the config file is nested, this PR makes such paths
relative to the folder where the config is located.

When bundle is initialised, these paths will be transformed to relative
paths based on bundle root. For example,
we have file structure like this
```
- mybundle
| - bundle.yml
| - subfolder
| -- resource.yml
| -- my.whl
```

Previously, we had to reference `my.whl` in resource.yml like this,
which was confusing because resource.yml is in the same subfolder
```
sync:
  include:
    - ./subfolder/*.whl
...
tasks:
  - task_key: name
    libraries:
      - whl: ./subfolder/my.whl
...
```

After the change we can reference it like this (which is in line with
the current behaviour for notebooks)

```
sync:
  include:
    - ./*.whl
...
tasks:
  - task_key: name
    libraries:
      - whl: ./my.whl
...
```

## Tests
Existing `translate_path_tests` successfully passed after refactoring.

Added a couple of uses cases for `Libraries` paths.

Added a bundle config tests with include config and sync section

---------

Co-authored-by: Pieter Noordhuis <pieter.noordhuis@databricks.com>
This commit is contained in:
Andrew Nester 2023-09-04 11:55:01 +02:00 committed by GitHub
parent e22fd73b7d
commit 83443bae8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 482 additions and 121 deletions

View File

@ -8,9 +8,18 @@ import (
"path" "path"
"strings" "strings"
"github.com/databricks/cli/bundle/config/paths"
"github.com/databricks/databricks-sdk-go/service/compute" "github.com/databricks/databricks-sdk-go/service/compute"
) )
type Artifacts map[string]*Artifact
func (artifacts Artifacts) SetConfigFilePath(path string) {
for _, artifact := range artifacts {
artifact.ConfigFilePath = path
}
}
type ArtifactType string type ArtifactType string
const ArtifactPythonWheel ArtifactType = `whl` const ArtifactPythonWheel ArtifactType = `whl`
@ -34,6 +43,8 @@ type Artifact struct {
// (Python wheel, Java jar and etc) itself // (Python wheel, Java jar and etc) itself
Files []ArtifactFile `json:"files"` Files []ArtifactFile `json:"files"`
BuildCommand string `json:"build"` BuildCommand string `json:"build"`
paths.Paths
} }
func (a *Artifact) Build(ctx context.Context) ([]byte, error) { func (a *Artifact) Build(ctx context.Context) ([]byte, error) {

View File

@ -9,6 +9,7 @@ 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/cli/bundle/config/paths"
"github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/databricks-sdk-go/service/jobs" "github.com/databricks/databricks-sdk-go/service/jobs"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -64,7 +65,7 @@ func TestGenerateTrampoline(t *testing.T) {
Resources: config.Resources{ Resources: config.Resources{
Jobs: map[string]*resources.Job{ Jobs: map[string]*resources.Job{
"test": { "test": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: tmpDir, ConfigFilePath: tmpDir,
}, },
JobSettings: &jobs.JobSettings{ JobSettings: &jobs.JobSettings{

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/url"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@ -11,8 +12,6 @@ import (
"github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle"
"github.com/databricks/cli/libs/notebook" "github.com/databricks/cli/libs/notebook"
"github.com/databricks/databricks-sdk-go/service/jobs"
"github.com/databricks/databricks-sdk-go/service/pipelines"
) )
type ErrIsNotebook struct { type ErrIsNotebook struct {
@ -44,7 +43,9 @@ func (m *translatePaths) Name() string {
return "TranslatePaths" return "TranslatePaths"
} }
// rewritePath converts a given relative path to a stable remote workspace path. type rewriteFunc func(literal, localFullPath, localRelPath, remotePath string) (string, error)
// rewritePath converts a given relative path from the loaded config to a new path based on the passed rewriting function
// //
// It takes these arguments: // It takes these arguments:
// - The argument `dir` is the directory relative to which the given relative path is. // - The argument `dir` is the directory relative to which the given relative path is.
@ -57,13 +58,23 @@ func (m *translatePaths) rewritePath(
dir string, dir string,
b *bundle.Bundle, b *bundle.Bundle,
p *string, p *string,
fn func(literal, localPath, remotePath string) (string, error), fn rewriteFunc,
) error { ) error {
// We assume absolute paths point to a location in the workspace // We assume absolute paths point to a location in the workspace
if path.IsAbs(filepath.ToSlash(*p)) { if path.IsAbs(filepath.ToSlash(*p)) {
return nil return nil
} }
url, err := url.Parse(*p)
if err != nil {
return err
}
// If the file path has scheme, it's a full path and we don't need to transform it
if url.Scheme != "" {
return nil
}
// Local path is relative to the directory the resource was defined in. // Local path is relative to the directory the resource was defined in.
localPath := filepath.Join(dir, filepath.FromSlash(*p)) localPath := filepath.Join(dir, filepath.FromSlash(*p))
if interp, ok := m.seen[localPath]; ok { if interp, ok := m.seen[localPath]; ok {
@ -72,19 +83,19 @@ func (m *translatePaths) rewritePath(
} }
// Remote path must be relative to the bundle root. // Remote path must be relative to the bundle root.
remotePath, err := filepath.Rel(b.Config.Path, localPath) localRelPath, err := filepath.Rel(b.Config.Path, localPath)
if err != nil { if err != nil {
return err return err
} }
if strings.HasPrefix(remotePath, "..") { if strings.HasPrefix(localRelPath, "..") {
return fmt.Errorf("path %s is not contained in bundle root path", localPath) return fmt.Errorf("path %s is not contained in bundle root path", localPath)
} }
// Prefix remote path with its remote root path. // Prefix remote path with its remote root path.
remotePath = path.Join(b.Config.Workspace.FilesPath, filepath.ToSlash(remotePath)) remotePath := path.Join(b.Config.Workspace.FilesPath, filepath.ToSlash(localRelPath))
// Convert local path into workspace path via specified function. // Convert local path into workspace path via specified function.
interp, err := fn(*p, localPath, filepath.ToSlash(remotePath)) interp, err := fn(*p, localPath, localRelPath, filepath.ToSlash(remotePath))
if err != nil { if err != nil {
return err return err
} }
@ -94,81 +105,69 @@ func (m *translatePaths) rewritePath(
return nil return nil
} }
func (m *translatePaths) translateNotebookPath(literal, localPath, remotePath string) (string, error) { func translateNotebookPath(literal, localFullPath, localRelPath, remotePath string) (string, error) {
nb, _, err := notebook.Detect(localPath) nb, _, err := notebook.Detect(localFullPath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return "", fmt.Errorf("notebook %s not found", literal) return "", fmt.Errorf("notebook %s not found", literal)
} }
if err != nil { if err != nil {
return "", fmt.Errorf("unable to determine if %s is a notebook: %w", localPath, err) return "", fmt.Errorf("unable to determine if %s is a notebook: %w", localFullPath, err)
} }
if !nb { if !nb {
return "", ErrIsNotNotebook{localPath} return "", ErrIsNotNotebook{localFullPath}
} }
// Upon import, notebooks are stripped of their extension. // Upon import, notebooks are stripped of their extension.
return strings.TrimSuffix(remotePath, filepath.Ext(localPath)), nil return strings.TrimSuffix(remotePath, filepath.Ext(localFullPath)), nil
} }
func (m *translatePaths) translateFilePath(literal, localPath, remotePath string) (string, error) { func translateFilePath(literal, localFullPath, localRelPath, remotePath string) (string, error) {
nb, _, err := notebook.Detect(localPath) nb, _, err := notebook.Detect(localFullPath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return "", fmt.Errorf("file %s not found", literal) return "", fmt.Errorf("file %s not found", literal)
} }
if err != nil { if err != nil {
return "", fmt.Errorf("unable to determine if %s is not a notebook: %w", localPath, err) return "", fmt.Errorf("unable to determine if %s is not a notebook: %w", localFullPath, err)
} }
if nb { if nb {
return "", ErrIsNotebook{localPath} return "", ErrIsNotebook{localFullPath}
} }
return remotePath, nil return remotePath, nil
} }
func (m *translatePaths) translateJobTask(dir string, b *bundle.Bundle, task *jobs.Task) error { func translateNoOp(literal, localFullPath, localRelPath, remotePath string) (string, error) {
var err error return localRelPath, nil
if task.NotebookTask != nil {
err = m.rewritePath(dir, b, &task.NotebookTask.NotebookPath, m.translateNotebookPath)
if target := (&ErrIsNotNotebook{}); errors.As(err, target) {
return fmt.Errorf(`expected a notebook for "tasks.notebook_task.notebook_path" but got a file: %w`, target)
}
if err != nil {
return err
}
}
if task.SparkPythonTask != nil {
err = m.rewritePath(dir, b, &task.SparkPythonTask.PythonFile, m.translateFilePath)
if target := (&ErrIsNotebook{}); errors.As(err, target) {
return fmt.Errorf(`expected a file for "tasks.spark_python_task.python_file" but got a notebook: %w`, target)
}
if err != nil {
return err
}
}
return nil
} }
func (m *translatePaths) translatePipelineLibrary(dir string, b *bundle.Bundle, library *pipelines.PipelineLibrary) error { type transformer struct {
var err error // A directory path relative to which `path` will be transformed
dir string
// A path to transform
path *string
// Name of the config property where the path string is coming from
configPath string
// A function that performs the actual rewriting logic.
fn rewriteFunc
}
if library.Notebook != nil { type transformFunc func(resource any, dir string) *transformer
err = m.rewritePath(dir, b, &library.Notebook.Path, m.translateNotebookPath)
if target := (&ErrIsNotNotebook{}); errors.As(err, target) {
return fmt.Errorf(`expected a notebook for "libraries.notebook.path" but got a file: %w`, target)
}
if err != nil {
return err
}
}
if library.File != nil { // Apply all matches transformers for the given resource
err = m.rewritePath(dir, b, &library.File.Path, m.translateFilePath) func (m *translatePaths) applyTransformers(funcs []transformFunc, b *bundle.Bundle, resource any, dir string) error {
if target := (&ErrIsNotebook{}); errors.As(err, target) { for _, transformFn := range funcs {
return fmt.Errorf(`expected a file for "libraries.file.path" but got a notebook: %w`, target) transformer := transformFn(resource, dir)
if transformer == nil {
continue
} }
err := m.rewritePath(transformer.dir, b, transformer.path, transformer.fn)
if err != nil { if err != nil {
if target := (&ErrIsNotebook{}); errors.As(err, target) {
return fmt.Errorf(`expected a file for "%s" but got a notebook: %w`, transformer.configPath, target)
}
if target := (&ErrIsNotNotebook{}); errors.As(err, target) {
return fmt.Errorf(`expected a notebook for "%s" but got a file: %w`, transformer.configPath, target)
}
return err return err
} }
} }
@ -179,36 +178,14 @@ func (m *translatePaths) translatePipelineLibrary(dir string, b *bundle.Bundle,
func (m *translatePaths) Apply(_ context.Context, b *bundle.Bundle) error { func (m *translatePaths) Apply(_ context.Context, b *bundle.Bundle) error {
m.seen = make(map[string]string) m.seen = make(map[string]string)
for key, job := range b.Config.Resources.Jobs { for _, fn := range []func(*translatePaths, *bundle.Bundle) error{
dir, err := job.ConfigFileDirectory() applyJobTransformers,
applyPipelineTransformers,
applyArtifactTransformers,
} {
err := fn(m, b)
if err != nil { if err != nil {
return fmt.Errorf("unable to determine directory for job %s: %w", key, err) return err
}
// Do not translate job task paths if using git source
if job.GitSource != nil {
continue
}
for i := 0; i < len(job.Tasks); i++ {
err := m.translateJobTask(dir, b, &job.Tasks[i])
if err != nil {
return err
}
}
}
for key, pipeline := range b.Config.Resources.Pipelines {
dir, err := pipeline.ConfigFileDirectory()
if err != nil {
return fmt.Errorf("unable to determine directory for pipeline %s: %w", key, err)
}
for i := 0; i < len(pipeline.Libraries); i++ {
err := m.translatePipelineLibrary(dir, b, &pipeline.Libraries[i])
if err != nil {
return err
}
} }
} }

View File

@ -0,0 +1,42 @@
package mutator
import (
"fmt"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/config"
)
func transformArtifactPath(resource any, dir string) *transformer {
artifact, ok := resource.(*config.Artifact)
if !ok {
return nil
}
return &transformer{
dir,
&artifact.Path,
"artifacts.path",
translateNoOp,
}
}
func applyArtifactTransformers(m *translatePaths, b *bundle.Bundle) error {
artifactTransformers := []transformFunc{
transformArtifactPath,
}
for key, artifact := range b.Config.Artifacts {
dir, err := artifact.ConfigFileDirectory()
if err != nil {
return fmt.Errorf("unable to determine directory for artifact %s: %w", key, err)
}
err = m.applyTransformers(artifactTransformers, b, artifact, dir)
if err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,103 @@
package mutator
import (
"fmt"
"github.com/databricks/cli/bundle"
"github.com/databricks/databricks-sdk-go/service/compute"
"github.com/databricks/databricks-sdk-go/service/jobs"
)
func transformNotebookTask(resource any, dir string) *transformer {
task, ok := resource.(*jobs.Task)
if !ok || task.NotebookTask == nil {
return nil
}
return &transformer{
dir,
&task.NotebookTask.NotebookPath,
"tasks.notebook_task.notebook_path",
translateNotebookPath,
}
}
func transformSparkTask(resource any, dir string) *transformer {
task, ok := resource.(*jobs.Task)
if !ok || task.SparkPythonTask == nil {
return nil
}
return &transformer{
dir,
&task.SparkPythonTask.PythonFile,
"tasks.spark_python_task.python_file",
translateFilePath,
}
}
func transformWhlLibrary(resource any, dir string) *transformer {
library, ok := resource.(*compute.Library)
if !ok || library.Whl == "" {
return nil
}
return &transformer{
dir,
&library.Whl,
"libraries.whl",
translateNoOp,
}
}
func transformJarLibrary(resource any, dir string) *transformer {
library, ok := resource.(*compute.Library)
if !ok || library.Jar == "" {
return nil
}
return &transformer{
dir,
&library.Jar,
"libraries.jar",
translateFilePath,
}
}
func applyJobTransformers(m *translatePaths, b *bundle.Bundle) error {
jobTransformers := []transformFunc{
transformNotebookTask,
transformSparkTask,
transformWhlLibrary,
transformJarLibrary,
}
for key, job := range b.Config.Resources.Jobs {
dir, err := job.ConfigFileDirectory()
if err != nil {
return fmt.Errorf("unable to determine directory for job %s: %w", key, err)
}
// Do not translate job task paths if using git source
if job.GitSource != nil {
continue
}
for i := 0; i < len(job.Tasks); i++ {
task := &job.Tasks[i]
err := m.applyTransformers(jobTransformers, b, task, dir)
if err != nil {
return err
}
for j := 0; j < len(task.Libraries); j++ {
library := &task.Libraries[j]
err := m.applyTransformers(jobTransformers, b, library, dir)
if err != nil {
return err
}
}
}
}
return nil
}

View File

@ -0,0 +1,60 @@
package mutator
import (
"fmt"
"github.com/databricks/cli/bundle"
"github.com/databricks/databricks-sdk-go/service/pipelines"
)
func transformLibraryNotebook(resource any, dir string) *transformer {
library, ok := resource.(*pipelines.PipelineLibrary)
if !ok || library.Notebook == nil {
return nil
}
return &transformer{
dir,
&library.Notebook.Path,
"libraries.notebook.path",
translateNotebookPath,
}
}
func transformLibraryFile(resource any, dir string) *transformer {
library, ok := resource.(*pipelines.PipelineLibrary)
if !ok || library.File == nil {
return nil
}
return &transformer{
dir,
&library.File.Path,
"libraries.file.path",
translateFilePath,
}
}
func applyPipelineTransformers(m *translatePaths, b *bundle.Bundle) error {
pipelineTransformers := []transformFunc{
transformLibraryNotebook,
transformLibraryFile,
}
for key, pipeline := range b.Config.Resources.Pipelines {
dir, err := pipeline.ConfigFileDirectory()
if err != nil {
return fmt.Errorf("unable to determine directory for pipeline %s: %w", key, err)
}
for i := 0; i < len(pipeline.Libraries); i++ {
library := &pipeline.Libraries[i]
err := m.applyTransformers(pipelineTransformers, b, library, dir)
if err != nil {
return err
}
}
}
return nil
}

View File

@ -9,7 +9,9 @@ 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/cli/bundle/config/mutator" "github.com/databricks/cli/bundle/config/mutator"
"github.com/databricks/cli/bundle/config/paths"
"github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/databricks-sdk-go/service/compute"
"github.com/databricks/databricks-sdk-go/service/jobs" "github.com/databricks/databricks-sdk-go/service/jobs"
"github.com/databricks/databricks-sdk-go/service/pipelines" "github.com/databricks/databricks-sdk-go/service/pipelines"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -43,7 +45,7 @@ func TestTranslatePathsSkippedWithGitSource(t *testing.T) {
Jobs: map[string]*resources.Job{ Jobs: map[string]*resources.Job{
"job": { "job": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: filepath.Join(dir, "resource.yml"), ConfigFilePath: filepath.Join(dir, "resource.yml"),
}, },
JobSettings: &jobs.JobSettings{ JobSettings: &jobs.JobSettings{
@ -103,6 +105,7 @@ func TestTranslatePaths(t *testing.T) {
touchNotebookFile(t, filepath.Join(dir, "my_job_notebook.py")) touchNotebookFile(t, filepath.Join(dir, "my_job_notebook.py"))
touchNotebookFile(t, filepath.Join(dir, "my_pipeline_notebook.py")) touchNotebookFile(t, filepath.Join(dir, "my_pipeline_notebook.py"))
touchEmptyFile(t, filepath.Join(dir, "my_python_file.py")) touchEmptyFile(t, filepath.Join(dir, "my_python_file.py"))
touchEmptyFile(t, filepath.Join(dir, "dist", "task.jar"))
bundle := &bundle.Bundle{ bundle := &bundle.Bundle{
Config: config.Root{ Config: config.Root{
@ -113,7 +116,7 @@ func TestTranslatePaths(t *testing.T) {
Resources: config.Resources{ Resources: config.Resources{
Jobs: map[string]*resources.Job{ Jobs: map[string]*resources.Job{
"job": { "job": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: filepath.Join(dir, "resource.yml"), ConfigFilePath: filepath.Join(dir, "resource.yml"),
}, },
JobSettings: &jobs.JobSettings{ JobSettings: &jobs.JobSettings{
@ -122,6 +125,9 @@ func TestTranslatePaths(t *testing.T) {
NotebookTask: &jobs.NotebookTask{ NotebookTask: &jobs.NotebookTask{
NotebookPath: "./my_job_notebook.py", NotebookPath: "./my_job_notebook.py",
}, },
Libraries: []compute.Library{
{Whl: "./dist/task.whl"},
},
}, },
{ {
NotebookTask: &jobs.NotebookTask{ NotebookTask: &jobs.NotebookTask{
@ -143,13 +149,29 @@ func TestTranslatePaths(t *testing.T) {
PythonFile: "./my_python_file.py", PythonFile: "./my_python_file.py",
}, },
}, },
{
SparkJarTask: &jobs.SparkJarTask{
MainClassName: "HelloWorld",
},
Libraries: []compute.Library{
{Jar: "./dist/task.jar"},
},
},
{
SparkJarTask: &jobs.SparkJarTask{
MainClassName: "HelloWorldRemote",
},
Libraries: []compute.Library{
{Jar: "dbfs:///bundle/dist/task_remote.jar"},
},
},
}, },
}, },
}, },
}, },
Pipelines: map[string]*resources.Pipeline{ Pipelines: map[string]*resources.Pipeline{
"pipeline": { "pipeline": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: filepath.Join(dir, "resource.yml"), ConfigFilePath: filepath.Join(dir, "resource.yml"),
}, },
PipelineSpec: &pipelines.PipelineSpec{ PipelineSpec: &pipelines.PipelineSpec{
@ -194,6 +216,11 @@ func TestTranslatePaths(t *testing.T) {
"/bundle/my_job_notebook", "/bundle/my_job_notebook",
bundle.Config.Resources.Jobs["job"].Tasks[0].NotebookTask.NotebookPath, bundle.Config.Resources.Jobs["job"].Tasks[0].NotebookTask.NotebookPath,
) )
assert.Equal(
t,
filepath.Join("dist", "task.whl"),
bundle.Config.Resources.Jobs["job"].Tasks[0].Libraries[0].Whl,
)
assert.Equal( assert.Equal(
t, t,
"/Users/jane.doe@databricks.com/doesnt_exist.py", "/Users/jane.doe@databricks.com/doesnt_exist.py",
@ -209,6 +236,16 @@ func TestTranslatePaths(t *testing.T) {
"/bundle/my_python_file.py", "/bundle/my_python_file.py",
bundle.Config.Resources.Jobs["job"].Tasks[4].SparkPythonTask.PythonFile, bundle.Config.Resources.Jobs["job"].Tasks[4].SparkPythonTask.PythonFile,
) )
assert.Equal(
t,
"/bundle/dist/task.jar",
bundle.Config.Resources.Jobs["job"].Tasks[5].Libraries[0].Jar,
)
assert.Equal(
t,
"dbfs:///bundle/dist/task_remote.jar",
bundle.Config.Resources.Jobs["job"].Tasks[6].Libraries[0].Jar,
)
// Assert that the path in the libraries now refer to the artifact. // Assert that the path in the libraries now refer to the artifact.
assert.Equal( assert.Equal(
@ -236,6 +273,7 @@ func TestTranslatePaths(t *testing.T) {
func TestTranslatePathsInSubdirectories(t *testing.T) { func TestTranslatePathsInSubdirectories(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
touchEmptyFile(t, filepath.Join(dir, "job", "my_python_file.py")) touchEmptyFile(t, filepath.Join(dir, "job", "my_python_file.py"))
touchEmptyFile(t, filepath.Join(dir, "job", "dist", "task.jar"))
touchEmptyFile(t, filepath.Join(dir, "pipeline", "my_python_file.py")) touchEmptyFile(t, filepath.Join(dir, "pipeline", "my_python_file.py"))
bundle := &bundle.Bundle{ bundle := &bundle.Bundle{
@ -247,7 +285,7 @@ func TestTranslatePathsInSubdirectories(t *testing.T) {
Resources: config.Resources{ Resources: config.Resources{
Jobs: map[string]*resources.Job{ Jobs: map[string]*resources.Job{
"job": { "job": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: filepath.Join(dir, "job/resource.yml"), ConfigFilePath: filepath.Join(dir, "job/resource.yml"),
}, },
JobSettings: &jobs.JobSettings{ JobSettings: &jobs.JobSettings{
@ -257,13 +295,21 @@ func TestTranslatePathsInSubdirectories(t *testing.T) {
PythonFile: "./my_python_file.py", PythonFile: "./my_python_file.py",
}, },
}, },
{
SparkJarTask: &jobs.SparkJarTask{
MainClassName: "HelloWorld",
},
Libraries: []compute.Library{
{Jar: "./dist/task.jar"},
},
},
}, },
}, },
}, },
}, },
Pipelines: map[string]*resources.Pipeline{ Pipelines: map[string]*resources.Pipeline{
"pipeline": { "pipeline": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: filepath.Join(dir, "pipeline/resource.yml"), ConfigFilePath: filepath.Join(dir, "pipeline/resource.yml"),
}, },
@ -290,6 +336,11 @@ func TestTranslatePathsInSubdirectories(t *testing.T) {
"/bundle/job/my_python_file.py", "/bundle/job/my_python_file.py",
bundle.Config.Resources.Jobs["job"].Tasks[0].SparkPythonTask.PythonFile, bundle.Config.Resources.Jobs["job"].Tasks[0].SparkPythonTask.PythonFile,
) )
assert.Equal(
t,
"/bundle/job/dist/task.jar",
bundle.Config.Resources.Jobs["job"].Tasks[1].Libraries[0].Jar,
)
assert.Equal( assert.Equal(
t, t,
@ -310,7 +361,7 @@ func TestTranslatePathsOutsideBundleRoot(t *testing.T) {
Resources: config.Resources{ Resources: config.Resources{
Jobs: map[string]*resources.Job{ Jobs: map[string]*resources.Job{
"job": { "job": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: filepath.Join(dir, "../resource.yml"), ConfigFilePath: filepath.Join(dir, "../resource.yml"),
}, },
JobSettings: &jobs.JobSettings{ JobSettings: &jobs.JobSettings{
@ -341,7 +392,7 @@ func TestJobNotebookDoesNotExistError(t *testing.T) {
Resources: config.Resources{ Resources: config.Resources{
Jobs: map[string]*resources.Job{ Jobs: map[string]*resources.Job{
"job": { "job": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: filepath.Join(dir, "fake.yml"), ConfigFilePath: filepath.Join(dir, "fake.yml"),
}, },
JobSettings: &jobs.JobSettings{ JobSettings: &jobs.JobSettings{
@ -372,7 +423,7 @@ func TestJobFileDoesNotExistError(t *testing.T) {
Resources: config.Resources{ Resources: config.Resources{
Jobs: map[string]*resources.Job{ Jobs: map[string]*resources.Job{
"job": { "job": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: filepath.Join(dir, "fake.yml"), ConfigFilePath: filepath.Join(dir, "fake.yml"),
}, },
JobSettings: &jobs.JobSettings{ JobSettings: &jobs.JobSettings{
@ -403,7 +454,7 @@ func TestPipelineNotebookDoesNotExistError(t *testing.T) {
Resources: config.Resources{ Resources: config.Resources{
Pipelines: map[string]*resources.Pipeline{ Pipelines: map[string]*resources.Pipeline{
"pipeline": { "pipeline": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: filepath.Join(dir, "fake.yml"), ConfigFilePath: filepath.Join(dir, "fake.yml"),
}, },
PipelineSpec: &pipelines.PipelineSpec{ PipelineSpec: &pipelines.PipelineSpec{
@ -434,7 +485,7 @@ func TestPipelineFileDoesNotExistError(t *testing.T) {
Resources: config.Resources{ Resources: config.Resources{
Pipelines: map[string]*resources.Pipeline{ Pipelines: map[string]*resources.Pipeline{
"pipeline": { "pipeline": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: filepath.Join(dir, "fake.yml"), ConfigFilePath: filepath.Join(dir, "fake.yml"),
}, },
PipelineSpec: &pipelines.PipelineSpec{ PipelineSpec: &pipelines.PipelineSpec{
@ -469,7 +520,7 @@ func TestJobSparkPythonTaskWithNotebookSourceError(t *testing.T) {
Resources: config.Resources{ Resources: config.Resources{
Jobs: map[string]*resources.Job{ Jobs: map[string]*resources.Job{
"job": { "job": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: filepath.Join(dir, "resource.yml"), ConfigFilePath: filepath.Join(dir, "resource.yml"),
}, },
JobSettings: &jobs.JobSettings{ JobSettings: &jobs.JobSettings{
@ -504,7 +555,7 @@ func TestJobNotebookTaskWithFileSourceError(t *testing.T) {
Resources: config.Resources{ Resources: config.Resources{
Jobs: map[string]*resources.Job{ Jobs: map[string]*resources.Job{
"job": { "job": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: filepath.Join(dir, "resource.yml"), ConfigFilePath: filepath.Join(dir, "resource.yml"),
}, },
JobSettings: &jobs.JobSettings{ JobSettings: &jobs.JobSettings{
@ -539,7 +590,7 @@ func TestPipelineNotebookLibraryWithFileSourceError(t *testing.T) {
Resources: config.Resources{ Resources: config.Resources{
Pipelines: map[string]*resources.Pipeline{ Pipelines: map[string]*resources.Pipeline{
"pipeline": { "pipeline": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: filepath.Join(dir, "resource.yml"), ConfigFilePath: filepath.Join(dir, "resource.yml"),
}, },
PipelineSpec: &pipelines.PipelineSpec{ PipelineSpec: &pipelines.PipelineSpec{
@ -574,7 +625,7 @@ func TestPipelineFileLibraryWithNotebookSourceError(t *testing.T) {
Resources: config.Resources{ Resources: config.Resources{
Pipelines: map[string]*resources.Pipeline{ Pipelines: map[string]*resources.Pipeline{
"pipeline": { "pipeline": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: filepath.Join(dir, "resource.yml"), ConfigFilePath: filepath.Join(dir, "resource.yml"),
}, },
PipelineSpec: &pipelines.PipelineSpec{ PipelineSpec: &pipelines.PipelineSpec{

View File

@ -1,4 +1,4 @@
package resources package paths
import ( import (
"fmt" "fmt"

View File

@ -1,6 +1,7 @@
package resources package resources
import ( import (
"github.com/databricks/cli/bundle/config/paths"
"github.com/databricks/databricks-sdk-go/service/jobs" "github.com/databricks/databricks-sdk-go/service/jobs"
"github.com/imdario/mergo" "github.com/imdario/mergo"
) )
@ -9,7 +10,7 @@ type Job struct {
ID string `json:"id,omitempty" bundle:"readonly"` ID string `json:"id,omitempty" bundle:"readonly"`
Permissions []Permission `json:"permissions,omitempty"` Permissions []Permission `json:"permissions,omitempty"`
Paths paths.Paths
*jobs.JobSettings *jobs.JobSettings
} }

View File

@ -1,11 +1,14 @@
package resources package resources
import "github.com/databricks/databricks-sdk-go/service/ml" import (
"github.com/databricks/cli/bundle/config/paths"
"github.com/databricks/databricks-sdk-go/service/ml"
)
type MlflowExperiment struct { type MlflowExperiment struct {
Permissions []Permission `json:"permissions,omitempty"` Permissions []Permission `json:"permissions,omitempty"`
Paths paths.Paths
*ml.Experiment *ml.Experiment
} }

View File

@ -1,11 +1,14 @@
package resources package resources
import "github.com/databricks/databricks-sdk-go/service/ml" import (
"github.com/databricks/cli/bundle/config/paths"
"github.com/databricks/databricks-sdk-go/service/ml"
)
type MlflowModel struct { type MlflowModel struct {
Permissions []Permission `json:"permissions,omitempty"` Permissions []Permission `json:"permissions,omitempty"`
Paths paths.Paths
*ml.Model *ml.Model
} }

View File

@ -1,12 +1,15 @@
package resources package resources
import "github.com/databricks/databricks-sdk-go/service/pipelines" import (
"github.com/databricks/cli/bundle/config/paths"
"github.com/databricks/databricks-sdk-go/service/pipelines"
)
type Pipeline struct { type Pipeline struct {
ID string `json:"id,omitempty" bundle:"readonly"` ID string `json:"id,omitempty" bundle:"readonly"`
Permissions []Permission `json:"permissions,omitempty"` Permissions []Permission `json:"permissions,omitempty"`
Paths paths.Paths
*pipelines.PipelineSpec *pipelines.PipelineSpec
} }

View File

@ -3,6 +3,7 @@ package config
import ( import (
"testing" "testing"
"github.com/databricks/cli/bundle/config/paths"
"github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/bundle/config/resources"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -11,21 +12,21 @@ func TestVerifyUniqueResourceIdentifiers(t *testing.T) {
r := Resources{ r := Resources{
Jobs: map[string]*resources.Job{ Jobs: map[string]*resources.Job{
"foo": { "foo": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: "foo.yml", ConfigFilePath: "foo.yml",
}, },
}, },
}, },
Models: map[string]*resources.MlflowModel{ Models: map[string]*resources.MlflowModel{
"bar": { "bar": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: "bar.yml", ConfigFilePath: "bar.yml",
}, },
}, },
}, },
Experiments: map[string]*resources.MlflowExperiment{ Experiments: map[string]*resources.MlflowExperiment{
"foo": { "foo": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: "foo2.yml", ConfigFilePath: "foo2.yml",
}, },
}, },
@ -39,14 +40,14 @@ func TestVerifySafeMerge(t *testing.T) {
r := Resources{ r := Resources{
Jobs: map[string]*resources.Job{ Jobs: map[string]*resources.Job{
"foo": { "foo": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: "foo.yml", ConfigFilePath: "foo.yml",
}, },
}, },
}, },
Models: map[string]*resources.MlflowModel{ Models: map[string]*resources.MlflowModel{
"bar": { "bar": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: "bar.yml", ConfigFilePath: "bar.yml",
}, },
}, },
@ -55,7 +56,7 @@ func TestVerifySafeMerge(t *testing.T) {
other := Resources{ other := Resources{
Pipelines: map[string]*resources.Pipeline{ Pipelines: map[string]*resources.Pipeline{
"foo": { "foo": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: "foo2.yml", ConfigFilePath: "foo2.yml",
}, },
}, },
@ -69,14 +70,14 @@ func TestVerifySafeMergeForSameResourceType(t *testing.T) {
r := Resources{ r := Resources{
Jobs: map[string]*resources.Job{ Jobs: map[string]*resources.Job{
"foo": { "foo": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: "foo.yml", ConfigFilePath: "foo.yml",
}, },
}, },
}, },
Models: map[string]*resources.MlflowModel{ Models: map[string]*resources.MlflowModel{
"bar": { "bar": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: "bar.yml", ConfigFilePath: "bar.yml",
}, },
}, },
@ -85,7 +86,7 @@ func TestVerifySafeMergeForSameResourceType(t *testing.T) {
other := Resources{ other := Resources{
Jobs: map[string]*resources.Job{ Jobs: map[string]*resources.Job{
"foo": { "foo": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: "foo2.yml", ConfigFilePath: "foo2.yml",
}, },
}, },

View File

@ -64,7 +64,7 @@ type Root struct {
Workspace Workspace `json:"workspace,omitempty"` Workspace Workspace `json:"workspace,omitempty"`
// Artifacts contains a description of all code artifacts in this bundle. // Artifacts contains a description of all code artifacts in this bundle.
Artifacts map[string]*Artifact `json:"artifacts,omitempty"` Artifacts Artifacts `json:"artifacts,omitempty"`
// Resources contains a description of all Databricks resources // Resources contains a description of all Databricks resources
// to deploy in this bundle (e.g. jobs, pipelines, etc.). // to deploy in this bundle (e.g. jobs, pipelines, etc.).
@ -113,6 +113,10 @@ func Load(path string) (*Root, error) {
// was loaded from in configuration leafs that require it. // was loaded from in configuration leafs that require it.
func (r *Root) SetConfigFilePath(path string) { func (r *Root) SetConfigFilePath(path string) {
r.Resources.SetConfigFilePath(path) r.Resources.SetConfigFilePath(path)
if r.Artifacts != nil {
r.Artifacts.SetConfigFilePath(path)
}
if r.Targets != nil { if r.Targets != nil {
for _, env := range r.Targets { for _, env := range r.Targets {
if env == nil { if env == nil {
@ -121,6 +125,9 @@ func (r *Root) SetConfigFilePath(path string) {
if env.Resources != nil { if env.Resources != nil {
env.Resources.SetConfigFilePath(path) env.Resources.SetConfigFilePath(path)
} }
if env.Artifacts != nil {
env.Artifacts.SetConfigFilePath(path)
}
} }
} }
} }
@ -175,11 +182,17 @@ func (r *Root) Load(path string) error {
} }
func (r *Root) Merge(other *Root) error { func (r *Root) Merge(other *Root) error {
err := r.Sync.Merge(r, other)
if err != nil {
return err
}
other.Sync = Sync{}
// TODO: when hooking into merge semantics, disallow setting path on the target instance. // TODO: when hooking into merge semantics, disallow setting path on the target instance.
other.Path = "" other.Path = ""
// Check for safe merge, protecting against duplicate resource identifiers // Check for safe merge, protecting against duplicate resource identifiers
err := r.Resources.VerifySafeMerge(&other.Resources) err = r.Resources.VerifySafeMerge(&other.Resources)
if err != nil { if err != nil {
return err return err
} }

View File

@ -1,5 +1,7 @@
package config package config
import "path/filepath"
type Sync struct { type Sync struct {
// Include contains a list of globs evaluated relative to the bundle root path // Include contains a list of globs evaluated relative to the bundle root path
// to explicitly include files that were excluded by the user's gitignore. // to explicitly include files that were excluded by the user's gitignore.
@ -11,3 +13,19 @@ type Sync struct {
// 2) the `Include` field above. // 2) the `Include` field above.
Exclude []string `json:"exclude,omitempty"` Exclude []string `json:"exclude,omitempty"`
} }
func (s *Sync) Merge(root *Root, other *Root) error {
path, err := filepath.Rel(root.Path, other.Path)
if err != nil {
return err
}
for _, include := range other.Sync.Include {
s.Include = append(s.Include, filepath.Join(path, include))
}
for _, exclude := range other.Sync.Exclude {
s.Exclude = append(s.Exclude, filepath.Join(path, exclude))
}
return nil
}

View File

@ -23,7 +23,7 @@ type Target struct {
Workspace *Workspace `json:"workspace,omitempty"` Workspace *Workspace `json:"workspace,omitempty"`
Artifacts map[string]*Artifact `json:"artifacts,omitempty"` Artifacts Artifacts `json:"artifacts,omitempty"`
Resources *Resources `json:"resources,omitempty"` Resources *Resources `json:"resources,omitempty"`

View File

@ -7,6 +7,7 @@ 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/cli/bundle/config/paths"
"github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/databricks-sdk-go/service/jobs" "github.com/databricks/databricks-sdk-go/service/jobs"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -112,7 +113,7 @@ func TestNoPanicWithNoPythonWheelTasks(t *testing.T) {
Resources: config.Resources{ Resources: config.Resources{
Jobs: map[string]*resources.Job{ Jobs: map[string]*resources.Job{
"test": { "test": {
Paths: resources.Paths{ Paths: paths.Paths{
ConfigFilePath: tmpDir, ConfigFilePath: tmpDir,
}, },
JobSettings: &jobs.JobSettings{ JobSettings: &jobs.JobSettings{

View File

@ -0,0 +1,25 @@
bundle:
name: sync_include
include:
- "*/*.yml"
sync:
include:
- ./folder_a/*.*
exclude:
- ./folder_b/*.*
artifacts:
test_a:
type: whl
path: ./artifact_a
resources:
jobs:
job_a:
name: "job_a"
tasks:
- task_key: "task_a"
libraries:
- whl: ./dist/job_a.whl

View File

@ -0,0 +1,20 @@
sync:
include:
- ./folder_c/*.*
exclude:
- ./folder_d/*.*
artifacts:
test_b:
type: whl
path: ./artifact_b
resources:
jobs:
job_b:
name: "job_b"
tasks:
- task_key: "task_a"
libraries:
- whl: ./dist/job_b.whl

View File

@ -0,0 +1,28 @@
package config_tests
import (
"context"
"path/filepath"
"testing"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/config/mutator"
"github.com/stretchr/testify/assert"
)
func TestRelativePathsWithIncludes(t *testing.T) {
b := load(t, "./relative_path_with_includes")
m := mutator.TranslatePaths()
err := bundle.Apply(context.Background(), b, m)
assert.NoError(t, err)
assert.Equal(t, "artifact_a", b.Config.Artifacts["test_a"].Path)
assert.Equal(t, filepath.Join("subfolder", "artifact_b"), b.Config.Artifacts["test_b"].Path)
assert.ElementsMatch(t, []string{"./folder_a/*.*", filepath.Join("subfolder", "folder_c", "*.*")}, b.Config.Sync.Include)
assert.ElementsMatch(t, []string{"./folder_b/*.*", filepath.Join("subfolder", "folder_d", "*.*")}, b.Config.Sync.Exclude)
assert.Equal(t, filepath.Join("dist", "job_a.whl"), b.Config.Resources.Jobs["job_a"].Tasks[0].Libraries[0].Whl)
assert.Equal(t, filepath.Join("subfolder", "dist", "job_b.whl"), b.Config.Resources.Jobs["job_b"].Tasks[0].Libraries[0].Whl)
}