diff --git a/acceptance/experimental/open/out.test.toml b/acceptance/experimental/open/out.test.toml new file mode 100644 index 0000000000..216969a761 --- /dev/null +++ b/acceptance/experimental/open/out.test.toml @@ -0,0 +1,9 @@ +Local = true +Cloud = false + +[GOOS] + linux = false + windows = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/experimental/open/output.txt b/acceptance/experimental/open/output.txt new file mode 100644 index 0000000000..a83e0676fe --- /dev/null +++ b/acceptance/experimental/open/output.txt @@ -0,0 +1,32 @@ + +=== print URL for a job +>>> [CLI] experimental open --url jobs 123 +[DATABRICKS_URL]/jobs/123?o=[NUMID] + +=== print URL for a notebook +>>> [CLI] experimental open --url notebooks 12345 +[DATABRICKS_URL]/?o=[NUMID]#notebook/12345 + +=== unknown resource type +>>> [CLI] experimental open --url unknown 123 +Error: unknown resource type "unknown", must be one of: alerts, apps, clusters, dashboards, experiments, jobs, model_serving_endpoints, models, notebooks, pipelines, queries, registered_models, warehouses + +Exit code: 1 + +=== test auto-completion handler +>>> [CLI] __complete experimental open , +alerts +apps +clusters +dashboards +experiments +jobs +model_serving_endpoints +models +notebooks +pipelines +queries +registered_models +warehouses +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/acceptance/experimental/open/script b/acceptance/experimental/open/script new file mode 100644 index 0000000000..820175db8d --- /dev/null +++ b/acceptance/experimental/open/script @@ -0,0 +1,11 @@ +title "print URL for a job" +trace $CLI experimental open --url jobs 123 + +title "print URL for a notebook" +trace $CLI experimental open --url notebooks 12345 + +title "unknown resource type" +errcode trace $CLI experimental open --url unknown 123 + +title "test auto-completion handler" +trace $CLI __complete experimental open , diff --git a/acceptance/experimental/open/test.toml b/acceptance/experimental/open/test.toml new file mode 100644 index 0000000000..9e03c20d49 --- /dev/null +++ b/acceptance/experimental/open/test.toml @@ -0,0 +1,5 @@ +GOOS.windows = false +GOOS.linux = false + +# No bundle engine needed for this command. +[EnvMatrix] diff --git a/bundle/config/resources/alerts.go b/bundle/config/resources/alerts.go index bfdd64e900..f1d81c8c2f 100644 --- a/bundle/config/resources/alerts.go +++ b/bundle/config/resources/alerts.go @@ -5,6 +5,7 @@ import ( "net/url" "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/workspaceurls" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/service/sql" @@ -52,8 +53,7 @@ func (a *Alert) InitializeURL(baseURL url.URL) { if a.ID == "" { return } - baseURL.Path = "sql/alerts-v2/" + a.ID - a.URL = baseURL.String() + a.URL = workspaceurls.ResourceURL(baseURL, workspaceurls.AlertPattern, a.ID) } func (a *Alert) GetName() string { diff --git a/bundle/config/resources/apps.go b/bundle/config/resources/apps.go index e48f6e7dae..ee05be7c9f 100644 --- a/bundle/config/resources/apps.go +++ b/bundle/config/resources/apps.go @@ -5,6 +5,7 @@ import ( "net/url" "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/workspaceurls" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/service/apps" @@ -83,8 +84,7 @@ func (a *App) InitializeURL(baseURL url.URL) { if a.ModifiedStatus == "" || a.ModifiedStatus == ModifiedStatusCreated { return } - baseURL.Path = "apps/" + a.GetName() - a.URL = baseURL.String() + a.URL = workspaceurls.ResourceURL(baseURL, workspaceurls.AppPattern, a.GetName()) } func (a *App) GetName() string { diff --git a/bundle/config/resources/clusters.go b/bundle/config/resources/clusters.go index c549ac4a6b..89c6319c2e 100644 --- a/bundle/config/resources/clusters.go +++ b/bundle/config/resources/clusters.go @@ -5,6 +5,7 @@ import ( "net/url" "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/workspaceurls" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/service/compute" @@ -47,8 +48,7 @@ func (s *Cluster) InitializeURL(baseURL url.URL) { if s.ID == "" { return } - baseURL.Path = "compute/clusters/" + s.ID - s.URL = baseURL.String() + s.URL = workspaceurls.ResourceURL(baseURL, workspaceurls.ClusterPattern, s.ID) } func (s *Cluster) GetName() string { diff --git a/bundle/config/resources/dashboard.go b/bundle/config/resources/dashboard.go index c108ac8abe..94c5cbec5d 100644 --- a/bundle/config/resources/dashboard.go +++ b/bundle/config/resources/dashboard.go @@ -2,10 +2,10 @@ package resources import ( "context" - "fmt" "net/url" "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/workspaceurls" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/service/dashboards" @@ -114,8 +114,7 @@ func (r *Dashboard) InitializeURL(baseURL url.URL) { return } - baseURL.Path = fmt.Sprintf("dashboardsv3/%s/published", r.ID) - r.URL = baseURL.String() + r.URL = workspaceurls.ResourceURL(baseURL, workspaceurls.DashboardPattern, r.ID) } func (r *Dashboard) GetName() string { diff --git a/bundle/config/resources/job.go b/bundle/config/resources/job.go index b2b7ff15f1..67dff8b6cc 100644 --- a/bundle/config/resources/job.go +++ b/bundle/config/resources/job.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/workspaceurls" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/service/jobs" @@ -54,8 +55,7 @@ func (j *Job) InitializeURL(baseURL url.URL) { if j.ID == "" { return } - baseURL.Path = "jobs/" + j.ID - j.URL = baseURL.String() + j.URL = workspaceurls.ResourceURL(baseURL, workspaceurls.JobPattern, j.ID) } func (j *Job) GetName() string { diff --git a/bundle/config/resources/mlflow_experiment.go b/bundle/config/resources/mlflow_experiment.go index 0a2a36b840..75af42f435 100644 --- a/bundle/config/resources/mlflow_experiment.go +++ b/bundle/config/resources/mlflow_experiment.go @@ -5,6 +5,7 @@ import ( "net/url" "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/workspaceurls" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/service/ml" @@ -49,8 +50,7 @@ func (s *MlflowExperiment) InitializeURL(baseURL url.URL) { if s.ID == "" { return } - baseURL.Path = "ml/experiments/" + s.ID - s.URL = baseURL.String() + s.URL = workspaceurls.ResourceURL(baseURL, workspaceurls.ExperimentPattern, s.ID) } func (s *MlflowExperiment) GetName() string { diff --git a/bundle/config/resources/mlflow_model.go b/bundle/config/resources/mlflow_model.go index a867f55a0e..9b739b8787 100644 --- a/bundle/config/resources/mlflow_model.go +++ b/bundle/config/resources/mlflow_model.go @@ -5,6 +5,7 @@ import ( "net/url" "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/workspaceurls" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/service/ml" @@ -49,8 +50,7 @@ func (s *MlflowModel) InitializeURL(baseURL url.URL) { if s.ID == "" { return } - baseURL.Path = "ml/models/" + s.ID - s.URL = baseURL.String() + s.URL = workspaceurls.ResourceURL(baseURL, workspaceurls.ModelPattern, s.ID) } func (s *MlflowModel) GetName() string { diff --git a/bundle/config/resources/model_serving_endpoint.go b/bundle/config/resources/model_serving_endpoint.go index d3e390596d..06d39f7093 100644 --- a/bundle/config/resources/model_serving_endpoint.go +++ b/bundle/config/resources/model_serving_endpoint.go @@ -5,6 +5,7 @@ import ( "net/url" "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/workspaceurls" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/service/serving" @@ -54,8 +55,7 @@ func (s *ModelServingEndpoint) InitializeURL(baseURL url.URL) { if s.ID == "" { return } - baseURL.Path = "ml/endpoints/" + s.ID - s.URL = baseURL.String() + s.URL = workspaceurls.ResourceURL(baseURL, workspaceurls.ModelServingEndpointPattern, s.ID) } func (s *ModelServingEndpoint) GetName() string { diff --git a/bundle/config/resources/pipeline.go b/bundle/config/resources/pipeline.go index e213ff9c00..90d9b4c071 100644 --- a/bundle/config/resources/pipeline.go +++ b/bundle/config/resources/pipeline.go @@ -5,6 +5,7 @@ import ( "net/url" "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/workspaceurls" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/service/pipelines" @@ -49,8 +50,7 @@ func (p *Pipeline) InitializeURL(baseURL url.URL) { if p.ID == "" { return } - baseURL.Path = "pipelines/" + p.ID - p.URL = baseURL.String() + p.URL = workspaceurls.ResourceURL(baseURL, workspaceurls.PipelinePattern, p.ID) } func (p *Pipeline) GetName() string { diff --git a/bundle/config/resources/registered_model.go b/bundle/config/resources/registered_model.go index c51450bf74..d30886ae9b 100644 --- a/bundle/config/resources/registered_model.go +++ b/bundle/config/resources/registered_model.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/workspaceurls" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/service/catalog" @@ -54,8 +55,11 @@ func (s *RegisteredModel) InitializeURL(baseURL url.URL) { if s.ID == "" { return } - baseURL.Path = "explore/data/models/" + strings.ReplaceAll(s.ID, ".", "/") - s.URL = baseURL.String() + s.URL = workspaceurls.ResourceURL( + baseURL, + workspaceurls.RegisteredModelPattern, + strings.ReplaceAll(s.ID, ".", "/"), + ) } func (s *RegisteredModel) GetName() string { diff --git a/bundle/config/resources/sql_warehouses.go b/bundle/config/resources/sql_warehouses.go index bed567b805..4841c7387e 100644 --- a/bundle/config/resources/sql_warehouses.go +++ b/bundle/config/resources/sql_warehouses.go @@ -5,6 +5,7 @@ import ( "net/url" "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/workspaceurls" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/service/sql" @@ -47,8 +48,7 @@ func (sw *SqlWarehouse) InitializeURL(baseURL url.URL) { if sw.ID == "" { return } - baseURL.Path = "sql/warehouses/" + sw.ID - sw.URL = baseURL.String() + sw.URL = workspaceurls.ResourceURL(baseURL, workspaceurls.WarehousePattern, sw.ID) } func (sw *SqlWarehouse) GetName() string { diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 1a1240d84c..b40981db84 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -4,23 +4,21 @@ import ( "context" "errors" "fmt" - "io" "runtime" "strings" "time" "github.com/databricks/cli/libs/auth" + "github.com/databricks/cli/libs/browser" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/databrickscfg" "github.com/databricks/cli/libs/databrickscfg/cfgpickers" "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/env" - "github.com/databricks/cli/libs/exec" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/config" "github.com/databricks/databricks-sdk-go/config/experimental/auth/authconv" "github.com/databricks/databricks-sdk-go/credentials/u2m" - browserpkg "github.com/pkg/browser" "github.com/spf13/cobra" ) @@ -174,7 +172,9 @@ depends on the existing profiles you have set in your configuration file } persistentAuthOpts := []u2m.PersistentAuthOption{ u2m.WithOAuthArgument(oauthArgument), - u2m.WithBrowser(getBrowserFunc(cmd)), + u2m.WithBrowser(browser.NewOpener(cmd.Context(), ".", + browser.WithDisabledMessage("Please complete authentication by opening this link in your browser:\n"), + )), } if len(scopesList) > 0 { persistentAuthOpts = append(persistentAuthOpts, u2m.WithScopes(scopesList)) @@ -400,60 +400,9 @@ func loadProfileByName(ctx context.Context, profileName string, profiler profile return nil, nil } -// openURLSuppressingStderr opens a URL in the browser while suppressing stderr output. -// This prevents xdg-open error messages from being displayed to the user. -func openURLSuppressingStderr(url string) error { - // Save the original stderr from the browser package - originalStderr := browserpkg.Stderr - defer func() { - browserpkg.Stderr = originalStderr - }() - - // Redirect stderr to discard to suppress xdg-open errors - browserpkg.Stderr = io.Discard - - // Call the browser open function - return browserpkg.OpenURL(url) -} - // oauthLoginClearKeys returns profile keys that should be explicitly removed // when performing an OAuth login. Derives auth credential fields dynamically // from the SDK's ConfigAttributes to stay in sync as new auth methods are added. func oauthLoginClearKeys() []string { return databrickscfg.AuthCredentialKeys() } - -// getBrowserFunc returns a function that opens the given URL in the browser. -// It respects the BROWSER environment variable: -// - empty string: uses the default browser -// - "none": prints the URL to stdout without opening a browser -// - custom command: executes the specified command with the URL as argument -func getBrowserFunc(cmd *cobra.Command) func(url string) error { - browser := env.Get(cmd.Context(), "BROWSER") - switch browser { - case "": - return openURLSuppressingStderr - case "none": - return func(url string) error { - cmdio.LogString(cmd.Context(), "Please complete authentication by opening this link in your browser:\n"+url) - return nil - } - default: - return func(url string) error { - // Run the browser command via a shell. - // It can be a script or a binary and scripts cannot be executed directly on Windows. - e, err := exec.NewCommandExecutor(".") - if err != nil { - return err - } - - e.WithInheritOutput() - cmd, err := e.StartCommand(cmd.Context(), fmt.Sprintf("%q %q", browser, url)) - if err != nil { - return err - } - - return cmd.Wait() - } - } -} diff --git a/cmd/auth/token.go b/cmd/auth/token.go index 5f695ce6bc..97ef4f6863 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -9,6 +9,7 @@ import ( "time" "github.com/databricks/cli/libs/auth" + "github.com/databricks/cli/libs/browser" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/databrickscfg" "github.com/databricks/cli/libs/databrickscfg/profile" @@ -432,7 +433,9 @@ func runInlineLogin(ctx context.Context, profiler profile.Profiler) (string, *pr } persistentAuthOpts := []u2m.PersistentAuthOption{ u2m.WithOAuthArgument(oauthArgument), - u2m.WithBrowser(openURLSuppressingStderr), + u2m.WithBrowser(browser.NewOpener(ctx, ".", + browser.WithDisabledMessage("Please complete authentication by opening this link in your browser:\n"), + )), } if len(scopesList) > 0 { persistentAuthOpts = append(persistentAuthOpts, u2m.WithScopes(scopesList)) diff --git a/cmd/experimental/experimental.go b/cmd/experimental/experimental.go index eb3b7814e1..36ad876589 100644 --- a/cmd/experimental/experimental.go +++ b/cmd/experimental/experimental.go @@ -21,6 +21,7 @@ development. They may change or be removed in future versions without notice.`, } cmd.AddCommand(aitoolscmd.NewAitoolsCmd()) + cmd.AddCommand(newWorkspaceOpenCommand()) return cmd } diff --git a/cmd/experimental/workspace_open.go b/cmd/experimental/workspace_open.go new file mode 100644 index 0000000000..2a61e62da8 --- /dev/null +++ b/cmd/experimental/workspace_open.go @@ -0,0 +1,124 @@ +package experimental + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/browser" + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/workspaceurls" +) + +var supportedOpenResourceTypes = []string{ + workspaceurls.ResourceAlerts, + workspaceurls.ResourceApps, + workspaceurls.ResourceClusters, + workspaceurls.ResourceDashboards, + workspaceurls.ResourceExperiments, + workspaceurls.ResourceJobs, + workspaceurls.ResourceModels, + workspaceurls.ResourceModelServingEndpoints, + workspaceurls.ResourceNotebooks, + workspaceurls.ResourcePipelines, + workspaceurls.ResourceQueries, + workspaceurls.ResourceRegisteredModels, + workspaceurls.ResourceWarehouses, +} + +var currentWorkspaceID = func(ctx context.Context) (int64, error) { + return cmdctx.WorkspaceClient(ctx).CurrentWorkspaceID(ctx) +} + +var openWorkspaceURL = func(ctx context.Context, targetURL string) error { + opener := browser.NewOpener(ctx, ".", + browser.WithDisabledMessage("Open this URL in your browser to view the resource:\n"), + ) + return opener(targetURL) +} + +func resourceTypeNames() []string { + return workspaceurls.SortResourceTypes(supportedOpenResourceTypes) +} + +func supportedResourceTypes() string { + return strings.Join(resourceTypeNames(), ", ") +} + +// buildWorkspaceURL constructs a full workspace URL for a given resource type and ID. +func buildWorkspaceURL(host, resourceType, id string, workspaceID int64) (string, error) { + pattern, ok := workspaceurls.LookupPattern(resourceType) + if !ok { + return "", fmt.Errorf("unknown resource type %q, must be one of: %s", resourceType, supportedResourceTypes()) + } + + id = workspaceurls.NormalizeDotSeparatedID(resourceType, id) + return workspaceurls.BuildResourceURL(host, pattern, id, workspaceID) +} + +func newWorkspaceOpenCommand() *cobra.Command { + var printURL bool + + cmd := &cobra.Command{ + Use: "open [flags] RESOURCE_TYPE ID_OR_PATH", + Short: "Open a workspace resource or print its URL", + Long: fmt.Sprintf(`Open a workspace resource in the default browser, or print its URL. + +Supported resource types: %s. + +For registered_models, use the dot-separated name (catalog.schema.model) +and it will be converted to the correct URL path automatically. + +Examples: + databricks experimental open jobs 123456789 + databricks experimental open notebooks /Users/user@example.com/my-notebook + databricks experimental open clusters 0123-456789-abcdef + databricks experimental open registered_models catalog.schema.my_model + databricks experimental open jobs 123456789 --url`, supportedResourceTypes()), + Args: cobra.ExactArgs(2), + PreRunE: root.MustWorkspaceClient, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + w := cmdctx.WorkspaceClient(ctx) + + resourceType := args[0] + id := args[1] + + workspaceID, err := currentWorkspaceID(ctx) + if err != nil { + log.Warnf(ctx, "Could not determine workspace ID: %v", err) + } + + resourceURL, err := buildWorkspaceURL(w.Config.Host, resourceType, id, workspaceID) + if err != nil { + return err + } + + if printURL { + _, err := fmt.Fprintln(cmd.OutOrStdout(), resourceURL) + return err + } + + if !browser.IsDisabled(ctx) { + cmdio.LogString(ctx, fmt.Sprintf("Opening %s %s in the browser...", resourceType, id)) + } + + return openWorkspaceURL(ctx, resourceURL) + }, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return resourceTypeNames(), cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + } + + cmd.Flags().BoolVar(&printURL, "url", false, "Print the workspace URL instead of opening the browser") + + return cmd +} diff --git a/cmd/experimental/workspace_open_test.go b/cmd/experimental/workspace_open_test.go new file mode 100644 index 0000000000..a5ed0f0ff8 --- /dev/null +++ b/cmd/experimental/workspace_open_test.go @@ -0,0 +1,266 @@ +package experimental + +import ( + "bytes" + "context" + "errors" + "log/slog" + "testing" + + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/log/handler" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/config" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildWorkspaceURLPathBasedResources(t *testing.T) { + tests := []struct { + resourceType string + id string + expected string + }{ + {"jobs", "123", "https://myworkspace.databricks.com/jobs/123"}, + {"pipelines", "abc-def", "https://myworkspace.databricks.com/pipelines/abc-def"}, + {"dashboards", "dash-1", "https://myworkspace.databricks.com/dashboardsv3/dash-1/published"}, + {"experiments", "exp-1", "https://myworkspace.databricks.com/ml/experiments/exp-1"}, + {"warehouses", "wh-1", "https://myworkspace.databricks.com/sql/warehouses/wh-1"}, + {"queries", "q-1", "https://myworkspace.databricks.com/sql/editor/q-1"}, + {"apps", "my-app", "https://myworkspace.databricks.com/apps/my-app"}, + {"clusters", "0123-456789-abc", "https://myworkspace.databricks.com/compute/clusters/0123-456789-abc"}, + {"registered_models", "catalog.schema.model", "https://myworkspace.databricks.com/explore/data/models/catalog/schema/model"}, + } + + for _, tt := range tests { + t.Run(tt.resourceType+"/"+tt.id, func(t *testing.T) { + got, err := buildWorkspaceURL("https://myworkspace.databricks.com", tt.resourceType, tt.id, 0) + require.NoError(t, err) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestBuildWorkspaceURLFragmentBasedResources(t *testing.T) { + tests := []struct { + resourceType string + id string + expected string + }{ + {"notebooks", "12345", "https://myworkspace.databricks.com/#notebook/12345"}, + {"notebooks", "/Users/user@example.com/my-notebook", "https://myworkspace.databricks.com/#notebook//Users/user@example.com/my-notebook"}, + } + + for _, tt := range tests { + t.Run(tt.id, func(t *testing.T) { + got, err := buildWorkspaceURL("https://myworkspace.databricks.com", tt.resourceType, tt.id, 0) + require.NoError(t, err) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestBuildWorkspaceURLUnknownResourceType(t *testing.T) { + _, err := buildWorkspaceURL("https://myworkspace.databricks.com", "unknown", "123", 0) + assert.ErrorContains(t, err, "unknown resource type \"unknown\"") + assert.ErrorContains(t, err, "alerts, apps, clusters, dashboards, experiments, jobs, model_serving_endpoints, models, notebooks, pipelines, queries, registered_models, warehouses") +} + +func TestBuildWorkspaceURLHostWithTrailingSlash(t *testing.T) { + got, err := buildWorkspaceURL("https://myworkspace.databricks.com/", "jobs", "123", 0) + require.NoError(t, err) + assert.Equal(t, "https://myworkspace.databricks.com/jobs/123", got) +} + +func TestBuildWorkspaceURLWithWorkspaceID(t *testing.T) { + got, err := buildWorkspaceURL("https://myworkspace.databricks.com", "jobs", "123", 123456) + require.NoError(t, err) + assert.Equal(t, "https://myworkspace.databricks.com/jobs/123?o=123456", got) +} + +func TestBuildWorkspaceURLWithWorkspaceIDInHostname(t *testing.T) { + got, err := buildWorkspaceURL("https://adb-123456.azuredatabricks.net", "jobs", "123", 123456) + require.NoError(t, err) + // Workspace ID is already in the hostname, so ?o= should not be appended. + assert.Equal(t, "https://adb-123456.azuredatabricks.net/jobs/123", got) +} + +func TestBuildWorkspaceURLWithWorkspaceIDInVanityHostname(t *testing.T) { + got, err := buildWorkspaceURL("https://workspace-123456.example.com", "jobs", "123", 123456) + require.NoError(t, err) + assert.Equal(t, "https://workspace-123456.example.com/jobs/123?o=123456", got) +} + +func TestBuildWorkspaceURLFragmentWithWorkspaceID(t *testing.T) { + got, err := buildWorkspaceURL("https://myworkspace.databricks.com", "notebooks", "12345", 789) + require.NoError(t, err) + assert.Equal(t, "https://myworkspace.databricks.com/?o=789#notebook/12345", got) +} + +func TestWorkspaceOpenCommandCompletion(t *testing.T) { + cmd := newWorkspaceOpenCommand() + + completions, directive := cmd.ValidArgsFunction(cmd, []string{}, "") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Contains(t, completions, "alerts") + assert.Contains(t, completions, "apps") + assert.Contains(t, completions, "clusters") + assert.Contains(t, completions, "dashboards") + assert.Contains(t, completions, "experiments") + assert.Contains(t, completions, "jobs") + assert.Contains(t, completions, "models") + assert.Contains(t, completions, "model_serving_endpoints") + assert.Contains(t, completions, "notebooks") + assert.Contains(t, completions, "pipelines") + assert.Contains(t, completions, "queries") + assert.Contains(t, completions, "registered_models") + assert.Contains(t, completions, "warehouses") + assert.Len(t, completions, 13) +} + +func TestWorkspaceOpenCommandCompletionSecondArg(t *testing.T) { + cmd := newWorkspaceOpenCommand() + + completions, directive := cmd.ValidArgsFunction(cmd, []string{"jobs"}, "") + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Nil(t, completions) +} + +func TestWorkspaceOpenCommandHelpText(t *testing.T) { + cmd := newWorkspaceOpenCommand() + + assert.Contains(t, cmd.Long, "Supported resource types: alerts, apps, clusters, dashboards, experiments, jobs, model_serving_endpoints, models, notebooks, pipelines, queries, registered_models, warehouses.") + assert.Contains(t, cmd.Long, "databricks experimental open jobs 123456789") + assert.Contains(t, cmd.Long, "databricks experimental open notebooks /Users/user@example.com/my-notebook") + assert.Contains(t, cmd.Long, "databricks experimental open registered_models catalog.schema.my_model") + assert.Contains(t, cmd.Long, "databricks experimental open jobs 123456789 --url") + assert.Contains(t, cmd.Long, "dot-separated name") + + flag := cmd.Flags().Lookup("url") + require.NotNil(t, flag) + assert.Equal(t, "false", flag.DefValue) +} + +func TestWorkspaceOpenCommandOpensBrowserByDefault(t *testing.T) { + originalCurrentWorkspaceID := currentWorkspaceID + originalOpenWorkspaceURL := openWorkspaceURL + t.Cleanup(func() { + currentWorkspaceID = originalCurrentWorkspaceID + openWorkspaceURL = originalOpenWorkspaceURL + }) + + currentWorkspaceID = func(context.Context) (int64, error) { + return 0, nil + } + + var gotURL string + openWorkspaceURL = func(ctx context.Context, targetURL string) error { + gotURL = targetURL + return nil + } + + ctx, stderr := cmdio.NewTestContextWithStderr(t.Context()) + ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{ + Config: &config.Config{ + Host: "https://myworkspace.databricks.com", + }, + }) + + cmd := newWorkspaceOpenCommand() + cmd.SetContext(ctx) + + var stdout bytes.Buffer + cmd.SetOut(&stdout) + + err := cmd.RunE(cmd, []string{"jobs", "123"}) + require.NoError(t, err) + + assert.Equal(t, "https://myworkspace.databricks.com/jobs/123", gotURL) + assert.Equal(t, "", stdout.String()) + assert.Contains(t, stderr.String(), "Opening jobs 123 in the browser...") +} + +func TestWorkspaceOpenCommandURLFlag(t *testing.T) { + originalCurrentWorkspaceID := currentWorkspaceID + originalOpenWorkspaceURL := openWorkspaceURL + t.Cleanup(func() { + currentWorkspaceID = originalCurrentWorkspaceID + openWorkspaceURL = originalOpenWorkspaceURL + }) + + currentWorkspaceID = func(context.Context) (int64, error) { + return 789, nil + } + + browserOpened := false + openWorkspaceURL = func(ctx context.Context, targetURL string) error { + browserOpened = true + return nil + } + + ctx, stderr := cmdio.NewTestContextWithStderr(t.Context()) + ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{ + Config: &config.Config{ + Host: "https://myworkspace.databricks.com", + }, + }) + + cmd := newWorkspaceOpenCommand() + cmd.SetContext(ctx) + + var stdout bytes.Buffer + cmd.SetOut(&stdout) + + require.NoError(t, cmd.Flags().Set("url", "true")) + + err := cmd.RunE(cmd, []string{"jobs", "123"}) + require.NoError(t, err) + + assert.False(t, browserOpened) + assert.Equal(t, "https://myworkspace.databricks.com/jobs/123?o=789\n", stdout.String()) + assert.Equal(t, "", stderr.String()) +} + +func TestWorkspaceOpenCommandWarnsWhenWorkspaceIDLookupFails(t *testing.T) { + originalCurrentWorkspaceID := currentWorkspaceID + originalOpenWorkspaceURL := openWorkspaceURL + t.Cleanup(func() { + currentWorkspaceID = originalCurrentWorkspaceID + openWorkspaceURL = originalOpenWorkspaceURL + }) + + currentWorkspaceID = func(context.Context) (int64, error) { + return 0, errors.New("lookup failed") + } + + openWorkspaceURL = func(ctx context.Context, targetURL string) error { + return nil + } + + ctx, stderr := cmdio.NewTestContextWithStderr(t.Context()) + ctx = log.NewContext(ctx, slog.New(handler.NewFriendlyHandler(stderr, &handler.Options{ + Level: log.LevelWarn, + }))) + ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{ + Config: &config.Config{ + Host: "https://myworkspace.databricks.com", + }, + }) + + cmd := newWorkspaceOpenCommand() + cmd.SetContext(ctx) + + var stdout bytes.Buffer + cmd.SetOut(&stdout) + + require.NoError(t, cmd.Flags().Set("url", "true")) + + err := cmd.RunE(cmd, []string{"jobs", "123"}) + require.NoError(t, err) + + assert.Equal(t, "https://myworkspace.databricks.com/jobs/123\n", stdout.String()) + assert.Contains(t, stderr.String(), "Could not determine workspace ID: lookup failed") +} diff --git a/libs/browser/open.go b/libs/browser/open.go new file mode 100644 index 0000000000..409ebc7f19 --- /dev/null +++ b/libs/browser/open.go @@ -0,0 +1,123 @@ +package browser + +import ( + "context" + "io" + "os" + osexec "os/exec" + "runtime" + "strings" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/env" + browserpkg "github.com/pkg/browser" +) + +const ( + browserEnvVar = "BROWSER" + disabledBrowser = "none" + defaultDisabledMessage = "Open this link in your browser:\n" +) + +var openDefaultBrowserURL = func(targetURL string) error { + originalStderr := browserpkg.Stderr + defer func() { + browserpkg.Stderr = originalStderr + }() + + browserpkg.Stderr = io.Discard + return browserpkg.OpenURL(targetURL) +} + +var runBrowserCommand = func(ctx context.Context, workingDirectory string, browserCommand []string, targetURL string) error { + cmd := osexec.CommandContext(ctx, browserCommand[0], append(browserCommand[1:], targetURL)...) + cmd.Dir = workingDirectory + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// shellName returns the shell executable name for the current OS. +func shellName() string { + if runtime.GOOS == "windows" { + return "cmd" + } + return "sh" +} + +// shellFlag returns the flag to pass an inline command to the shell. +func shellFlag() string { + if runtime.GOOS == "windows" { + return "/c" + } + return "-c" +} + +// containsQuotes reports whether s contains single or double quote characters. +func containsQuotes(s string) bool { + return strings.ContainsAny(s, `"'`) +} + +// parseBrowserCommand splits the BROWSER env var into a command slice. +// If the value contains quotes it delegates to the system shell so that +// values like `open -a "Google Chrome"` are handled correctly. +func parseBrowserCommand(raw string) []string { + if raw == "" { + return nil + } + if containsQuotes(raw) { + return []string{shellName(), shellFlag(), raw} + } + return strings.Fields(raw) +} + +// IsDisabled reports whether browser launching is disabled for the context. +func IsDisabled(ctx context.Context) bool { + return env.Get(ctx, browserEnvVar) == disabledBrowser +} + +// OpenerOption configures NewOpener. +type OpenerOption func(*openerConfig) + +type openerConfig struct { + disabledMessage string +} + +// WithDisabledMessage overrides the message printed when BROWSER=none. +func WithDisabledMessage(msg string) OpenerOption { + return func(cfg *openerConfig) { + cfg.disabledMessage = msg + } +} + +// NewOpener returns a function that opens URLs in the browser. +func NewOpener(ctx context.Context, workingDirectory string, opts ...OpenerOption) func(string) error { + cfg := &openerConfig{ + disabledMessage: defaultDisabledMessage, + } + for _, opt := range opts { + opt(cfg) + } + + raw := env.Get(ctx, browserEnvVar) + browserCommand := parseBrowserCommand(raw) + switch { + case len(browserCommand) == 0: + return openDefaultBrowserURL + case raw == disabledBrowser: + return func(targetURL string) error { + cmdio.LogString(ctx, cfg.disabledMessage+targetURL) + return nil + } + default: + return func(targetURL string) error { + return runBrowserCommand(ctx, workingDirectory, browserCommand, targetURL) + } + } +} + +// OpenURL opens a URL in the browser. +func OpenURL(ctx context.Context, workingDirectory, targetURL string) error { + return NewOpener(ctx, workingDirectory)(targetURL) +} diff --git a/libs/browser/open_test.go b/libs/browser/open_test.go new file mode 100644 index 0000000000..1a0a8898a8 --- /dev/null +++ b/libs/browser/open_test.go @@ -0,0 +1,138 @@ +package browser + +import ( + "context" + "runtime" + "testing" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenURLUsesDefaultBrowser(t *testing.T) { + original := openDefaultBrowserURL + t.Cleanup(func() { + openDefaultBrowserURL = original + }) + + var got string + openDefaultBrowserURL = func(targetURL string) error { + got = targetURL + return nil + } + + err := OpenURL(t.Context(), ".", "https://example.com") + require.NoError(t, err) + assert.Equal(t, "https://example.com", got) +} + +func TestOpenURLWithDisabledBrowser(t *testing.T) { + ctx, stderr := cmdio.NewTestContextWithStderr(t.Context()) + ctx = env.Set(ctx, browserEnvVar, disabledBrowser) + + err := OpenURL(ctx, ".", "https://example.com") + require.NoError(t, err) + assert.Contains(t, stderr.String(), "Open this link in your browser:") + assert.Contains(t, stderr.String(), "https://example.com") +} + +func TestOpenURLWithDisabledBrowserCustomMessage(t *testing.T) { + ctx, stderr := cmdio.NewTestContextWithStderr(t.Context()) + ctx = env.Set(ctx, browserEnvVar, disabledBrowser) + + opener := NewOpener(ctx, ".", WithDisabledMessage("Custom message:\n")) + err := opener("https://example.com") + require.NoError(t, err) + assert.Contains(t, stderr.String(), "Custom message:\nhttps://example.com") +} + +func TestOpenURLUsesCustomBrowserCommand(t *testing.T) { + original := runBrowserCommand + t.Cleanup(func() { + runBrowserCommand = original + }) + + ctx := env.Set(t.Context(), browserEnvVar, "custom-browser --private-window") + + var gotCtx context.Context + var gotDirectory string + var gotCommand []string + var gotURL string + runBrowserCommand = func(ctx context.Context, workingDirectory string, browserCommand []string, targetURL string) error { + gotCtx = ctx + gotDirectory = workingDirectory + gotCommand = browserCommand + gotURL = targetURL + return nil + } + + err := OpenURL(ctx, "test-dir", "https://example.com") + require.NoError(t, err) + assert.Same(t, ctx, gotCtx) + assert.Equal(t, "test-dir", gotDirectory) + assert.Equal(t, []string{"custom-browser", "--private-window"}, gotCommand) + assert.Equal(t, "https://example.com", gotURL) +} + +func TestOpenURLUsesShellForQuotedBrowserCommand(t *testing.T) { + original := runBrowserCommand + t.Cleanup(func() { + runBrowserCommand = original + }) + + ctx := env.Set(t.Context(), browserEnvVar, `open -a "Google Chrome"`) + + var gotCommand []string + runBrowserCommand = func(ctx context.Context, workingDirectory string, browserCommand []string, targetURL string) error { + gotCommand = browserCommand + return nil + } + + err := OpenURL(ctx, ".", "https://example.com") + require.NoError(t, err) + + if runtime.GOOS == "windows" { + assert.Equal(t, []string{"cmd", "/c", `open -a "Google Chrome"`}, gotCommand) + } else { + assert.Equal(t, []string{"sh", "-c", `open -a "Google Chrome"`}, gotCommand) + } +} + +func TestParseBrowserCommand(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + {"empty", "", nil}, + {"simple", "firefox", []string{"firefox"}}, + {"with flags", "firefox --private-window", []string{"firefox", "--private-window"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseBrowserCommand(tt.input) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestParseBrowserCommandWithQuotes(t *testing.T) { + got := parseBrowserCommand(`open -a "Google Chrome"`) + shell := "sh" + flag := "-c" + if runtime.GOOS == "windows" { + shell = "cmd" + flag = "/c" + } + assert.Equal(t, []string{shell, flag, `open -a "Google Chrome"`}, got) +} + +func TestIsDisabled(t *testing.T) { + assert.False(t, IsDisabled(t.Context())) + + ctx := env.Set(t.Context(), browserEnvVar, disabledBrowser) + assert.True(t, IsDisabled(ctx)) +} diff --git a/libs/workspaceurls/urls.go b/libs/workspaceurls/urls.go new file mode 100644 index 0000000000..4e387d5d77 --- /dev/null +++ b/libs/workspaceurls/urls.go @@ -0,0 +1,138 @@ +package workspaceurls + +import ( + "fmt" + "net/url" + "slices" + "strconv" + "strings" +) + +const ( + ResourceAlerts = "alerts" + ResourceApps = "apps" + ResourceClusters = "clusters" + ResourceDashboards = "dashboards" + ResourceExperiments = "experiments" + ResourceJobs = "jobs" + ResourceModels = "models" + ResourceModelServingEndpoints = "model_serving_endpoints" + ResourceNotebooks = "notebooks" + ResourcePipelines = "pipelines" + ResourceQueries = "queries" + ResourceRegisteredModels = "registered_models" + ResourceWarehouses = "warehouses" +) + +const ( + AlertPattern = "sql/alerts-v2/%s" + AppPattern = "apps/%s" + ClusterPattern = "compute/clusters/%s" + DashboardPattern = "dashboardsv3/%s/published" + ExperimentPattern = "ml/experiments/%s" + JobPattern = "jobs/%s" + ModelPattern = "ml/models/%s" + ModelServingEndpointPattern = "ml/endpoints/%s" + NotebookPattern = "#notebook/%s" + PipelinePattern = "pipelines/%s" + QueryPattern = "sql/editor/%s" + RegisteredModelPattern = "explore/data/models/%s" + WarehousePattern = "sql/warehouses/%s" +) + +var resourceURLPatterns = map[string]string{ + ResourceAlerts: AlertPattern, + ResourceApps: AppPattern, + ResourceClusters: ClusterPattern, + ResourceDashboards: DashboardPattern, + ResourceExperiments: ExperimentPattern, + ResourceJobs: JobPattern, + ResourceModels: ModelPattern, + ResourceModelServingEndpoints: ModelServingEndpointPattern, + ResourceNotebooks: NotebookPattern, + ResourcePipelines: PipelinePattern, + ResourceQueries: QueryPattern, + ResourceRegisteredModels: RegisteredModelPattern, + ResourceWarehouses: WarehousePattern, +} + +// dotSeparatedResources lists resource types where the identifier is commonly +// provided as a dot-separated name (e.g. "catalog.schema.model") but the URL +// requires slash-separated segments. +var dotSeparatedResources = map[string]bool{ + ResourceRegisteredModels: true, +} + +// NormalizeDotSeparatedID converts dots to slashes for resource types that use +// multi-part names (e.g. registered_models: "catalog.schema.model" becomes +// "catalog/schema/model"). +func NormalizeDotSeparatedID(resourceType, id string) string { + if dotSeparatedResources[resourceType] { + return strings.ReplaceAll(id, ".", "/") + } + return id +} + +// LookupPattern returns the workspace URL pattern for a resource type. +func LookupPattern(resourceType string) (string, bool) { + pattern, ok := resourceURLPatterns[resourceType] + return pattern, ok +} + +// SortResourceTypes returns a sorted copy of resource types. +func SortResourceTypes(resourceTypes []string) []string { + names := append([]string(nil), resourceTypes...) + slices.Sort(names) + return names +} + +// WorkspaceBaseURL parses a workspace host and appends ?o= when needed. +func WorkspaceBaseURL(host string, workspaceID int64) (*url.URL, error) { + baseURL, err := url.Parse(host) + if err != nil { + return nil, fmt.Errorf("invalid workspace host %q: %w", host, err) + } + + if workspaceID == 0 { + return baseURL, nil + } + + orgID := strconv.FormatInt(workspaceID, 10) + if hasWorkspaceIDInHostname(baseURL.Hostname(), orgID) { + return baseURL, nil + } + + values := baseURL.Query() + values.Add("o", orgID) + baseURL.RawQuery = values.Encode() + + return baseURL, nil +} + +// BuildResourceURL constructs a full workspace URL for a resource path pattern. +func BuildResourceURL(host, pattern, id string, workspaceID int64) (string, error) { + baseURL, err := WorkspaceBaseURL(host, workspaceID) + if err != nil { + return "", err + } + + return ResourceURL(*baseURL, pattern, id), nil +} + +// ResourceURL formats a workspace URL for a resource path pattern. +func ResourceURL(baseURL url.URL, pattern, id string) string { + resourcePath := fmt.Sprintf(pattern, id) + if strings.HasPrefix(resourcePath, "#") { + baseURL.Path = "/" + baseURL.Fragment = resourcePath[1:] + } else { + baseURL.Path = resourcePath + } + + return baseURL.String() +} + +func hasWorkspaceIDInHostname(hostname, workspaceID string) bool { + remainder, ok := strings.CutPrefix(strings.ToLower(hostname), "adb-"+workspaceID) + return ok && (remainder == "" || strings.HasPrefix(remainder, ".")) +}