diff --git a/bundle/config/mutator/apply_presets.go b/bundle/config/mutator/apply_presets.go index 38170375..57b765a8 100644 --- a/bundle/config/mutator/apply_presets.go +++ b/bundle/config/mutator/apply_presets.go @@ -222,6 +222,19 @@ func (m *applyPresets) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnos dashboard.DisplayName = prefix + dashboard.DisplayName } + // Apps: Prefix + for key, app := range r.Apps { + if app == nil || app.App == nil { + diags = diags.Extend(diag.Errorf("app %s is not defined", key)) + continue + } + + app.Name = textutil.NormalizeString(prefix + app.Name) + // Normalize the app name to ensure it is a valid identifier. + // App supports only alphanumeric characters and hyphens. + app.Name = strings.ReplaceAll(app.Name, "_", "-") + } + if config.IsExplicitlyEnabled((b.Config.Presets.SourceLinkedDeployment)) { isDatabricksWorkspace := dbr.RunsOnRuntime(ctx) && strings.HasPrefix(b.SyncRootPath, "/Workspace/") if !isDatabricksWorkspace { diff --git a/bundle/config/mutator/apply_presets_test.go b/bundle/config/mutator/apply_presets_test.go index 497ef051..a1403d30 100644 --- a/bundle/config/mutator/apply_presets_test.go +++ b/bundle/config/mutator/apply_presets_test.go @@ -12,6 +12,7 @@ import ( "github.com/databricks/cli/bundle/internal/bundletest" "github.com/databricks/cli/libs/dbr" "github.com/databricks/cli/libs/dyn" + "github.com/databricks/databricks-sdk-go/service/apps" "github.com/databricks/databricks-sdk-go/service/catalog" "github.com/databricks/databricks-sdk-go/service/jobs" "github.com/stretchr/testify/require" @@ -453,3 +454,59 @@ func TestApplyPresetsSourceLinkedDeployment(t *testing.T) { } } + +func TestApplyPresetsPrefixForApps(t *testing.T) { + tests := []struct { + name string + prefix string + app *resources.App + want string + }{ + { + name: "add prefix to app", + prefix: "[prefix] ", + app: &resources.App{ + App: &apps.App{ + Name: "app1", + }, + }, + want: "prefix-app1", + }, + { + name: "add empty prefix to app", + prefix: "", + app: &resources.App{ + App: &apps.App{ + Name: "app1", + }, + }, + want: "app1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + Apps: map[string]*resources.App{ + "app1": tt.app, + }, + }, + Presets: config.Presets{ + NamePrefix: tt.prefix, + }, + }, + } + + ctx := context.Background() + diag := bundle.Apply(ctx, b, mutator.ApplyPresets()) + + if diag.HasError() { + t.Fatalf("unexpected error: %v", diag) + } + + require.Equal(t, tt.want, b.Config.Resources.Apps["app1"].Name) + }) + } +} diff --git a/bundle/config/mutator/merge_apps.go b/bundle/config/mutator/merge_apps.go new file mode 100644 index 00000000..88c745a8 --- /dev/null +++ b/bundle/config/mutator/merge_apps.go @@ -0,0 +1,45 @@ +package mutator + +import ( + "context" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/libs/diag" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/dyn/merge" +) + +type mergeApps struct{} + +func MergeApps() bundle.Mutator { + return &mergeApps{} +} + +func (m *mergeApps) Name() string { + return "MergeApps" +} + +func (m *mergeApps) resourceName(v dyn.Value) string { + switch v.Kind() { + case dyn.KindInvalid, dyn.KindNil: + return "" + case dyn.KindString: + return v.MustString() + default: + panic("job cluster key must be a string") + } +} + +func (m *mergeApps) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics { + err := b.Config.Mutate(func(v dyn.Value) (dyn.Value, error) { + if v.Kind() == dyn.KindNil { + return v, nil + } + + return dyn.Map(v, "resources.apps", dyn.Foreach(func(_ dyn.Path, app dyn.Value) (dyn.Value, error) { + return dyn.Map(app, "resources", merge.ElementsByKey("name", m.resourceName)) + })) + }) + + return diag.FromErr(err) +} diff --git a/bundle/config/mutator/merge_apps_test.go b/bundle/config/mutator/merge_apps_test.go new file mode 100644 index 00000000..2cdef830 --- /dev/null +++ b/bundle/config/mutator/merge_apps_test.go @@ -0,0 +1,64 @@ +package mutator_test + +import ( + "context" + "testing" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/config" + "github.com/databricks/cli/bundle/config/mutator" + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/stretchr/testify/assert" +) + +func TestMergeApps(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + Apps: map[string]*resources.App{ + "foo": { + App: &apps.App{ + Name: "foo", + Resources: []apps.AppResource{ + { + Name: "job1", + Job: &apps.AppResourceJob{ + Id: "1234", + Permission: "CAN_MANAGE_RUN", + }, + }, + { + Name: "sql1", + SqlWarehouse: &apps.AppResourceSqlWarehouse{ + Id: "5678", + Permission: "CAN_USE", + }, + }, + { + Name: "job1", + Job: &apps.AppResourceJob{ + Id: "1234", + Permission: "CAN_MANAGE", + }, + }, + }, + }, + }, + }, + }, + }, + } + + diags := bundle.Apply(context.Background(), b, mutator.MergeApps()) + assert.NoError(t, diags.Error()) + + j := b.Config.Resources.Apps["foo"] + + assert.Len(t, j.Resources, 2) + assert.Equal(t, "job1", j.Resources[0].Name) + assert.Equal(t, "sql1", j.Resources[1].Name) + + assert.Equal(t, "CAN_MANAGE", string(j.Resources[0].Job.Permission)) + assert.Equal(t, "CAN_USE", string(j.Resources[1].SqlWarehouse.Permission)) +} diff --git a/bundle/config/mutator/process_target_mode_test.go b/bundle/config/mutator/process_target_mode_test.go index c5ea9ade..2a3ecff9 100644 --- a/bundle/config/mutator/process_target_mode_test.go +++ b/bundle/config/mutator/process_target_mode_test.go @@ -15,6 +15,7 @@ import ( "github.com/databricks/cli/libs/tags" "github.com/databricks/cli/libs/vfs" sdkconfig "github.com/databricks/databricks-sdk-go/config" + "github.com/databricks/databricks-sdk-go/service/apps" "github.com/databricks/databricks-sdk-go/service/catalog" "github.com/databricks/databricks-sdk-go/service/compute" "github.com/databricks/databricks-sdk-go/service/dashboards" @@ -141,6 +142,13 @@ func mockBundle(mode config.Mode) *bundle.Bundle { }, }, }, + Apps: map[string]*resources.App{ + "app1": { + App: &apps.App{ + Name: "app1", + }, + }, + }, }, }, SyncRoot: vfs.MustNew("/Users/lennart.kats@databricks.com"), diff --git a/bundle/config/mutator/run_as_test.go b/bundle/config/mutator/run_as_test.go index acb6c3a4..aa672b8f 100644 --- a/bundle/config/mutator/run_as_test.go +++ b/bundle/config/mutator/run_as_test.go @@ -32,6 +32,7 @@ func allResourceTypes(t *testing.T) []string { // the dyn library gives us the correct list of all resources supported. Please // also update this check when adding a new resource require.Equal(t, []string{ + "apps", "clusters", "dashboards", "experiments", @@ -141,6 +142,7 @@ func TestRunAsErrorForUnsupportedResources(t *testing.T) { "registered_models", "experiments", "schemas", + "apps", } base := config.Root{ diff --git a/bundle/config/mutator/translate_paths.go b/bundle/config/mutator/translate_paths.go index 5e016d8a..954aa41c 100644 --- a/bundle/config/mutator/translate_paths.go +++ b/bundle/config/mutator/translate_paths.go @@ -262,6 +262,7 @@ func (m *translatePaths) Apply(_ context.Context, b *bundle.Bundle) diag.Diagnos t.applyPipelineTranslations, t.applyArtifactTranslations, t.applyDashboardTranslations, + t.applyAppsTranslations, } { v, err = fn(v) if err != nil { diff --git a/bundle/config/mutator/translate_paths_apps.go b/bundle/config/mutator/translate_paths_apps.go new file mode 100644 index 00000000..0ed7e192 --- /dev/null +++ b/bundle/config/mutator/translate_paths_apps.go @@ -0,0 +1,28 @@ +package mutator + +import ( + "fmt" + + "github.com/databricks/cli/libs/dyn" +) + +func (t *translateContext) applyAppsTranslations(v dyn.Value) (dyn.Value, error) { + // Convert the `source_code_path` field to a remote absolute path. + // We use this path for app deployment to point to the source code. + pattern := dyn.NewPattern( + dyn.Key("resources"), + dyn.Key("apps"), + dyn.AnyKey(), + dyn.Key("source_code_path"), + ) + + return dyn.MapByPattern(v, pattern, func(p dyn.Path, v dyn.Value) (dyn.Value, error) { + key := p[2].Key() + dir, err := v.Location().Directory() + if err != nil { + return dyn.InvalidValue, fmt.Errorf("unable to determine directory for app %s: %w", key, err) + } + + return t.rewriteRelativeTo(p, v, t.translateDirectoryPath, dir, "") + }) +} diff --git a/bundle/config/mutator/translate_paths_apps_test.go b/bundle/config/mutator/translate_paths_apps_test.go new file mode 100644 index 00000000..7ca0412d --- /dev/null +++ b/bundle/config/mutator/translate_paths_apps_test.go @@ -0,0 +1,54 @@ +package mutator_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/config" + "github.com/databricks/cli/bundle/config/mutator" + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/bundle/internal/bundletest" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/vfs" + "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTranslatePathsApps_FilePathRelativeSubDirectory(t *testing.T) { + dir := t.TempDir() + touchEmptyFile(t, filepath.Join(dir, "src", "app", "app.py")) + + b := &bundle.Bundle{ + SyncRootPath: dir, + SyncRoot: vfs.MustNew(dir), + Config: config.Root{ + Resources: config.Resources{ + Apps: map[string]*resources.App{ + "app": { + App: &apps.App{ + Name: "My App", + }, + SourceCodePath: "../src/app", + }, + }, + }, + }, + } + + bundletest.SetLocation(b, "resources.apps", []dyn.Location{{ + File: filepath.Join(dir, "resources/app.yml"), + }}) + + diags := bundle.Apply(context.Background(), b, mutator.TranslatePaths()) + require.NoError(t, diags.Error()) + + // Assert that the file path for the app has been converted to its local absolute path. + assert.Equal( + t, + filepath.Join("src", "app"), + b.Config.Resources.Apps["app"].SourceCodePath, + ) +} diff --git a/bundle/config/resources.go b/bundle/config/resources.go index 0affb6ef..5d75d83c 100644 --- a/bundle/config/resources.go +++ b/bundle/config/resources.go @@ -22,6 +22,7 @@ type Resources struct { Schemas map[string]*resources.Schema `json:"schemas,omitempty"` Clusters map[string]*resources.Cluster `json:"clusters,omitempty"` Dashboards map[string]*resources.Dashboard `json:"dashboards,omitempty"` + Apps map[string]*resources.App `json:"apps,omitempty"` } type ConfigResource interface { @@ -79,6 +80,7 @@ func (r *Resources) AllResources() []ResourceGroup { collectResourceMap(descriptions["schemas"], r.Schemas), collectResourceMap(descriptions["clusters"], r.Clusters), collectResourceMap(descriptions["dashboards"], r.Dashboards), + collectResourceMap(descriptions["apps"], r.Apps), } } @@ -183,5 +185,11 @@ func SupportedResources() map[string]ResourceDescription { SingularTitle: "Dashboard", PluralTitle: "Dashboards", }, + "apps": { + SingularName: "app", + PluralName: "apps", + SingularTitle: "App", + PluralTitle: "Apps", + }, } } diff --git a/bundle/config/resources/apps.go b/bundle/config/resources/apps.go new file mode 100644 index 00000000..f5d36a5f --- /dev/null +++ b/bundle/config/resources/apps.go @@ -0,0 +1,71 @@ +package resources + +import ( + "context" + "fmt" + "net/url" + + "github.com/databricks/cli/libs/log" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/marshal" + "github.com/databricks/databricks-sdk-go/service/apps" +) + +type App struct { + // This represents the id which is the name of the app that can be used + // as a reference in other resources. This value is returned by terraform. + ID string `json:"id,omitempty" bundle:"readonly"` + + // SourceCodePath is a required field used by DABs to point databricks app source code + // on local disk and use it to point to this source code in the app deployment + SourceCodePath string `json:"source_code_path"` + + // Config is an optional field which allows configuring the app following Databricks app configuration format like in app.yml. + // When this field is set, DABs read the configuration set in this field and write + // it to app.yml in the root of the source code folder in Databricks workspace. + // If there’s app.yml defined already, it will be overridden. + Config map[string]interface{} `json:"config,omitempty"` + + Permissions []Permission `json:"permissions,omitempty"` + ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"` + URL string `json:"url,omitempty" bundle:"internal"` + + *apps.App +} + +func (a *App) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, a) +} + +func (a App) MarshalJSON() ([]byte, error) { + return marshal.Marshal(a) +} + +func (a *App) Exists(ctx context.Context, w *databricks.WorkspaceClient, name string) (bool, error) { + _, err := w.Apps.GetByName(ctx, name) + if err != nil { + log.Debugf(ctx, "app %s does not exist", name) + return false, err + } + return true, nil +} + +func (a *App) TerraformResourceName() string { + return "databricks_cluster" +} + +func (a *App) InitializeURL(baseURL url.URL) { + if a.ID == "" { + return + } + baseURL.Path = fmt.Sprintf("apps/%s", a.ID) + a.URL = baseURL.String() +} + +func (a *App) GetName() string { + return a.Name +} + +func (a *App) GetURL() string { + return a.URL +} diff --git a/bundle/deploy/terraform/convert.go b/bundle/deploy/terraform/convert.go index 0ace7c66..68acb673 100644 --- a/bundle/deploy/terraform/convert.go +++ b/bundle/deploy/terraform/convert.go @@ -186,6 +186,16 @@ func TerraformToBundle(state *resourcesState, config *config.Root) error { } cur.ID = instance.Attributes.ID config.Resources.Dashboards[resource.Name] = cur + case "databricks_app": + if config.Resources.Apps == nil { + config.Resources.Apps = make(map[string]*resources.App) + } + cur := config.Resources.Apps[resource.Name] + if cur == nil { + cur = &resources.App{ModifiedStatus: resources.ModifiedStatusDeleted} + } + cur.ID = instance.Attributes.ID + config.Resources.Apps[resource.Name] = cur case "databricks_permissions": case "databricks_grants": // Ignore; no need to pull these back into the configuration. @@ -245,6 +255,11 @@ func TerraformToBundle(state *resourcesState, config *config.Root) error { src.ModifiedStatus = resources.ModifiedStatusCreated } } + for _, src := range config.Resources.Apps { + if src.ModifiedStatus == "" && src.ID == "" { + src.ModifiedStatus = resources.ModifiedStatusCreated + } + } return nil } diff --git a/bundle/deploy/terraform/convert_test.go b/bundle/deploy/terraform/convert_test.go index 6ed34d43..7a5f972b 100644 --- a/bundle/deploy/terraform/convert_test.go +++ b/bundle/deploy/terraform/convert_test.go @@ -10,6 +10,7 @@ import ( "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/apps" "github.com/databricks/databricks-sdk-go/service/catalog" "github.com/databricks/databricks-sdk-go/service/compute" "github.com/databricks/databricks-sdk-go/service/dashboards" @@ -686,6 +687,14 @@ func TestTerraformToBundleEmptyLocalResources(t *testing.T) { {Attributes: stateInstanceAttributes{ID: "1"}}, }, }, + { + Type: "databricks_app", + Mode: "managed", + Name: "test_app", + Instances: []stateResourceInstance{ + {Attributes: stateInstanceAttributes{ID: "1"}}, + }, + }, }, } err := TerraformToBundle(&tfState, &config) @@ -721,6 +730,9 @@ func TestTerraformToBundleEmptyLocalResources(t *testing.T) { assert.Equal(t, "1", config.Resources.Dashboards["test_dashboard"].ID) assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.Dashboards["test_dashboard"].ModifiedStatus) + assert.Equal(t, "1", config.Resources.Apps["test_app"].ID) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.Apps["test_app"].ModifiedStatus) + AssertFullResourceCoverage(t, &config) } @@ -797,6 +809,13 @@ func TestTerraformToBundleEmptyRemoteResources(t *testing.T) { }, }, }, + Apps: map[string]*resources.App{ + "test_app": { + App: &apps.App{ + Name: "test_app", + }, + }, + }, }, } var tfState = resourcesState{ @@ -835,6 +854,9 @@ func TestTerraformToBundleEmptyRemoteResources(t *testing.T) { assert.Equal(t, "", config.Resources.Dashboards["test_dashboard"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.Dashboards["test_dashboard"].ModifiedStatus) + assert.Equal(t, "", config.Resources.Apps["test_app"].ID) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.Apps["test_app"].ModifiedStatus) + AssertFullResourceCoverage(t, &config) } @@ -961,6 +983,18 @@ func TestTerraformToBundleModifiedResources(t *testing.T) { }, }, }, + Apps: map[string]*resources.App{ + "test_app": { + App: &apps.App{ + Name: "test_app", + }, + }, + "test_app_new": { + App: &apps.App{ + Name: "test_app_new", + }, + }, + }, }, } var tfState = resourcesState{ @@ -1125,6 +1159,22 @@ func TestTerraformToBundleModifiedResources(t *testing.T) { {Attributes: stateInstanceAttributes{ID: "2"}}, }, }, + { + Type: "databricks_app", + Mode: "managed", + Name: "test_app", + Instances: []stateResourceInstance{ + {Attributes: stateInstanceAttributes{ID: "1"}}, + }, + }, + { + Type: "databricks_app", + Mode: "managed", + Name: "test_app_old", + Instances: []stateResourceInstance{ + {Attributes: stateInstanceAttributes{ID: "2"}}, + }, + }, }, } err := TerraformToBundle(&tfState, &config) @@ -1200,6 +1250,13 @@ func TestTerraformToBundleModifiedResources(t *testing.T) { assert.Equal(t, "", config.Resources.Dashboards["test_dashboard_new"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.Dashboards["test_dashboard_new"].ModifiedStatus) + assert.Equal(t, "1", config.Resources.Apps["test_app"].ID) + assert.Equal(t, "", config.Resources.Apps["test_app"].ModifiedStatus) + assert.Equal(t, "2", config.Resources.Apps["test_app_old"].ID) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.Apps["test_app_old"].ModifiedStatus) + assert.Equal(t, "", config.Resources.Apps["test_app_new"].ID) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.Apps["test_app_new"].ModifiedStatus) + AssertFullResourceCoverage(t, &config) } diff --git a/bundle/deploy/terraform/interpolate.go b/bundle/deploy/terraform/interpolate.go index eb15c63e..cfc5c9f7 100644 --- a/bundle/deploy/terraform/interpolate.go +++ b/bundle/deploy/terraform/interpolate.go @@ -62,6 +62,8 @@ func (m *interpolateMutator) Apply(ctx context.Context, b *bundle.Bundle) diag.D path = dyn.NewPath(dyn.Key("databricks_cluster")).Append(path[2:]...) case dyn.Key("dashboards"): path = dyn.NewPath(dyn.Key("databricks_dashboard")).Append(path[2:]...) + case dyn.Key("apps"): + path = dyn.NewPath(dyn.Key("databricks_app")).Append(path[2:]...) default: // Trigger "key not found" for unknown resource types. return dyn.GetByPath(root, path) diff --git a/bundle/deploy/terraform/interpolate_test.go b/bundle/deploy/terraform/interpolate_test.go index b26ef928..b82e32bd 100644 --- a/bundle/deploy/terraform/interpolate_test.go +++ b/bundle/deploy/terraform/interpolate_test.go @@ -33,6 +33,7 @@ func TestInterpolate(t *testing.T) { "other_schema": "${resources.schemas.other_schema.id}", "other_cluster": "${resources.clusters.other_cluster.id}", "other_dashboard": "${resources.dashboards.other_dashboard.id}", + "other_app": "${resources.apps.other_app.id}", }, Tasks: []jobs.Task{ { @@ -71,6 +72,7 @@ func TestInterpolate(t *testing.T) { assert.Equal(t, "${databricks_schema.other_schema.id}", j.Tags["other_schema"]) assert.Equal(t, "${databricks_cluster.other_cluster.id}", j.Tags["other_cluster"]) assert.Equal(t, "${databricks_dashboard.other_dashboard.id}", j.Tags["other_dashboard"]) + assert.Equal(t, "${databricks_app.other_app.id}", j.Tags["other_app"]) m := b.Config.Resources.Models["my_model"] assert.Equal(t, "my_model", m.Model.Name) diff --git a/bundle/deploy/terraform/tfdyn/convert_app.go b/bundle/deploy/terraform/tfdyn/convert_app.go new file mode 100644 index 00000000..f4849cab --- /dev/null +++ b/bundle/deploy/terraform/tfdyn/convert_app.go @@ -0,0 +1,55 @@ +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" + "github.com/databricks/databricks-sdk-go/service/apps" +) + +func convertAppResource(ctx context.Context, vin dyn.Value) (dyn.Value, error) { + // Normalize the output value to the target schema. + vout, diags := convert.Normalize(apps.App{}, vin) + for _, diag := range diags { + log.Debugf(ctx, "app normalization diagnostic: %s", diag.Summary) + } + + return vout, nil +} + +type appConverter struct{} + +func (appConverter) Convert(ctx context.Context, key string, vin dyn.Value, out *schema.Resources) error { + vout, err := convertAppResource(ctx, vin) + if err != nil { + return err + } + + // Modify top-level keys. + vout, err = renameKeys(vout, map[string]string{ + "resources": "resource", + }) + + if err != nil { + return err + } + + // Add the converted resource to the output. + out.App[key] = vout.AsAny() + + // Configure permissions for this resource. + if permissions := convertPermissionsResource(ctx, vin); permissions != nil { + permissions.AppName = fmt.Sprintf("${databricks_app.%s.name}", key) + out.Permissions["app_"+key] = permissions + } + + return nil +} + +func init() { + registerConverter("apps", appConverter{}) +} diff --git a/bundle/deploy/terraform/tfdyn/convert_app_test.go b/bundle/deploy/terraform/tfdyn/convert_app_test.go new file mode 100644 index 00000000..95b9bdae --- /dev/null +++ b/bundle/deploy/terraform/tfdyn/convert_app_test.go @@ -0,0 +1,99 @@ +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/apps" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConvertApp(t *testing.T) { + var src = resources.App{ + SourceCodePath: "./app", + Config: map[string]interface{}{ + "command": []string{"python", "app.py"}, + }, + App: &apps.App{ + Name: "app_id", + Description: "app description", + Resources: []apps.AppResource{ + { + Name: "job1", + Job: &apps.AppResourceJob{ + Id: "1234", + Permission: "CAN_MANAGE_RUN", + }, + }, + { + Name: "sql1", + SqlWarehouse: &apps.AppResourceSqlWarehouse{ + Id: "5678", + Permission: "CAN_USE", + }, + }, + }, + }, + Permissions: []resources.Permission{ + { + Level: "CAN_RUN", + UserName: "jack@gmail.com", + }, + { + Level: "CAN_MANAGE", + ServicePrincipalName: "sp", + }, + }, + } + + vin, err := convert.FromTyped(src, dyn.NilValue) + require.NoError(t, err) + + ctx := context.Background() + out := schema.NewResources() + err = appConverter{}.Convert(ctx, "my_app", vin, out) + require.NoError(t, err) + + app := out.App["my_app"] + assert.Equal(t, map[string]interface{}{ + "description": "app description", + "name": "app_id", + "resource": []interface{}{ + map[string]interface{}{ + "name": "job1", + "job": map[string]interface{}{ + "id": "1234", + "permission": "CAN_MANAGE_RUN", + }, + }, + map[string]interface{}{ + "name": "sql1", + "sql_warehouse": map[string]interface{}{ + "id": "5678", + "permission": "CAN_USE", + }, + }, + }, + }, app) + + // Assert equality on the permissions + assert.Equal(t, &schema.ResourcePermissions{ + AppName: "${databricks_app.my_app.name}", + AccessControl: []schema.ResourcePermissionsAccessControl{ + { + PermissionLevel: "CAN_RUN", + UserName: "jack@gmail.com", + }, + { + PermissionLevel: "CAN_MANAGE", + ServicePrincipalName: "sp", + }, + }, + }, out.Permissions["app_my_app"]) + +} diff --git a/bundle/internal/tf/schema/data_source_aws_assume_role_policy.go b/bundle/internal/tf/schema/data_source_aws_assume_role_policy.go index 7c1cace3..25fea090 100644 --- a/bundle/internal/tf/schema/data_source_aws_assume_role_policy.go +++ b/bundle/internal/tf/schema/data_source_aws_assume_role_policy.go @@ -3,6 +3,7 @@ package schema type DataSourceAwsAssumeRolePolicy struct { + AwsPartition string `json:"aws_partition,omitempty"` DatabricksAccountId string `json:"databricks_account_id,omitempty"` ExternalId string `json:"external_id"` ForLogDelivery bool `json:"for_log_delivery,omitempty"` diff --git a/bundle/internal/tf/schema/data_source_aws_bucket_policy.go b/bundle/internal/tf/schema/data_source_aws_bucket_policy.go index e1ce2f50..d6b26c8c 100644 --- a/bundle/internal/tf/schema/data_source_aws_bucket_policy.go +++ b/bundle/internal/tf/schema/data_source_aws_bucket_policy.go @@ -3,6 +3,7 @@ package schema type DataSourceAwsBucketPolicy struct { + AwsPartition string `json:"aws_partition,omitempty"` Bucket string `json:"bucket"` DatabricksAccountId string `json:"databricks_account_id,omitempty"` DatabricksE2AccountId string `json:"databricks_e2_account_id,omitempty"` diff --git a/bundle/internal/tf/schema/data_source_aws_crossaccount_policy.go b/bundle/internal/tf/schema/data_source_aws_crossaccount_policy.go index d639c82a..9591940d 100644 --- a/bundle/internal/tf/schema/data_source_aws_crossaccount_policy.go +++ b/bundle/internal/tf/schema/data_source_aws_crossaccount_policy.go @@ -4,6 +4,7 @@ package schema type DataSourceAwsCrossaccountPolicy struct { AwsAccountId string `json:"aws_account_id,omitempty"` + AwsPartition string `json:"aws_partition,omitempty"` Id string `json:"id,omitempty"` Json string `json:"json,omitempty"` PassRoles []string `json:"pass_roles,omitempty"` diff --git a/bundle/internal/tf/schema/data_source_aws_unity_catalog_assume_role_policy.go b/bundle/internal/tf/schema/data_source_aws_unity_catalog_assume_role_policy.go index 14d5c169..29d47f92 100644 --- a/bundle/internal/tf/schema/data_source_aws_unity_catalog_assume_role_policy.go +++ b/bundle/internal/tf/schema/data_source_aws_unity_catalog_assume_role_policy.go @@ -4,6 +4,7 @@ package schema type DataSourceAwsUnityCatalogAssumeRolePolicy struct { AwsAccountId string `json:"aws_account_id"` + AwsPartition string `json:"aws_partition,omitempty"` ExternalId string `json:"external_id"` Id string `json:"id,omitempty"` Json string `json:"json,omitempty"` diff --git a/bundle/internal/tf/schema/data_source_aws_unity_catalog_policy.go b/bundle/internal/tf/schema/data_source_aws_unity_catalog_policy.go index 2832bdf7..6d6acc57 100644 --- a/bundle/internal/tf/schema/data_source_aws_unity_catalog_policy.go +++ b/bundle/internal/tf/schema/data_source_aws_unity_catalog_policy.go @@ -4,6 +4,7 @@ package schema type DataSourceAwsUnityCatalogPolicy struct { AwsAccountId string `json:"aws_account_id"` + AwsPartition string `json:"aws_partition,omitempty"` BucketName string `json:"bucket_name"` Id string `json:"id,omitempty"` Json string `json:"json,omitempty"` diff --git a/bundle/internal/tf/schema/data_source_mws_network_connectivity_config.go b/bundle/internal/tf/schema/data_source_mws_network_connectivity_config.go new file mode 100644 index 00000000..5d03bd49 --- /dev/null +++ b/bundle/internal/tf/schema/data_source_mws_network_connectivity_config.go @@ -0,0 +1,51 @@ +// Generated from Databricks Terraform provider schema. DO NOT EDIT. + +package schema + +type DataSourceMwsNetworkConnectivityConfigEgressConfigDefaultRulesAwsStableIpRule struct { + CidrBlocks []string `json:"cidr_blocks,omitempty"` +} + +type DataSourceMwsNetworkConnectivityConfigEgressConfigDefaultRulesAzureServiceEndpointRule struct { + Subnets []string `json:"subnets,omitempty"` + TargetRegion string `json:"target_region,omitempty"` + TargetServices []string `json:"target_services,omitempty"` +} + +type DataSourceMwsNetworkConnectivityConfigEgressConfigDefaultRules struct { + AwsStableIpRule *DataSourceMwsNetworkConnectivityConfigEgressConfigDefaultRulesAwsStableIpRule `json:"aws_stable_ip_rule,omitempty"` + AzureServiceEndpointRule *DataSourceMwsNetworkConnectivityConfigEgressConfigDefaultRulesAzureServiceEndpointRule `json:"azure_service_endpoint_rule,omitempty"` +} + +type DataSourceMwsNetworkConnectivityConfigEgressConfigTargetRulesAzurePrivateEndpointRules struct { + ConnectionState string `json:"connection_state,omitempty"` + CreationTime int `json:"creation_time,omitempty"` + Deactivated bool `json:"deactivated,omitempty"` + DeactivatedAt int `json:"deactivated_at,omitempty"` + EndpointName string `json:"endpoint_name,omitempty"` + GroupId string `json:"group_id,omitempty"` + NetworkConnectivityConfigId string `json:"network_connectivity_config_id,omitempty"` + ResourceId string `json:"resource_id,omitempty"` + RuleId string `json:"rule_id,omitempty"` + UpdatedTime int `json:"updated_time,omitempty"` +} + +type DataSourceMwsNetworkConnectivityConfigEgressConfigTargetRules struct { + AzurePrivateEndpointRules []DataSourceMwsNetworkConnectivityConfigEgressConfigTargetRulesAzurePrivateEndpointRules `json:"azure_private_endpoint_rules,omitempty"` +} + +type DataSourceMwsNetworkConnectivityConfigEgressConfig struct { + DefaultRules *DataSourceMwsNetworkConnectivityConfigEgressConfigDefaultRules `json:"default_rules,omitempty"` + TargetRules *DataSourceMwsNetworkConnectivityConfigEgressConfigTargetRules `json:"target_rules,omitempty"` +} + +type DataSourceMwsNetworkConnectivityConfig struct { + AccountId string `json:"account_id,omitempty"` + CreationTime int `json:"creation_time,omitempty"` + Id string `json:"id,omitempty"` + Name string `json:"name"` + NetworkConnectivityConfigId string `json:"network_connectivity_config_id,omitempty"` + Region string `json:"region,omitempty"` + UpdatedTime int `json:"updated_time,omitempty"` + EgressConfig *DataSourceMwsNetworkConnectivityConfigEgressConfig `json:"egress_config,omitempty"` +} diff --git a/bundle/internal/tf/schema/data_source_mws_network_connectivity_configs.go b/bundle/internal/tf/schema/data_source_mws_network_connectivity_configs.go new file mode 100644 index 00000000..721483a9 --- /dev/null +++ b/bundle/internal/tf/schema/data_source_mws_network_connectivity_configs.go @@ -0,0 +1,9 @@ +// Generated from Databricks Terraform provider schema. DO NOT EDIT. + +package schema + +type DataSourceMwsNetworkConnectivityConfigs struct { + Id string `json:"id,omitempty"` + Names []string `json:"names,omitempty"` + Region string `json:"region,omitempty"` +} diff --git a/bundle/internal/tf/schema/data_source_registered_model_versions.go b/bundle/internal/tf/schema/data_source_registered_model_versions.go new file mode 100644 index 00000000..f70e58f8 --- /dev/null +++ b/bundle/internal/tf/schema/data_source_registered_model_versions.go @@ -0,0 +1,52 @@ +// Generated from Databricks Terraform provider schema. DO NOT EDIT. + +package schema + +type DataSourceRegisteredModelVersionsModelVersionsAliases struct { + AliasName string `json:"alias_name,omitempty"` + VersionNum int `json:"version_num,omitempty"` +} + +type DataSourceRegisteredModelVersionsModelVersionsModelVersionDependenciesDependenciesFunction struct { + FunctionFullName string `json:"function_full_name"` +} + +type DataSourceRegisteredModelVersionsModelVersionsModelVersionDependenciesDependenciesTable struct { + TableFullName string `json:"table_full_name"` +} + +type DataSourceRegisteredModelVersionsModelVersionsModelVersionDependenciesDependencies struct { + Function []DataSourceRegisteredModelVersionsModelVersionsModelVersionDependenciesDependenciesFunction `json:"function,omitempty"` + Table []DataSourceRegisteredModelVersionsModelVersionsModelVersionDependenciesDependenciesTable `json:"table,omitempty"` +} + +type DataSourceRegisteredModelVersionsModelVersionsModelVersionDependencies struct { + Dependencies []DataSourceRegisteredModelVersionsModelVersionsModelVersionDependenciesDependencies `json:"dependencies,omitempty"` +} + +type DataSourceRegisteredModelVersionsModelVersions struct { + BrowseOnly bool `json:"browse_only,omitempty"` + CatalogName string `json:"catalog_name,omitempty"` + Comment string `json:"comment,omitempty"` + CreatedAt int `json:"created_at,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + Id string `json:"id,omitempty"` + MetastoreId string `json:"metastore_id,omitempty"` + ModelName string `json:"model_name,omitempty"` + RunId string `json:"run_id,omitempty"` + RunWorkspaceId int `json:"run_workspace_id,omitempty"` + SchemaName string `json:"schema_name,omitempty"` + Source string `json:"source,omitempty"` + Status string `json:"status,omitempty"` + StorageLocation string `json:"storage_location,omitempty"` + UpdatedAt int `json:"updated_at,omitempty"` + UpdatedBy string `json:"updated_by,omitempty"` + Version int `json:"version,omitempty"` + Aliases []DataSourceRegisteredModelVersionsModelVersionsAliases `json:"aliases,omitempty"` + ModelVersionDependencies []DataSourceRegisteredModelVersionsModelVersionsModelVersionDependencies `json:"model_version_dependencies,omitempty"` +} + +type DataSourceRegisteredModelVersions struct { + FullName string `json:"full_name"` + ModelVersions []DataSourceRegisteredModelVersionsModelVersions `json:"model_versions,omitempty"` +} diff --git a/bundle/internal/tf/schema/data_source_serving_endpoints.go b/bundle/internal/tf/schema/data_source_serving_endpoints.go new file mode 100644 index 00000000..028121b5 --- /dev/null +++ b/bundle/internal/tf/schema/data_source_serving_endpoints.go @@ -0,0 +1,178 @@ +// Generated from Databricks Terraform provider schema. DO NOT EDIT. + +package schema + +type DataSourceServingEndpointsEndpointsAiGatewayGuardrailsInputPii struct { + Behavior string `json:"behavior"` +} + +type DataSourceServingEndpointsEndpointsAiGatewayGuardrailsInput struct { + InvalidKeywords []string `json:"invalid_keywords,omitempty"` + Safety bool `json:"safety,omitempty"` + ValidTopics []string `json:"valid_topics,omitempty"` + Pii []DataSourceServingEndpointsEndpointsAiGatewayGuardrailsInputPii `json:"pii,omitempty"` +} + +type DataSourceServingEndpointsEndpointsAiGatewayGuardrailsOutputPii struct { + Behavior string `json:"behavior"` +} + +type DataSourceServingEndpointsEndpointsAiGatewayGuardrailsOutput struct { + InvalidKeywords []string `json:"invalid_keywords,omitempty"` + Safety bool `json:"safety,omitempty"` + ValidTopics []string `json:"valid_topics,omitempty"` + Pii []DataSourceServingEndpointsEndpointsAiGatewayGuardrailsOutputPii `json:"pii,omitempty"` +} + +type DataSourceServingEndpointsEndpointsAiGatewayGuardrails struct { + Input []DataSourceServingEndpointsEndpointsAiGatewayGuardrailsInput `json:"input,omitempty"` + Output []DataSourceServingEndpointsEndpointsAiGatewayGuardrailsOutput `json:"output,omitempty"` +} + +type DataSourceServingEndpointsEndpointsAiGatewayInferenceTableConfig struct { + CatalogName string `json:"catalog_name,omitempty"` + Enabled bool `json:"enabled,omitempty"` + SchemaName string `json:"schema_name,omitempty"` + TableNamePrefix string `json:"table_name_prefix,omitempty"` +} + +type DataSourceServingEndpointsEndpointsAiGatewayRateLimits struct { + Calls int `json:"calls"` + Key string `json:"key,omitempty"` + RenewalPeriod string `json:"renewal_period"` +} + +type DataSourceServingEndpointsEndpointsAiGatewayUsageTrackingConfig struct { + Enabled bool `json:"enabled,omitempty"` +} + +type DataSourceServingEndpointsEndpointsAiGateway struct { + Guardrails []DataSourceServingEndpointsEndpointsAiGatewayGuardrails `json:"guardrails,omitempty"` + InferenceTableConfig []DataSourceServingEndpointsEndpointsAiGatewayInferenceTableConfig `json:"inference_table_config,omitempty"` + RateLimits []DataSourceServingEndpointsEndpointsAiGatewayRateLimits `json:"rate_limits,omitempty"` + UsageTrackingConfig []DataSourceServingEndpointsEndpointsAiGatewayUsageTrackingConfig `json:"usage_tracking_config,omitempty"` +} + +type DataSourceServingEndpointsEndpointsConfigServedEntitiesExternalModelAi21LabsConfig struct { + Ai21LabsApiKey string `json:"ai21labs_api_key,omitempty"` + Ai21LabsApiKeyPlaintext string `json:"ai21labs_api_key_plaintext,omitempty"` +} + +type DataSourceServingEndpointsEndpointsConfigServedEntitiesExternalModelAmazonBedrockConfig struct { + AwsAccessKeyId string `json:"aws_access_key_id,omitempty"` + AwsAccessKeyIdPlaintext string `json:"aws_access_key_id_plaintext,omitempty"` + AwsRegion string `json:"aws_region"` + AwsSecretAccessKey string `json:"aws_secret_access_key,omitempty"` + AwsSecretAccessKeyPlaintext string `json:"aws_secret_access_key_plaintext,omitempty"` + BedrockProvider string `json:"bedrock_provider"` +} + +type DataSourceServingEndpointsEndpointsConfigServedEntitiesExternalModelAnthropicConfig struct { + AnthropicApiKey string `json:"anthropic_api_key,omitempty"` + AnthropicApiKeyPlaintext string `json:"anthropic_api_key_plaintext,omitempty"` +} + +type DataSourceServingEndpointsEndpointsConfigServedEntitiesExternalModelCohereConfig struct { + CohereApiBase string `json:"cohere_api_base,omitempty"` + CohereApiKey string `json:"cohere_api_key,omitempty"` + CohereApiKeyPlaintext string `json:"cohere_api_key_plaintext,omitempty"` +} + +type DataSourceServingEndpointsEndpointsConfigServedEntitiesExternalModelDatabricksModelServingConfig struct { + DatabricksApiToken string `json:"databricks_api_token,omitempty"` + DatabricksApiTokenPlaintext string `json:"databricks_api_token_plaintext,omitempty"` + DatabricksWorkspaceUrl string `json:"databricks_workspace_url"` +} + +type DataSourceServingEndpointsEndpointsConfigServedEntitiesExternalModelGoogleCloudVertexAiConfig struct { + PrivateKey string `json:"private_key,omitempty"` + PrivateKeyPlaintext string `json:"private_key_plaintext,omitempty"` + ProjectId string `json:"project_id,omitempty"` + Region string `json:"region,omitempty"` +} + +type DataSourceServingEndpointsEndpointsConfigServedEntitiesExternalModelOpenaiConfig struct { + MicrosoftEntraClientId string `json:"microsoft_entra_client_id,omitempty"` + MicrosoftEntraClientSecret string `json:"microsoft_entra_client_secret,omitempty"` + MicrosoftEntraClientSecretPlaintext string `json:"microsoft_entra_client_secret_plaintext,omitempty"` + MicrosoftEntraTenantId string `json:"microsoft_entra_tenant_id,omitempty"` + OpenaiApiBase string `json:"openai_api_base,omitempty"` + OpenaiApiKey string `json:"openai_api_key,omitempty"` + OpenaiApiKeyPlaintext string `json:"openai_api_key_plaintext,omitempty"` + OpenaiApiType string `json:"openai_api_type,omitempty"` + OpenaiApiVersion string `json:"openai_api_version,omitempty"` + OpenaiDeploymentName string `json:"openai_deployment_name,omitempty"` + OpenaiOrganization string `json:"openai_organization,omitempty"` +} + +type DataSourceServingEndpointsEndpointsConfigServedEntitiesExternalModelPalmConfig struct { + PalmApiKey string `json:"palm_api_key,omitempty"` + PalmApiKeyPlaintext string `json:"palm_api_key_plaintext,omitempty"` +} + +type DataSourceServingEndpointsEndpointsConfigServedEntitiesExternalModel struct { + Name string `json:"name"` + Provider string `json:"provider"` + Task string `json:"task"` + Ai21LabsConfig []DataSourceServingEndpointsEndpointsConfigServedEntitiesExternalModelAi21LabsConfig `json:"ai21labs_config,omitempty"` + AmazonBedrockConfig []DataSourceServingEndpointsEndpointsConfigServedEntitiesExternalModelAmazonBedrockConfig `json:"amazon_bedrock_config,omitempty"` + AnthropicConfig []DataSourceServingEndpointsEndpointsConfigServedEntitiesExternalModelAnthropicConfig `json:"anthropic_config,omitempty"` + CohereConfig []DataSourceServingEndpointsEndpointsConfigServedEntitiesExternalModelCohereConfig `json:"cohere_config,omitempty"` + DatabricksModelServingConfig []DataSourceServingEndpointsEndpointsConfigServedEntitiesExternalModelDatabricksModelServingConfig `json:"databricks_model_serving_config,omitempty"` + GoogleCloudVertexAiConfig []DataSourceServingEndpointsEndpointsConfigServedEntitiesExternalModelGoogleCloudVertexAiConfig `json:"google_cloud_vertex_ai_config,omitempty"` + OpenaiConfig []DataSourceServingEndpointsEndpointsConfigServedEntitiesExternalModelOpenaiConfig `json:"openai_config,omitempty"` + PalmConfig []DataSourceServingEndpointsEndpointsConfigServedEntitiesExternalModelPalmConfig `json:"palm_config,omitempty"` +} + +type DataSourceServingEndpointsEndpointsConfigServedEntitiesFoundationModel struct { + Description string `json:"description,omitempty"` + DisplayName string `json:"display_name,omitempty"` + Docs string `json:"docs,omitempty"` + Name string `json:"name,omitempty"` +} + +type DataSourceServingEndpointsEndpointsConfigServedEntities struct { + EntityName string `json:"entity_name,omitempty"` + EntityVersion string `json:"entity_version,omitempty"` + Name string `json:"name,omitempty"` + ExternalModel []DataSourceServingEndpointsEndpointsConfigServedEntitiesExternalModel `json:"external_model,omitempty"` + FoundationModel []DataSourceServingEndpointsEndpointsConfigServedEntitiesFoundationModel `json:"foundation_model,omitempty"` +} + +type DataSourceServingEndpointsEndpointsConfigServedModels struct { + ModelName string `json:"model_name,omitempty"` + ModelVersion string `json:"model_version,omitempty"` + Name string `json:"name,omitempty"` +} + +type DataSourceServingEndpointsEndpointsConfig struct { + ServedEntities []DataSourceServingEndpointsEndpointsConfigServedEntities `json:"served_entities,omitempty"` + ServedModels []DataSourceServingEndpointsEndpointsConfigServedModels `json:"served_models,omitempty"` +} + +type DataSourceServingEndpointsEndpointsState struct { + ConfigUpdate string `json:"config_update,omitempty"` + Ready string `json:"ready,omitempty"` +} + +type DataSourceServingEndpointsEndpointsTags struct { + Key string `json:"key"` + Value string `json:"value,omitempty"` +} + +type DataSourceServingEndpointsEndpoints struct { + CreationTimestamp int `json:"creation_timestamp,omitempty"` + Creator string `json:"creator,omitempty"` + Id string `json:"id,omitempty"` + LastUpdatedTimestamp int `json:"last_updated_timestamp,omitempty"` + Name string `json:"name,omitempty"` + Task string `json:"task,omitempty"` + AiGateway []DataSourceServingEndpointsEndpointsAiGateway `json:"ai_gateway,omitempty"` + Config []DataSourceServingEndpointsEndpointsConfig `json:"config,omitempty"` + State []DataSourceServingEndpointsEndpointsState `json:"state,omitempty"` + Tags []DataSourceServingEndpointsEndpointsTags `json:"tags,omitempty"` +} + +type DataSourceServingEndpoints struct { + Endpoints []DataSourceServingEndpointsEndpoints `json:"endpoints,omitempty"` +} diff --git a/bundle/internal/tf/schema/data_sources.go b/bundle/internal/tf/schema/data_sources.go index e32609b0..3a59bf8c 100644 --- a/bundle/internal/tf/schema/data_sources.go +++ b/bundle/internal/tf/schema/data_sources.go @@ -33,6 +33,8 @@ type DataSources struct { MlflowModel map[string]any `json:"databricks_mlflow_model,omitempty"` MlflowModels map[string]any `json:"databricks_mlflow_models,omitempty"` MwsCredentials map[string]any `json:"databricks_mws_credentials,omitempty"` + MwsNetworkConnectivityConfig map[string]any `json:"databricks_mws_network_connectivity_config,omitempty"` + MwsNetworkConnectivityConfigs map[string]any `json:"databricks_mws_network_connectivity_configs,omitempty"` MwsWorkspaces map[string]any `json:"databricks_mws_workspaces,omitempty"` NodeType map[string]any `json:"databricks_node_type,omitempty"` Notebook map[string]any `json:"databricks_notebook,omitempty"` @@ -40,10 +42,12 @@ type DataSources struct { NotificationDestinations map[string]any `json:"databricks_notification_destinations,omitempty"` Pipelines map[string]any `json:"databricks_pipelines,omitempty"` RegisteredModel map[string]any `json:"databricks_registered_model,omitempty"` + RegisteredModelVersions map[string]any `json:"databricks_registered_model_versions,omitempty"` Schema map[string]any `json:"databricks_schema,omitempty"` Schemas map[string]any `json:"databricks_schemas,omitempty"` ServicePrincipal map[string]any `json:"databricks_service_principal,omitempty"` ServicePrincipals map[string]any `json:"databricks_service_principals,omitempty"` + ServingEndpoints map[string]any `json:"databricks_serving_endpoints,omitempty"` Share map[string]any `json:"databricks_share,omitempty"` Shares map[string]any `json:"databricks_shares,omitempty"` SparkVersion map[string]any `json:"databricks_spark_version,omitempty"` @@ -92,6 +96,8 @@ func NewDataSources() *DataSources { MlflowModel: make(map[string]any), MlflowModels: make(map[string]any), MwsCredentials: make(map[string]any), + MwsNetworkConnectivityConfig: make(map[string]any), + MwsNetworkConnectivityConfigs: make(map[string]any), MwsWorkspaces: make(map[string]any), NodeType: make(map[string]any), Notebook: make(map[string]any), @@ -99,10 +105,12 @@ func NewDataSources() *DataSources { NotificationDestinations: make(map[string]any), Pipelines: make(map[string]any), RegisteredModel: make(map[string]any), + RegisteredModelVersions: make(map[string]any), Schema: make(map[string]any), Schemas: make(map[string]any), ServicePrincipal: make(map[string]any), ServicePrincipals: make(map[string]any), + ServingEndpoints: make(map[string]any), Share: make(map[string]any), Shares: make(map[string]any), SparkVersion: make(map[string]any), diff --git a/bundle/internal/tf/schema/resource_app.go b/bundle/internal/tf/schema/resource_app.go new file mode 100644 index 00000000..bd787f3f --- /dev/null +++ b/bundle/internal/tf/schema/resource_app.go @@ -0,0 +1,103 @@ +// Generated from Databricks Terraform provider schema. DO NOT EDIT. + +package schema + +type ResourceAppActiveDeploymentDeploymentArtifacts struct { + SourceCodePath string `json:"source_code_path,omitempty"` +} + +type ResourceAppActiveDeploymentStatus struct { + Message string `json:"message,omitempty"` + State string `json:"state,omitempty"` +} + +type ResourceAppActiveDeployment struct { + CreateTime string `json:"create_time,omitempty"` + Creator string `json:"creator,omitempty"` + DeploymentId string `json:"deployment_id,omitempty"` + Mode string `json:"mode,omitempty"` + SourceCodePath string `json:"source_code_path,omitempty"` + UpdateTime string `json:"update_time,omitempty"` + DeploymentArtifacts *ResourceAppActiveDeploymentDeploymentArtifacts `json:"deployment_artifacts,omitempty"` + Status *ResourceAppActiveDeploymentStatus `json:"status,omitempty"` +} + +type ResourceAppAppStatus struct { + Message string `json:"message,omitempty"` + State string `json:"state,omitempty"` +} + +type ResourceAppComputeStatus struct { + Message string `json:"message,omitempty"` + State string `json:"state,omitempty"` +} + +type ResourceAppPendingDeploymentDeploymentArtifacts struct { + SourceCodePath string `json:"source_code_path,omitempty"` +} + +type ResourceAppPendingDeploymentStatus struct { + Message string `json:"message,omitempty"` + State string `json:"state,omitempty"` +} + +type ResourceAppPendingDeployment struct { + CreateTime string `json:"create_time,omitempty"` + Creator string `json:"creator,omitempty"` + DeploymentId string `json:"deployment_id,omitempty"` + Mode string `json:"mode,omitempty"` + SourceCodePath string `json:"source_code_path,omitempty"` + UpdateTime string `json:"update_time,omitempty"` + DeploymentArtifacts *ResourceAppPendingDeploymentDeploymentArtifacts `json:"deployment_artifacts,omitempty"` + Status *ResourceAppPendingDeploymentStatus `json:"status,omitempty"` +} + +type ResourceAppResourceJob struct { + Id string `json:"id"` + Permission string `json:"permission"` +} + +type ResourceAppResourceSecret struct { + Key string `json:"key"` + Permission string `json:"permission"` + Scope string `json:"scope"` +} + +type ResourceAppResourceServingEndpoint struct { + Name string `json:"name"` + Permission string `json:"permission"` +} + +type ResourceAppResourceSqlWarehouse struct { + Id string `json:"id"` + Permission string `json:"permission"` +} + +type ResourceAppResource struct { + Description string `json:"description,omitempty"` + Name string `json:"name"` + Job *ResourceAppResourceJob `json:"job,omitempty"` + Secret *ResourceAppResourceSecret `json:"secret,omitempty"` + ServingEndpoint *ResourceAppResourceServingEndpoint `json:"serving_endpoint,omitempty"` + SqlWarehouse *ResourceAppResourceSqlWarehouse `json:"sql_warehouse,omitempty"` +} + +type ResourceApp struct { + CreateTime string `json:"create_time,omitempty"` + Creator string `json:"creator,omitempty"` + DefaultSourceCodePath string `json:"default_source_code_path,omitempty"` + Description string `json:"description,omitempty"` + Id string `json:"id,omitempty"` + Name string `json:"name"` + ServicePrincipalClientId string `json:"service_principal_client_id,omitempty"` + ServicePrincipalId int `json:"service_principal_id,omitempty"` + ServicePrincipalName string `json:"service_principal_name,omitempty"` + UpdateTime string `json:"update_time,omitempty"` + Updater string `json:"updater,omitempty"` + Url string `json:"url,omitempty"` + ActiveDeployment *ResourceAppActiveDeployment `json:"active_deployment,omitempty"` + AppStatus *ResourceAppAppStatus `json:"app_status,omitempty"` + ComputeStatus *ResourceAppComputeStatus `json:"compute_status,omitempty"` + PendingDeployment *ResourceAppPendingDeployment `json:"pending_deployment,omitempty"` + Resource []ResourceAppResource `json:"resource,omitempty"` +} diff --git a/bundle/internal/tf/schema/resource_permissions.go b/bundle/internal/tf/schema/resource_permissions.go index 0c3b90ed..7dfb84b5 100644 --- a/bundle/internal/tf/schema/resource_permissions.go +++ b/bundle/internal/tf/schema/resource_permissions.go @@ -10,29 +10,31 @@ type ResourcePermissionsAccessControl struct { } type ResourcePermissions struct { - Authorization string `json:"authorization,omitempty"` - ClusterId string `json:"cluster_id,omitempty"` - ClusterPolicyId string `json:"cluster_policy_id,omitempty"` - DashboardId string `json:"dashboard_id,omitempty"` - DirectoryId string `json:"directory_id,omitempty"` - DirectoryPath string `json:"directory_path,omitempty"` - ExperimentId string `json:"experiment_id,omitempty"` - Id string `json:"id,omitempty"` - InstancePoolId string `json:"instance_pool_id,omitempty"` - JobId string `json:"job_id,omitempty"` - NotebookId string `json:"notebook_id,omitempty"` - NotebookPath string `json:"notebook_path,omitempty"` - ObjectType string `json:"object_type,omitempty"` - PipelineId string `json:"pipeline_id,omitempty"` - RegisteredModelId string `json:"registered_model_id,omitempty"` - RepoId string `json:"repo_id,omitempty"` - RepoPath string `json:"repo_path,omitempty"` - ServingEndpointId string `json:"serving_endpoint_id,omitempty"` - SqlAlertId string `json:"sql_alert_id,omitempty"` - SqlDashboardId string `json:"sql_dashboard_id,omitempty"` - SqlEndpointId string `json:"sql_endpoint_id,omitempty"` - SqlQueryId string `json:"sql_query_id,omitempty"` - WorkspaceFileId string `json:"workspace_file_id,omitempty"` - WorkspaceFilePath string `json:"workspace_file_path,omitempty"` - AccessControl []ResourcePermissionsAccessControl `json:"access_control,omitempty"` + AppName string `json:"app_name,omitempty"` + Authorization string `json:"authorization,omitempty"` + ClusterId string `json:"cluster_id,omitempty"` + ClusterPolicyId string `json:"cluster_policy_id,omitempty"` + DashboardId string `json:"dashboard_id,omitempty"` + DirectoryId string `json:"directory_id,omitempty"` + DirectoryPath string `json:"directory_path,omitempty"` + ExperimentId string `json:"experiment_id,omitempty"` + Id string `json:"id,omitempty"` + InstancePoolId string `json:"instance_pool_id,omitempty"` + JobId string `json:"job_id,omitempty"` + NotebookId string `json:"notebook_id,omitempty"` + NotebookPath string `json:"notebook_path,omitempty"` + ObjectType string `json:"object_type,omitempty"` + PipelineId string `json:"pipeline_id,omitempty"` + RegisteredModelId string `json:"registered_model_id,omitempty"` + RepoId string `json:"repo_id,omitempty"` + RepoPath string `json:"repo_path,omitempty"` + ServingEndpointId string `json:"serving_endpoint_id,omitempty"` + SqlAlertId string `json:"sql_alert_id,omitempty"` + SqlDashboardId string `json:"sql_dashboard_id,omitempty"` + SqlEndpointId string `json:"sql_endpoint_id,omitempty"` + SqlQueryId string `json:"sql_query_id,omitempty"` + VectorSearchEndpointId string `json:"vector_search_endpoint_id,omitempty"` + WorkspaceFileId string `json:"workspace_file_id,omitempty"` + WorkspaceFilePath string `json:"workspace_file_path,omitempty"` + AccessControl []ResourcePermissionsAccessControl `json:"access_control,omitempty"` } diff --git a/bundle/internal/tf/schema/resources.go b/bundle/internal/tf/schema/resources.go index ea5b618f..b6afa818 100644 --- a/bundle/internal/tf/schema/resources.go +++ b/bundle/internal/tf/schema/resources.go @@ -5,6 +5,7 @@ package schema type Resources struct { AccessControlRuleSet map[string]any `json:"databricks_access_control_rule_set,omitempty"` Alert map[string]any `json:"databricks_alert,omitempty"` + App map[string]any `json:"databricks_app,omitempty"` ArtifactAllowlist map[string]any `json:"databricks_artifact_allowlist,omitempty"` AutomaticClusterUpdateWorkspaceSetting map[string]any `json:"databricks_automatic_cluster_update_workspace_setting,omitempty"` AwsS3Mount map[string]any `json:"databricks_aws_s3_mount,omitempty"` @@ -111,6 +112,7 @@ func NewResources() *Resources { return &Resources{ AccessControlRuleSet: make(map[string]any), Alert: make(map[string]any), + App: make(map[string]any), ArtifactAllowlist: make(map[string]any), AutomaticClusterUpdateWorkspaceSetting: make(map[string]any), AwsS3Mount: make(map[string]any), diff --git a/bundle/phases/initialize.go b/bundle/phases/initialize.go index 3d5ad5e8..76703604 100644 --- a/bundle/phases/initialize.go +++ b/bundle/phases/initialize.go @@ -37,6 +37,8 @@ func Initialize() bundle.Mutator { mutator.MergeJobParameters(), mutator.MergeJobTasks(), mutator.MergePipelineClusters(), + mutator.MergeApps(), + mutator.InitializeWorkspaceClient(), mutator.PopulateCurrentUser(), diff --git a/bundle/run/app.go b/bundle/run/app.go new file mode 100644 index 00000000..cbf2d013 --- /dev/null +++ b/bundle/run/app.go @@ -0,0 +1,248 @@ +package run + +import ( + "bytes" + "context" + "fmt" + "path" + "path/filepath" + "time" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/bundle/deploy" + "github.com/databricks/cli/bundle/run/output" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/filer" + "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/spf13/cobra" + + "gopkg.in/yaml.v3" +) + +func logProgress(ctx context.Context, msg string) { + if msg == "" { + return + } + cmdio.LogString(ctx, fmt.Sprintf("✓ %s", msg)) +} + +type appRunner struct { + key + + bundle *bundle.Bundle + app *resources.App + + filerFactory deploy.FilerFactory +} + +func (a *appRunner) Name() string { + if a.app == nil { + return "" + } + + return a.app.Name +} + +func (a *appRunner) Run(ctx context.Context, opts *Options) (output.RunOutput, error) { + app := a.app + b := a.bundle + if app == nil { + return nil, fmt.Errorf("app is not defined") + } + + logProgress(ctx, fmt.Sprintf("Getting the status of the app %s", app.Name)) + w := b.WorkspaceClient() + + // Check the status of the app first. + createdApp, err := w.Apps.Get(ctx, apps.GetAppRequest{Name: app.Name}) + if err != nil { + return nil, err + } + + if createdApp.AppStatus != nil { + logProgress(ctx, fmt.Sprintf("App is in %s state", createdApp.AppStatus.State)) + } + + // If the app is not running, start it. + if createdApp.AppStatus == nil || createdApp.AppStatus.State != apps.ApplicationStateRunning { + err := a.start(ctx) + if err != nil { + return nil, err + } + } + + // Deploy the app. + err = a.deploy(ctx) + if err != nil { + return nil, err + } + + // TODO: We should return the app URL here. + cmdio.LogString(ctx, "You can access the app at ") + return nil, nil +} + +func (a *appRunner) start(ctx context.Context) error { + app := a.app + b := a.bundle + w := b.WorkspaceClient() + + logProgress(ctx, fmt.Sprintf("Starting the app %s", app.Name)) + wait, err := w.Apps.Start(ctx, apps.StartAppRequest{Name: app.Name}) + if err != nil { + return err + } + + startedApp, err := wait.OnProgress(func(p *apps.App) { + if p.AppStatus == nil { + return + } + logProgress(ctx, "App is starting...") + }).Get() + + if err != nil { + return err + } + + // If the app has a pending deployment, wait for it to complete. + if startedApp.PendingDeployment != nil { + _, err := w.Apps.WaitGetDeploymentAppSucceeded(ctx, + startedApp.Name, startedApp.PendingDeployment.DeploymentId, + 20*time.Minute, nil) + + if err != nil { + return err + } + } + + // If the app has an active deployment, wait for it to complete as well + if startedApp.ActiveDeployment != nil { + _, err := w.Apps.WaitGetDeploymentAppSucceeded(ctx, + startedApp.Name, startedApp.ActiveDeployment.DeploymentId, + 20*time.Minute, nil) + + if err != nil { + return err + } + } + + logProgress(ctx, "App is started!") + return nil +} + +func (a *appRunner) deploy(ctx context.Context) error { + app := a.app + b := a.bundle + w := b.WorkspaceClient() + + // If the app has a config, we need to deploy it first. + // It means we need to write app.yml file with the content of the config field + // to the remote source code path of the app. + if app.Config != nil { + appPath, err := filepath.Rel(b.Config.Workspace.FilePath, app.SourceCodePath) + if err != nil { + return fmt.Errorf("failed to get relative path of app source code path: %w", err) + } + + buf, err := configToYaml(app) + if err != nil { + return err + } + + // When the app is started, create a new app deployment and wait for it to complete. + f, err := a.filerFactory(b) + if err != nil { + return err + } + + err = f.Write(ctx, path.Join(appPath, "app.yml"), buf, filer.OverwriteIfExists) + if err != nil { + return fmt.Errorf("failed to write %s file: %w", path.Join(app.SourceCodePath, "app.yml"), err) + } + } + + wait, err := w.Apps.Deploy(ctx, apps.CreateAppDeploymentRequest{ + AppName: app.Name, + AppDeployment: &apps.AppDeployment{ + Mode: apps.AppDeploymentModeSnapshot, + SourceCodePath: app.SourceCodePath, + }, + }) + + if err != nil { + return err + } + + _, err = wait.OnProgress(func(ad *apps.AppDeployment) { + if ad.Status == nil { + return + } + logProgress(ctx, ad.Status.Message) + }).Get() + + if err != nil { + return err + } + + return nil +} + +func (a *appRunner) Cancel(ctx context.Context) error { + // We should cancel the app by stopping it. + app := a.app + b := a.bundle + if app == nil { + return fmt.Errorf("app is not defined") + } + + w := b.WorkspaceClient() + + logProgress(ctx, fmt.Sprintf("Stopping app %s", app.Name)) + wait, err := w.Apps.Stop(ctx, apps.StopAppRequest{Name: app.Name}) + if err != nil { + return err + } + + _, err = wait.OnProgress(func(p *apps.App) { + if p.AppStatus == nil { + return + } + logProgress(ctx, p.AppStatus.Message) + }).Get() + + logProgress(ctx, "App is stopped!") + return err +} + +func (a *appRunner) Restart(ctx context.Context, opts *Options) (output.RunOutput, error) { + // We should restart the app by just running it again meaning a new app deployment will be done. + return a.Run(ctx, opts) +} + +func (a *appRunner) ParseArgs(args []string, opts *Options) error { + if len(args) == 0 { + return nil + } + + return fmt.Errorf("received %d unexpected positional arguments", len(args)) +} + +func (a *appRunner) CompleteArgs(args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveNoFileComp +} + +func configToYaml(app *resources.App) (*bytes.Buffer, error) { + buf := bytes.NewBuffer(nil) + enc := yaml.NewEncoder(buf) + enc.SetIndent(2) + + err := enc.Encode(app.Config) + defer enc.Close() + + if err != nil { + return nil, fmt.Errorf("failed to encode app config to yaml: %w", err) + } + + return buf, nil +} diff --git a/bundle/run/app_test.go b/bundle/run/app_test.go new file mode 100644 index 00000000..731219f1 --- /dev/null +++ b/bundle/run/app_test.go @@ -0,0 +1,208 @@ +package run + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/config" + "github.com/databricks/cli/bundle/config/mutator" + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/bundle/internal/bundletest" + mockfiler "github.com/databricks/cli/internal/mocks/libs/filer" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/filer" + "github.com/databricks/cli/libs/flags" + "github.com/databricks/cli/libs/vfs" + "github.com/databricks/databricks-sdk-go/experimental/mocks" + "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type testAppRunner struct { + m *mocks.MockWorkspaceClient + b *bundle.Bundle + mockFiler *mockfiler.MockFiler + ctx context.Context +} + +func (ta *testAppRunner) run(t *testing.T) { + r := appRunner{ + key: "my_app", + bundle: ta.b, + app: ta.b.Config.Resources.Apps["my_app"], + filerFactory: func(b *bundle.Bundle) (filer.Filer, error) { + return ta.mockFiler, nil + }, + } + + _, err := r.Run(ta.ctx, &Options{}) + require.NoError(t, err) +} + +func setupBundle(t *testing.T) (context.Context, *bundle.Bundle, *mocks.MockWorkspaceClient) { + root := t.TempDir() + err := os.MkdirAll(filepath.Join(root, "my_app"), 0700) + require.NoError(t, err) + + b := &bundle.Bundle{ + BundleRootPath: root, + SyncRoot: vfs.MustNew(root), + Config: config.Root{ + Workspace: config.Workspace{ + RootPath: "/Workspace/Users/foo@bar.com/", + }, + Resources: config.Resources{ + Apps: map[string]*resources.App{ + "my_app": { + App: &apps.App{ + Name: "my_app", + }, + SourceCodePath: "./my_app", + Config: map[string]interface{}{ + "command": []string{"echo", "hello"}, + "env": []map[string]string{ + {"name": "MY_APP", "value": "my value"}, + }, + }, + }, + }, + }, + }, + } + + mwc := mocks.NewMockWorkspaceClient(t) + b.SetWorkpaceClient(mwc.WorkspaceClient) + bundletest.SetLocation(b, "resources.apps.my_app", []dyn.Location{{File: "./databricks.yml"}}) + + ctx := context.Background() + ctx = cmdio.InContext(ctx, cmdio.NewIO(flags.OutputText, &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}, "", "...")) + ctx = cmdio.NewContext(ctx, cmdio.NewLogger(flags.ModeAppend)) + + diags := bundle.Apply(ctx, b, bundle.Seq( + mutator.DefineDefaultWorkspacePaths(), + mutator.TranslatePaths(), + )) + require.Empty(t, diags) + + return ctx, b, mwc +} + +func setupTestApp(t *testing.T, initialAppState apps.ApplicationState) *testAppRunner { + ctx, b, mwc := setupBundle(t) + + appApi := mwc.GetMockAppsAPI() + appApi.EXPECT().Get(mock.Anything, apps.GetAppRequest{ + Name: "my_app", + }).Return(&apps.App{ + Name: "my_app", + AppStatus: &apps.ApplicationStatus{ + State: initialAppState, + }, + }, nil) + + wait := &apps.WaitGetDeploymentAppSucceeded[apps.AppDeployment]{ + Poll: func(_ time.Duration, _ func(*apps.AppDeployment)) (*apps.AppDeployment, error) { + return nil, nil + }, + } + appApi.EXPECT().Deploy(mock.Anything, apps.CreateAppDeploymentRequest{ + AppName: "my_app", + AppDeployment: &apps.AppDeployment{ + Mode: apps.AppDeploymentModeSnapshot, + SourceCodePath: "/Workspace/Users/foo@bar.com/files/my_app", + }, + }).Return(wait, nil) + + mockFiler := mockfiler.NewMockFiler(t) + mockFiler.EXPECT().Write(mock.Anything, "my_app/app.yml", bytes.NewBufferString(`command: + - echo + - hello +env: + - name: MY_APP + value: my value +`), filer.OverwriteIfExists).Return(nil) + + return &testAppRunner{ + m: mwc, + b: b, + mockFiler: mockFiler, + ctx: ctx, + } +} + +func TestAppRunStartedApp(t *testing.T) { + r := setupTestApp(t, apps.ApplicationStateRunning) + r.run(t) +} + +func TestAppRunStoppedApp(t *testing.T) { + r := setupTestApp(t, apps.ApplicationStateCrashed) + + appsApi := r.m.GetMockAppsAPI() + appsApi.EXPECT().Start(mock.Anything, apps.StartAppRequest{ + Name: "my_app", + }).Return(&apps.WaitGetAppActive[apps.App]{ + Poll: func(_ time.Duration, _ func(*apps.App)) (*apps.App, error) { + return &apps.App{ + Name: "my_app", + AppStatus: &apps.ApplicationStatus{ + State: apps.ApplicationStateRunning, + }, + ActiveDeployment: &apps.AppDeployment{ + SourceCodePath: "/foo/bar", + DeploymentId: "123", + Status: &apps.AppDeploymentStatus{ + State: apps.AppDeploymentStateInProgress, + }, + }, + PendingDeployment: &apps.AppDeployment{ + SourceCodePath: "/foo/bar", + DeploymentId: "456", + Status: &apps.AppDeploymentStatus{ + State: apps.AppDeploymentStateInProgress, + }, + }, + }, nil + }, + }, nil) + + appsApi.EXPECT().WaitGetDeploymentAppSucceeded(mock.Anything, "my_app", "123", mock.Anything, mock.Anything).Return(nil, nil) + appsApi.EXPECT().WaitGetDeploymentAppSucceeded(mock.Anything, "my_app", "456", mock.Anything, mock.Anything).Return(nil, nil) + r.run(t) +} + +func TestStopApp(t *testing.T) { + ctx, b, mwc := setupBundle(t) + appsApi := mwc.GetMockAppsAPI() + appsApi.EXPECT().Stop(mock.Anything, apps.StopAppRequest{ + Name: "my_app", + }).Return(&apps.WaitGetAppStopped[apps.App]{ + Poll: func(_ time.Duration, _ func(*apps.App)) (*apps.App, error) { + return &apps.App{ + Name: "my_app", + AppStatus: &apps.ApplicationStatus{ + State: apps.ApplicationStateUnavailable, + }, + }, nil + }, + }, nil) + + r := appRunner{ + key: "my_app", + bundle: b, + app: b.Config.Resources.Apps["my_app"], + filerFactory: func(b *bundle.Bundle) (filer.Filer, error) { + return nil, nil + }, + } + + err := r.Cancel(ctx) + require.NoError(t, err) +} diff --git a/bundle/run/runner.go b/bundle/run/runner.go index 4c907d06..e4706ca5 100644 --- a/bundle/run/runner.go +++ b/bundle/run/runner.go @@ -8,6 +8,7 @@ import ( "github.com/databricks/cli/bundle/config/resources" refs "github.com/databricks/cli/bundle/resources" "github.com/databricks/cli/bundle/run/output" + "github.com/databricks/cli/libs/filer" ) type key string @@ -42,7 +43,7 @@ type Runner interface { // IsRunnable returns a filter that only allows runnable resources. func IsRunnable(ref refs.Reference) bool { switch ref.Resource.(type) { - case *resources.Job, *resources.Pipeline: + case *resources.Job, *resources.Pipeline, *resources.App: return true default: return false @@ -56,6 +57,15 @@ func ToRunner(b *bundle.Bundle, ref refs.Reference) (Runner, error) { return &jobRunner{key: key(ref.KeyWithType), bundle: b, job: resource}, nil case *resources.Pipeline: return &pipelineRunner{key: key(ref.KeyWithType), bundle: b, pipeline: resource}, nil + case *resources.App: + return &appRunner{ + key: key(ref.KeyWithType), + bundle: b, + app: resource, + filerFactory: func(b *bundle.Bundle) (filer.Filer, error) { + return filer.NewWorkspaceFilesClient(b.WorkspaceClient(), b.Config.Workspace.FilePath) + }, + }, nil default: return nil, fmt.Errorf("unsupported resource type: %T", resource) } diff --git a/bundle/tests/apps/databricks.yml b/bundle/tests/apps/databricks.yml new file mode 100644 index 00000000..ad7e9300 --- /dev/null +++ b/bundle/tests/apps/databricks.yml @@ -0,0 +1,71 @@ +bundle: + name: apps + +workspace: + host: https://acme.cloud.databricks.com/ + +variables: + app_config: + type: complex + default: + command: + - "python" + - "app.py" + env: + - name: SOME_ENV_VARIABLE + value: "Some value" + +resources: + apps: + my_app: + name: "my-app" + description: "My App" + source_code_path: ./app + config: ${var.app_config} + + resources: + - name: "my-sql-warehouse" + sql_warehouse: + id: 1234 + permission: "CAN_USE" + - name: "my-job" + job: + id: 5678 + permission: "CAN_MANAGE_RUN" + permissions: + - user_name: "foo@bar.com" + level: "CAN_VIEW" + - service_principal_name: "my_sp" + level: "CAN_MANAGE" + + +targets: + default: + + development: + variables: + app_config: + command: + - "python" + - "dev.py" + env: + - name: SOME_ENV_VARIABLE_2 + value: "Some value 2" + resources: + apps: + my_app: + source_code_path: ./app-dev + resources: + - name: "my-sql-warehouse" + sql_warehouse: + id: 1234 + permission: "CAN_MANAGE" + - name: "my-job" + job: + id: 5678 + permission: "CAN_MANAGE" + - name: "my-secret" + secret: + key: "key" + scope: "scope" + permission: "CAN_USE" diff --git a/bundle/tests/apps_test.go b/bundle/tests/apps_test.go new file mode 100644 index 00000000..86c8eb08 --- /dev/null +++ b/bundle/tests/apps_test.go @@ -0,0 +1,61 @@ +package config_tests + +import ( + "context" + "testing" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/config/mutator" + "github.com/stretchr/testify/assert" +) + +func TestApps(t *testing.T) { + b := load(t, "./apps") + assert.Equal(t, "apps", b.Config.Bundle.Name) + + diags := bundle.Apply(context.Background(), b, + bundle.Seq( + mutator.SetVariables(), + mutator.ResolveVariableReferences("variables"), + )) + assert.Empty(t, diags) + + app := b.Config.Resources.Apps["my_app"] + assert.Equal(t, "my-app", app.Name) + assert.Equal(t, "My App", app.Description) + assert.Equal(t, []interface{}{"python", "app.py"}, app.Config["command"]) + assert.Equal(t, []interface{}{map[string]interface{}{"name": "SOME_ENV_VARIABLE", "value": "Some value"}}, app.Config["env"]) + + assert.Len(t, app.Resources, 2) + assert.Equal(t, "1234", app.Resources[0].SqlWarehouse.Id) + assert.Equal(t, "CAN_USE", string(app.Resources[0].SqlWarehouse.Permission)) + assert.Equal(t, "5678", app.Resources[1].Job.Id) + assert.Equal(t, "CAN_MANAGE_RUN", string(app.Resources[1].Job.Permission)) +} + +func TestAppsOverride(t *testing.T) { + b := loadTarget(t, "./apps", "development") + assert.Equal(t, "apps", b.Config.Bundle.Name) + + diags := bundle.Apply(context.Background(), b, + bundle.Seq( + mutator.SetVariables(), + mutator.ResolveVariableReferences("variables"), + )) + assert.Empty(t, diags) + app := b.Config.Resources.Apps["my_app"] + assert.Equal(t, "my-app", app.Name) + assert.Equal(t, "My App", app.Description) + assert.Equal(t, []interface{}{"python", "dev.py"}, app.Config["command"]) + assert.Equal(t, []interface{}{map[string]interface{}{"name": "SOME_ENV_VARIABLE_2", "value": "Some value 2"}}, app.Config["env"]) + + assert.Len(t, app.Resources, 3) + assert.Equal(t, "1234", app.Resources[0].SqlWarehouse.Id) + assert.Equal(t, "CAN_MANAGE", string(app.Resources[0].SqlWarehouse.Permission)) + assert.Equal(t, "5678", app.Resources[1].Job.Id) + assert.Equal(t, "CAN_MANAGE", string(app.Resources[1].Job.Permission)) + assert.Equal(t, "key", app.Resources[2].Secret.Key) + assert.Equal(t, "scope", app.Resources[2].Secret.Scope) + assert.Equal(t, "CAN_USE", string(app.Resources[2].Secret.Permission)) + +} diff --git a/bundle/tests/loader.go b/bundle/tests/loader.go index 5c48d81c..8e009db4 100644 --- a/bundle/tests/loader.go +++ b/bundle/tests/loader.go @@ -46,6 +46,7 @@ func loadTargetWithDiags(path, env string) (*bundle.Bundle, diag.Diagnostics) { mutator.MergeJobParameters(), mutator.MergeJobTasks(), mutator.MergePipelineClusters(), + mutator.MergeApps(), )) return b, diags }