Skip to content

Commit

Permalink
Use dyn.Value as input to generating Terraform JSON (#1218)
Browse files Browse the repository at this point in the history
## Changes

This builds on #1098 and uses the `dyn.Value` representation of the
bundle configuration to generate the Terraform JSON definition of
resources in the bundle.

The existing code (in `BundleToTerraform`) was not great and in an
effort to slightly improve this, I added a package `tfdyn` that includes
dedicated files for each resource type. Every resource type has its own
conversion type that takes the `dyn.Value` of the bundle-side resource
and converts it into Terraform resources (e.g. a job and optionally its
permissions).

Because we now use a `dyn.Value` as input, we can represent and emit
zero-values that have so far been omitted. For example, setting
`num_workers: 0` in your bundle configuration now propagates all the way
to the Terraform JSON definition.

## Tests

* Unit tests for every converter. I reused the test inputs from
`convert_test.go`.
* Equivalence tests in every existing test case checks that the
resulting JSON is identical.
* I manually compared the TF JSON file generated by the CLI from the
main branch and from this PR on all of our bundles and bundle examples
(internal and external) and found the output doesn't change (with the
exception of the odd zero-value being included by the version in this
PR).
  • Loading branch information
pietern authored Feb 16, 2024
1 parent 87dd46a commit f70ec35
Show file tree
Hide file tree
Showing 22 changed files with 1,291 additions and 1 deletion.
61 changes: 61 additions & 0 deletions bundle/deploy/terraform/convert.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package terraform

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

"github.com/databricks/cli/bundle/config"
"github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/cli/bundle/deploy/terraform/tfdyn"
"github.com/databricks/cli/bundle/internal/tf/schema"
"github.com/databricks/cli/libs/dyn"
tfjson "github.com/hashicorp/terraform-json"
)

Expand Down Expand Up @@ -228,6 +231,64 @@ func BundleToTerraform(config *config.Root) *schema.Root {
return tfroot
}

// BundleToTerraformWithDynValue converts resources in a bundle configuration
// to the equivalent Terraform JSON representation.
func BundleToTerraformWithDynValue(ctx context.Context, root dyn.Value) (*schema.Root, error) {
tfroot := schema.NewRoot()
tfroot.Provider = schema.NewProviders()

// Convert each resource in the bundle to the equivalent Terraform representation.
resources, err := dyn.Get(root, "resources")
if err != nil {
// If the resources key is missing, return an empty root.
if dyn.IsNoSuchKeyError(err) {
return tfroot, nil
}
return nil, err
}

tfroot.Resource = schema.NewResources()

numResources := 0
_, err = dyn.Walk(resources, func(p dyn.Path, v dyn.Value) (dyn.Value, error) {
if len(p) < 2 {
return v, nil
}

typ := p[0].Key()
key := p[1].Key()

// Lookup the converter based on the resource type.
c, ok := tfdyn.GetConverter(typ)
if !ok {
return dyn.InvalidValue, fmt.Errorf("no converter for resource type %s", typ)
}

// Convert resource to Terraform representation.
err := c.Convert(ctx, key, v, tfroot.Resource)
if err != nil {
return dyn.InvalidValue, err
}

numResources++

// Skip traversal of the resource itself.
return v, dyn.ErrSkip
})
if err != nil {
return nil, err
}

// We explicitly set "resource" to nil to omit it from a JSON encoding.
// This is required because the terraform CLI requires >= 1 resources defined
// if the "resource" property is used in a .tf.json file.
if numResources == 0 {
tfroot.Resource = nil
}

return tfroot, nil
}

func TerraformToBundle(state *tfjson.State, config *config.Root) error {
if state.Values != nil && state.Values.RootModule != nil {
for _, resource := range state.Values.RootModule.Resources {
Expand Down
64 changes: 64 additions & 0 deletions bundle/deploy/terraform/convert_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package terraform

import (
"context"
"encoding/json"
"reflect"
"testing"

"github.com/databricks/cli/bundle/config"
"github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/cli/bundle/internal/tf/schema"
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/dyn/convert"
"github.com/databricks/databricks-sdk-go/service/catalog"
"github.com/databricks/databricks-sdk-go/service/compute"
"github.com/databricks/databricks-sdk-go/service/jobs"
Expand Down Expand Up @@ -65,6 +69,8 @@ func TestBundleToTerraformJob(t *testing.T) {
assert.Equal(t, "param1", resource.Parameter[0].Name)
assert.Equal(t, "param2", resource.Parameter[1].Name)
assert.Nil(t, out.Data)

bundleToTerraformEquivalenceTest(t, &config)
}

func TestBundleToTerraformJobPermissions(t *testing.T) {
Expand Down Expand Up @@ -92,6 +98,8 @@ func TestBundleToTerraformJobPermissions(t *testing.T) {
assert.Len(t, resource.AccessControl, 1)
assert.Equal(t, "jane@doe.com", resource.AccessControl[0].UserName)
assert.Equal(t, "CAN_VIEW", resource.AccessControl[0].PermissionLevel)

bundleToTerraformEquivalenceTest(t, &config)
}

func TestBundleToTerraformJobTaskLibraries(t *testing.T) {
Expand Down Expand Up @@ -128,6 +136,8 @@ func TestBundleToTerraformJobTaskLibraries(t *testing.T) {
require.Len(t, resource.Task, 1)
require.Len(t, resource.Task[0].Library, 1)
assert.Equal(t, "mlflow", resource.Task[0].Library[0].Pypi.Package)

bundleToTerraformEquivalenceTest(t, &config)
}

func TestBundleToTerraformPipeline(t *testing.T) {
Expand Down Expand Up @@ -188,6 +198,8 @@ func TestBundleToTerraformPipeline(t *testing.T) {
assert.Equal(t, resource.Notification[1].Alerts, []string{"on-update-failure", "on-flow-failure"})
assert.Equal(t, resource.Notification[1].EmailRecipients, []string{"jane@doe.com", "john@doe.com"})
assert.Nil(t, out.Data)

bundleToTerraformEquivalenceTest(t, &config)
}

func TestBundleToTerraformPipelinePermissions(t *testing.T) {
Expand Down Expand Up @@ -215,6 +227,8 @@ func TestBundleToTerraformPipelinePermissions(t *testing.T) {
assert.Len(t, resource.AccessControl, 1)
assert.Equal(t, "jane@doe.com", resource.AccessControl[0].UserName)
assert.Equal(t, "CAN_VIEW", resource.AccessControl[0].PermissionLevel)

bundleToTerraformEquivalenceTest(t, &config)
}

func TestBundleToTerraformModel(t *testing.T) {
Expand Down Expand Up @@ -254,10 +268,15 @@ func TestBundleToTerraformModel(t *testing.T) {
assert.Equal(t, "k2", resource.Tags[1].Key)
assert.Equal(t, "v2", resource.Tags[1].Value)
assert.Nil(t, out.Data)

bundleToTerraformEquivalenceTest(t, &config)
}

func TestBundleToTerraformModelPermissions(t *testing.T) {
var src = resources.MlflowModel{
Model: &ml.Model{
Name: "name",
},
Permissions: []resources.Permission{
{
Level: "CAN_READ",
Expand All @@ -281,6 +300,8 @@ func TestBundleToTerraformModelPermissions(t *testing.T) {
assert.Len(t, resource.AccessControl, 1)
assert.Equal(t, "jane@doe.com", resource.AccessControl[0].UserName)
assert.Equal(t, "CAN_READ", resource.AccessControl[0].PermissionLevel)

bundleToTerraformEquivalenceTest(t, &config)
}

func TestBundleToTerraformExperiment(t *testing.T) {
Expand All @@ -303,10 +324,15 @@ func TestBundleToTerraformExperiment(t *testing.T) {

assert.Equal(t, "name", resource.Name)
assert.Nil(t, out.Data)

bundleToTerraformEquivalenceTest(t, &config)
}

func TestBundleToTerraformExperimentPermissions(t *testing.T) {
var src = resources.MlflowExperiment{
Experiment: &ml.Experiment{
Name: "name",
},
Permissions: []resources.Permission{
{
Level: "CAN_READ",
Expand All @@ -331,6 +357,7 @@ func TestBundleToTerraformExperimentPermissions(t *testing.T) {
assert.Equal(t, "jane@doe.com", resource.AccessControl[0].UserName)
assert.Equal(t, "CAN_READ", resource.AccessControl[0].PermissionLevel)

bundleToTerraformEquivalenceTest(t, &config)
}

func TestBundleToTerraformModelServing(t *testing.T) {
Expand Down Expand Up @@ -377,10 +404,15 @@ func TestBundleToTerraformModelServing(t *testing.T) {
assert.Equal(t, "model_name-1", resource.Config.TrafficConfig.Routes[0].ServedModelName)
assert.Equal(t, 100, resource.Config.TrafficConfig.Routes[0].TrafficPercentage)
assert.Nil(t, out.Data)

bundleToTerraformEquivalenceTest(t, &config)
}

func TestBundleToTerraformModelServingPermissions(t *testing.T) {
var src = resources.ModelServingEndpoint{
CreateServingEndpoint: &serving.CreateServingEndpoint{
Name: "name",
},
Permissions: []resources.Permission{
{
Level: "CAN_VIEW",
Expand All @@ -405,6 +437,7 @@ func TestBundleToTerraformModelServingPermissions(t *testing.T) {
assert.Equal(t, "jane@doe.com", resource.AccessControl[0].UserName)
assert.Equal(t, "CAN_VIEW", resource.AccessControl[0].PermissionLevel)

bundleToTerraformEquivalenceTest(t, &config)
}

func TestBundleToTerraformRegisteredModel(t *testing.T) {
Expand Down Expand Up @@ -433,10 +466,17 @@ func TestBundleToTerraformRegisteredModel(t *testing.T) {
assert.Equal(t, "schema", resource.SchemaName)
assert.Equal(t, "comment", resource.Comment)
assert.Nil(t, out.Data)

bundleToTerraformEquivalenceTest(t, &config)
}

func TestBundleToTerraformRegisteredModelGrants(t *testing.T) {
var src = resources.RegisteredModel{
CreateRegisteredModelRequest: &catalog.CreateRegisteredModelRequest{
Name: "name",
CatalogName: "catalog",
SchemaName: "schema",
},
Grants: []resources.Grant{
{
Privileges: []string{"EXECUTE"},
Expand All @@ -460,6 +500,8 @@ func TestBundleToTerraformRegisteredModelGrants(t *testing.T) {
assert.Len(t, resource.Grant, 1)
assert.Equal(t, "jane@doe.com", resource.Grant[0].Principal)
assert.Equal(t, "EXECUTE", resource.Grant[0].Privileges[0])

bundleToTerraformEquivalenceTest(t, &config)
}

func TestTerraformToBundleEmptyLocalResources(t *testing.T) {
Expand Down Expand Up @@ -827,3 +869,25 @@ func AssertFullResourceCoverage(t *testing.T, config *config.Root) {
}
}
}

func assertEqualTerraformRoot(t *testing.T, a, b *schema.Root) {
ba, err := json.Marshal(a)
require.NoError(t, err)
bb, err := json.Marshal(b)
require.NoError(t, err)
assert.JSONEq(t, string(ba), string(bb))
}

func bundleToTerraformEquivalenceTest(t *testing.T, config *config.Root) {
t.Run("dyn equivalence", func(t *testing.T) {
tf1 := BundleToTerraform(config)

vin, err := convert.FromTyped(config, dyn.NilValue)
require.NoError(t, err)
tf2, err := BundleToTerraformWithDynValue(context.Background(), vin)
require.NoError(t, err)

// Compare roots
assertEqualTerraformRoot(t, tf1, tf2)
})
}
23 changes: 23 additions & 0 deletions bundle/deploy/terraform/tfdyn/convert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package tfdyn

import (
"context"

"github.com/databricks/cli/bundle/internal/tf/schema"
"github.com/databricks/cli/libs/dyn"
)

type Converter interface {
Convert(ctx context.Context, key string, vin dyn.Value, out *schema.Resources) error
}

var converters = map[string]Converter{}

func GetConverter(name string) (Converter, bool) {
c, ok := converters[name]
return c, ok
}

func registerConverter(name string, c Converter) {
converters[name] = c
}
45 changes: 45 additions & 0 deletions bundle/deploy/terraform/tfdyn/convert_experiment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package tfdyn

import (
"context"
"fmt"

"github.com/databricks/cli/bundle/internal/tf/schema"
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/dyn/convert"
"github.com/databricks/cli/libs/log"
)

func convertExperimentResource(ctx context.Context, vin dyn.Value) (dyn.Value, error) {
// Normalize the output value to the target schema.
vout, diags := convert.Normalize(schema.ResourceMlflowExperiment{}, vin)
for _, diag := range diags {
log.Debugf(ctx, "experiment normalization diagnostic: %s", diag.Summary)
}

return vout, nil
}

type experimentConverter struct{}

func (experimentConverter) Convert(ctx context.Context, key string, vin dyn.Value, out *schema.Resources) error {
vout, err := convertExperimentResource(ctx, vin)
if err != nil {
return err
}

// Add the converted resource to the output.
out.MlflowExperiment[key] = vout.AsAny()

// Configure permissions for this resource.
if permissions := convertPermissionsResource(ctx, vin); permissions != nil {
permissions.ExperimentId = fmt.Sprintf("${databricks_mlflow_experiment.%s.id}", key)
out.Permissions["mlflow_experiment_"+key] = permissions
}

return nil
}

func init() {
registerConverter("experiments", experimentConverter{})
}
52 changes: 52 additions & 0 deletions bundle/deploy/terraform/tfdyn/convert_experiment_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package tfdyn

import (
"context"
"testing"

"github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/cli/bundle/internal/tf/schema"
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/dyn/convert"
"github.com/databricks/databricks-sdk-go/service/ml"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestConvertExperiment(t *testing.T) {
var src = resources.MlflowExperiment{
Experiment: &ml.Experiment{
Name: "name",
},
Permissions: []resources.Permission{
{
Level: "CAN_READ",
UserName: "jane@doe.com",
},
},
}

vin, err := convert.FromTyped(src, dyn.NilValue)
require.NoError(t, err)

ctx := context.Background()
out := schema.NewResources()
err = experimentConverter{}.Convert(ctx, "my_experiment", vin, out)
require.NoError(t, err)

// Assert equality on the experiment
assert.Equal(t, map[string]any{
"name": "name",
}, out.MlflowExperiment["my_experiment"])

// Assert equality on the permissions
assert.Equal(t, &schema.ResourcePermissions{
ExperimentId: "${databricks_mlflow_experiment.my_experiment.id}",
AccessControl: []schema.ResourcePermissionsAccessControl{
{
PermissionLevel: "CAN_READ",
UserName: "jane@doe.com",
},
},
}, out.Permissions["mlflow_experiment_my_experiment"])
}
Loading

0 comments on commit f70ec35

Please sign in to comment.