-
Notifications
You must be signed in to change notification settings - Fork 64
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
base: main
Are you sure you want to change the base?
Changes from 2 commits
a794490
3f0b5f8
a75c6af
66dae81
d0db1d7
2c9adcb
15c9c5c
01a6a66
c5109cd
f52423c
2a1c4d1
bdb8f1e
19b5018
44aba7e
e4164c1
bfec3c4
6dd8fc2
1656bf5
422585c
47c8fb3
6f20e88
ad6343a
e0c81f0
38b7d27
3e1a335
229fdb2
6e8f5f3
cf42459
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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} |
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" |
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 | ||
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 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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)) | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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{ | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's keep it in simple and with ordered rule:
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,6 +2,9 @@ package utils | |
|
||
import ( | ||
"context" | ||
"encoding/json" | ||
"fmt" | ||
"os" | ||
|
||
"github.com/databricks/cli/bundle" | ||
"github.com/databricks/cli/cmd/root" | ||
|
@@ -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)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you pass a file with |
||
} | ||
|
||
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) | ||
|
@@ -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") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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.
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.
Does not matter, we do not do any type of merging or other logic based on type. We just select the value. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yeah I meant order. I'm not sure whether it's clear to user, they should follow documentation to understand this priority There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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"`) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's call this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 or --varfile, don't have to remember to put dash. |
||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about testing
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@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?
There was a problem hiding this comment.
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