From e47fa6195132aec1a662162bed7f1260afc53ac4 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Fri, 18 Nov 2022 10:57:31 +0100 Subject: [PATCH] Skeleton for configuration loading and mutation (#92) Load a tree of configuration files anchored at `bundle.yml` into the `config.Root` struct. All mutations (from setting defaults to merging files) are observable through the `mutator.Mutator` interface. --- bundle/bundle.go | 24 ++++ bundle/bundle_test.go | 21 ++++ bundle/config/bundle.go | 13 +++ bundle/config/environment.go | 11 ++ bundle/config/mutator/default_environment.go | 35 ++++++ .../mutator/default_environment_test.go | 31 +++++ bundle/config/mutator/default_include.go | 34 ++++++ bundle/config/mutator/default_include_test.go | 17 +++ bundle/config/mutator/mutator.go | 46 ++++++++ bundle/config/mutator/process_include.go | 32 +++++ bundle/config/mutator/process_include_test.go | 34 ++++++ .../config/mutator/process_root_includes.go | 68 +++++++++++ .../mutator/process_root_includes_test.go | 102 ++++++++++++++++ bundle/config/mutator/select_environment.go | 44 +++++++ .../config/mutator/select_environment_test.go | 38 ++++++ bundle/config/resources.go | 9 ++ bundle/config/resources/job.go | 5 + bundle/config/resources/pipeline.go | 5 + bundle/config/root.go | 109 ++++++++++++++++++ bundle/config/root_test.go | 72 ++++++++++++ bundle/config/tests/README.md | 4 + bundle/config/tests/basic/bundle.yml | 2 + bundle/config/tests/basic_test.go | 12 ++ .../tests/environment_overrides/bundle.yml | 14 +++ .../tests/environment_overrides_test.go | 14 +++ .../config/tests/include_default/bundle.yml | 2 + .../include_default/my_first_job/resource.yml | 4 + .../my_second_job/resource.yml | 4 + bundle/config/tests/include_default_test.go | 20 ++++ .../config/tests/include_override/bundle.yml | 7 ++ .../this_file_isnt_included.yml | 4 + bundle/config/tests/include_override_test.go | 12 ++ bundle/config/tests/loader.go | 24 ++++ bundle/config/workspace.go | 8 ++ bundle/root.go | 38 ++++++ bundle/root_test.go | 104 +++++++++++++++++ go.mod | 1 + go.sum | 3 + 38 files changed, 1027 insertions(+) create mode 100644 bundle/bundle.go create mode 100644 bundle/bundle_test.go create mode 100644 bundle/config/bundle.go create mode 100644 bundle/config/environment.go create mode 100644 bundle/config/mutator/default_environment.go create mode 100644 bundle/config/mutator/default_environment_test.go create mode 100644 bundle/config/mutator/default_include.go create mode 100644 bundle/config/mutator/default_include_test.go create mode 100644 bundle/config/mutator/mutator.go create mode 100644 bundle/config/mutator/process_include.go create mode 100644 bundle/config/mutator/process_include_test.go create mode 100644 bundle/config/mutator/process_root_includes.go create mode 100644 bundle/config/mutator/process_root_includes_test.go create mode 100644 bundle/config/mutator/select_environment.go create mode 100644 bundle/config/mutator/select_environment_test.go create mode 100644 bundle/config/resources.go create mode 100644 bundle/config/resources/job.go create mode 100644 bundle/config/resources/pipeline.go create mode 100644 bundle/config/root.go create mode 100644 bundle/config/root_test.go create mode 100644 bundle/config/tests/README.md create mode 100644 bundle/config/tests/basic/bundle.yml create mode 100644 bundle/config/tests/basic_test.go create mode 100644 bundle/config/tests/environment_overrides/bundle.yml create mode 100644 bundle/config/tests/environment_overrides_test.go create mode 100644 bundle/config/tests/include_default/bundle.yml create mode 100644 bundle/config/tests/include_default/my_first_job/resource.yml create mode 100644 bundle/config/tests/include_default/my_second_job/resource.yml create mode 100644 bundle/config/tests/include_default_test.go create mode 100644 bundle/config/tests/include_override/bundle.yml create mode 100644 bundle/config/tests/include_override/this_file_isnt_included.yml create mode 100644 bundle/config/tests/include_override_test.go create mode 100644 bundle/config/tests/loader.go create mode 100644 bundle/config/workspace.go create mode 100644 bundle/root.go create mode 100644 bundle/root_test.go diff --git a/bundle/bundle.go b/bundle/bundle.go new file mode 100644 index 000000000..679a04f90 --- /dev/null +++ b/bundle/bundle.go @@ -0,0 +1,24 @@ +package bundle + +import ( + "path/filepath" + + "github.com/databricks/bricks/bundle/config" +) + +type Bundle struct { + Config config.Root +} + +func Load(path string) (*Bundle, error) { + bundle := &Bundle{ + Config: config.Root{ + Path: path, + }, + } + err := bundle.Config.Load(filepath.Join(path, config.FileName)) + if err != nil { + return nil, err + } + return bundle, nil +} diff --git a/bundle/bundle_test.go b/bundle/bundle_test.go new file mode 100644 index 000000000..ef4df7f91 --- /dev/null +++ b/bundle/bundle_test.go @@ -0,0 +1,21 @@ +package bundle + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadNotExists(t *testing.T) { + b, err := Load("/doesntexist") + assert.True(t, os.IsNotExist(err)) + assert.Nil(t, b) +} + +func TestLoadExists(t *testing.T) { + b, err := Load("./config/tests/basic") + require.Nil(t, err) + assert.Equal(t, "basic", b.Config.Bundle.Name) +} diff --git a/bundle/config/bundle.go b/bundle/config/bundle.go new file mode 100644 index 000000000..ba9196695 --- /dev/null +++ b/bundle/config/bundle.go @@ -0,0 +1,13 @@ +package config + +type Bundle struct { + Name string `json:"name,omitempty"` + + // TODO + // Default cluster to run commands on (Python, Scala). + // DefaultCluster string `json:"default_cluster,omitempty"` + + // TODO + // Default warehouse to run SQL on. + // DefaultWarehouse string `json:"default_warehouse,omitempty"` +} diff --git a/bundle/config/environment.go b/bundle/config/environment.go new file mode 100644 index 000000000..f9ea40b47 --- /dev/null +++ b/bundle/config/environment.go @@ -0,0 +1,11 @@ +package config + +// Environment defines overrides for a single environment. +// This structure is recursively merged into the root configuration. +type Environment struct { + Bundle *Bundle `json:"bundle,omitempty"` + + Workspace *Workspace `json:"workspace,omitempty"` + + Resources *Resources `json:"resources,omitempty"` +} diff --git a/bundle/config/mutator/default_environment.go b/bundle/config/mutator/default_environment.go new file mode 100644 index 000000000..aa79e600c --- /dev/null +++ b/bundle/config/mutator/default_environment.go @@ -0,0 +1,35 @@ +package mutator + +import ( + "fmt" + + "github.com/databricks/bricks/bundle/config" +) + +type defineDefaultEnvironment struct { + name string +} + +// DefineDefaultEnvironment adds an environment named "default" +// to the configuration if none have been defined. +func DefineDefaultEnvironment() Mutator { + return &defineDefaultEnvironment{ + name: "default", + } +} + +func (m *defineDefaultEnvironment) Name() string { + return fmt.Sprintf("DefineDefaultEnvironment(%s)", m.name) +} + +func (m *defineDefaultEnvironment) Apply(root *config.Root) ([]Mutator, error) { + // Nothing to do if the configuration has at least 1 environment. + if root.Environments != nil || len(root.Environments) > 0 { + return nil, nil + } + + // Define default environment. + root.Environments = make(map[string]*config.Environment) + root.Environments[m.name] = &config.Environment{} + return nil, nil +} diff --git a/bundle/config/mutator/default_environment_test.go b/bundle/config/mutator/default_environment_test.go new file mode 100644 index 000000000..8b250d2c4 --- /dev/null +++ b/bundle/config/mutator/default_environment_test.go @@ -0,0 +1,31 @@ +package mutator_test + +import ( + "testing" + + "github.com/databricks/bricks/bundle/config" + "github.com/databricks/bricks/bundle/config/mutator" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultEnvironment(t *testing.T) { + root := &config.Root{} + _, err := mutator.DefineDefaultEnvironment().Apply(root) + require.NoError(t, err) + env, ok := root.Environments["default"] + assert.True(t, ok) + assert.Equal(t, &config.Environment{}, env) +} + +func TestDefaultEnvironmentAlreadySpecified(t *testing.T) { + root := &config.Root{ + Environments: map[string]*config.Environment{ + "development": {}, + }, + } + _, err := mutator.DefineDefaultEnvironment().Apply(root) + require.NoError(t, err) + _, ok := root.Environments["default"] + assert.False(t, ok) +} diff --git a/bundle/config/mutator/default_include.go b/bundle/config/mutator/default_include.go new file mode 100644 index 000000000..8b9a68d20 --- /dev/null +++ b/bundle/config/mutator/default_include.go @@ -0,0 +1,34 @@ +package mutator + +import ( + "github.com/databricks/bricks/bundle/config" + "golang.org/x/exp/slices" +) + +type defineDefaultInclude struct { + include []string +} + +// DefineDefaultInclude sets the list of includes to a default if it hasn't been set. +func DefineDefaultInclude() Mutator { + return &defineDefaultInclude{ + // When we support globstar we can collapse below into a single line. + include: []string{ + // Load YAML files in the same directory. + "*.yml", + // Load YAML files in subdirectories. + "*/*.yml", + }, + } +} + +func (m *defineDefaultInclude) Name() string { + return "DefineDefaultInclude" +} + +func (m *defineDefaultInclude) Apply(root *config.Root) ([]Mutator, error) { + if len(root.Include) == 0 { + root.Include = slices.Clone(m.include) + } + return nil, nil +} diff --git a/bundle/config/mutator/default_include_test.go b/bundle/config/mutator/default_include_test.go new file mode 100644 index 000000000..1ffa1766c --- /dev/null +++ b/bundle/config/mutator/default_include_test.go @@ -0,0 +1,17 @@ +package mutator_test + +import ( + "testing" + + "github.com/databricks/bricks/bundle/config" + "github.com/databricks/bricks/bundle/config/mutator" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultInclude(t *testing.T) { + root := &config.Root{} + _, err := mutator.DefineDefaultInclude().Apply(root) + require.NoError(t, err) + assert.Equal(t, []string{"*.yml", "*/*.yml"}, root.Include) +} diff --git a/bundle/config/mutator/mutator.go b/bundle/config/mutator/mutator.go new file mode 100644 index 000000000..4e42e908e --- /dev/null +++ b/bundle/config/mutator/mutator.go @@ -0,0 +1,46 @@ +package mutator + +import "github.com/databricks/bricks/bundle/config" + +// Mutator is the interface types that mutate the bundle configuration. +// This makes every mutation observable and debuggable. +type Mutator interface { + // Name returns the mutators name. + Name() string + + // Apply mutates the specified configuration object. + // It optionally returns a list of mutators to invoke immediately after this mutator. + // This is used when processing all configuration files in the tree; each file gets + // its own mutator instance. + Apply(*config.Root) ([]Mutator, error) +} + +func DefaultMutators() []Mutator { + return []Mutator{ + DefineDefaultInclude(), + ProcessRootIncludes(), + DefineDefaultEnvironment(), + } +} + +func DefaultMutatorsForEnvironment(env string) []Mutator { + return append(DefaultMutators(), SelectEnvironment(env)) +} + +func Apply(root *config.Root, ms []Mutator) error { + if len(ms) == 0 { + return nil + } + for _, m := range ms { + ms_, err := m.Apply(root) + if err != nil { + return err + } + // Apply recursively. + err = Apply(root, ms_) + if err != nil { + return err + } + } + return nil +} diff --git a/bundle/config/mutator/process_include.go b/bundle/config/mutator/process_include.go new file mode 100644 index 000000000..6b0f34e3e --- /dev/null +++ b/bundle/config/mutator/process_include.go @@ -0,0 +1,32 @@ +package mutator + +import ( + "fmt" + + "github.com/databricks/bricks/bundle/config" +) + +type processInclude struct { + fullPath string + relPath string +} + +// ProcessInclude loads the configuration at [fullPath] and merges it into the configuration. +func ProcessInclude(fullPath, relPath string) Mutator { + return &processInclude{ + fullPath: fullPath, + relPath: relPath, + } +} + +func (m *processInclude) Name() string { + return fmt.Sprintf("ProcessInclude(%s)", m.relPath) +} + +func (m *processInclude) Apply(root *config.Root) ([]Mutator, error) { + this, err := config.Load(m.fullPath) + if err != nil { + return nil, err + } + return nil, root.Merge(this) +} diff --git a/bundle/config/mutator/process_include_test.go b/bundle/config/mutator/process_include_test.go new file mode 100644 index 000000000..701386925 --- /dev/null +++ b/bundle/config/mutator/process_include_test.go @@ -0,0 +1,34 @@ +package mutator_test + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/databricks/bricks/bundle/config" + "github.com/databricks/bricks/bundle/config/mutator" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProcessInclude(t *testing.T) { + root := &config.Root{ + Path: t.TempDir(), + Workspace: config.Workspace{ + Host: "foo", + }, + } + + relPath := "./file.yml" + fullPath := filepath.Join(root.Path, relPath) + f, err := os.Create(fullPath) + require.NoError(t, err) + fmt.Fprint(f, "workspace:\n host: bar\n") + f.Close() + + assert.Equal(t, "foo", root.Workspace.Host) + _, err = mutator.ProcessInclude(fullPath, relPath).Apply(root) + require.NoError(t, err) + assert.Equal(t, "bar", root.Workspace.Host) +} diff --git a/bundle/config/mutator/process_root_includes.go b/bundle/config/mutator/process_root_includes.go new file mode 100644 index 000000000..32f32cb1b --- /dev/null +++ b/bundle/config/mutator/process_root_includes.go @@ -0,0 +1,68 @@ +package mutator + +import ( + "fmt" + "path/filepath" + + "github.com/databricks/bricks/bundle/config" + "golang.org/x/exp/slices" +) + +type processRootIncludes struct{} + +// ProcessRootIncludes expands the patterns in the configuration's include list +// into a list of mutators for each matching file. +func ProcessRootIncludes() Mutator { + return &processRootIncludes{} +} + +func (m *processRootIncludes) Name() string { + return "ProcessRootIncludes" +} + +func (m *processRootIncludes) Apply(root *config.Root) ([]Mutator, error) { + var out []Mutator + + // Map with files we've already seen to avoid loading them twice. + var seen = map[string]bool{ + config.FileName: true, + } + + // For each glob, find all files to load. + // Ordering of the list of globs is maintained in the output. + // For matches that appear in multiple globs, only the first is kept. + for _, entry := range root.Include { + // Include paths must be relative. + if filepath.IsAbs(entry) { + return nil, fmt.Errorf("%s: includes must be relative paths", entry) + } + + // Anchor includes to the bundle root path. + matches, err := filepath.Glob(filepath.Join(root.Path, entry)) + if err != nil { + return nil, err + } + + // Filter matches to ones we haven't seen yet. + var includes []string + for _, match := range matches { + rel, err := filepath.Rel(root.Path, match) + if err != nil { + return nil, err + } + if _, ok := seen[rel]; ok { + continue + } + seen[rel] = true + includes = append(includes, rel) + } + + // Add matches to list of mutators to return. + slices.Sort(includes) + for _, include := range includes { + out = append(out, ProcessInclude(filepath.Join(root.Path, include), include)) + } + } + + return out, nil +} diff --git a/bundle/config/mutator/process_root_includes_test.go b/bundle/config/mutator/process_root_includes_test.go new file mode 100644 index 000000000..de112b7d1 --- /dev/null +++ b/bundle/config/mutator/process_root_includes_test.go @@ -0,0 +1,102 @@ +package mutator_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/databricks/bricks/bundle/config" + "github.com/databricks/bricks/bundle/config/mutator" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func touch(t *testing.T, path, file string) { + f, err := os.Create(filepath.Join(path, file)) + require.NoError(t, err) + f.Close() +} + +func TestProcessRootIncludesEmpty(t *testing.T) { + root := &config.Root{Path: "."} + _, err := mutator.ProcessRootIncludes().Apply(root) + require.NoError(t, err) +} + +func TestProcessRootIncludesAbs(t *testing.T) { + root := &config.Root{ + Path: ".", + Include: []string{ + "/tmp/*.yml", + }, + } + _, err := mutator.ProcessRootIncludes().Apply(root) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be relative paths") +} + +func TestProcessRootIncludesSingleGlob(t *testing.T) { + root := &config.Root{ + Path: t.TempDir(), + Include: []string{ + "*.yml", + }, + } + + touch(t, root.Path, "bundle.yml") + touch(t, root.Path, "a.yml") + touch(t, root.Path, "b.yml") + + ms, err := mutator.ProcessRootIncludes().Apply(root) + require.NoError(t, err) + + var names []string + for _, m := range ms { + names = append(names, m.Name()) + } + + assert.NotContains(t, names, "ProcessInclude(bundle.yml)") + assert.Contains(t, names, "ProcessInclude(a.yml)") + assert.Contains(t, names, "ProcessInclude(b.yml)") +} + +func TestProcessRootIncludesMultiGlob(t *testing.T) { + root := &config.Root{ + Path: t.TempDir(), + Include: []string{ + "a*.yml", + "b*.yml", + }, + } + + touch(t, root.Path, "a1.yml") + touch(t, root.Path, "b1.yml") + + ms, err := mutator.ProcessRootIncludes().Apply(root) + require.NoError(t, err) + + var names []string + for _, m := range ms { + names = append(names, m.Name()) + } + + assert.Contains(t, names, "ProcessInclude(a1.yml)") + assert.Contains(t, names, "ProcessInclude(b1.yml)") +} + +func TestProcessRootIncludesRemoveDups(t *testing.T) { + root := &config.Root{ + Path: t.TempDir(), + Include: []string{ + "*.yml", + "*.yml", + }, + } + + touch(t, root.Path, "a.yml") + + ms, err := mutator.ProcessRootIncludes().Apply(root) + require.NoError(t, err) + assert.Len(t, ms, 1) + assert.Equal(t, "ProcessInclude(a.yml)", ms[0].Name()) +} diff --git a/bundle/config/mutator/select_environment.go b/bundle/config/mutator/select_environment.go new file mode 100644 index 000000000..3b99b4563 --- /dev/null +++ b/bundle/config/mutator/select_environment.go @@ -0,0 +1,44 @@ +package mutator + +import ( + "fmt" + + "github.com/databricks/bricks/bundle/config" +) + +type selectEnvironment struct { + name string +} + +// SelectEnvironment merges the specified environment into the root configuration. +func SelectEnvironment(name string) Mutator { + return &selectEnvironment{ + name: name, + } +} + +func (m *selectEnvironment) Name() string { + return fmt.Sprintf("SelectEnvironment(%s)", m.name) +} + +func (m *selectEnvironment) Apply(root *config.Root) ([]Mutator, error) { + if root.Environments == nil { + return nil, fmt.Errorf("no environments defined") + } + + // Get specified environment + env, ok := root.Environments[m.name] + if !ok { + return nil, fmt.Errorf("%s: no such environment", m.name) + } + + // Merge specified environment into root configuration structure. + err := root.MergeEnvironment(env) + if err != nil { + return nil, err + } + + // Clear environments after loading. + root.Environments = nil + return nil, nil +} diff --git a/bundle/config/mutator/select_environment_test.go b/bundle/config/mutator/select_environment_test.go new file mode 100644 index 000000000..eaa98b18f --- /dev/null +++ b/bundle/config/mutator/select_environment_test.go @@ -0,0 +1,38 @@ +package mutator_test + +import ( + "testing" + + "github.com/databricks/bricks/bundle/config" + "github.com/databricks/bricks/bundle/config/mutator" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSelectEnvironment(t *testing.T) { + root := &config.Root{ + Workspace: config.Workspace{ + Host: "foo", + }, + Environments: map[string]*config.Environment{ + "default": { + Workspace: &config.Workspace{ + Host: "bar", + }, + }, + }, + } + _, err := mutator.SelectEnvironment("default").Apply(root) + require.NoError(t, err) + assert.Equal(t, "bar", root.Workspace.Host) +} + +func TestSelectEnvironmentNotFound(t *testing.T) { + root := &config.Root{ + Environments: map[string]*config.Environment{ + "default": {}, + }, + } + _, err := mutator.SelectEnvironment("doesnt-exist").Apply(root) + require.Error(t, err, "no environments defined") +} diff --git a/bundle/config/resources.go b/bundle/config/resources.go new file mode 100644 index 000000000..6728252b6 --- /dev/null +++ b/bundle/config/resources.go @@ -0,0 +1,9 @@ +package config + +import "github.com/databricks/bricks/bundle/config/resources" + +// Resources defines Databricks resources associated with the bundle. +type Resources struct { + Jobs map[string]resources.Job `json:"jobs,omitempty"` + Pipelines map[string]resources.Pipeline `json:"pipelines,omitempty"` +} diff --git a/bundle/config/resources/job.go b/bundle/config/resources/job.go new file mode 100644 index 000000000..f10c36fe5 --- /dev/null +++ b/bundle/config/resources/job.go @@ -0,0 +1,5 @@ +package resources + +type Job struct { + ID string `json:"id,omitempty"` +} diff --git a/bundle/config/resources/pipeline.go b/bundle/config/resources/pipeline.go new file mode 100644 index 000000000..cc3df650b --- /dev/null +++ b/bundle/config/resources/pipeline.go @@ -0,0 +1,5 @@ +package resources + +type Pipeline struct { + ID string `json:"id,omitempty"` +} diff --git a/bundle/config/root.go b/bundle/config/root.go new file mode 100644 index 000000000..effabfdf1 --- /dev/null +++ b/bundle/config/root.go @@ -0,0 +1,109 @@ +package config + +import ( + "os" + "path/filepath" + + "github.com/ghodss/yaml" + "github.com/imdario/mergo" +) + +// FileName is the name of bundle configuration file. +const FileName = "bundle.yml" + +type Root struct { + // Path contains the directory path to the root of the bundle. + // It is set when loading `bundle.yml`. + Path string `json:"-"` + + // Bundle contains details about this bundle, such as its name, + // version of the spec (TODO), default cluster, default warehouse, etc. + Bundle Bundle `json:"bundle"` + + // Include specifies a list of patterns of file names to load and + // merge into the this configuration. If not set in `bundle.yml`, + // it defaults to loading `*.yml` and `*/*.yml`. + // + // Also see [mutator.DefineDefaultInclude]. + // + Include []string `json:"include,omitempty"` + + // Workspace contains details about the workspace to connect to + // and paths in the workspace tree to use for this bundle. + Workspace Workspace `json:"workspace"` + + // Resources contains a description of all Databricks resources + // to deploy in this bundle (e.g. jobs, pipelines, etc.). + Resources Resources `json:"resources"` + + // Environments can be used to differentiate settings and resources between + // bundle deployment environments (e.g. development, staging, production). + // If not specified, the code below initializes this field with a + // single default-initialized environment called "default". + Environments map[string]*Environment `json:"environments,omitempty"` +} + +func Load(path string) (*Root, error) { + var r Root + + stat, err := os.Stat(path) + if err != nil { + return nil, err + } + + // If we were given a directory, assume this is the bundle root. + if stat.IsDir() { + r.Path = path + path = filepath.Join(path, FileName) + } + + if err := r.Load(path); err != nil { + return nil, err + } + + return &r, nil +} + +func (r *Root) Load(file string) error { + raw, err := os.ReadFile(file) + if err != nil { + return err + } + err = yaml.Unmarshal(raw, r) + if err != nil { + return err + } + return nil +} + +func (r *Root) Merge(other *Root) error { + // TODO: define and test semantics for merging. + return mergo.MergeWithOverwrite(r, other) +} + +func (r *Root) MergeEnvironment(env *Environment) error { + var err error + + if env.Bundle != nil { + err = mergo.MergeWithOverwrite(&r.Bundle, env.Bundle) + if err != nil { + return err + } + } + + if env.Workspace != nil { + err = mergo.MergeWithOverwrite(&r.Workspace, env.Workspace) + if err != nil { + return err + } + } + + if env.Resources != nil { + err = mergo.MergeWithOverwrite(&r.Resources, env.Resources) + if err != nil { + return err + } + } + + return nil +} diff --git a/bundle/config/root_test.go b/bundle/config/root_test.go new file mode 100644 index 000000000..596b57da7 --- /dev/null +++ b/bundle/config/root_test.go @@ -0,0 +1,72 @@ +package config + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRootMarshalUnmarshal(t *testing.T) { + // Marshal empty + buf, err := json.Marshal(&Root{}) + require.NoError(t, err) + + // Unmarshal empty + var root Root + err = json.Unmarshal(buf, &root) + require.NoError(t, err) + + // Compare + assert.True(t, reflect.DeepEqual(Root{}, root)) +} + +func TestRootLoad(t *testing.T) { + root := &Root{} + err := root.Load("./tests/basic/bundle.yml") + require.NoError(t, err) + assert.Equal(t, "basic", root.Bundle.Name) +} + +func TestRootMergeStruct(t *testing.T) { + root := &Root{ + Workspace: Workspace{ + Host: "foo", + Profile: "profile", + }, + } + other := &Root{ + Workspace: Workspace{ + Host: "bar", + }, + } + assert.NoError(t, root.Merge(other)) + assert.Equal(t, "bar", root.Workspace.Host) + assert.Equal(t, "profile", root.Workspace.Profile) +} + +func TestRootMergeMap(t *testing.T) { + root := &Root{ + Environments: map[string]*Environment{ + "development": { + Workspace: &Workspace{ + Host: "foo", + Profile: "profile", + }, + }, + }, + } + other := &Root{ + Environments: map[string]*Environment{ + "development": { + Workspace: &Workspace{ + Host: "bar", + }, + }, + }, + } + assert.NoError(t, root.Merge(other)) + assert.Equal(t, &Workspace{Host: "bar", Profile: "profile"}, root.Environments["development"].Workspace) +} diff --git a/bundle/config/tests/README.md b/bundle/config/tests/README.md new file mode 100644 index 000000000..b50570199 --- /dev/null +++ b/bundle/config/tests/README.md @@ -0,0 +1,4 @@ +# Bundle configuration tests + +Every test here uses an example bundle configuration. +Each bundle configuration is located in a dedicated subdirectory. diff --git a/bundle/config/tests/basic/bundle.yml b/bundle/config/tests/basic/bundle.yml new file mode 100644 index 000000000..de02d20bc --- /dev/null +++ b/bundle/config/tests/basic/bundle.yml @@ -0,0 +1,2 @@ +bundle: + name: basic diff --git a/bundle/config/tests/basic_test.go b/bundle/config/tests/basic_test.go new file mode 100644 index 000000000..2a078594e --- /dev/null +++ b/bundle/config/tests/basic_test.go @@ -0,0 +1,12 @@ +package config_tests + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBasic(t *testing.T) { + root := load(t, "./basic") + assert.Equal(t, "basic", root.Bundle.Name) +} diff --git a/bundle/config/tests/environment_overrides/bundle.yml b/bundle/config/tests/environment_overrides/bundle.yml new file mode 100644 index 000000000..78a2b42bf --- /dev/null +++ b/bundle/config/tests/environment_overrides/bundle.yml @@ -0,0 +1,14 @@ +bundle: + name: environment_overrides + +workspace: + host: https://acme.cloud.databricks.com/ + +environments: + development: + workspace: + host: https://development.acme.cloud.databricks.com/ + + staging: + workspace: + host: https://staging.acme.cloud.databricks.com/ diff --git a/bundle/config/tests/environment_overrides_test.go b/bundle/config/tests/environment_overrides_test.go new file mode 100644 index 000000000..9fbf235f2 --- /dev/null +++ b/bundle/config/tests/environment_overrides_test.go @@ -0,0 +1,14 @@ +package config_tests + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEnvironmentOverridesDev(t *testing.T) { + development := loadEnvironment(t, "./environment_overrides", "development") + assert.Equal(t, "https://development.acme.cloud.databricks.com/", development.Workspace.Host) + staging := loadEnvironment(t, "./environment_overrides", "staging") + assert.Equal(t, "https://staging.acme.cloud.databricks.com/", staging.Workspace.Host) +} diff --git a/bundle/config/tests/include_default/bundle.yml b/bundle/config/tests/include_default/bundle.yml new file mode 100644 index 000000000..62d99e509 --- /dev/null +++ b/bundle/config/tests/include_default/bundle.yml @@ -0,0 +1,2 @@ +bundle: + name: include_default diff --git a/bundle/config/tests/include_default/my_first_job/resource.yml b/bundle/config/tests/include_default/my_first_job/resource.yml new file mode 100644 index 000000000..c2be5a160 --- /dev/null +++ b/bundle/config/tests/include_default/my_first_job/resource.yml @@ -0,0 +1,4 @@ +resources: + jobs: + my_first_job: + id: 1 diff --git a/bundle/config/tests/include_default/my_second_job/resource.yml b/bundle/config/tests/include_default/my_second_job/resource.yml new file mode 100644 index 000000000..2c28c4622 --- /dev/null +++ b/bundle/config/tests/include_default/my_second_job/resource.yml @@ -0,0 +1,4 @@ +resources: + jobs: + my_second_job: + id: 2 diff --git a/bundle/config/tests/include_default_test.go b/bundle/config/tests/include_default_test.go new file mode 100644 index 000000000..e154b69af --- /dev/null +++ b/bundle/config/tests/include_default_test.go @@ -0,0 +1,20 @@ +package config_tests + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/exp/maps" +) + +func TestIncludeDefault(t *testing.T) { + root := load(t, "./include_default") + + // Test that both jobs were loaded. + keys := maps.Keys(root.Resources.Jobs) + sort.Strings(keys) + assert.Equal(t, []string{"my_first_job", "my_second_job"}, keys) + assert.Equal(t, "1", root.Resources.Jobs["my_first_job"].ID) + assert.Equal(t, "2", root.Resources.Jobs["my_second_job"].ID) +} diff --git a/bundle/config/tests/include_override/bundle.yml b/bundle/config/tests/include_override/bundle.yml new file mode 100644 index 000000000..02de362cd --- /dev/null +++ b/bundle/config/tests/include_override/bundle.yml @@ -0,0 +1,7 @@ +bundle: + name: include_override + +# Setting this explicitly means default globs are not processed. +# As a result, ./this_file_isnt_included.yml isn't included. +include: + - doesnt-exist/*.yml diff --git a/bundle/config/tests/include_override/this_file_isnt_included.yml b/bundle/config/tests/include_override/this_file_isnt_included.yml new file mode 100644 index 000000000..c9ba1452f --- /dev/null +++ b/bundle/config/tests/include_override/this_file_isnt_included.yml @@ -0,0 +1,4 @@ +resources: + jobs: + this_job_isnt_defined: + id: 1 diff --git a/bundle/config/tests/include_override_test.go b/bundle/config/tests/include_override_test.go new file mode 100644 index 000000000..8779c6b62 --- /dev/null +++ b/bundle/config/tests/include_override_test.go @@ -0,0 +1,12 @@ +package config_tests + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIncludeOverride(t *testing.T) { + root := load(t, "./include_override") + assert.Empty(t, root.Resources.Jobs) +} diff --git a/bundle/config/tests/loader.go b/bundle/config/tests/loader.go new file mode 100644 index 000000000..1cc5ffc3a --- /dev/null +++ b/bundle/config/tests/loader.go @@ -0,0 +1,24 @@ +package config_tests + +import ( + "testing" + + "github.com/databricks/bricks/bundle/config" + "github.com/databricks/bricks/bundle/config/mutator" + "github.com/stretchr/testify/require" +) + +func load(t *testing.T, path string) *config.Root { + root, err := config.Load(path) + require.NoError(t, err) + err = mutator.Apply(root, mutator.DefaultMutators()) + require.NoError(t, err) + return root +} + +func loadEnvironment(t *testing.T, path, env string) *config.Root { + root := load(t, path) + err := mutator.Apply(root, []mutator.Mutator{mutator.SelectEnvironment(env)}) + require.NoError(t, err) + return root +} diff --git a/bundle/config/workspace.go b/bundle/config/workspace.go new file mode 100644 index 000000000..7e777db71 --- /dev/null +++ b/bundle/config/workspace.go @@ -0,0 +1,8 @@ +package config + +// Workspace defines configurables at the workspace level. +type Workspace struct { + // TODO: Add all unified authentication configurables. + Host string `json:"host,omitempty"` + Profile string `json:"profile,omitempty"` +} diff --git a/bundle/root.go b/bundle/root.go new file mode 100644 index 000000000..3b4d79141 --- /dev/null +++ b/bundle/root.go @@ -0,0 +1,38 @@ +package bundle + +import ( + "fmt" + "os" + + "github.com/databricks/bricks/bundle/config" + "github.com/databricks/bricks/folders" +) + +const envBundleRoot = "BUNDLE_ROOT" + +// getRoot returns the bundle root. +// If the `BUNDLE_ROOT` environment variable is set, we assume its value +// to be a valid bundle root. Otherwise we try to find it by traversing +// the path and looking for a project configuration file. +func getRoot() (string, error) { + path, ok := os.LookupEnv(envBundleRoot) + if ok { + stat, err := os.Stat(path) + if err == nil && !stat.IsDir() { + err = fmt.Errorf("not a directory") + } + if err != nil { + return "", fmt.Errorf(`invalid bundle root %s="%s": %w`, envBundleRoot, path, err) + } + return path, nil + } + wd, err := os.Getwd() + if err != nil { + return "", err + } + path, err = folders.FindDirWithLeaf(wd, config.FileName) + if err != nil { + return "", fmt.Errorf(`unable to locate bundle root`) + } + return path, nil +} diff --git a/bundle/root_test.go b/bundle/root_test.go new file mode 100644 index 000000000..323a2a79a --- /dev/null +++ b/bundle/root_test.go @@ -0,0 +1,104 @@ +package bundle + +import ( + "os" + "path/filepath" + "testing" + + "github.com/databricks/bricks/bundle/config" + "github.com/stretchr/testify/require" +) + +// Changes into specified directory for the duration of the test. +// Returns the current working directory. +func chdir(t *testing.T, dir string) string { + wd, err := os.Getwd() + require.NoError(t, err) + + abs, err := filepath.Abs(dir) + require.NoError(t, err) + + err = os.Chdir(abs) + require.NoError(t, err) + + t.Cleanup(func() { + err := os.Chdir(wd) + require.NoError(t, err) + }) + + return wd +} + +func TestRootFromEnv(t *testing.T) { + dir := t.TempDir() + t.Setenv(envBundleRoot, dir) + + // It should pull the root from the environment variable. + root, err := getRoot() + require.NoError(t, err) + require.Equal(t, root, dir) +} + +func TestRootFromEnvDoesntExist(t *testing.T) { + dir := t.TempDir() + t.Setenv(envBundleRoot, filepath.Join(dir, "doesntexist")) + + // It should pull the root from the environment variable. + _, err := getRoot() + require.Errorf(t, err, "invalid bundle root") +} + +func TestRootFromEnvIsFile(t *testing.T) { + dir := t.TempDir() + f, err := os.Create(filepath.Join(dir, "invalid")) + require.NoError(t, err) + f.Close() + t.Setenv(envBundleRoot, f.Name()) + + // It should pull the root from the environment variable. + _, err = getRoot() + require.Errorf(t, err, "invalid bundle root") +} + +func TestRootIfEnvIsEmpty(t *testing.T) { + dir := "" + t.Setenv(envBundleRoot, dir) + + // It should pull the root from the environment variable. + _, err := getRoot() + require.Errorf(t, err, "invalid bundle root") +} + +func TestRootLookup(t *testing.T) { + // Have to set then unset to allow the testing package to revert it to its original value. + t.Setenv(envBundleRoot, "") + os.Unsetenv(envBundleRoot) + + chdir(t, t.TempDir()) + + // Create bundle.yml file. + f, err := os.Create(config.FileName) + require.NoError(t, err) + defer f.Close() + + // Create directory tree. + err = os.MkdirAll("./a/b/c", 0755) + require.NoError(t, err) + + // It should find the project root from $PWD. + wd := chdir(t, "./a/b/c") + root, err := getRoot() + require.NoError(t, err) + require.Equal(t, wd, root) +} + +func TestRootLookupError(t *testing.T) { + // Have to set then unset to allow the testing package to revert it to its original value. + t.Setenv(envBundleRoot, "") + os.Unsetenv(envBundleRoot) + + // It can't find a project root from a temporary directory. + _ = chdir(t, t.TempDir()) + _, err := getRoot() + require.ErrorContains(t, err, "unable to locate bundle root") +} diff --git a/go.mod b/go.mod index 09b9a2d2f..920467e34 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/golang/protobuf v1.5.2 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.0 // indirect + github.com/imdario/mergo v0.3.13 github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/go.sum b/go.sum index b336c2f6c..b68185128 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,8 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/googleapis/enterprise-certificate-proxy v0.2.0 h1:y8Yozv7SZtlU//QXbezB6QkpuE6jMD2/gfzk4AftXjs= github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg= github.com/googleapis/gax-go/v2 v2.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ= +github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= +github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= @@ -182,6 +184,7 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=