Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reading variables from file #2171

Open
wants to merge 28 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a794490
feat: Read variables from file
ilyakuz-db Jan 16, 2025
3f0b5f8
test: Acceptance test for flag overrides
ilyakuz-db Jan 16, 2025
a75c6af
fix: Rename to --var-file
ilyakuz-db Jan 17, 2025
66dae81
fix: More detailed error for json parsing
ilyakuz-db Jan 17, 2025
d0db1d7
fix: Cleanup
ilyakuz-db Jan 17, 2025
2c9adcb
fix: More acceptance tests
ilyakuz-db Jan 17, 2025
15c9c5c
fix: Flaky test
ilyakuz-db Jan 18, 2025
01a6a66
fix: Remove extra tests
ilyakuz-db Jan 20, 2025
c5109cd
Merge branch 'main' of github.com:databricks/cli into feat/variable-f…
ilyakuz-db Jan 20, 2025
f52423c
fix: Update `help` snapshots
ilyakuz-db Jan 20, 2025
2a1c4d1
fix: Message in test
ilyakuz-db Jan 20, 2025
bdb8f1e
fix: Message in acceptance tests
ilyakuz-db Jan 20, 2025
19b5018
fix: Flaky test
ilyakuz-db Jan 20, 2025
44aba7e
fix: Remove platform specific part from output
ilyakuz-db Jan 20, 2025
e4164c1
fix: Try `jq -b` to preserve window output
ilyakuz-db Jan 20, 2025
bfec3c4
Merge branch 'main' of github.com:databricks/cli into feat/variable-f…
ilyakuz-db Jan 20, 2025
6dd8fc2
fix: Less flaky sed execution
ilyakuz-db Jan 21, 2025
1656bf5
fix: Remove redundant test
ilyakuz-db Jan 21, 2025
422585c
feat: Default value for variable path
ilyakuz-db Jan 21, 2025
47c8fb3
chore: Regenerate other snapshots
ilyakuz-db Jan 21, 2025
6f20e88
Added annotations to output
ilyakuz-db Jan 21, 2025
ad6343a
Cleanup in flag description
ilyakuz-db Jan 21, 2025
e0c81f0
Regenerate help snapshots
ilyakuz-db Jan 21, 2025
38b7d27
Remove unnecessary locations
ilyakuz-db Jan 21, 2025
3e1a335
Fix test with error message
ilyakuz-db Jan 21, 2025
229fdb2
Use variable-overrides.json name
ilyakuz-db Jan 22, 2025
6e8f5f3
Fix message in test
ilyakuz-db Jan 22, 2025
cf42459
Add file path to parsing error
ilyakuz-db Jan 22, 2025
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
21 changes: 21 additions & 0 deletions acceptance/bundle/variables/flag_overrides/databricks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
bundle:
name: TestResolveVariablesFromFile

variables:
cluster:
type: "complex"
default:
node_type_id: "unused"
cluster_key:
default: "unused"
cluster_workers:
default: 1

resources:
jobs:
job1:
job_clusters:
- job_cluster_key: ${var.cluster_key}
new_cluster:
node_type_id: "${var.cluster.node_type_id}"
num_workers: ${var.cluster_workers}
8 changes: 8 additions & 0 deletions acceptance/bundle/variables/flag_overrides/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"job_cluster_key": "mlops_stacks-cluster",
"new_cluster": {
"node_type_id": "Standard_DS3_v2",
"num_workers": 2
}
}
"mlops_stacks-cluster"
5 changes: 5 additions & 0 deletions acceptance/bundle/variables/flag_overrides/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# variable file
$CLI bundle validate -o json --vars-file-path=vars.json | jq .resources.jobs.job1.job_clusters[0]

# variable flag
$CLI bundle validate -o json --var="cluster_key=mlops_stacks-cluster" | jq .resources.jobs.job1.job_clusters[0].job_cluster_key
Copy link
Contributor

Choose a reason for hiding this comment

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

How about testing

  • both --var and --vars-file-path?
  • file not found
  • file cannot be parsed
  • file has variable name that is not defined
  • file has variable name that is complex but default is string and vice versa
  • variable is required but it's not provided in the file

Copy link
Contributor Author

Choose a reason for hiding this comment

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

file has variable name that is not defined
file has variable name that is complex but default is string and vice versa
variable is required but it's not provided in the file

@denik Not sure about these, they are part of generic variable handling, should they be covered here? It feels like we starting to cover edge cases in place where it shouldn't be tested, WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added tests, but would be nice to hear your opinion on testing edge cases

What I don't like in that approach is that snapshot tests are very easy to add/update but at the same time hard to debug and I've seen many times how such tests became write-only thing when people didn't want to investigate the failure and just updated the snapshot

7 changes: 7 additions & 0 deletions acceptance/bundle/variables/flag_overrides/vars.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"cluster": {
"node_type_id": "Standard_DS3_v2"
},
"cluster_key": "mlops_stacks-cluster",
"cluster_workers": 2
}
16 changes: 16 additions & 0 deletions bundle/config/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,22 @@ func (r *Root) InitializeVariables(vars []string) error {
return nil
}

// Initializes variables parsed from variable file
// Variables can have any type of value, including complex types
func (r *Root) InitializeAnyTypeVariables(vars map[string]any) error {
for name, val := range vars {
if _, ok := r.Variables[name]; !ok {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: instead of assigning to _, let's give it a name and use below to call Set() on it.

return fmt.Errorf("variable %s has not been defined", name)
}

err := r.Variables[name].Set(val)
if err != nil {
return fmt.Errorf("failed to assign %s to %s: %s", val, name, err)
}
}
return nil
}

func (r *Root) Merge(other *Root) error {
// Merge dynamic configuration values.
return r.Mutate(func(root dyn.Value) (dyn.Value, error) {
Expand Down
50 changes: 50 additions & 0 deletions bundle/config/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,56 @@ func TestInitializeVariables(t *testing.T) {
assert.Equal(t, "456", (root.Variables["bar"].Value))
}

func TestInitializeAnyTypeVariables(t *testing.T) {
root := &Root{
Variables: map[string]*variable.Variable{
"string": {
Default: "default",
Description: "string variable",
},
"int": {
Default: 0,
Description: "int variable",
},
"complex": {
Default: []map[string]int{{"a": 1}, {"b": 2}},
Description: "complex variable",
Type: variable.VariableTypeComplex,
},
"unused": {
Default: "should remain default",
Description: "unused variable",
},
},
}

err := root.InitializeAnyTypeVariables(map[string]any{
"string": "value",
"int": 1,
"complex": []map[string]int{{"c": 3}},
})
assert.NoError(t, err)
assert.Equal(t, "value", (root.Variables["string"].Value))
assert.Equal(t, 1, (root.Variables["int"].Value))
assert.Equal(t, []map[string]int{{"c": 3}}, (root.Variables["complex"].Value))
}
Copy link
Contributor

Choose a reason for hiding this comment

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

What does it add on top of acceptance test? I think it can be removed/merged into acceptance test.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Would it be better to also have simple localised check? It also serve showcase purpose

Acceptance test is nice because it's top-level and is really easy to add but at the same time they are harder to debug in case of failure

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added acceptance tests, let me know what you think of keeping these as well

Copy link
Contributor

Choose a reason for hiding this comment

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

If acceptance tests cover the same functionality, I'd remove this ones, because they have non-zero maintenance cost.


func TestInitializeAnyTypeVariablesUndeclared(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here, it would be good to see full output, so acceptance test is preferred. That shows whether you use diagnostics correctly.

root := &Root{
Variables: map[string]*variable.Variable{
"string": {
Default: "default",
Description: "string variable",
},
},
}

err := root.InitializeAnyTypeVariables(map[string]any{
"not_declared": "value",
})
assert.ErrorContains(t, err, "variable not_declared has not been defined")
}

func TestInitializeVariablesWithAnEqualSignInValue(t *testing.T) {
root := &Root{
Variables: map[string]*variable.Variable{
Expand Down
8 changes: 5 additions & 3 deletions bundle/config/variable/variable.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ type Variable struct {
// This field stores the resolved value for the variable. The variable are
// resolved in the following priority order (from highest to lowest)
//
// 1. Command line flag. For example: `--var="foo=bar"`
// 2. Target variable. eg: BUNDLE_VAR_foo=bar
// 3. Default value as defined in the applicable environments block
// 1. Command line flag, one of these is used
// a. Variable value obtained from arguments, example: `--var="foo=bar"`
// b. Variable value obtained from the file, example: `--vars-file-path="/path/to/file"`
// 2. Environment variable. eg: BUNDLE_VAR_foo=bar
// 3. Default value as defined in the applicable targets block
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's keep it in simple and with ordered rule:

  1. Command line flag. For example: `--var="foo=bar"-2
  2. Command line flag with json variables: --varfile
  3. Target variable. eg: BUNDLE_VAR_foo=bar and the test
  4. ...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Let's discuss here whether we need to merge values from flags

// 4. Default value defined in variable definition
// 5. Throw error, since if no default value is defined, then the variable
// is required
Expand Down
42 changes: 40 additions & 2 deletions cmd/bundle/utils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package utils

import (
"context"
"encoding/json"
"fmt"
"os"

"github.com/databricks/cli/bundle"
"github.com/databricks/cli/cmd/root"
Expand All @@ -16,6 +19,30 @@ func configureVariables(cmd *cobra.Command, b *bundle.Bundle, variables []string
})
}

func configureVariablesFromFile(cmd *cobra.Command, b *bundle.Bundle, filePath string) diag.Diagnostics {
return bundle.ApplyFunc(cmd.Context(), b, func(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
f, err := os.ReadFile(filePath)
if err != nil {
return diag.FromErr(fmt.Errorf("failed to read variables file: %w", err))
}

vars := map[string]any{}
err = json.Unmarshal(f, &vars)
if err != nil {
return diag.FromErr(fmt.Errorf("failed to parse variables file: %w", err))
Copy link
Contributor

Choose a reason for hiding this comment

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

If you pass a file with "foo" in there, or a list, this will return an error without suggesting what the right format is. Could you look into using jsonloader here? It would be nice to return a diag.Error with details suggesting what the correct format is, if the contents doesn't match what we expect.

}

if len(vars) > 0 {
err = b.Config.InitializeAnyTypeVariables(vars)
if err != nil {
return diag.FromErr(err)
}
}

return nil
})
}

func ConfigureBundleWithVariables(cmd *cobra.Command) (*bundle.Bundle, diag.Diagnostics) {
// Load bundle config and apply target
b, diags := root.MustConfigureBundle(cmd)
Expand All @@ -27,9 +54,20 @@ func ConfigureBundleWithVariables(cmd *cobra.Command) (*bundle.Bundle, diag.Diag
if err != nil {
return b, diag.FromErr(err)
}
variableFilePath, err := cmd.Flags().GetString("vars-file-path")
if err != nil {
return b, diag.FromErr(err)
}

// Initialize variables by assigning them values passed as command line flags
diags = diags.Extend(configureVariables(cmd, b, variables))
if len(variables) > 0 && variableFilePath != "" {
return b, diag.Errorf("cannot specify both --var and --vars-file-path flags")
Copy link
Contributor

Choose a reason for hiding this comment

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

That seems like unnecessary constraint? Why not process one (e.g. file first) and then the other?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

IIRC we discussed on meeting that it may cause confusion in some edge cases but with low profit:

  1. is it be obvious to give priority to second arg?
  2. since both flags might be partial - should we merge them or not
  3. non-obvious type mismatch between values of these flags (--var is always string)

Copy link
Contributor

@denik denik Jan 17, 2025

Choose a reason for hiding this comment

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

is it be obvious to give priority to second arg?

What do you mean by second? Simple thing would be to have fixed priority: --var takes over --varfile. Complex but better thing would be to take into account order on the command line --varfile file1 --var ... --varfile file2, but we can leave that out of scope.

since both flags might be partial - should we merge them or not

Yes. Same as we do with env and command line parameters currently, we merge them. Command line take priority of both specify the same var.

non-obvious type mismatch between values of these flags (--var is always string)

Does not matter, we do not do any type of merging or other logic based on type. We just select the value.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What do you mean by second?

Yeah I meant order. I'm not sure whether it's clear to user, they should follow documentation to understand this priority

Copy link
Contributor

Choose a reason for hiding this comment

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

If it's not too hard to implement following command-line arguments order that would be great.

Otherwise fixed order is much better that complete disable --var.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So in general my concern is whether it brings value to user because the use case of using both flags might be not so common but it may add some confusion and code becomes more complex

Variable resolution might feel non-obvious already and it will add more uncertainty

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also if we add this feature now it won't be possible to remove later because of backward-compatibility but if we restrict now we can always allow later if customers ask for that

Copy link
Contributor

Choose a reason for hiding this comment

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

BTW, passing --var twice for the same argument raises an error: #2176

So to be consistent with that I change my suggestion to raise an error if variable is defined in both --var and --varfile. But I still think it should be per-variable check.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Let's discuss in the slack thread? I don't mind adding merge but at the same time I don't want logic to be too complex because we already have multiple layers of overrides and with files added we will also have some type inconsistency between values

Copy link
Contributor

Choose a reason for hiding this comment

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

Discussed offline - we can limit the scope of this PR by erroring in case both --var and --var-file is used and we'll revisit that later.

} else if len(variables) > 0 {
// Initialize variables by assigning them values passed as command line flags
diags = diags.Extend(configureVariables(cmd, b, variables))
} else if variableFilePath != "" {
// Initialize variables by loading them from a file
diags = diags.Extend(configureVariablesFromFile(cmd, b, variableFilePath))
}

return b, diags
}
1 change: 1 addition & 0 deletions cmd/bundle/variables.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ import (

func initVariableFlag(cmd *cobra.Command) {
cmd.PersistentFlags().StringSlice("var", []string{}, `set values for variables defined in bundle config. Example: --var="foo=bar"`)
cmd.PersistentFlags().String("vars-file-path", "", `file path to a JSON file containing variables. Example: --vars-file-path="/path/to/vars.json"`)
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's call this --var-file, shorter and same meaning.

Copy link
Contributor

Choose a reason for hiding this comment

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

+1 or --varfile, don't have to remember to put dash.

}
Loading