diff --git a/acceptance/apps/deploy/bundle-no-args/output.txt b/acceptance/apps/deploy/bundle-no-args/output.txt index b1302fa56a..18509ae92f 100644 --- a/acceptance/apps/deploy/bundle-no-args/output.txt +++ b/acceptance/apps/deploy/bundle-no-args/output.txt @@ -1,6 +1,6 @@ >>> [CLI] apps deploy --skip-validation -Deploying bundle... +Deploying project... Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... Deploying resources... Updating deployment state... diff --git a/cmd/apps/bundle_helpers.go b/cmd/apps/bundle_helpers.go new file mode 100644 index 0000000000..2bf4416eac --- /dev/null +++ b/cmd/apps/bundle_helpers.go @@ -0,0 +1,187 @@ +package apps + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/flags" + "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/spf13/cobra" +) + +const defaultAppWaitTimeout = 20 * time.Minute + +// makeArgsOptionalWithBundle updates a command to allow optional NAME argument +// when running from a bundle directory. +func makeArgsOptionalWithBundle(cmd *cobra.Command, usage string) { + cmd.Use = usage + + cmd.Args = func(cmd *cobra.Command, args []string) error { + if len(args) > 1 { + return fmt.Errorf("accepts at most 1 arg(s), received %d", len(args)) + } + if !hasBundleConfig() && len(args) != 1 { + return fmt.Errorf("accepts 1 arg(s), received %d", len(args)) + } + return nil + } +} + +// getAppNameFromArgs returns the app name from args or detects it from the bundle. +// Returns (appName, fromBundle, error). +func getAppNameFromArgs(cmd *cobra.Command, args []string) (string, bool, error) { + if len(args) > 0 { + return args[0], false, nil + } + + appName := detectAppNameFromBundle(cmd) + if appName != "" { + return appName, true, nil + } + + return "", false, errors.New("no app name provided and unable to detect from project configuration") +} + +// updateCommandHelp updates the help text for a command to explain bundle behavior. +func updateCommandHelp(cmd *cobra.Command, commandVerb, commandName string) { + cmd.Long = fmt.Sprintf(`%s an app. + +When run from a Databricks Apps project directory (containing databricks.yml) +without a NAME argument, this command automatically detects the app name from +the project configuration and %ss it. + +When a NAME argument is provided (or when not in a project directory), +%ss the specified app using the API directly. + +Arguments: + NAME: The name of the app. Required when not in a project directory. + When provided in a project directory, uses the specified name instead of auto-detection. + +Examples: + # %s app from a project directory (auto-detects app name) + databricks apps %s + + # %s app from a specific target + databricks apps %s --target prod + + # %s a specific app using the API (even from a project directory) + databricks apps %s my-app`, + commandVerb, + commandName, + commandName, + commandVerb, + commandName, + commandVerb, + commandName, + commandVerb, + commandName) +} + +// isIdempotencyError checks if an error message indicates the operation is already in the desired state. +func isIdempotencyError(err error, keywords ...string) bool { + if err == nil { + return false + } + errMsg := err.Error() + for _, keyword := range keywords { + if strings.Contains(errMsg, keyword) { + return true + } + } + return false +} + +// displayAppURL displays the app URL in a consistent format if available. +func displayAppURL(ctx context.Context, appInfo *apps.App) { + if appInfo != nil && appInfo.Url != "" { + cmdio.LogString(ctx, fmt.Sprintf("\nšŸ”— %s\n", appInfo.Url)) + } +} + +// formatAppStatusMessage formats a user-friendly status message for an app. +func formatAppStatusMessage(appInfo *apps.App, appName, verb string) string { + computeState := "unknown" + if appInfo != nil && appInfo.ComputeStatus != nil { + computeState = string(appInfo.ComputeStatus.State) + } + + if appInfo != nil && appInfo.AppStatus != nil && appInfo.AppStatus.State == apps.ApplicationStateUnavailable { + return fmt.Sprintf("⚠ App '%s' %s but is unavailable (compute: %s, app: %s)", appName, verb, computeState, appInfo.AppStatus.State) + } + + if appInfo != nil && appInfo.ComputeStatus != nil { + state := appInfo.ComputeStatus.State + switch state { + case apps.ComputeStateActive: + if verb == "is deployed" { + return fmt.Sprintf("āœ” App '%s' is already running (status: %s)", appName, state) + } + return fmt.Sprintf("āœ” App '%s' started successfully (status: %s)", appName, state) + case apps.ComputeStateStarting: + return fmt.Sprintf("⚠ App '%s' is already starting (status: %s)", appName, state) + default: + return fmt.Sprintf("āœ” App '%s' status: %s", appName, state) + } + } + + return fmt.Sprintf("āœ” App '%s' status: unknown", appName) +} + +// getWaitTimeout gets the timeout value for app wait operations. +func getWaitTimeout(cmd *cobra.Command) time.Duration { + timeout, _ := cmd.Flags().GetDuration("timeout") + if timeout == 0 { + timeout = defaultAppWaitTimeout + } + return timeout +} + +// shouldWaitForCompletion checks if the command should wait for app operation completion. +func shouldWaitForCompletion(cmd *cobra.Command) bool { + skipWait, _ := cmd.Flags().GetBool("no-wait") + return !skipWait +} + +// createAppProgressCallback creates a progress callback for app operations. +func createAppProgressCallback(spinner chan<- string) func(*apps.App) { + return func(i *apps.App) { + if i.ComputeStatus == nil { + return + } + statusMessage := i.ComputeStatus.Message + if statusMessage == "" { + statusMessage = fmt.Sprintf("current status: %s", i.ComputeStatus.State) + } + spinner <- statusMessage + } +} + +// handleAlreadyInStateError handles idempotency errors and displays appropriate status. +// Returns true if the error was handled (already in desired state), false otherwise. +func handleAlreadyInStateError(ctx context.Context, cmd *cobra.Command, err error, appName string, keywords []string, verb string, wrapError ErrorWrapper) (bool, error) { + if !isIdempotencyError(err, keywords...) { + return false, nil + } + + outputFormat := root.OutputType(cmd) + if outputFormat != flags.OutputText { + return true, nil + } + + w := cmdctx.WorkspaceClient(ctx) + appInfo, getErr := w.Apps.Get(ctx, apps.GetAppRequest{Name: appName}) + if getErr != nil { + return true, wrapError(cmd, appName, getErr) + } + + message := formatAppStatusMessage(appInfo, appName, verb) + cmdio.LogString(ctx, message) + displayAppURL(ctx, appInfo) + return true, nil +} diff --git a/cmd/apps/bundle_helpers_test.go b/cmd/apps/bundle_helpers_test.go new file mode 100644 index 0000000000..359d365f86 --- /dev/null +++ b/cmd/apps/bundle_helpers_test.go @@ -0,0 +1,172 @@ +package apps + +import ( + "context" + "errors" + "testing" + + "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestIsIdempotencyError(t *testing.T) { + t.Run("returns true when error contains keyword", func(t *testing.T) { + err := errors.New("app is already in ACTIVE state") + assert.True(t, isIdempotencyError(err, "ACTIVE state")) + }) + + t.Run("returns true when error contains any keyword", func(t *testing.T) { + err := errors.New("already running") + assert.True(t, isIdempotencyError(err, "ACTIVE state", "already")) + }) + + t.Run("returns false when error does not contain keywords", func(t *testing.T) { + err := errors.New("something went wrong") + assert.False(t, isIdempotencyError(err, "ACTIVE state", "already")) + }) + + t.Run("returns false for nil error", func(t *testing.T) { + assert.False(t, isIdempotencyError(nil, "ACTIVE state")) + }) + + t.Run("matches partial strings", func(t *testing.T) { + err := errors.New("error: ACTIVE state detected") + assert.True(t, isIdempotencyError(err, "ACTIVE state")) + }) +} + +func TestFormatAppStatusMessage(t *testing.T) { + t.Run("handles nil appInfo", func(t *testing.T) { + msg := formatAppStatusMessage(nil, "test-app", "started") + assert.Equal(t, "āœ” App 'test-app' status: unknown", msg) + }) + + t.Run("handles unavailable app state", func(t *testing.T) { + appInfo := &apps.App{ + AppStatus: &apps.ApplicationStatus{ + State: apps.ApplicationStateUnavailable, + }, + ComputeStatus: &apps.ComputeStatus{ + State: apps.ComputeStateActive, + }, + } + msg := formatAppStatusMessage(appInfo, "test-app", "started") + assert.Contains(t, msg, "unavailable") + assert.Contains(t, msg, "ACTIVE") + }) + + t.Run("formats active state with 'is deployed' verb", func(t *testing.T) { + appInfo := &apps.App{ + ComputeStatus: &apps.ComputeStatus{ + State: apps.ComputeStateActive, + }, + } + msg := formatAppStatusMessage(appInfo, "test-app", "is deployed") + assert.Contains(t, msg, "already running") + assert.Contains(t, msg, "ACTIVE") + }) + + t.Run("formats active state with 'started' verb", func(t *testing.T) { + appInfo := &apps.App{ + ComputeStatus: &apps.ComputeStatus{ + State: apps.ComputeStateActive, + }, + } + msg := formatAppStatusMessage(appInfo, "test-app", "started") + assert.Contains(t, msg, "started successfully") + assert.Contains(t, msg, "ACTIVE") + }) + + t.Run("formats starting state", func(t *testing.T) { + appInfo := &apps.App{ + ComputeStatus: &apps.ComputeStatus{ + State: apps.ComputeStateStarting, + }, + } + msg := formatAppStatusMessage(appInfo, "test-app", "started") + assert.Contains(t, msg, "already starting") + assert.Contains(t, msg, "STARTING") + }) + + t.Run("formats other compute states", func(t *testing.T) { + appInfo := &apps.App{ + ComputeStatus: &apps.ComputeStatus{ + State: apps.ComputeStateStopped, + }, + } + msg := formatAppStatusMessage(appInfo, "test-app", "stopped") + assert.Contains(t, msg, "status: STOPPED") + }) + + t.Run("handles nil compute status", func(t *testing.T) { + appInfo := &apps.App{} + msg := formatAppStatusMessage(appInfo, "test-app", "started") + assert.Equal(t, "āœ” App 'test-app' status: unknown", msg) + }) +} + +func TestMakeArgsOptionalWithBundle(t *testing.T) { + t.Run("updates command usage", func(t *testing.T) { + cmd := &cobra.Command{} + makeArgsOptionalWithBundle(cmd, "test [NAME]") + assert.Equal(t, "test [NAME]", cmd.Use) + }) + + t.Run("sets Args validator", func(t *testing.T) { + cmd := &cobra.Command{} + makeArgsOptionalWithBundle(cmd, "test [NAME]") + assert.NotNil(t, cmd.Args) + }) +} + +func TestGetAppNameFromArgs(t *testing.T) { + t.Run("returns arg when provided", func(t *testing.T) { + cmd := &cobra.Command{} + name, fromBundle, err := getAppNameFromArgs(cmd, []string{"my-app"}) + assert.NoError(t, err) + assert.Equal(t, "my-app", name) + assert.False(t, fromBundle) + }) +} + +func TestUpdateCommandHelp(t *testing.T) { + t.Run("sets Long help text", func(t *testing.T) { + cmd := &cobra.Command{} + updateCommandHelp(cmd, "Start", "start") + assert.NotEmpty(t, cmd.Long) + }) + + t.Run("includes verb in help text", func(t *testing.T) { + cmd := &cobra.Command{} + updateCommandHelp(cmd, "Start", "start") + assert.Contains(t, cmd.Long, "Start an app") + }) + + t.Run("includes command name in examples", func(t *testing.T) { + cmd := &cobra.Command{} + updateCommandHelp(cmd, "Stop", "stop") + assert.Contains(t, cmd.Long, "databricks apps stop") + }) + + t.Run("includes all example scenarios", func(t *testing.T) { + cmd := &cobra.Command{} + updateCommandHelp(cmd, "Start", "start") + assert.Contains(t, cmd.Long, "from a project directory") + assert.Contains(t, cmd.Long, "--target prod") + assert.Contains(t, cmd.Long, "my-app") + }) +} + +func TestHandleAlreadyInStateError(t *testing.T) { + t.Run("returns false when not an idempotency error", func(t *testing.T) { + err := errors.New("some other error") + cmd := &cobra.Command{} + mockWrapper := func(cmd *cobra.Command, appName string, err error) error { + return err + } + + handled, _ := handleAlreadyInStateError(context.Background(), cmd, err, "test-app", []string{"ACTIVE"}, "is deployed", mockWrapper) + assert.False(t, handled) + }) +} diff --git a/cmd/apps/delete_bundle.go b/cmd/apps/delete_bundle.go new file mode 100644 index 0000000000..b4c0e72f87 --- /dev/null +++ b/cmd/apps/delete_bundle.go @@ -0,0 +1,58 @@ +package apps + +import ( + "github.com/databricks/cli/cmd/bundle" + "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/spf13/cobra" +) + +// BundleDeleteOverrideWithWrapper creates a delete override function that uses +// the provided error wrapper for API fallback errors. +func BundleDeleteOverrideWithWrapper(wrapError ErrorWrapper) func(*cobra.Command, *apps.DeleteAppRequest) { + return func(deleteCmd *cobra.Command, deleteReq *apps.DeleteAppRequest) { + var ( + autoApprove bool + forceDestroy bool + ) + + deleteCmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "Skip interactive approvals for deleting resources and files") + deleteCmd.Flags().BoolVar(&forceDestroy, "force-lock", false, "Force acquisition of deployment lock.") + + makeArgsOptionalWithBundle(deleteCmd, "delete [NAME]") + + originalRunE := deleteCmd.RunE + deleteCmd.RunE = func(cmd *cobra.Command, args []string) error { + if len(args) == 0 && hasBundleConfig() { + return bundle.CommandBundleDestroy(cmd, args, autoApprove, forceDestroy) + } + + err := originalRunE(cmd, args) + return wrapError(cmd, deleteReq.Name, err) + } + + deleteCmd.Long = `Delete an app. + +When run from a Databricks Apps project directory (containing databricks.yml) +without a NAME argument, this command destroys all resources deployed by the project. + +When a NAME argument is provided (or when not in a project directory), +deletes the specified app using the API directly. + +Arguments: + NAME: The name of the app. Required when not in a project directory. + When provided in a project directory, uses API delete instead of project destroy. + +Examples: + # Destroy all project resources from a project directory + databricks apps delete + + # Destroy project resources from a specific target + databricks apps delete --target prod + + # Destroy project resources with auto-approval + databricks apps delete --auto-approve + + # Delete a specific app resource using the API (even from a project directory) + databricks apps delete my-app` + } +} diff --git a/cmd/apps/delete_bundle_test.go b/cmd/apps/delete_bundle_test.go new file mode 100644 index 0000000000..0e717bbf73 --- /dev/null +++ b/cmd/apps/delete_bundle_test.go @@ -0,0 +1,93 @@ +package apps + +import ( + "errors" + "testing" + + "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBundleDeleteOverrideWithWrapper(t *testing.T) { + mockWrapper := func(cmd *cobra.Command, appName string, err error) error { + return err + } + + overrideFunc := BundleDeleteOverrideWithWrapper(mockWrapper) + assert.NotNil(t, overrideFunc) + + cmd := &cobra.Command{} + deleteReq := &apps.DeleteAppRequest{} + + overrideFunc(cmd, deleteReq) + + assert.Equal(t, "delete [NAME]", cmd.Use) + assert.NotNil(t, cmd.Flags().Lookup("auto-approve")) + assert.NotNil(t, cmd.Flags().Lookup("force-lock")) +} + +func TestBundleDeleteOverrideFlags(t *testing.T) { + mockWrapper := func(cmd *cobra.Command, appName string, err error) error { + return err + } + + cmd := &cobra.Command{} + deleteReq := &apps.DeleteAppRequest{} + + overrideFunc := BundleDeleteOverrideWithWrapper(mockWrapper) + overrideFunc(cmd, deleteReq) + + t.Run("auto-approve flag defaults to false", func(t *testing.T) { + flag := cmd.Flags().Lookup("auto-approve") + require.NotNil(t, flag) + assert.Equal(t, "false", flag.DefValue) + }) + + t.Run("force-lock flag defaults to false", func(t *testing.T) { + flag := cmd.Flags().Lookup("force-lock") + require.NotNil(t, flag) + assert.Equal(t, "false", flag.DefValue) + }) +} + +func TestBundleDeleteOverrideHelpText(t *testing.T) { + mockWrapper := func(cmd *cobra.Command, appName string, err error) error { + return err + } + + cmd := &cobra.Command{} + deleteReq := &apps.DeleteAppRequest{} + + overrideFunc := BundleDeleteOverrideWithWrapper(mockWrapper) + overrideFunc(cmd, deleteReq) + + assert.NotEmpty(t, cmd.Long) + assert.Contains(t, cmd.Long, "Delete an app") + assert.Contains(t, cmd.Long, "project directory") + assert.Contains(t, cmd.Long, "databricks.yml") +} + +func TestBundleDeleteOverrideErrorWrapping(t *testing.T) { + wrapperCalled := false + mockWrapper := func(cmd *cobra.Command, appName string, err error) error { + wrapperCalled = true + assert.Equal(t, "test-app", appName) + return err + } + + cmd := &cobra.Command{ + RunE: func(cmd *cobra.Command, args []string) error { + return errors.New("api error") + }, + } + deleteReq := &apps.DeleteAppRequest{Name: "test-app"} + + overrideFunc := BundleDeleteOverrideWithWrapper(mockWrapper) + overrideFunc(cmd, deleteReq) + + err := cmd.RunE(cmd, []string{"test-app"}) + assert.Error(t, err) + assert.True(t, wrapperCalled) +} diff --git a/cmd/apps/deploy_bundle.go b/cmd/apps/deploy_bundle.go index 592b5921f1..38e312e388 100644 --- a/cmd/apps/deploy_bundle.go +++ b/cmd/apps/deploy_bundle.go @@ -46,63 +46,47 @@ func BundleDeployOverrideWithWrapper(wrapError ErrorWrapper) func(*cobra.Command deployCmd.Flags().BoolVar(&skipValidation, "skip-validation", false, "Skip project validation (build, typecheck, lint)") deployCmd.Flags().BoolVar(&skipTests, "skip-tests", true, "Skip running tests during validation") - // Update the command usage to reflect that APP_NAME is optional when in bundle mode - deployCmd.Use = "deploy [APP_NAME]" - - // Override Args to allow 0 or 1 arguments (bundle mode vs API mode) - deployCmd.Args = func(cmd *cobra.Command, args []string) error { - // Never allow more than 1 argument - if len(args) > 1 { - return fmt.Errorf("accepts at most 1 arg(s), received %d", len(args)) - } - // In non-bundle mode, exactly 1 argument is required - if !hasBundleConfig() && len(args) != 1 { - return fmt.Errorf("accepts 1 arg(s), received %d", len(args)) - } - // In bundle mode: 0 args = bundle deploy, 1 arg = API fallback - return nil - } + makeArgsOptionalWithBundle(deployCmd, "deploy [APP_NAME]") originalRunE := deployCmd.RunE deployCmd.RunE = func(cmd *cobra.Command, args []string) error { - // If no APP_NAME provided, try to use bundle deploy flow if len(args) == 0 { - // Try to load bundle configuration b := root.TryConfigureBundle(cmd) if b != nil { return runBundleDeploy(cmd, force, skipValidation, skipTests) } } - // Otherwise, fall back to the original API deploy command err := originalRunE(cmd, args) return wrapError(cmd, deployReq.AppName, err) } - // Update the help text to explain the dual behavior deployCmd.Long = `Create an app deployment. -When run from a directory containing a databricks.yml bundle configuration +When run from a Databricks Apps project directory (containing databricks.yml) without an APP_NAME argument, this command runs an enhanced deployment pipeline: 1. Validates the project (build, typecheck, lint for Node.js projects) -2. Deploys the bundle to the workspace +2. Deploys the project to the workspace 3. Runs the app -When an APP_NAME argument is provided (or when not in a bundle directory), +When an APP_NAME argument is provided (or when not in a project directory), creates an app deployment using the API directly. Arguments: - APP_NAME: The name of the app. Required when not in a bundle directory. - When provided in a bundle directory, uses API deploy instead of bundle deploy. + APP_NAME: The name of the app. Required when not in a project directory. + When provided in a project directory, uses API deploy instead of project deploy. Examples: - # Deploy from a bundle directory (enhanced flow with validation) + # Deploy from a project directory (enhanced flow with validation) databricks apps deploy - # Deploy a specific app using the API (even from a bundle directory) + # Deploy from a specific target + databricks apps deploy --target prod + + # Deploy a specific app using the API (even from a project directory) databricks apps deploy my-app - # Deploy from bundle with validation skip + # Deploy from project with validation skip databricks apps deploy --skip-validation # Force deploy (override git branch validation) @@ -110,11 +94,10 @@ Examples: } } -// runBundleDeploy executes the enhanced deployment flow for bundle directories. +// runBundleDeploy executes the enhanced deployment flow for project directories. func runBundleDeploy(cmd *cobra.Command, force, skipValidation, skipTests bool) error { ctx := cmd.Context() - // Get current working directory for validation workDir, err := os.Getwd() if err != nil { return fmt.Errorf("failed to get working directory: %w", err) @@ -133,7 +116,6 @@ func runBundleDeploy(cmd *cobra.Command, force, skipValidation, skipTests bool) } if !result.Success { - // Show error details if result.Details != nil { cmdio.LogString(ctx, result.Details.Error()) } @@ -145,8 +127,8 @@ func runBundleDeploy(cmd *cobra.Command, force, skipValidation, skipTests bool) } } - // Step 2: Deploy bundle - cmdio.LogString(ctx, "Deploying bundle...") + // Step 2: Deploy project + cmdio.LogString(ctx, "Deploying project...") b, err := utils.ProcessBundle(cmd, utils.ProcessOptions{ InitFunc: func(b *bundle.Bundle) { b.Config.Bundle.Force = force @@ -172,24 +154,23 @@ func runBundleDeploy(cmd *cobra.Command, force, skipValidation, skipTests bool) log.Infof(ctx, "Running app: %s", appKey) if err := runBundleApp(ctx, b, appKey); err != nil { cmdio.LogString(ctx, "āœ” Deployment succeeded, but failed to start app") - appName := b.Config.Resources.Apps[appKey].Name - return fmt.Errorf("failed to run app: %w. Run `databricks apps logs %s` to view logs", err, appName) + return fmt.Errorf("failed to run app: %w. Run `databricks apps logs` to view logs", err) } cmdio.LogString(ctx, "āœ” Deployment complete!") return nil } -// detectBundleApp finds the single app in the bundle configuration. +// detectBundleApp finds the single app in the project configuration. func detectBundleApp(b *bundle.Bundle) (string, error) { bundleApps := b.Config.Resources.Apps if len(bundleApps) == 0 { - return "", errors.New("no apps found in bundle configuration") + return "", errors.New("no apps found in project configuration") } if len(bundleApps) > 1 { - return "", errors.New("multiple apps found in bundle, cannot auto-detect") + return "", errors.New("multiple apps found in project, cannot auto-detect") } for key := range bundleApps { diff --git a/cmd/apps/logs.go b/cmd/apps/logs.go index d3ecf56f15..c6cab93652 100644 --- a/cmd/apps/logs.go +++ b/cmd/apps/logs.go @@ -46,14 +46,30 @@ func newLogsCommand() *cobra.Command { ) cmd := &cobra.Command{ - Use: "logs NAME", + Use: "logs [NAME]", Short: "Show Databricks app logs", - Long: `Stream stdout/stderr logs for a Databricks app via its log stream. + Long: `Show Databricks app logs. -By default the command fetches the most recent logs (up to --tail-lines, default 200) and exits. -Use --follow to continue streaming logs until cancelled, optionally bounding the duration with --timeout. -Server-side filtering is available through --search (same semantics as the Databricks UI) and client-side filtering -via --source APP|SYSTEM. Use --output-file to mirror the stream to a local file (created with 0600 permissions).`, +When run from a Databricks Apps project directory (containing databricks.yml) +without a NAME argument, this command automatically detects the app name from +the project configuration and shows its logs. + +When a NAME argument is provided (or when not in a project directory), +shows logs for the specified app using the API directly. + +Arguments: + NAME: The name of the app. Required when not in a project directory. + When provided in a project directory, uses the specified name instead of auto-detection. + +Examples: + # Show logs from a project directory (auto-detects app name) + databricks apps logs + + # Show logs from a specific target + databricks apps logs --target prod + + # Show logs for a specific app using the API (even from a project directory) + databricks apps logs my-app`, Example: ` # Fetch the last 50 log lines databricks apps logs my-app --tail-lines 50 @@ -62,9 +78,28 @@ via --source APP|SYSTEM. Use --output-file to mirror the stream to a local file # Mirror streamed logs to a local file while following for up to 5 minutes databricks apps logs my-app --follow --timeout 5m --output-file /tmp/my-app.log`, - Args: root.ExactArgs(1), + Args: func(cmd *cobra.Command, args []string) error { + if len(args) > 1 { + return fmt.Errorf("accepts at most 1 arg(s), received %d", len(args)) + } + if !hasBundleConfig() && len(args) != 1 { + return fmt.Errorf("accepts 1 arg(s), received %d", len(args)) + } + return nil + }, PreRunE: root.MustWorkspaceClient, RunE: func(cmd *cobra.Command, args []string) error { + appName, fromBundle, err := getAppNameFromArgs(cmd, args) + if err != nil { + return err + } + + if fromBundle && root.OutputType(cmd) != flags.OutputJSON { + fmt.Fprintf(cmd.ErrOrStderr(), "Streaming logs for app '%s' from project configuration\n", appName) + } + + // Override args with detected app name + args = []string{appName} ctx := cmd.Context() if tailLines < 0 { diff --git a/cmd/apps/start_bundle.go b/cmd/apps/start_bundle.go new file mode 100644 index 0000000000..334860ec8e --- /dev/null +++ b/cmd/apps/start_bundle.go @@ -0,0 +1,83 @@ +package apps + +import ( + "fmt" + + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/flags" + "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/spf13/cobra" +) + +// BundleStartOverrideWithWrapper creates a start override function that uses +// the provided error wrapper for API fallback errors. +func BundleStartOverrideWithWrapper(wrapError ErrorWrapper) func(*cobra.Command, *apps.StartAppRequest) { + return func(startCmd *cobra.Command, startReq *apps.StartAppRequest) { + makeArgsOptionalWithBundle(startCmd, "start [NAME]") + + originalRunE := startCmd.RunE + startCmd.RunE = func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + outputFormat := root.OutputType(cmd) + + if len(args) == 0 { + appName, fromBundle, err := getAppNameFromArgs(cmd, args) + if err != nil { + return err + } + if fromBundle { + startReq.Name = appName + + if outputFormat == flags.OutputText { + cmdio.LogString(ctx, fmt.Sprintf("Starting app '%s' from project configuration", appName)) + + w := cmdctx.WorkspaceClient(ctx) + wait, err := w.Apps.Start(ctx, *startReq) + if err != nil { + handled, handledErr := handleAlreadyInStateError(ctx, cmd, err, appName, []string{"ACTIVE state", "already"}, "is deployed", wrapError) + if handled { + return handledErr + } + return wrapError(cmd, appName, err) + } + + var appInfo *apps.App + if shouldWaitForCompletion(cmd) { + spinner := cmdio.Spinner(ctx) + appInfo, err = wait.OnProgress(createAppProgressCallback(spinner)).GetWithTimeout(getWaitTimeout(cmd)) + close(spinner) + if err != nil { + return wrapError(cmd, appName, err) + } + } else { + appInfo, err = w.Apps.Get(ctx, apps.GetAppRequest{Name: appName}) + if err != nil { + return wrapError(cmd, appName, err) + } + } + + message := formatAppStatusMessage(appInfo, appName, "started") + cmdio.LogString(ctx, message) + displayAppURL(ctx, appInfo) + return nil + } + + return originalRunE(cmd, []string{appName}) + } + } + + err := originalRunE(cmd, args) + if err != nil { + handled, handledErr := handleAlreadyInStateError(ctx, cmd, err, startReq.Name, []string{"ACTIVE state", "already"}, "is deployed", wrapError) + if handled { + return handledErr + } + } + return wrapError(cmd, startReq.Name, err) + } + + updateCommandHelp(startCmd, "Start", "start") + } +} diff --git a/cmd/apps/start_bundle_test.go b/cmd/apps/start_bundle_test.go new file mode 100644 index 0000000000..e7287d4950 --- /dev/null +++ b/cmd/apps/start_bundle_test.go @@ -0,0 +1,41 @@ +package apps + +import ( + "testing" + + "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestBundleStartOverrideWithWrapper(t *testing.T) { + mockWrapper := func(cmd *cobra.Command, appName string, err error) error { + return err + } + + overrideFunc := BundleStartOverrideWithWrapper(mockWrapper) + assert.NotNil(t, overrideFunc) + + cmd := &cobra.Command{} + startReq := &apps.StartAppRequest{} + + overrideFunc(cmd, startReq) + + assert.Equal(t, "start [NAME]", cmd.Use) +} + +func TestBundleStartOverrideHelpText(t *testing.T) { + mockWrapper := func(cmd *cobra.Command, appName string, err error) error { + return err + } + + cmd := &cobra.Command{} + startReq := &apps.StartAppRequest{} + + overrideFunc := BundleStartOverrideWithWrapper(mockWrapper) + overrideFunc(cmd, startReq) + + assert.NotEmpty(t, cmd.Long) + assert.Contains(t, cmd.Long, "Start an app") + assert.Contains(t, cmd.Long, "project directory") +} diff --git a/cmd/apps/stop_bundle.go b/cmd/apps/stop_bundle.go new file mode 100644 index 0000000000..c8b7ec6417 --- /dev/null +++ b/cmd/apps/stop_bundle.go @@ -0,0 +1,82 @@ +package apps + +import ( + "fmt" + + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/flags" + "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/spf13/cobra" +) + +// BundleStopOverrideWithWrapper creates a stop override function that uses +// the provided error wrapper for API fallback errors. +func BundleStopOverrideWithWrapper(wrapError ErrorWrapper) func(*cobra.Command, *apps.StopAppRequest) { + return func(stopCmd *cobra.Command, stopReq *apps.StopAppRequest) { + makeArgsOptionalWithBundle(stopCmd, "stop [NAME]") + + originalRunE := stopCmd.RunE + stopCmd.RunE = func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + outputFormat := root.OutputType(cmd) + + if len(args) == 0 { + appName, fromBundle, err := getAppNameFromArgs(cmd, args) + if err != nil { + return err + } + if fromBundle { + stopReq.Name = appName + + if outputFormat == flags.OutputText { + cmdio.LogString(ctx, fmt.Sprintf("Stopping app '%s' from project configuration", appName)) + + w := cmdctx.WorkspaceClient(ctx) + wait, err := w.Apps.Stop(ctx, *stopReq) + if err != nil { + handled, handledErr := handleAlreadyInStateError(ctx, cmd, err, appName, []string{"STOPPED state", "already"}, "stopped", wrapError) + if handled { + return handledErr + } + return wrapError(cmd, appName, err) + } + + var appInfo *apps.App + if shouldWaitForCompletion(cmd) { + spinner := cmdio.Spinner(ctx) + appInfo, err = wait.OnProgress(createAppProgressCallback(spinner)).GetWithTimeout(getWaitTimeout(cmd)) + close(spinner) + if err != nil { + return wrapError(cmd, appName, err) + } + } else { + appInfo, err = w.Apps.Get(ctx, apps.GetAppRequest{Name: appName}) + if err != nil { + return wrapError(cmd, appName, err) + } + } + + message := formatAppStatusMessage(appInfo, appName, "stopped") + cmdio.LogString(ctx, message) + return nil + } + + return originalRunE(cmd, []string{appName}) + } + } + + err := originalRunE(cmd, args) + if err != nil { + handled, handledErr := handleAlreadyInStateError(ctx, cmd, err, stopReq.Name, []string{"STOPPED state", "already"}, "stopped", wrapError) + if handled { + return handledErr + } + } + return wrapError(cmd, stopReq.Name, err) + } + + updateCommandHelp(stopCmd, "Stop", "stop") + } +} diff --git a/cmd/apps/stop_bundle_test.go b/cmd/apps/stop_bundle_test.go new file mode 100644 index 0000000000..7d82905548 --- /dev/null +++ b/cmd/apps/stop_bundle_test.go @@ -0,0 +1,41 @@ +package apps + +import ( + "testing" + + "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestBundleStopOverrideWithWrapper(t *testing.T) { + mockWrapper := func(cmd *cobra.Command, appName string, err error) error { + return err + } + + overrideFunc := BundleStopOverrideWithWrapper(mockWrapper) + assert.NotNil(t, overrideFunc) + + cmd := &cobra.Command{} + stopReq := &apps.StopAppRequest{} + + overrideFunc(cmd, stopReq) + + assert.Equal(t, "stop [NAME]", cmd.Use) +} + +func TestBundleStopOverrideHelpText(t *testing.T) { + mockWrapper := func(cmd *cobra.Command, appName string, err error) error { + return err + } + + cmd := &cobra.Command{} + stopReq := &apps.StopAppRequest{} + + overrideFunc := BundleStopOverrideWithWrapper(mockWrapper) + overrideFunc(cmd, stopReq) + + assert.NotEmpty(t, cmd.Long) + assert.Contains(t, cmd.Long, "Stop an app") + assert.Contains(t, cmd.Long, "project directory") +} diff --git a/cmd/bundle/destroy.go b/cmd/bundle/destroy.go index d9baf8eb13..bd9b65f71c 100644 --- a/cmd/bundle/destroy.go +++ b/cmd/bundle/destroy.go @@ -51,6 +51,9 @@ func CommandBundleDestroy(cmd *cobra.Command, args []string, autoApprove, forceD return errors.New("please specify --auto-approve since terminal does not support interactive prompts") } + // 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. @@ -59,8 +62,9 @@ func CommandBundleDestroy(cmd *cobra.Command, args []string, autoApprove, forceD // If `--auto-approve`` is specified, we skip confirmation checks b.AutoApprove = autoApprove }, - AlwaysPull: true, - // Do we need initialize phase here? + // Skip context initialization if already initialized by parent command + SkipInitContext: skipInitContext, + AlwaysPull: true, } b, stateDesc, err := utils.ProcessBundleRet(cmd, opts) diff --git a/cmd/workspace/apps/overrides.go b/cmd/workspace/apps/overrides.go index f678cc3045..6a909a943e 100644 --- a/cmd/workspace/apps/overrides.go +++ b/cmd/workspace/apps/overrides.go @@ -44,14 +44,6 @@ func createUpdateOverride(createUpdateCmd *cobra.Command, createUpdateReq *apps. } } -func startOverride(startCmd *cobra.Command, startReq *apps.StartAppRequest) { - originalRunE := startCmd.RunE - startCmd.RunE = func(cmd *cobra.Command, args []string) error { - err := originalRunE(cmd, args) - return wrapDeploymentError(cmd, startReq.Name, err) - } -} - func init() { cmdOverrides = append(cmdOverrides, func(cmd *cobra.Command) { // Commands that should NOT go into the management group @@ -59,6 +51,12 @@ func init() { nonManagementCommands := []string{ // 'deploy' is overloaded as API and bundle command "deploy", + // 'delete' is overloaded as API and bundle command + "delete", + // 'start' is overloaded as API and bundle command + "start", + // 'stop' is overloaded as API and bundle command + "stop", // permission commands are assigned into "permission" group in cmd/cmd.go "get-permission-levels", "get-permissions", @@ -90,6 +88,8 @@ func init() { listDeploymentsOverrides = append(listDeploymentsOverrides, listDeploymentsOverride) createOverrides = append(createOverrides, createOverride) deployOverrides = append(deployOverrides, appsCli.BundleDeployOverrideWithWrapper(wrapDeploymentError)) + deleteOverrides = append(deleteOverrides, appsCli.BundleDeleteOverrideWithWrapper(wrapDeploymentError)) + startOverrides = append(startOverrides, appsCli.BundleStartOverrideWithWrapper(wrapDeploymentError)) + stopOverrides = append(stopOverrides, appsCli.BundleStopOverrideWithWrapper(wrapDeploymentError)) createUpdateOverrides = append(createUpdateOverrides, createUpdateOverride) - startOverrides = append(startOverrides, startOverride) }