Compare commits

...

3 Commits

Author SHA1 Message Date
Pieter Noordhuis a3e32c0adb
Marshal contents of 'serialized_dashboard' if not a string 2024-10-24 17:10:13 +02:00
Pieter Noordhuis c4df41c3d6
Rename remote modification check 2024-10-24 16:29:52 +02:00
Pieter Noordhuis 93155f1c77
Inline SDK struct 2024-10-24 16:24:06 +02:00
11 changed files with 150 additions and 67 deletions

View File

@ -214,7 +214,7 @@ func (m *applyPresets) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnos
// Dashboards: Prefix // Dashboards: Prefix
for key, dashboard := range r.Dashboards { for key, dashboard := range r.Dashboards {
if dashboard == nil { if dashboard == nil || dashboard.CreateDashboardRequest == nil {
diags = diags.Extend(diag.Errorf("dashboard %s s is not defined", key)) diags = diags.Extend(diag.Errorf("dashboard %s s is not defined", key))
continue continue
} }

View File

@ -10,6 +10,7 @@ import (
"github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/cli/bundle/internal/bundletest" "github.com/databricks/cli/bundle/internal/bundletest"
"github.com/databricks/cli/libs/dyn" "github.com/databricks/cli/libs/dyn"
"github.com/databricks/databricks-sdk-go/service/dashboards"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -25,11 +26,15 @@ func TestConfigureDashboardDefaultsParentPath(t *testing.T) {
"d1": { "d1": {
// Empty string is skipped. // Empty string is skipped.
// See below for how it is set. // See below for how it is set.
ParentPath: "", CreateDashboardRequest: &dashboards.CreateDashboardRequest{
ParentPath: "",
},
}, },
"d2": { "d2": {
// Non-empty string is skipped. // Non-empty string is skipped.
ParentPath: "already-set", CreateDashboardRequest: &dashboards.CreateDashboardRequest{
ParentPath: "already-set",
},
}, },
"d3": { "d3": {
// No parent path set. // No parent path set.

View File

@ -8,6 +8,7 @@ import (
"github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/databricks-sdk-go/service/catalog" "github.com/databricks/databricks-sdk-go/service/catalog"
"github.com/databricks/databricks-sdk-go/service/compute" "github.com/databricks/databricks-sdk-go/service/compute"
"github.com/databricks/databricks-sdk-go/service/dashboards"
"github.com/databricks/databricks-sdk-go/service/jobs" "github.com/databricks/databricks-sdk-go/service/jobs"
"github.com/databricks/databricks-sdk-go/service/ml" "github.com/databricks/databricks-sdk-go/service/ml"
"github.com/databricks/databricks-sdk-go/service/pipelines" "github.com/databricks/databricks-sdk-go/service/pipelines"
@ -87,8 +88,10 @@ func TestInitializeURLs(t *testing.T) {
}, },
Dashboards: map[string]*resources.Dashboard{ Dashboards: map[string]*resources.Dashboard{
"dashboard1": { "dashboard1": {
ID: "01ef8d56871e1d50ae30ce7375e42478", ID: "01ef8d56871e1d50ae30ce7375e42478",
DisplayName: "My special dashboard", CreateDashboardRequest: &dashboards.CreateDashboardRequest{
DisplayName: "My special dashboard",
},
}, },
}, },
}, },

View File

@ -14,6 +14,7 @@ import (
sdkconfig "github.com/databricks/databricks-sdk-go/config" sdkconfig "github.com/databricks/databricks-sdk-go/config"
"github.com/databricks/databricks-sdk-go/service/catalog" "github.com/databricks/databricks-sdk-go/service/catalog"
"github.com/databricks/databricks-sdk-go/service/compute" "github.com/databricks/databricks-sdk-go/service/compute"
"github.com/databricks/databricks-sdk-go/service/dashboards"
"github.com/databricks/databricks-sdk-go/service/iam" "github.com/databricks/databricks-sdk-go/service/iam"
"github.com/databricks/databricks-sdk-go/service/jobs" "github.com/databricks/databricks-sdk-go/service/jobs"
"github.com/databricks/databricks-sdk-go/service/ml" "github.com/databricks/databricks-sdk-go/service/ml"
@ -124,7 +125,11 @@ func mockBundle(mode config.Mode) *bundle.Bundle {
"cluster1": {ClusterSpec: &compute.ClusterSpec{ClusterName: "cluster1", SparkVersion: "13.2.x", NumWorkers: 1}}, "cluster1": {ClusterSpec: &compute.ClusterSpec{ClusterName: "cluster1", SparkVersion: "13.2.x", NumWorkers: 1}},
}, },
Dashboards: map[string]*resources.Dashboard{ Dashboards: map[string]*resources.Dashboard{
"dashboard1": {DisplayName: "dashboard1"}, "dashboard1": {
CreateDashboardRequest: &dashboards.CreateDashboardRequest{
DisplayName: "dashboard1",
},
},
}, },
}, },
}, },

View File

@ -17,27 +17,18 @@ type Dashboard struct {
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"` ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"`
URL string `json:"url,omitempty" bundle:"internal"` URL string `json:"url,omitempty" bundle:"internal"`
// =========================== *dashboards.CreateDashboardRequest
// === BEGIN OF API FIELDS ===
// ===========================
// DisplayName is the display name of the dashboard (both as title and as basename in the workspace). // =========================
DisplayName string `json:"display_name"` // === Additional fields ===
// =========================
// WarehouseID is the ID of the SQL Warehouse used to run the dashboard's queries.
WarehouseID string `json:"warehouse_id"`
// SerializedDashboard holds the contents of the dashboard in serialized JSON form. // SerializedDashboard holds the contents of the dashboard in serialized JSON form.
// Note: its type is any and not string such that it can be inlined as YAML. // We override the field's type from the SDK struct here to allow for inlining as YAML.
// If it is not a string, its contents will be marshalled as JSON. // If the value is a string, it is used as is.
// If it is not a string, its contents is marshalled as JSON.
SerializedDashboard any `json:"serialized_dashboard,omitempty"` SerializedDashboard any `json:"serialized_dashboard,omitempty"`
// ParentPath is the workspace path of the folder containing the dashboard.
// Includes leading slash and no trailing slash.
//
// Defaults to ${workspace.resource_path} if not set.
ParentPath string `json:"parent_path,omitempty"`
// EmbedCredentials is a flag to indicate if the publisher's credentials should // EmbedCredentials is a flag to indicate if the publisher's credentials should
// be embedded in the published dashboard. These embedded credentials will be used // be embedded in the published dashboard. These embedded credentials will be used
// to execute the published dashboard's queries. // to execute the published dashboard's queries.
@ -45,10 +36,6 @@ type Dashboard struct {
// Defaults to false if not set. // Defaults to false if not set.
EmbedCredentials bool `json:"embed_credentials,omitempty"` EmbedCredentials bool `json:"embed_credentials,omitempty"`
// ===========================
// ==== END OF API FIELDS ====
// ===========================
// FilePath points to the local `.lvdash.json` file containing the dashboard definition. // FilePath points to the local `.lvdash.json` file containing the dashboard definition.
FilePath string `json:"file_path,omitempty"` FilePath string `json:"file_path,omitempty"`
} }

View File

@ -16,7 +16,7 @@ type dashboardState struct {
ETag string ETag string
} }
func collectDashboards(ctx context.Context, b *bundle.Bundle) ([]dashboardState, error) { func collectDashboardsFromState(ctx context.Context, b *bundle.Bundle) ([]dashboardState, error) {
state, err := ParseResourcesState(ctx, b) state, err := ParseResourcesState(ctx, b)
if err != nil && state == nil { if err != nil && state == nil {
return nil, err return nil, err
@ -28,22 +28,12 @@ func collectDashboards(ctx context.Context, b *bundle.Bundle) ([]dashboardState,
continue continue
} }
for _, instance := range resource.Instances { for _, instance := range resource.Instances {
id := instance.Attributes.ID
if id == "" {
continue
}
switch resource.Type { switch resource.Type {
case "databricks_dashboard": case "databricks_dashboard":
etag := instance.Attributes.ETag
if etag == "" {
continue
}
dashboards = append(dashboards, dashboardState{ dashboards = append(dashboards, dashboardState{
Name: resource.Name, Name: resource.Name,
ID: id, ID: instance.Attributes.ID,
ETag: etag, ETag: instance.Attributes.ETag,
}) })
} }
} }
@ -52,14 +42,14 @@ func collectDashboards(ctx context.Context, b *bundle.Bundle) ([]dashboardState,
return dashboards, nil return dashboards, nil
} }
type checkModifiedDashboards struct { type checkDashboardsModifiedRemotely struct {
} }
func (l *checkModifiedDashboards) Name() string { func (l *checkDashboardsModifiedRemotely) Name() string {
return "CheckModifiedDashboards" return "CheckDashboardsModifiedRemotely"
} }
func (l *checkModifiedDashboards) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics { func (l *checkDashboardsModifiedRemotely) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
// This mutator is relevant only if the bundle includes dashboards. // This mutator is relevant only if the bundle includes dashboards.
if len(b.Config.Resources.Dashboards) == 0 { if len(b.Config.Resources.Dashboards) == 0 {
return nil return nil
@ -70,7 +60,7 @@ func (l *checkModifiedDashboards) Apply(ctx context.Context, b *bundle.Bundle) d
return nil return nil
} }
dashboards, err := collectDashboards(ctx, b) dashboards, err := collectDashboardsFromState(ctx, b)
if err != nil { if err != nil {
return diag.FromErr(err) return diag.FromErr(err)
} }
@ -122,6 +112,6 @@ func (l *checkModifiedDashboards) Apply(ctx context.Context, b *bundle.Bundle) d
return diags return diags
} }
func CheckModifiedDashboards() *checkModifiedDashboards { func CheckDashboardsModifiedRemotely() *checkDashboardsModifiedRemotely {
return &checkModifiedDashboards{} return &checkDashboardsModifiedRemotely{}
} }

View File

@ -29,7 +29,9 @@ func mockDashboardBundle(t *testing.T) *bundle.Bundle {
Resources: config.Resources{ Resources: config.Resources{
Dashboards: map[string]*resources.Dashboard{ Dashboards: map[string]*resources.Dashboard{
"dash1": { "dash1": {
DisplayName: "My Special Dashboard", CreateDashboardRequest: &dashboards.CreateDashboardRequest{
DisplayName: "My Special Dashboard",
},
}, },
}, },
}, },
@ -38,7 +40,7 @@ func mockDashboardBundle(t *testing.T) *bundle.Bundle {
return b return b
} }
func TestCheckModifiedDashboards_NoDashboards(t *testing.T) { func TestCheckDashboardsModifiedRemotely_NoDashboards(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
b := &bundle.Bundle{ b := &bundle.Bundle{
BundleRootPath: dir, BundleRootPath: dir,
@ -50,17 +52,17 @@ func TestCheckModifiedDashboards_NoDashboards(t *testing.T) {
}, },
} }
diags := bundle.Apply(context.Background(), b, CheckModifiedDashboards()) diags := bundle.Apply(context.Background(), b, CheckDashboardsModifiedRemotely())
assert.Empty(t, diags) assert.Empty(t, diags)
} }
func TestCheckModifiedDashboards_FirstDeployment(t *testing.T) { func TestCheckDashboardsModifiedRemotely_FirstDeployment(t *testing.T) {
b := mockDashboardBundle(t) b := mockDashboardBundle(t)
diags := bundle.Apply(context.Background(), b, CheckModifiedDashboards()) diags := bundle.Apply(context.Background(), b, CheckDashboardsModifiedRemotely())
assert.Empty(t, diags) assert.Empty(t, diags)
} }
func TestCheckModifiedDashboards_ExistingStateNoChange(t *testing.T) { func TestCheckDashboardsModifiedRemotely_ExistingStateNoChange(t *testing.T) {
ctx := context.Background() ctx := context.Background()
b := mockDashboardBundle(t) b := mockDashboardBundle(t)
@ -79,11 +81,11 @@ func TestCheckModifiedDashboards_ExistingStateNoChange(t *testing.T) {
b.SetWorkpaceClient(m.WorkspaceClient) b.SetWorkpaceClient(m.WorkspaceClient)
// No changes, so no diags. // No changes, so no diags.
diags := bundle.Apply(ctx, b, CheckModifiedDashboards()) diags := bundle.Apply(ctx, b, CheckDashboardsModifiedRemotely())
assert.Empty(t, diags) assert.Empty(t, diags)
} }
func TestCheckModifiedDashboards_ExistingStateChange(t *testing.T) { func TestCheckDashboardsModifiedRemotely_ExistingStateChange(t *testing.T) {
ctx := context.Background() ctx := context.Background()
b := mockDashboardBundle(t) b := mockDashboardBundle(t)
@ -102,14 +104,14 @@ func TestCheckModifiedDashboards_ExistingStateChange(t *testing.T) {
b.SetWorkpaceClient(m.WorkspaceClient) b.SetWorkpaceClient(m.WorkspaceClient)
// The dashboard has changed, so expect an error. // The dashboard has changed, so expect an error.
diags := bundle.Apply(ctx, b, CheckModifiedDashboards()) diags := bundle.Apply(ctx, b, CheckDashboardsModifiedRemotely())
if assert.Len(t, diags, 1) { if assert.Len(t, diags, 1) {
assert.Equal(t, diag.Error, diags[0].Severity) assert.Equal(t, diag.Error, diags[0].Severity)
assert.Equal(t, `dashboard "dash1" has been modified remotely`, diags[0].Summary) assert.Equal(t, `dashboard "dash1" has been modified remotely`, diags[0].Summary)
} }
} }
func TestCheckModifiedDashboards_ExistingStateFailureToGet(t *testing.T) { func TestCheckDashboardsModifiedRemotely_ExistingStateFailureToGet(t *testing.T) {
ctx := context.Background() ctx := context.Background()
b := mockDashboardBundle(t) b := mockDashboardBundle(t)
@ -125,7 +127,7 @@ func TestCheckModifiedDashboards_ExistingStateFailureToGet(t *testing.T) {
b.SetWorkpaceClient(m.WorkspaceClient) b.SetWorkpaceClient(m.WorkspaceClient)
// Unable to get the dashboard, so expect an error. // Unable to get the dashboard, so expect an error.
diags := bundle.Apply(ctx, b, CheckModifiedDashboards()) diags := bundle.Apply(ctx, b, CheckDashboardsModifiedRemotely())
if assert.Len(t, diags, 1) { if assert.Len(t, diags, 1) {
assert.Equal(t, diag.Error, diags[0].Severity) assert.Equal(t, diag.Error, diags[0].Severity)
assert.Equal(t, `failed to get dashboard "dash1"`, diags[0].Summary) assert.Equal(t, `failed to get dashboard "dash1"`, diags[0].Summary)

View File

@ -12,6 +12,7 @@ import (
"github.com/databricks/cli/libs/dyn/convert" "github.com/databricks/cli/libs/dyn/convert"
"github.com/databricks/databricks-sdk-go/service/catalog" "github.com/databricks/databricks-sdk-go/service/catalog"
"github.com/databricks/databricks-sdk-go/service/compute" "github.com/databricks/databricks-sdk-go/service/compute"
"github.com/databricks/databricks-sdk-go/service/dashboards"
"github.com/databricks/databricks-sdk-go/service/jobs" "github.com/databricks/databricks-sdk-go/service/jobs"
"github.com/databricks/databricks-sdk-go/service/ml" "github.com/databricks/databricks-sdk-go/service/ml"
"github.com/databricks/databricks-sdk-go/service/pipelines" "github.com/databricks/databricks-sdk-go/service/pipelines"
@ -791,7 +792,9 @@ func TestTerraformToBundleEmptyRemoteResources(t *testing.T) {
}, },
Dashboards: map[string]*resources.Dashboard{ Dashboards: map[string]*resources.Dashboard{
"test_dashboard": { "test_dashboard": {
DisplayName: "test_dashboard", CreateDashboardRequest: &dashboards.CreateDashboardRequest{
DisplayName: "test_dashboard",
},
}, },
}, },
}, },
@ -948,10 +951,14 @@ func TestTerraformToBundleModifiedResources(t *testing.T) {
}, },
Dashboards: map[string]*resources.Dashboard{ Dashboards: map[string]*resources.Dashboard{
"test_dashboard": { "test_dashboard": {
DisplayName: "test_dashboard", CreateDashboardRequest: &dashboards.CreateDashboardRequest{
DisplayName: "test_dashboard",
},
}, },
"test_dashboard_new": { "test_dashboard_new": {
DisplayName: "test_dashboard_new", CreateDashboardRequest: &dashboards.CreateDashboardRequest{
DisplayName: "test_dashboard_new",
},
}, },
}, },
}, },

View File

@ -2,6 +2,7 @@ package tfdyn
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"github.com/databricks/cli/bundle/internal/tf/schema" "github.com/databricks/cli/bundle/internal/tf/schema"
@ -10,6 +11,34 @@ import (
"github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/log"
) )
const (
filePathFieldName = "file_path"
serializedDashboardFieldName = "serialized_dashboard"
)
// Marshal "serialized_dashboard" as JSON if it is set in the input but not in the output.
func marshalSerializedDashboard(vin dyn.Value, vout dyn.Value) (dyn.Value, error) {
// Skip if the "serialized_dashboard" field is already set.
if v := vout.Get(serializedDashboardFieldName); v.IsValid() {
return vout, nil
}
// Skip if the "serialized_dashboard" field on the input is not set.
v := vin.Get(serializedDashboardFieldName)
if !v.IsValid() {
return vout, nil
}
// Marshal the "serialized_dashboard" field as JSON.
data, err := json.Marshal(v.AsAny())
if err != nil {
return dyn.InvalidValue, fmt.Errorf("failed to marshal serialized_dashboard: %w", err)
}
// Set the "serialized_dashboard" field on the output.
return dyn.Set(vout, serializedDashboardFieldName, dyn.V(string(data)))
}
func convertDashboardResource(ctx context.Context, vin dyn.Value) (dyn.Value, error) { func convertDashboardResource(ctx context.Context, vin dyn.Value) (dyn.Value, error) {
var err error var err error
@ -22,8 +51,8 @@ func convertDashboardResource(ctx context.Context, vin dyn.Value) (dyn.Value, er
// Include "serialized_dashboard" field if "file_path" is set. // Include "serialized_dashboard" field if "file_path" is set.
// Note: the Terraform resource supports "file_path" natively, but its // Note: the Terraform resource supports "file_path" natively, but its
// change detection mechanism doesn't work as expected at the time of writing (Sep 30). // change detection mechanism doesn't work as expected at the time of writing (Sep 30).
if path, ok := vout.Get("file_path").AsString(); ok { if path, ok := vout.Get(filePathFieldName).AsString(); ok {
vout, err = dyn.Set(vout, "serialized_dashboard", dyn.V(fmt.Sprintf("${file(\"%s\")}", path))) vout, err = dyn.Set(vout, serializedDashboardFieldName, dyn.V(fmt.Sprintf("${file(\"%s\")}", path)))
if err != nil { if err != nil {
return dyn.InvalidValue, fmt.Errorf("failed to set serialized_dashboard: %w", err) return dyn.InvalidValue, fmt.Errorf("failed to set serialized_dashboard: %w", err)
} }
@ -33,7 +62,7 @@ func convertDashboardResource(ctx context.Context, vin dyn.Value) (dyn.Value, er
case 0: case 0:
return v, nil return v, nil
case 1: case 1:
if p[0] == dyn.Key("file_path") { if p[0] == dyn.Key(filePathFieldName) {
return v, dyn.ErrDrop return v, dyn.ErrDrop
} }
} }
@ -46,6 +75,12 @@ func convertDashboardResource(ctx context.Context, vin dyn.Value) (dyn.Value, er
} }
} }
// Marshal "serialized_dashboard" as JSON if it is set in the input but not in the output.
vout, err = marshalSerializedDashboard(vin, vout)
if err != nil {
return dyn.InvalidValue, err
}
return vout, nil return vout, nil
} }

View File

@ -8,15 +8,19 @@ import (
"github.com/databricks/cli/bundle/internal/tf/schema" "github.com/databricks/cli/bundle/internal/tf/schema"
"github.com/databricks/cli/libs/dyn" "github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/dyn/convert" "github.com/databricks/cli/libs/dyn/convert"
"github.com/databricks/databricks-sdk-go/service/dashboards"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestConvertDashboard(t *testing.T) { func TestConvertDashboard(t *testing.T) {
var src = resources.Dashboard{ var src = resources.Dashboard{
DisplayName: "my dashboard", CreateDashboardRequest: &dashboards.CreateDashboardRequest{
WarehouseID: "f00dcafe", DisplayName: "my dashboard",
ParentPath: "/some/path", WarehouseId: "f00dcafe",
ParentPath: "/some/path",
},
EmbedCredentials: true, EmbedCredentials: true,
Permissions: []resources.Permission{ Permissions: []resources.Permission{
@ -78,3 +82,48 @@ func TestConvertDashboardFilePath(t *testing.T) {
"file_path": "some/path", "file_path": "some/path",
}) })
} }
func TestConvertDashboardSerializedDashboardString(t *testing.T) {
var src = resources.Dashboard{
SerializedDashboard: `{ "json": true }`,
}
vin, err := convert.FromTyped(src, dyn.NilValue)
require.NoError(t, err)
ctx := context.Background()
out := schema.NewResources()
err = dashboardConverter{}.Convert(ctx, "my_dashboard", vin, out)
require.NoError(t, err)
// Assert that the "serialized_dashboard" is included.
assert.Subset(t, out.Dashboard["my_dashboard"], map[string]any{
"serialized_dashboard": `{ "json": true }`,
})
}
func TestConvertDashboardSerializedDashboardAny(t *testing.T) {
var src = resources.Dashboard{
SerializedDashboard: map[string]any{
"pages": []map[string]any{
{
"displayName": "New Page",
"layout": []map[string]any{},
},
},
},
}
vin, err := convert.FromTyped(src, dyn.NilValue)
require.NoError(t, err)
ctx := context.Background()
out := schema.NewResources()
err = dashboardConverter{}.Convert(ctx, "my_dashboard", vin, out)
require.NoError(t, err)
// Assert that the "serialized_dashboard" is included.
assert.Subset(t, out.Dashboard["my_dashboard"], map[string]any{
"serialized_dashboard": `{"pages":[{"displayName":"New Page","layout":[]}]}`,
})
}

View File

@ -152,7 +152,7 @@ func Deploy(outputHandler sync.OutputHandler) bundle.Mutator {
bundle.Defer( bundle.Defer(
bundle.Seq( bundle.Seq(
terraform.StatePull(), terraform.StatePull(),
terraform.CheckModifiedDashboards(), terraform.CheckDashboardsModifiedRemotely(),
deploy.StatePull(), deploy.StatePull(),
mutator.ValidateGitDetails(), mutator.ValidateGitDetails(),
artifacts.CleanUp(), artifacts.CleanUp(),