Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions acceptance/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"regexp"
"runtime"
"slices"
Expand All @@ -24,6 +25,7 @@ import (
"unicode/utf8"

"github.com/google/uuid"
"gopkg.in/yaml.v3"

"github.com/databricks/cli/acceptance/internal"
"github.com/databricks/cli/internal/testutil"
Expand Down Expand Up @@ -393,6 +395,18 @@ func runTest(t *testing.T,
err = CopyDir(dir, tmpDir, inputs, outputs)
require.NoError(t, err)

bundleConfigTarget := "databricks.yml"
if config.BundleConfigTarget != nil {
bundleConfigTarget = *config.BundleConfigTarget
}

if bundleConfigTarget != "" {
configCreated := applyBundleConfig(t, tmpDir, config.BundleConfig, bundleConfigTarget)
if configCreated {
inputs[bundleConfigTarget] = true
}
}

timeout := config.Timeout

if runtime.GOOS == "windows" {
Expand Down Expand Up @@ -1001,3 +1015,74 @@ func prepareWheelBuildDirectory(t *testing.T, dir string) string {

return latestWheel
}

// Applies BundleConfig setting to file named bundleConfigTarget and updates it in place if there were any changes.
// Returns true if new file was created.
func applyBundleConfig(t *testing.T, tmpDir string, bundleConfig map[string]any, bundleConfigTarget string) bool {
validConfig := make(map[string]map[string]any, len(bundleConfig))

for _, configName := range utils.SortedKeys(bundleConfig) {
configValue := bundleConfig[configName]
// Setting BundleConfig.<name> to empty string disables it.
// This is useful when parent directory defines some config that child test wants to cancel.
if configValue == "" {
continue
}
cfg, ok := configValue.(map[string]any)
if !ok {
t.Fatalf("Unexpected type for BundleConfig.%s: %#v", configName, configValue)
}
validConfig[configName] = cfg
}

if len(validConfig) == 0 {
return false
}

configPath := filepath.Join(tmpDir, bundleConfigTarget)
configData, configExists := tryReading(t, configPath)

newConfigData := configData
var applied []string

for _, configName := range utils.SortedKeys(validConfig) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The keys are the "path" in YAML to apply the override to (e.g. config2.resources.jobs.example_job)?

If so, please include comments or examples of the shape of the input to this function to clarify.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

config2 is not part of yaml, it is the name of the config (for override purposes). There is selftest that shows how it works.

configValue := validConfig[configName]
updated, err := internal.MergeBundleConfig(newConfigData, configValue)
if err != nil {
t.Fatalf("Failed to merge BundleConfig.%s: %s\nvvalue: %#v\ntext:\n%s", configName, err, configValue, newConfigData)
}
if isSameYAMLContent(newConfigData, updated) {
t.Logf("No effective updates from BundleConfig.%s", configName)
} else {
newConfigData = updated
applied = append(applied, configName)
}
}

if newConfigData != configData {
t.Logf("Writing updated bundle config to %s. BundleConfig sections: %s", bundleConfigTarget, strings.Join(applied, ", "))
testutil.WriteFile(t, configPath, newConfigData)
return !configExists
}

return false
}

// Returns true if both strings are deep-equal after unmarshalling
func isSameYAMLContent(str1, str2 string) bool {
var obj1, obj2 any

if str1 == str2 {
return true
}

if err := yaml.Unmarshal([]byte(str1), &obj1); err != nil {
return false
}

if err := yaml.Unmarshal([]byte(str2), &obj2); err != nil {
return false
}

return reflect.DeepEqual(obj1, obj2)
}
38 changes: 38 additions & 0 deletions acceptance/internal/bundle_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package internal

import (
"bytes"

"dario.cat/mergo"
"gopkg.in/yaml.v3"
)

func MergeBundleConfig(source string, bundleConfig map[string]any) (string, error) {
config := make(map[string]any)

err := yaml.Unmarshal([]byte(source), &config)
if err != nil {
return "", err
}

err = mergo.Merge(
&config,
bundleConfig,
mergo.WithoutDereference,
)
if err != nil {
return "", err
}

var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)

err = enc.Encode(config)
if err != nil {
return "", err
}

updated := buf.String()
return updated, nil
}
162 changes: 162 additions & 0 deletions acceptance/internal/bundle_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package internal

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)

func TestMergeBundleConfig(t *testing.T) {
tests := []struct {
name string
initialYaml string
bundleConfig map[string]any
expected map[string]any
}{
{
name: "new_file",
initialYaml: "",
bundleConfig: map[string]any{},
expected: map[string]any{},
},
{
name: "empty_config",
initialYaml: "{}",
bundleConfig: map[string]any{},
expected: map[string]any{},
},
{
name: "simple_top_level",
initialYaml: "{}",
bundleConfig: map[string]any{
"name": "test-bundle",
},
expected: map[string]any{
"name": "test-bundle",
},
},
{
name: "simple_top_level_new_file",
initialYaml: "",
bundleConfig: map[string]any{
"name": "test-bundle",
},
expected: map[string]any{
"name": "test-bundle",
},
},
{
name: "nested_config",
initialYaml: "{}",
bundleConfig: map[string]any{
"bundle": map[string]any{
"name": "test-bundle",
},
},
expected: map[string]any{
"bundle": map[string]any{
"name": "test-bundle",
},
},
},
{
name: "nested_config_new_file",
initialYaml: "",
bundleConfig: map[string]any{
"bundle": map[string]any{
"name": "test-bundle",
},
},
expected: map[string]any{
"bundle": map[string]any{
"name": "test-bundle",
},
},
},
{
name: "merge_with_existing_config",
initialYaml: `
bundle:
name: original-name
target: dev
`,
bundleConfig: map[string]any{
"bundle": map[string]any{
"name": "default-name",
},
},
expected: map[string]any{
"bundle": map[string]any{
"name": "original-name",
"target": "dev",
},
},
},
{
name: "merge_with_existing_config_2",
initialYaml: `resources: {}`,
bundleConfig: map[string]any{
"bundle": map[string]any{
"name": "new-name",
},
},
expected: map[string]any{
"bundle": map[string]any{
"name": "new-name",
},
"resources": map[string]any{},
},
},

{
name: "multiple_nested_levels",
initialYaml: `
resources:
jobs:
myjob:
hello: world
pipelines:
mypipeline:
name: 123
`,
bundleConfig: map[string]any{
"resources": map[string]any{
"jobs": map[string]any{
"myjob": map[string]any{
"name": "My Job",
},
},
},
},
expected: map[string]any{
"resources": map[string]any{
"jobs": map[string]any{
"myjob": map[string]any{
"name": "My Job",
"hello": "world",
},
},
"pipelines": map[string]any{
"mypipeline": map[string]any{
"name": 123,
},
},
},
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, err := MergeBundleConfig(tt.initialYaml, tt.bundleConfig)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this function take a map[string]any as input instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it already does?

bundle_config.go:func MergeBundleConfig(source string, bundleConfig map[string]any) (string, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean for "source". The mix of unmarshalling and receiving an object is confusing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you mean, but there is some logic to it: the function receives config as a string and returns new updated config as a string. This allows it to encapsulate (and potentially have test cases for) details related to yaml parsing, such as whether it is strict or not, whether it preserves comments. The test runner on the other hand knows which file we're reading and writing and handles all I/O.

The fact that it also receives unmarshalled bundleConfig is just because we do all of unmarshalling of config in one place and there is no other way to receive bundleConfig.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a lot of unnecessary marshalling / unmarshalling going on with this implementation though. It works for now and does not cause noticeable overhead, but worth rewriting a bit (for later).

assert.NoError(t, err)

var result map[string]any
require.NoError(t, yaml.Unmarshal([]byte(out), &result))

assert.Equal(t, tt.expected, result)
})
}
}
28 changes: 26 additions & 2 deletions acceptance/internal/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,27 @@ type TestConfig struct {
// For cloud tests, max(Timeout, TimeoutCloud) is used for timeout
// For cloud+windows tests, max(Timeout, TimeoutWindows, TimeoutCloud) is used for timeout
TimeoutCloud time.Duration

// Maps a name (arbitrary string, can be used to override/disable setting in a child config) to a mapping that specifies how
// to update databricks.yml (or other file designated by BundleConfigTarget).
// Example:
// BundleConfig.header.bundle.name = "test-bundle"
// This overwrite bundle.name in the databricks.yml, so you can omit adding """
// bundle:
// name: somename
// """ to every test.
// If child config wants to disable or override this, they can simply do
// BundleConfig.header = ""
BundleConfig map[string]any

// Target config for BundleConfig updates. Empty string disables BundleConfig updates.
// Null means "databricks.yml"
BundleConfigTarget *string

// To be added:
// BundleConfigMatrix is to BundleConfig what EnvMatrix is to Env
// It creates different tests for each possible configuration update.
// BundleConfigMatrix map[string][]any
}

type ServerStub struct {
Expand Down Expand Up @@ -192,8 +213,11 @@ func DoLoadConfig(t *testing.T, path string) TestConfig {
require.NoError(t, err, "Failed to parse config %s", path)

keys := meta.Undecoded()
if len(keys) > 0 {
t.Fatalf("Undecoded keys in %s: %#v", path, keys)
for ind, key := range keys {
if len(key) > 0 && key[0] == "BundleConfig" {
continue
}
t.Errorf("Undecoded key in %s[%d]: %#v", path, ind, key)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does "undecoded" mean? How do these keys now end up in the config struct?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's means a key in TOML file was not mapped to any struct field.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But how does the "BundleConfig" map get populated if it is not decoded?

I would expect either:

  1. The "BundleConfig" field to be populated with the config and this code to never execute
  2. This code to execute and the "BundleConfig" field to remain empty

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question! This seems to be an issue with toml parser's handling of map[string]any type.

Somehow it decodes it but also complains about it, This is what happens if I remove that check:

    --- FAIL: TestAccept/selftest/bundleconfig/override (0.02s)
        config.go:221: Undecoded key in selftest/bundleconfig/test.toml[0]: toml.Key{"BundleConfig", "config1", "bundle"}
        config.go:221: Undecoded key in selftest/bundleconfig/test.toml[1]: toml.Key{"BundleConfig", "config1", "bundle", "name"}
        config.go:221: Undecoded key in selftest/bundleconfig/test.toml[2]: toml.Key{"BundleConfig", "config2", "resources", "jobs", "example_job"}
        config.go:221: Undecoded key in selftest/bundleconfig/test.toml[3]: toml.Key{"BundleConfig", "config2", "resources", "jobs", "example_job", "name"}
        config.go:221: Undecoded key in selftest/bundleconfig/test.toml[4]: toml.Key{"BundleConfig", "config2", "resources", "jobs", "example_job", "new_string"}
        config.go:221: Undecoded key in selftest/bundleconfig/test.toml[5]: toml.Key{"BundleConfig", "config2", "resources", "jobs", "example_job", "new_list"}
        config.go:221: Undecoded key in selftest/bundleconfig/test.toml[6]: toml.Key{"BundleConfig", "config2", "resources", "jobs", "example_job", "new_map"}
        config.go:221: Undecoded key in selftest/bundleconfig/test.toml[7]: toml.Key{"BundleConfig", "config2", "resources", "jobs", "example_job", "new_map", "key"}
        config.go:221: Undecoded key in selftest/bundleconfig/test.toml[8]: toml.Key{"BundleConfig", "config2", "resources", "jobs", "example_job", "list2"}
        config.go:221: Undecoded key in selftest/bundleconfig/test.toml[9]: toml.Key{"BundleConfig", "config2", "resources", "jobs", "example_job", "string2"}

}

return config
Expand Down
16 changes: 16 additions & 0 deletions acceptance/selftest/bundleconfig/different_target/out.my.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
bundle:
name: my-bundle
resources:
jobs:
example_job:
list2: 123
name: Example Job
new_list:
- abc
- def
new_map:
key: value
new_string: hello
string2:
- item1
- item2
Empty file.
1 change: 1 addition & 0 deletions acceptance/selftest/bundleconfig/different_target/script
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
mv my.yml out.my.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
BundleConfigTarget = "my.yml"
1 change: 1 addition & 0 deletions acceptance/selftest/bundleconfig/disabled1/databricks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# should not be changed
1 change: 1 addition & 0 deletions acceptance/selftest/bundleconfig/disabled1/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# should not be changed
1 change: 1 addition & 0 deletions acceptance/selftest/bundleconfig/disabled1/script
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
cat databricks.yml
1 change: 1 addition & 0 deletions acceptance/selftest/bundleconfig/disabled1/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
BundleConfigTarget = ""
1 change: 1 addition & 0 deletions acceptance/selftest/bundleconfig/disabled2/databricks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# should not be changed
1 change: 1 addition & 0 deletions acceptance/selftest/bundleconfig/disabled2/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# should not be changed
1 change: 1 addition & 0 deletions acceptance/selftest/bundleconfig/disabled2/script
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
cat databricks.yml
2 changes: 2 additions & 0 deletions acceptance/selftest/bundleconfig/disabled2/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
BundleConfig.config1 = ""
BundleConfig.config2 = ""
Loading
Loading