Skip to content

Commit cfbb5e3

Browse files
fjakobsclaude
andauthored
Add bundle mode support to apps delete/start/stop/logs commands (#4369)
## Summary This PR adds bundle mode support to `apps delete`, `start`, `stop`, and `logs` commands, allowing them to auto-detect the app name from project configuration. It also significantly reduces code duplication by extracting common functionality into shared helper functions. ## Changes ### New Features - **Bundle mode support**: Commands now work without explicit app name when run from project directory - `databricks apps delete` - Destroys all project resources (calls `bundle destroy`) - `databricks apps start/stop` - Auto-detects app name from `databricks.yml` - `databricks apps logs` - Streams logs for project app automatically - **Idempotent operations**: Start/stop commands handle "already in desired state" errors gracefully - **Enhanced output**: Display app URL after starting, improved status messages with emojis - **Target support**: All bundle-mode commands support `--target` flag ## Examples ```bash # From a Databricks Apps project directory: databricks apps start # Auto-detects app name databricks apps stop --target prod # Stop production app databricks apps delete # Destroy all project resources databricks apps logs # Stream logs for project app # API mode (works from anywhere): databricks apps start my-app databricks apps delete my-app ``` --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent ceb40b1 commit cfbb5e3

File tree

13 files changed

+834
-57
lines changed

13 files changed

+834
-57
lines changed

acceptance/apps/deploy/bundle-no-args/output.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11

22
>>> [CLI] apps deploy --skip-validation
3-
Deploying bundle...
3+
Deploying project...
44
Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files...
55
Deploying resources...
66
Updating deployment state...

cmd/apps/bundle_helpers.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package apps
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
"time"
9+
10+
"github.com/databricks/cli/cmd/root"
11+
"github.com/databricks/cli/libs/cmdctx"
12+
"github.com/databricks/cli/libs/cmdio"
13+
"github.com/databricks/cli/libs/flags"
14+
"github.com/databricks/databricks-sdk-go/service/apps"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
const defaultAppWaitTimeout = 20 * time.Minute
19+
20+
// makeArgsOptionalWithBundle updates a command to allow optional NAME argument
21+
// when running from a bundle directory.
22+
func makeArgsOptionalWithBundle(cmd *cobra.Command, usage string) {
23+
cmd.Use = usage
24+
25+
cmd.Args = func(cmd *cobra.Command, args []string) error {
26+
if len(args) > 1 {
27+
return fmt.Errorf("accepts at most 1 arg(s), received %d", len(args))
28+
}
29+
if !hasBundleConfig() && len(args) != 1 {
30+
return fmt.Errorf("accepts 1 arg(s), received %d", len(args))
31+
}
32+
return nil
33+
}
34+
}
35+
36+
// getAppNameFromArgs returns the app name from args or detects it from the bundle.
37+
// Returns (appName, fromBundle, error).
38+
func getAppNameFromArgs(cmd *cobra.Command, args []string) (string, bool, error) {
39+
if len(args) > 0 {
40+
return args[0], false, nil
41+
}
42+
43+
appName := detectAppNameFromBundle(cmd)
44+
if appName != "" {
45+
return appName, true, nil
46+
}
47+
48+
return "", false, errors.New("no app name provided and unable to detect from project configuration")
49+
}
50+
51+
// updateCommandHelp updates the help text for a command to explain bundle behavior.
52+
func updateCommandHelp(cmd *cobra.Command, commandVerb, commandName string) {
53+
cmd.Long = fmt.Sprintf(`%s an app.
54+
55+
When run from a Databricks Apps project directory (containing databricks.yml)
56+
without a NAME argument, this command automatically detects the app name from
57+
the project configuration and %ss it.
58+
59+
When a NAME argument is provided (or when not in a project directory),
60+
%ss the specified app using the API directly.
61+
62+
Arguments:
63+
NAME: The name of the app. Required when not in a project directory.
64+
When provided in a project directory, uses the specified name instead of auto-detection.
65+
66+
Examples:
67+
# %s app from a project directory (auto-detects app name)
68+
databricks apps %s
69+
70+
# %s app from a specific target
71+
databricks apps %s --target prod
72+
73+
# %s a specific app using the API (even from a project directory)
74+
databricks apps %s my-app`,
75+
commandVerb,
76+
commandName,
77+
commandName,
78+
commandVerb,
79+
commandName,
80+
commandVerb,
81+
commandName,
82+
commandVerb,
83+
commandName)
84+
}
85+
86+
// isIdempotencyError checks if an error message indicates the operation is already in the desired state.
87+
func isIdempotencyError(err error, keywords ...string) bool {
88+
if err == nil {
89+
return false
90+
}
91+
errMsg := err.Error()
92+
for _, keyword := range keywords {
93+
if strings.Contains(errMsg, keyword) {
94+
return true
95+
}
96+
}
97+
return false
98+
}
99+
100+
// displayAppURL displays the app URL in a consistent format if available.
101+
func displayAppURL(ctx context.Context, appInfo *apps.App) {
102+
if appInfo != nil && appInfo.Url != "" {
103+
cmdio.LogString(ctx, fmt.Sprintf("\n🔗 %s\n", appInfo.Url))
104+
}
105+
}
106+
107+
// formatAppStatusMessage formats a user-friendly status message for an app.
108+
func formatAppStatusMessage(appInfo *apps.App, appName, verb string) string {
109+
computeState := "unknown"
110+
if appInfo != nil && appInfo.ComputeStatus != nil {
111+
computeState = string(appInfo.ComputeStatus.State)
112+
}
113+
114+
if appInfo != nil && appInfo.AppStatus != nil && appInfo.AppStatus.State == apps.ApplicationStateUnavailable {
115+
return fmt.Sprintf("⚠ App '%s' %s but is unavailable (compute: %s, app: %s)", appName, verb, computeState, appInfo.AppStatus.State)
116+
}
117+
118+
if appInfo != nil && appInfo.ComputeStatus != nil {
119+
state := appInfo.ComputeStatus.State
120+
switch state {
121+
case apps.ComputeStateActive:
122+
if verb == "is deployed" {
123+
return fmt.Sprintf("✔ App '%s' is already running (status: %s)", appName, state)
124+
}
125+
return fmt.Sprintf("✔ App '%s' started successfully (status: %s)", appName, state)
126+
case apps.ComputeStateStarting:
127+
return fmt.Sprintf("⚠ App '%s' is already starting (status: %s)", appName, state)
128+
default:
129+
return fmt.Sprintf("✔ App '%s' status: %s", appName, state)
130+
}
131+
}
132+
133+
return fmt.Sprintf("✔ App '%s' status: unknown", appName)
134+
}
135+
136+
// getWaitTimeout gets the timeout value for app wait operations.
137+
func getWaitTimeout(cmd *cobra.Command) time.Duration {
138+
timeout, _ := cmd.Flags().GetDuration("timeout")
139+
if timeout == 0 {
140+
timeout = defaultAppWaitTimeout
141+
}
142+
return timeout
143+
}
144+
145+
// shouldWaitForCompletion checks if the command should wait for app operation completion.
146+
func shouldWaitForCompletion(cmd *cobra.Command) bool {
147+
skipWait, _ := cmd.Flags().GetBool("no-wait")
148+
return !skipWait
149+
}
150+
151+
// createAppProgressCallback creates a progress callback for app operations.
152+
func createAppProgressCallback(spinner chan<- string) func(*apps.App) {
153+
return func(i *apps.App) {
154+
if i.ComputeStatus == nil {
155+
return
156+
}
157+
statusMessage := i.ComputeStatus.Message
158+
if statusMessage == "" {
159+
statusMessage = fmt.Sprintf("current status: %s", i.ComputeStatus.State)
160+
}
161+
spinner <- statusMessage
162+
}
163+
}
164+
165+
// handleAlreadyInStateError handles idempotency errors and displays appropriate status.
166+
// Returns true if the error was handled (already in desired state), false otherwise.
167+
func handleAlreadyInStateError(ctx context.Context, cmd *cobra.Command, err error, appName string, keywords []string, verb string, wrapError ErrorWrapper) (bool, error) {
168+
if !isIdempotencyError(err, keywords...) {
169+
return false, nil
170+
}
171+
172+
outputFormat := root.OutputType(cmd)
173+
if outputFormat != flags.OutputText {
174+
return true, nil
175+
}
176+
177+
w := cmdctx.WorkspaceClient(ctx)
178+
appInfo, getErr := w.Apps.Get(ctx, apps.GetAppRequest{Name: appName})
179+
if getErr != nil {
180+
return true, wrapError(cmd, appName, getErr)
181+
}
182+
183+
message := formatAppStatusMessage(appInfo, appName, verb)
184+
cmdio.LogString(ctx, message)
185+
displayAppURL(ctx, appInfo)
186+
return true, nil
187+
}

cmd/apps/bundle_helpers_test.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package apps
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
"github.com/databricks/databricks-sdk-go/service/apps"
9+
"github.com/spf13/cobra"
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestIsIdempotencyError(t *testing.T) {
14+
t.Run("returns true when error contains keyword", func(t *testing.T) {
15+
err := errors.New("app is already in ACTIVE state")
16+
assert.True(t, isIdempotencyError(err, "ACTIVE state"))
17+
})
18+
19+
t.Run("returns true when error contains any keyword", func(t *testing.T) {
20+
err := errors.New("already running")
21+
assert.True(t, isIdempotencyError(err, "ACTIVE state", "already"))
22+
})
23+
24+
t.Run("returns false when error does not contain keywords", func(t *testing.T) {
25+
err := errors.New("something went wrong")
26+
assert.False(t, isIdempotencyError(err, "ACTIVE state", "already"))
27+
})
28+
29+
t.Run("returns false for nil error", func(t *testing.T) {
30+
assert.False(t, isIdempotencyError(nil, "ACTIVE state"))
31+
})
32+
33+
t.Run("matches partial strings", func(t *testing.T) {
34+
err := errors.New("error: ACTIVE state detected")
35+
assert.True(t, isIdempotencyError(err, "ACTIVE state"))
36+
})
37+
}
38+
39+
func TestFormatAppStatusMessage(t *testing.T) {
40+
t.Run("handles nil appInfo", func(t *testing.T) {
41+
msg := formatAppStatusMessage(nil, "test-app", "started")
42+
assert.Equal(t, "✔ App 'test-app' status: unknown", msg)
43+
})
44+
45+
t.Run("handles unavailable app state", func(t *testing.T) {
46+
appInfo := &apps.App{
47+
AppStatus: &apps.ApplicationStatus{
48+
State: apps.ApplicationStateUnavailable,
49+
},
50+
ComputeStatus: &apps.ComputeStatus{
51+
State: apps.ComputeStateActive,
52+
},
53+
}
54+
msg := formatAppStatusMessage(appInfo, "test-app", "started")
55+
assert.Contains(t, msg, "unavailable")
56+
assert.Contains(t, msg, "ACTIVE")
57+
})
58+
59+
t.Run("formats active state with 'is deployed' verb", func(t *testing.T) {
60+
appInfo := &apps.App{
61+
ComputeStatus: &apps.ComputeStatus{
62+
State: apps.ComputeStateActive,
63+
},
64+
}
65+
msg := formatAppStatusMessage(appInfo, "test-app", "is deployed")
66+
assert.Contains(t, msg, "already running")
67+
assert.Contains(t, msg, "ACTIVE")
68+
})
69+
70+
t.Run("formats active state with 'started' verb", func(t *testing.T) {
71+
appInfo := &apps.App{
72+
ComputeStatus: &apps.ComputeStatus{
73+
State: apps.ComputeStateActive,
74+
},
75+
}
76+
msg := formatAppStatusMessage(appInfo, "test-app", "started")
77+
assert.Contains(t, msg, "started successfully")
78+
assert.Contains(t, msg, "ACTIVE")
79+
})
80+
81+
t.Run("formats starting state", func(t *testing.T) {
82+
appInfo := &apps.App{
83+
ComputeStatus: &apps.ComputeStatus{
84+
State: apps.ComputeStateStarting,
85+
},
86+
}
87+
msg := formatAppStatusMessage(appInfo, "test-app", "started")
88+
assert.Contains(t, msg, "already starting")
89+
assert.Contains(t, msg, "STARTING")
90+
})
91+
92+
t.Run("formats other compute states", func(t *testing.T) {
93+
appInfo := &apps.App{
94+
ComputeStatus: &apps.ComputeStatus{
95+
State: apps.ComputeStateStopped,
96+
},
97+
}
98+
msg := formatAppStatusMessage(appInfo, "test-app", "stopped")
99+
assert.Contains(t, msg, "status: STOPPED")
100+
})
101+
102+
t.Run("handles nil compute status", func(t *testing.T) {
103+
appInfo := &apps.App{}
104+
msg := formatAppStatusMessage(appInfo, "test-app", "started")
105+
assert.Equal(t, "✔ App 'test-app' status: unknown", msg)
106+
})
107+
}
108+
109+
func TestMakeArgsOptionalWithBundle(t *testing.T) {
110+
t.Run("updates command usage", func(t *testing.T) {
111+
cmd := &cobra.Command{}
112+
makeArgsOptionalWithBundle(cmd, "test [NAME]")
113+
assert.Equal(t, "test [NAME]", cmd.Use)
114+
})
115+
116+
t.Run("sets Args validator", func(t *testing.T) {
117+
cmd := &cobra.Command{}
118+
makeArgsOptionalWithBundle(cmd, "test [NAME]")
119+
assert.NotNil(t, cmd.Args)
120+
})
121+
}
122+
123+
func TestGetAppNameFromArgs(t *testing.T) {
124+
t.Run("returns arg when provided", func(t *testing.T) {
125+
cmd := &cobra.Command{}
126+
name, fromBundle, err := getAppNameFromArgs(cmd, []string{"my-app"})
127+
assert.NoError(t, err)
128+
assert.Equal(t, "my-app", name)
129+
assert.False(t, fromBundle)
130+
})
131+
}
132+
133+
func TestUpdateCommandHelp(t *testing.T) {
134+
t.Run("sets Long help text", func(t *testing.T) {
135+
cmd := &cobra.Command{}
136+
updateCommandHelp(cmd, "Start", "start")
137+
assert.NotEmpty(t, cmd.Long)
138+
})
139+
140+
t.Run("includes verb in help text", func(t *testing.T) {
141+
cmd := &cobra.Command{}
142+
updateCommandHelp(cmd, "Start", "start")
143+
assert.Contains(t, cmd.Long, "Start an app")
144+
})
145+
146+
t.Run("includes command name in examples", func(t *testing.T) {
147+
cmd := &cobra.Command{}
148+
updateCommandHelp(cmd, "Stop", "stop")
149+
assert.Contains(t, cmd.Long, "databricks apps stop")
150+
})
151+
152+
t.Run("includes all example scenarios", func(t *testing.T) {
153+
cmd := &cobra.Command{}
154+
updateCommandHelp(cmd, "Start", "start")
155+
assert.Contains(t, cmd.Long, "from a project directory")
156+
assert.Contains(t, cmd.Long, "--target prod")
157+
assert.Contains(t, cmd.Long, "my-app")
158+
})
159+
}
160+
161+
func TestHandleAlreadyInStateError(t *testing.T) {
162+
t.Run("returns false when not an idempotency error", func(t *testing.T) {
163+
err := errors.New("some other error")
164+
cmd := &cobra.Command{}
165+
mockWrapper := func(cmd *cobra.Command, appName string, err error) error {
166+
return err
167+
}
168+
169+
handled, _ := handleAlreadyInStateError(context.Background(), cmd, err, "test-app", []string{"ACTIVE"}, "is deployed", mockWrapper)
170+
assert.False(t, handled)
171+
})
172+
}

0 commit comments

Comments
 (0)