diff --git a/cmd/apps/dev.go b/cmd/apps/dev.go index 4fb86527c25..d070c423a73 100644 --- a/cmd/apps/dev.go +++ b/cmd/apps/dev.go @@ -12,7 +12,6 @@ import ( "os/exec" "os/signal" "strconv" - "strings" "syscall" "time" @@ -22,6 +21,7 @@ import ( "github.com/databricks/cli/libs/apps/vite" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/databricks-sdk-go/apierr" "github.com/spf13/cobra" ) @@ -40,6 +40,12 @@ func isViteReady(port int) bool { return true } +// isAppNotFound reports whether err is the Apps API's 404, which covers both a +// never-created and a deleted app. +func isAppNotFound(err error) bool { + return errors.Is(err, apierr.ErrNotFound) +} + // detectAppNameFromBundle tries to extract the app name from a databricks.yml bundle config. // Returns the app name if found, or empty string if no bundle or no apps found. // This properly loads and initializes the bundle to resolve variables and apply prefixes. @@ -184,7 +190,7 @@ Examples: return domainErr }) if err != nil { - if strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "is deleted") { + if isAppNotFound(err) { return fmt.Errorf("application '%s' has not been deployed yet. Run `databricks apps deploy` to deploy and then try again", appName) } return fmt.Errorf("failed to get app domain: %w", err) diff --git a/cmd/apps/dev_test.go b/cmd/apps/dev_test.go index a1e8deef447..fc9b292fb16 100644 --- a/cmd/apps/dev_test.go +++ b/cmd/apps/dev_test.go @@ -1,6 +1,8 @@ package apps import ( + "errors" + "fmt" "net" "os" "testing" @@ -8,6 +10,7 @@ import ( "github.com/databricks/cli/libs/apps/vite" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/databricks-sdk-go/apierr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -44,6 +47,24 @@ func TestIsViteReady(t *testing.T) { }) } +func TestIsAppNotFound(t *testing.T) { + notFound := &apierr.APIError{ + StatusCode: 404, + ErrorCode: "NOT_FOUND", + Message: "App with name test-app does not exist or is deleted.", + } + assert.True(t, isAppNotFound(notFound)) + assert.True(t, isAppNotFound(fmt.Errorf("failed to get app: %w", notFound))) + + forbidden := &apierr.APIError{ + StatusCode: 403, + ErrorCode: "PERMISSION_DENIED", + Message: "user does not have permission", + } + assert.False(t, isAppNotFound(forbidden)) + assert.False(t, isAppNotFound(errors.New("app URL is empty"))) +} + func TestViteServerScriptContent(t *testing.T) { // Verify the embedded script is not empty assert.NotEmpty(t, vite.ServerScript) diff --git a/libs/filer/dbfs_client.go b/libs/filer/dbfs_client.go index ce9286ea8d9..63670560386 100644 --- a/libs/filer/dbfs_client.go +++ b/libs/filer/dbfs_client.go @@ -148,13 +148,13 @@ func (w *DbfsClient) Read(ctx context.Context, name string) (io.ReadCloser, erro handle, err := w.workspaceClient.Dbfs.Open(ctx, absPath, files.FileModeRead) if err != nil { - // Return error if file is a directory - if strings.Contains(err.Error(), "cannot open directory for reading") { - return nil, notAFile{absPath} - } - aerr, ok := errors.AsType[*apierr.APIError](err) if !ok { + // The SDK's Open fails with a client-side error carrying no sentinel + // when the path is a directory, so re-stat to detect that case. + if info, serr := w.workspaceClient.Dbfs.GetStatusByPath(ctx, absPath); serr == nil && info.IsDir { + return nil, notAFile{absPath} + } return nil, err } diff --git a/libs/filer/dbfs_client_test.go b/libs/filer/dbfs_client_test.go new file mode 100644 index 00000000000..ebdb4de659b --- /dev/null +++ b/libs/filer/dbfs_client_test.go @@ -0,0 +1,54 @@ +package filer + +import ( + "io/fs" + "testing" + + "github.com/databricks/cli/libs/testserver" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/service/files" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func dbfsReadWithGetStatusResponse(t *testing.T, response any) error { + t.Helper() + + server := testserver.New(t) + server.Handle("GET", "/api/2.0/dbfs/get-status", func(req testserver.Request) any { + return response + }) + testserver.AddDefaultHandlers(server) + + client, err := databricks.NewWorkspaceClient(&databricks.Config{ + Host: server.URL, + Token: "testtoken", + }) + require.NoError(t, err) + + f, err := NewDbfsClient(client, "/test") + require.NoError(t, err) + + _, err = f.Read(t.Context(), "file") + require.Error(t, err) + return err +} + +func TestDbfsClientReadDirectory(t *testing.T) { + err := dbfsReadWithGetStatusResponse(t, files.FileInfo{ + Path: "/test/file", + IsDir: true, + }) + assert.ErrorIs(t, err, fs.ErrInvalid) +} + +func TestDbfsClientReadFileDoesNotExist(t *testing.T) { + err := dbfsReadWithGetStatusResponse(t, testserver.Response{ + StatusCode: 404, + Body: map[string]string{ + "error_code": "RESOURCE_DOES_NOT_EXIST", + "message": "test error", + }, + }) + assert.ErrorIs(t, err, fs.ErrNotExist) +}