Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion acceptance/apps/deploy/bundle-no-args/output.txt
Original file line number Diff line number Diff line change
@@ -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...
Expand Down
187 changes: 187 additions & 0 deletions cmd/apps/bundle_helpers.go
Original file line number Diff line number Diff line change
@@ -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
}
172 changes: 172 additions & 0 deletions cmd/apps/bundle_helpers_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
Loading