diff --git a/.nextchanges/bundles/app-appkit.md b/.nextchanges/bundles/app-appkit.md new file mode 100644 index 00000000000..2fc1b0f63ce --- /dev/null +++ b/.nextchanges/bundles/app-appkit.md @@ -0,0 +1 @@ +* Add `app-appkit` to bundle templates to simplify starting a Databricks Apps project. ([#7004](https://github.com/databricks/cli/pull/7004), [#7007](https://github.com/databricks/cli/pull/7007)) diff --git a/acceptance/bundle/help/bundle-init/output.txt b/acceptance/bundle/help/bundle-init/output.txt index dad5e198e4c..521f69434c3 100644 --- a/acceptance/bundle/help/bundle-init/output.txt +++ b/acceptance/bundle/help/bundle-init/output.txt @@ -10,17 +10,16 @@ TEMPLATE_PATH optionally specifies which template to use. It can be one of the f - dbt-sql: The dbt SQL template (databricks.com/blog/delivering-cost-effective-data-real-time-dbt-and-databricks) - mlops-stacks: The Databricks MLOps Stacks template (github.com/databricks/mlops-stacks) - pydabs: A variant of the 'default-python' template that defines resources in Python instead of YAML +- app-appkit: Databricks App with AppKit (TypeScript) - a local file system path with a template directory - a Git repository URL, e.g. https://github.com/my/repository Examples: - databricks bundle init # Choose from built-in templates - databricks bundle init default-python # Python jobs and notebooks - databricks bundle init dbt-sql # dbt + SQL warehouse project - databricks bundle init --output-dir ./my-project + databricks bundle init default-python + databricks bundle init app-appkit --config-file input.json # {"project_name":"my-app"} After initialization: - databricks bundle deploy --target dev + databricks bundle deploy See https://docs.databricks.com/en/dev-tools/bundles/templates.html for more information on templates. diff --git a/acceptance/bundle/templates/app-appkit/input-version.json b/acceptance/bundle/templates/app-appkit/input-version.json new file mode 100644 index 00000000000..9e6199e9c3c --- /dev/null +++ b/acceptance/bundle/templates/app-appkit/input-version.json @@ -0,0 +1,4 @@ +{ + "project_name": "appkit-bundle-test", + "version": "0.76.1" +} diff --git a/acceptance/bundle/templates/app-appkit/input.json b/acceptance/bundle/templates/app-appkit/input.json new file mode 100644 index 00000000000..e6bf923499b --- /dev/null +++ b/acceptance/bundle/templates/app-appkit/input.json @@ -0,0 +1,5 @@ +{ + "project_name": "appkit-bundle-test", + "app_description": "AppKit bundle initialization test", + "skip_install": true +} diff --git a/acceptance/bundle/templates/app-appkit/out.test.toml b/acceptance/bundle/templates/app-appkit/out.test.toml new file mode 100644 index 00000000000..27ec2a7fcd6 --- /dev/null +++ b/acceptance/bundle/templates/app-appkit/out.test.toml @@ -0,0 +1,3 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.DMS = [""] diff --git a/acceptance/bundle/templates/app-appkit/output.txt b/acceptance/bundle/templates/app-appkit/output.txt new file mode 100644 index 00000000000..c2fe65dc66f --- /dev/null +++ b/acceptance/bundle/templates/app-appkit/output.txt @@ -0,0 +1,63 @@ + +=== Missing project name fails without a terminal +>>> musterr [CLI] bundle init app-appkit +Error: app-appkit requires project_name in --config-file in non-interactive mode + +=== Unsupported template directory fails before initialization +>>> musterr [CLI] bundle init app-appkit --template-dir template +Error: --template-dir is not supported for app-appkit; use databricks apps init --template for a custom AppKit template + +=== Version and branch cannot be combined +>>> musterr [CLI] bundle init app-appkit --config-file input-version.json --branch main +Error: version in --config-file cannot be combined with --tag or --branch + +=== JSON output is unsupported +>>> musterr [CLI] bundle init app-appkit --config-file input.json -o json +Error: -o json is not supported for app-appkit; use text output + +=== Initialize AppKit from a config file without a terminal +>>> [CLI] bundle init app-appkit --config-file input.json --output-dir output +✔ Creating project + +✔ Project created successfully! + + Location: [TEST_TMP_DIR]/output/appkit-bundle-test + Files: 5 + + Next steps: + + cd appkit-bundle-test + pnpm install --frozen-lockfile && pnpm run dev + + +>>> [CLI] bundle validate +Name: appkit-bundle-test +Target: default +Workspace: + Host: [DATABRICKS_URL] + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/appkit-bundle-test/default + +Validation OK! + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/appkit-bundle-test/default/files... +✓ Deployment succeeded +Created apps.app +Files: 5 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged + +>>> [CLI] apps get appkit-bundle-test -o json +{ + "name": "appkit-bundle-test", + "compute_state": "ACTIVE", + "deployment_state": "SUCCEEDED" +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.apps.app + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/appkit-bundle-test/default + +Destroy: 1 deleted diff --git a/acceptance/bundle/templates/app-appkit/output/appkit-bundle-test/app.yaml b/acceptance/bundle/templates/app-appkit/output/appkit-bundle-test/app.yaml new file mode 100644 index 00000000000..f474313bb13 --- /dev/null +++ b/acceptance/bundle/templates/app-appkit/output/appkit-bundle-test/app.yaml @@ -0,0 +1 @@ +command: ["npm", "run", "start"] diff --git a/acceptance/bundle/templates/app-appkit/output/appkit-bundle-test/appkit.plugins.json b/acceptance/bundle/templates/app-appkit/output/appkit-bundle-test/appkit.plugins.json new file mode 100644 index 00000000000..d391f596241 --- /dev/null +++ b/acceptance/bundle/templates/app-appkit/output/appkit-bundle-test/appkit.plugins.json @@ -0,0 +1,16 @@ +{ + "version": "2.0", + "plugins": { + "server": { + "name": "server", + "displayName": "Server", + "description": "HTTP server", + "package": "@databricks/appkit", + "requiredByTemplate": true, + "resources": { + "required": [], + "optional": [] + } + } + } +} diff --git a/acceptance/bundle/templates/app-appkit/output/appkit-bundle-test/databricks.yml b/acceptance/bundle/templates/app-appkit/output/appkit-bundle-test/databricks.yml new file mode 100644 index 00000000000..f0bdad79cdd --- /dev/null +++ b/acceptance/bundle/templates/app-appkit/output/appkit-bundle-test/databricks.yml @@ -0,0 +1,17 @@ +bundle: + name: appkit-bundle-test + +resources: + apps: + app: + name: appkit-bundle-test + description: AppKit bundle initialization test + source_code_path: ./ + lifecycle: + started: true + +targets: + default: + default: true + workspace: + host: [DATABRICKS_URL] diff --git a/acceptance/bundle/templates/app-appkit/output/appkit-bundle-test/package.json b/acceptance/bundle/templates/app-appkit/output/appkit-bundle-test/package.json new file mode 100644 index 00000000000..8f9f012cf8d --- /dev/null +++ b/acceptance/bundle/templates/app-appkit/output/appkit-bundle-test/package.json @@ -0,0 +1,13 @@ +{ + "name": "appkit-bundle-test", + "version": "1.0.0", + "type": "module", + "description": "AppKit bundle initialization test", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "@databricks/appkit": "0.57.0" + }, + "packageManager": "pnpm@11.0.8" +} diff --git a/acceptance/bundle/templates/app-appkit/output/appkit-bundle-test/server.js b/acceptance/bundle/templates/app-appkit/output/appkit-bundle-test/server.js new file mode 100644 index 00000000000..e0a132b230a --- /dev/null +++ b/acceptance/bundle/templates/app-appkit/output/appkit-bundle-test/server.js @@ -0,0 +1,5 @@ +import { createApp, server } from '@databricks/appkit'; + +createApp({ + plugins: [server(),], +}).catch(console.error); diff --git a/acceptance/bundle/templates/app-appkit/script b/acceptance/bundle/templates/app-appkit/script new file mode 100644 index 00000000000..6c4ff1b0b84 --- /dev/null +++ b/acceptance/bundle/templates/app-appkit/script @@ -0,0 +1,25 @@ +export DATABRICKS_APPKIT_TEMPLATE_PATH="$TESTDIR/template" + +title "Missing project name fails without a terminal" +trace musterr $CLI bundle init app-appkit < /dev/null + +title "Unsupported template directory fails before initialization" +trace musterr $CLI bundle init app-appkit --template-dir template < /dev/null + +title "Version and branch cannot be combined" +trace musterr $CLI bundle init app-appkit --config-file input-version.json --branch main < /dev/null + +title "JSON output is unsupported" +trace musterr $CLI bundle init app-appkit --config-file input.json -o json < /dev/null + +title "Initialize AppKit from a config file without a terminal" +trace $CLI bundle init app-appkit --config-file input.json --output-dir output < /dev/null +test ! -d output/appkit-bundle-test/node_modules + +cd output/appkit-bundle-test + +trace $CLI bundle validate < /dev/null +trace $CLI bundle deploy < /dev/null +trace $CLI apps get appkit-bundle-test -o json | jq '{name, compute_state: .compute_status.state, deployment_state: .active_deployment.status.state}' +trace $CLI bundle destroy --auto-approve < /dev/null +rm -r .databricks diff --git a/acceptance/bundle/templates/app-appkit/template/app.yaml b/acceptance/bundle/templates/app-appkit/template/app.yaml new file mode 100644 index 00000000000..f474313bb13 --- /dev/null +++ b/acceptance/bundle/templates/app-appkit/template/app.yaml @@ -0,0 +1 @@ +command: ["npm", "run", "start"] diff --git a/acceptance/bundle/templates/app-appkit/template/appkit.plugins.json b/acceptance/bundle/templates/app-appkit/template/appkit.plugins.json new file mode 100644 index 00000000000..d391f596241 --- /dev/null +++ b/acceptance/bundle/templates/app-appkit/template/appkit.plugins.json @@ -0,0 +1,16 @@ +{ + "version": "2.0", + "plugins": { + "server": { + "name": "server", + "displayName": "Server", + "description": "HTTP server", + "package": "@databricks/appkit", + "requiredByTemplate": true, + "resources": { + "required": [], + "optional": [] + } + } + } +} diff --git a/acceptance/bundle/templates/app-appkit/template/databricks.yml.tmpl b/acceptance/bundle/templates/app-appkit/template/databricks.yml.tmpl new file mode 100644 index 00000000000..b7afbfd83d0 --- /dev/null +++ b/acceptance/bundle/templates/app-appkit/template/databricks.yml.tmpl @@ -0,0 +1,17 @@ +bundle: + name: {{.projectName}} + +resources: + apps: + app: + name: {{.projectName}} + description: {{.appDescription}} + source_code_path: ./ + lifecycle: + started: true + +targets: + default: + default: true + workspace: + host: {{.workspaceHost}} diff --git a/acceptance/bundle/templates/app-appkit/template/package.json b/acceptance/bundle/templates/app-appkit/template/package.json new file mode 100644 index 00000000000..292e1a0ac95 --- /dev/null +++ b/acceptance/bundle/templates/app-appkit/template/package.json @@ -0,0 +1,12 @@ +{ + "name": "{{.projectName}}", + "version": "1.0.0", + "type": "module", + "description": "{{.appDescription}}", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "@databricks/appkit": "0.57.0" + } +} diff --git a/acceptance/bundle/templates/app-appkit/template/server.js b/acceptance/bundle/templates/app-appkit/template/server.js new file mode 100644 index 00000000000..67f9aab20d3 --- /dev/null +++ b/acceptance/bundle/templates/app-appkit/template/server.js @@ -0,0 +1,5 @@ +import { createApp{{range $name, $_ := .plugins}}, {{$name}}{{end}} } from '@databricks/appkit'; + +createApp({ + plugins: [{{range $name, $_ := .plugins}}{{$name}}(),{{end}}], +}).catch(console.error); diff --git a/acceptance/bundle/templates/app-appkit/test.toml b/acceptance/bundle/templates/app-appkit/test.toml new file mode 100644 index 00000000000..9609e1af299 --- /dev/null +++ b/acceptance/bundle/templates/app-appkit/test.toml @@ -0,0 +1 @@ +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/cmd/apps/delete_bundle.go b/cmd/apps/delete_bundle.go index b4c0e72f87c..c57df36a2ea 100644 --- a/cmd/apps/delete_bundle.go +++ b/cmd/apps/delete_bundle.go @@ -1,7 +1,7 @@ package apps import ( - "github.com/databricks/cli/cmd/bundle" + "github.com/databricks/cli/cmd/bundle/utils" "github.com/databricks/databricks-sdk-go/service/apps" "github.com/spf13/cobra" ) @@ -23,7 +23,7 @@ func BundleDeleteOverrideWithWrapper(wrapError ErrorWrapper) func(*cobra.Command originalRunE := deleteCmd.RunE deleteCmd.RunE = func(cmd *cobra.Command, args []string) error { if len(args) == 0 && hasBundleConfig() { - return bundle.CommandBundleDestroy(cmd, args, autoApprove, forceDestroy) + return utils.CommandBundleDestroy(cmd, args, autoApprove, forceDestroy) } err := originalRunE(cmd, args) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index b93bbf8e86d..7ba36f79999 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -220,6 +220,11 @@ Environment variables: return cmd } +// NewInitCommand creates the AppKit initializer for use by other CLI commands. +func NewInitCommand() *cobra.Command { + return newInitCmd() +} + type createOptions struct { templatePath string branch string diff --git a/cmd/bundle/destroy.go b/cmd/bundle/destroy.go index 4819ab6b306..f227fc89abd 100644 --- a/cmd/bundle/destroy.go +++ b/cmd/bundle/destroy.go @@ -3,17 +3,8 @@ package bundle import ( - "context" - "errors" - - "github.com/databricks/cli/bundle" - "github.com/databricks/cli/bundle/phases" - "github.com/databricks/cli/bundle/statemgmt" "github.com/databricks/cli/cmd/bundle/utils" "github.com/databricks/cli/cmd/root" - "github.com/databricks/cli/libs/agent" - "github.com/databricks/cli/libs/cmdio" - "github.com/databricks/cli/libs/logdiag" "github.com/spf13/cobra" ) @@ -44,52 +35,8 @@ Typical use cases: cmd.Flags().CountVarP(&quiet, "quiet", "q", "Reduce output: -qq prints only warnings and errors.") cmd.RunE = func(cmd *cobra.Command, args []string) error { - return CommandBundleDestroy(cmd, args, autoApprove, forceDestroy) + return utils.CommandBundleDestroy(cmd, args, autoApprove, forceDestroy) } return cmd } - -func CommandBundleDestroy(cmd *cobra.Command, args []string, autoApprove, forceDestroy bool) error { - // We require auto-approve for non-interactive terminals since prompts are not possible. - if !cmdio.IsPromptSupported(cmd.Context()) && !autoApprove { - return errors.New("this command will destroy all resources deployed by this bundle, " + - "including workspace files in the deployment directory.\n" + - phases.DataLossWarning + "\n" + - "To proceed, use --auto-approve." + agent.AgentNotice()) - } - - // Check if context is already initialized (e.g., when called from apps delete override) - skipInitContext := logdiag.IsSetup(cmd.Context()) - - opts := utils.ProcessOptions{ - InitFunc: func(b *bundle.Bundle) { - // If `--force-lock` is specified, force acquisition of the deployment lock. - utils.SetForceLock(cmd, b, forceDestroy) - - // If `--auto-approve`` is specified, we skip confirmation checks - b.AutoApprove = autoApprove - - // Read --quiet off the command rather than taking it as a parameter, since - // other commands reuse this function ("apps delete") without defining it. - if cmd.Flags().Lookup("quiet") != nil { - n, _ := cmd.Flags().GetCount("quiet") - b.Quiet = bundle.QuietLevel(n) - } - }, - // Skip context initialization if already initialized by parent command - SkipInitContext: skipInitContext, - AlwaysPull: true, - SkipEnforcingDeploymentHistorySetting: true, - PostStateFunc: func(ctx context.Context, b *bundle.Bundle, stateDesc *statemgmt.StateDesc) error { - phases.Destroy(ctx, b) - if logdiag.HasError(ctx) { - return root.ErrAlreadyPrinted - } - return nil - }, - } - - _, _, err := utils.ProcessBundleRet(cmd, opts) - return err -} diff --git a/cmd/bundle/init.go b/cmd/bundle/init.go index ed235ecc73c..00b8d73809a 100644 --- a/cmd/bundle/init.go +++ b/cmd/bundle/init.go @@ -24,13 +24,11 @@ TEMPLATE_PATH optionally specifies which template to use. It can be one of the f - a Git repository URL, e.g. https://github.com/my/repository Examples: - databricks bundle init # Choose from built-in templates - databricks bundle init default-python # Python jobs and notebooks - databricks bundle init dbt-sql # dbt + SQL warehouse project - databricks bundle init --output-dir ./my-project + databricks bundle init default-python + databricks bundle init app-appkit --config-file input.json # {"project_name":"my-app"} After initialization: - databricks bundle deploy --target dev + databricks bundle deploy See https://docs.databricks.com/en/dev-tools/bundles/templates.html for more information on templates.`, template.HelpDescriptions()), } @@ -51,10 +49,30 @@ See https://docs.databricks.com/en/dev-tools/bundles/templates.html for more inf if tag != "" && branch != "" { return errors.New("only one of --tag or --branch can be specified") } - + ctx := cmd.Context() var templatePathOrUrl string if len(args) > 0 { templatePathOrUrl = args[0] + } else { + selected, err := template.SelectTemplate(ctx) + if errors.Is(err, template.ErrCustomSelected) { + cmdio.LogString(ctx, "Please specify a path or Git repository to use a custom template.") + cmdio.LogString(ctx, "See https://docs.databricks.com/en/dev-tools/bundles/templates.html to learn more about custom templates.") + return nil + } + if err != nil { + return err + } + templatePathOrUrl = string(selected) + } + if templatePathOrUrl == string(template.AppKitApp) { + return runAppKitInit(cmd, appKitInitOptions{ + configFile: configFile, + outputDir: outputDir, + templateDir: templateDir, + tag: tag, + branch: branch, + }) } r := template.Resolver{ TemplatePathOrUrl: templatePathOrUrl, @@ -65,13 +83,7 @@ See https://docs.databricks.com/en/dev-tools/bundles/templates.html for more inf Branch: branch, } - ctx := cmd.Context() tmpl, err := r.Resolve(ctx) - if errors.Is(err, template.ErrCustomSelected) { - cmdio.LogString(ctx, "Please specify a path or Git repository to use a custom template.") - cmdio.LogString(ctx, "See https://docs.databricks.com/en/dev-tools/bundles/templates.html to learn more about custom templates.") - return nil - } if err != nil { return err } diff --git a/cmd/bundle/init_appkit.go b/cmd/bundle/init_appkit.go new file mode 100644 index 00000000000..bada4b0e345 --- /dev/null +++ b/cmd/bundle/init_appkit.go @@ -0,0 +1,128 @@ +package bundle + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/databricks/cli/cmd/apps" + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/flags" + "github.com/spf13/cobra" +) + +type appKitInitOptions struct { + configFile string + outputDir string + templateDir string + tag string + branch string +} + +type appKitInitConfig struct { + ProjectName string `json:"project_name"` + AppDescription string `json:"app_description"` + Features []string `json:"features"` + Set []string `json:"set"` + AuthMode string `json:"auth_mode"` + PackageManager string `json:"package_manager"` + SkipInstall bool `json:"skip_install"` + Version string `json:"version"` +} + +func readAppKitInitConfig(path string) (appKitInitConfig, error) { + var config appKitInitConfig + if path == "" { + return config, nil + } + + file, err := os.Open(path) + if err != nil { + return config, fmt.Errorf("open AppKit config file: %w", err) + } + defer file.Close() + + decoder := json.NewDecoder(file) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&config); err != nil { + return config, fmt.Errorf("read AppKit config file: %w", err) + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + return config, errors.New("AppKit config file must contain one JSON object") + } + return config, nil +} + +func runAppKitInit(cmd *cobra.Command, opts appKitInitOptions) error { + if opts.templateDir != "" { + return errors.New("--template-dir is not supported for app-appkit; use databricks apps init --template for a custom AppKit template") + } + if root.OutputType(cmd) == flags.OutputJSON { + return errors.New("-o json is not supported for app-appkit; use text output") + } + + config, err := readAppKitInitConfig(opts.configFile) + if err != nil { + return err + } + if config.Version != "" && (opts.tag != "" || opts.branch != "") { + return errors.New("version in --config-file cannot be combined with --tag or --branch") + } + if config.ProjectName == "" && !cmdio.IsPromptSupported(cmd.Context()) { + return errors.New("app-appkit requires project_name in --config-file in non-interactive mode") + } + + appCmd := apps.NewInitCommand() + appCmd.SetContext(cmd.Context()) + appFlags := appCmd.Flags() + values := map[string]string{ + "name": config.ProjectName, + "description": config.AppDescription, + "output-dir": opts.outputDir, + "auth-mode": config.AuthMode, + "package-manager": config.PackageManager, + } + for flag, value := range values { + if value == "" { + continue + } + if err := appFlags.Set(flag, value); err != nil { + return fmt.Errorf("set AppKit %s: %w", flag, err) + } + } + if len(config.Features) > 0 { + if err := appFlags.Set("features", strings.Join(config.Features, ",")); err != nil { + return fmt.Errorf("set AppKit features: %w", err) + } + } + for _, value := range config.Set { + if err := appFlags.Set("set", value); err != nil { + return fmt.Errorf("set AppKit resource value: %w", err) + } + } + if opts.branch != "" || opts.tag != "" { + ref := opts.branch + if ref == "" { + ref = opts.tag + } + if err := appFlags.Set("branch", ref); err != nil { + return fmt.Errorf("set AppKit template ref: %w", err) + } + } else if config.Version != "" { + if err := appFlags.Set("version", config.Version); err != nil { + return fmt.Errorf("set AppKit version: %w", err) + } + } + if config.SkipInstall { + if err := appFlags.Set("skip-install", "true"); err != nil { + return fmt.Errorf("set AppKit skip-install: %w", err) + } + } + + return appCmd.RunE(appCmd, nil) +} diff --git a/cmd/bundle/init_appkit_test.go b/cmd/bundle/init_appkit_test.go new file mode 100644 index 00000000000..08c7c1abd60 --- /dev/null +++ b/cmd/bundle/init_appkit_test.go @@ -0,0 +1,30 @@ +package bundle + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReadAppKitInitConfigRejectsInvalidJSON(t *testing.T) { + tests := []struct { + name string + contents string + want string + }{ + {"malformed", `{"project_name":`, "unexpected EOF"}, + {"unknown field", `{"project_name":"app","extra":true}`, `unknown field "extra"`}, + {"trailing object", `{"project_name":"app"} {"project_name":"other"}`, "must contain one JSON object"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "input.json") + require.NoError(t, os.WriteFile(path, []byte(tt.contents), 0o600)) + _, err := readAppKitInitConfig(path) + require.ErrorContains(t, err, tt.want) + }) + } +} diff --git a/cmd/bundle/utils/destroy.go b/cmd/bundle/utils/destroy.go new file mode 100644 index 00000000000..7df0a78c328 --- /dev/null +++ b/cmd/bundle/utils/destroy.go @@ -0,0 +1,60 @@ +package utils + +import ( + "context" + "errors" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/phases" + "github.com/databricks/cli/bundle/statemgmt" + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/agent" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/logdiag" + "github.com/spf13/cobra" +) + +// CommandBundleDestroy destroys a bundle for the bundle, apps, and pipelines commands. +func CommandBundleDestroy(cmd *cobra.Command, args []string, autoApprove, forceDestroy bool) error { + // We require auto-approve for non-interactive terminals since prompts are not possible. + if !cmdio.IsPromptSupported(cmd.Context()) && !autoApprove { + return errors.New("this command will destroy all resources deployed by this bundle, " + + "including workspace files in the deployment directory.\n" + + phases.DataLossWarning + "\n" + + "To proceed, use --auto-approve." + agent.AgentNotice()) + } + + // Check if context is already initialized (e.g., when called from apps delete override) + skipInitContext := logdiag.IsSetup(cmd.Context()) + + opts := ProcessOptions{ + InitFunc: func(b *bundle.Bundle) { + // If `--force-lock` is specified, force acquisition of the deployment lock. + SetForceLock(cmd, b, forceDestroy) + + // If `--auto-approve`` is specified, we skip confirmation checks + b.AutoApprove = autoApprove + + // Read --quiet off the command rather than taking it as a parameter, since + // other commands reuse this function ("apps delete") without defining it. + if cmd.Flags().Lookup("quiet") != nil { + n, _ := cmd.Flags().GetCount("quiet") + b.Quiet = bundle.QuietLevel(n) + } + }, + // Skip context initialization if already initialized by parent command + SkipInitContext: skipInitContext, + AlwaysPull: true, + SkipEnforcingDeploymentHistorySetting: true, + PostStateFunc: func(ctx context.Context, b *bundle.Bundle, stateDesc *statemgmt.StateDesc) error { + phases.Destroy(ctx, b) + if logdiag.HasError(ctx) { + return root.ErrAlreadyPrinted + } + return nil + }, + } + + _, _, err := ProcessBundleRet(cmd, opts) + return err +} diff --git a/cmd/pipelines/destroy.go b/cmd/pipelines/destroy.go index 9f98d525fa8..995e6ffd8da 100644 --- a/cmd/pipelines/destroy.go +++ b/cmd/pipelines/destroy.go @@ -3,7 +3,7 @@ package pipelines import ( - "github.com/databricks/cli/cmd/bundle" + "github.com/databricks/cli/cmd/bundle/utils" "github.com/spf13/cobra" ) @@ -20,7 +20,7 @@ func destroyCommand() *cobra.Command { cmd.Flags().BoolVar(&forceDestroy, "force-lock", false, "Force acquisition of deployment lock.") cmd.RunE = func(cmd *cobra.Command, args []string) error { - return bundle.CommandBundleDestroy(cmd, args, autoApprove, forceDestroy) + return utils.CommandBundleDestroy(cmd, args, autoApprove, forceDestroy) } return cmd diff --git a/libs/template/template.go b/libs/template/template.go index cb2e1a8e3a2..251b61cb9a2 100644 --- a/libs/template/template.go +++ b/libs/template/template.go @@ -36,10 +36,13 @@ const ( DbtSql TemplateName = "dbt-sql" MlopsStacks TemplateName = "mlops-stacks" Pydabs TemplateName = "pydabs" + AppKitApp TemplateName = "app-appkit" Custom TemplateName = "custom" Default TemplateName = "default" ) +const appKitDescription = "Databricks App with AppKit (TypeScript)" + var databricksTemplates = []Template{ { name: DefaultPython, @@ -116,6 +119,7 @@ func HelpDescriptions() string { lines = append(lines, fmt.Sprintf("- %s: %s", template.name, template.description)) } } + lines = append(lines, fmt.Sprintf("- %s: %s", AppKitApp, appKitDescription)) return strings.Join(lines, "\n") } @@ -133,6 +137,7 @@ func options() []cmdio.Tuple { } names = append(names, tuple) } + names = append(names, cmdio.Tuple{Name: string(AppKitApp), Id: appKitDescription}) names = append(names, cmdio.Tuple{ Name: "custom...", @@ -153,6 +158,9 @@ func SelectTemplate(ctx context.Context) (TemplateName, error) { if description == customTemplateDescription { return TemplateName(""), ErrCustomSelected } + if description == appKitDescription { + return AppKitApp, nil + } for _, template := range databricksTemplates { if template.description == description { diff --git a/libs/template/template_test.go b/libs/template/template_test.go index b0a5b6c641b..ac096300291 100644 --- a/libs/template/template_test.go +++ b/libs/template/template_test.go @@ -14,7 +14,8 @@ func TestTemplateHelpDescriptions(t *testing.T) { - default-scala: The default Scala template for JAR jobs - dbt-sql: The dbt SQL template (databricks.com/blog/delivering-cost-effective-data-real-time-dbt-and-databricks) - mlops-stacks: The Databricks MLOps Stacks template (github.com/databricks/mlops-stacks) -- pydabs: A variant of the 'default-python' template that defines resources in Python instead of YAML` +- pydabs: A variant of the 'default-python' template that defines resources in Python instead of YAML +- app-appkit: Databricks App with AppKit (TypeScript)` assert.Equal(t, expected, HelpDescriptions()) } @@ -27,6 +28,7 @@ func TestTemplateOptions(t *testing.T) { {Name: "dbt-sql", Id: "The dbt SQL template (databricks.com/blog/delivering-cost-effective-data-real-time-dbt-and-databricks)"}, {Name: "mlops-stacks", Id: "The Databricks MLOps Stacks template (github.com/databricks/mlops-stacks)"}, {Name: "pydabs", Id: "A variant of the 'default-python' template that defines resources in Python instead of YAML"}, + {Name: "app-appkit", Id: "Databricks App with AppKit (TypeScript)"}, {Name: "custom...", Id: "Bring your own template"}, } assert.Equal(t, expected, options())