From dd7a186979801c8de5691e7a2d33cd6c9c3d0bd9 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Mon, 24 Aug 2026 14:57:09 +0200 Subject: [PATCH 1/2] apps: route deploy to API mode on request flags and reject incompatible flags `apps deploy` previously routed purely on whether an APP_NAME argument was present: no arg meant bundle deploy, so an API request flag such as `--source-code-path` given without an app name was silently ignored while the bundle pipeline ran. Route to API deploy whenever an API request flag is set (inferring the app name from databricks.yml), and reject incompatible input early: - API deploy flags cannot be combined with bundle deploy flags - bundle deploy flags cannot be used together with an APP_NAME argument - API control flags (--no-wait, --timeout) do not select API mode on their own Add unit coverage for the rejected combinations and extend the bundle-with-appname acceptance test. Co-authored-by: Isaac --- .../deploy/bundle-with-appname/output.txt | 28 ++++++ .../apps/deploy/bundle-with-appname/script | 14 ++- .../apps/deploy/bundle-with-appname/test.toml | 14 +++ cmd/apps/deploy_bundle.go | 96 ++++++++++++++++++- cmd/apps/deploy_bundle_test.go | 80 ++++++++++++++++ 5 files changed, 227 insertions(+), 5 deletions(-) diff --git a/acceptance/apps/deploy/bundle-with-appname/output.txt b/acceptance/apps/deploy/bundle-with-appname/output.txt index 73b77d480cd..67d092ff5e1 100644 --- a/acceptance/apps/deploy/bundle-with-appname/output.txt +++ b/acceptance/apps/deploy/bundle-with-appname/output.txt @@ -1,4 +1,5 @@ +=== API deploy with an explicit app name >>> [CLI] apps deploy test-app --no-wait { "deployment_id": "dep-123", @@ -10,9 +11,36 @@ } } +=== API flags infer the app name from the bundle +>>> [CLI] apps deploy --source-code-path /Workspace/apps/inferred --no-wait +{ + "deployment_id": "dep-456", + "mode": "SNAPSHOT", + "source_code_path": "/Workspace/apps/inferred", + "status": { + "message": "Deployment pending", + "state": "PENDING" + } +} + +=== API and bundle deploy flags cannot be mixed +>>> musterr [CLI] apps deploy --source-code-path /Workspace/apps/inferred --force +Error: API deploy flags --source-code-path cannot be combined with bundle deploy flags --force + +=== API control flags do not select API mode +>>> musterr [CLI] apps deploy --no-wait +Error: API deploy flags --no-wait do not select API mode; provide APP_NAME or an API request flag such as --source-code-path, or omit them to use bundle deploy + >>> print_requests.py //apps { "method": "POST", "path": "/api/2.0/apps/test-app/deployments", "body": {} } +{ + "method": "POST", + "path": "/api/2.0/apps/myapp/deployments", + "body": { + "source_code_path": "/Workspace/apps/inferred" + } +} diff --git a/acceptance/apps/deploy/bundle-with-appname/script b/acceptance/apps/deploy/bundle-with-appname/script index cf5f53c351c..9271c3d1f4b 100644 --- a/acceptance/apps/deploy/bundle-with-appname/script +++ b/acceptance/apps/deploy/bundle-with-appname/script @@ -1,5 +1,15 @@ -# Test: apps deploy with APP_NAME in a bundle directory -# Expected: Falls back to API deploy (ignores bundle, no validation) +# Test: apps deploy API routing in a bundle directory +# Expected: explicit names and API flags use direct deploy; incompatible flags fail +title "API deploy with an explicit app name" trace $CLI apps deploy test-app --no-wait +title "API flags infer the app name from the bundle" +trace $CLI apps deploy --source-code-path /Workspace/apps/inferred --no-wait + +title "API and bundle deploy flags cannot be mixed" +trace musterr $CLI apps deploy --source-code-path /Workspace/apps/inferred --force + +title "API control flags do not select API mode" +trace musterr $CLI apps deploy --no-wait + trace print_requests.py //apps diff --git a/acceptance/apps/deploy/bundle-with-appname/test.toml b/acceptance/apps/deploy/bundle-with-appname/test.toml index 20906ff8378..498ae1ab787 100644 --- a/acceptance/apps/deploy/bundle-with-appname/test.toml +++ b/acceptance/apps/deploy/bundle-with-appname/test.toml @@ -14,3 +14,17 @@ Response.Body = ''' } } ''' + +[[Server]] +Pattern = "POST /api/2.0/apps/myapp/deployments" +Response.Body = ''' +{ + "deployment_id": "dep-456", + "source_code_path": "/Workspace/apps/inferred", + "mode": "SNAPSHOT", + "status": { + "state": "PENDING", + "message": "Deployment pending" + } +} +''' diff --git a/cmd/apps/deploy_bundle.go b/cmd/apps/deploy_bundle.go index 6494efe45f0..60ef5c110e4 100644 --- a/cmd/apps/deploy_bundle.go +++ b/cmd/apps/deploy_bundle.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "os" + "slices" + "strings" "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/config" @@ -18,6 +20,7 @@ import ( "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go/service/apps" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) // ErrorWrapper is a function type for wrapping deployment errors. @@ -46,6 +49,18 @@ type bundleDeployOptions struct { skipTests bool } +// changedFlagNames returns sorted command-line names for explicitly set flags. +func changedFlagNames(cmd *cobra.Command, names map[string]struct{}) []string { + var changed []string + for name := range names { + if cmd.Flags().Changed(name) { + changed = append(changed, "--"+name) + } + } + slices.Sort(changed) + return changed +} + // applyDeployFlags writes the deploy flag values onto the bundle config. // Flags that override bundle YAML are only applied when explicitly set by the user. func applyDeployFlags(cmd *cobra.Command, b *bundle.Bundle, opts bundleDeployOptions) { @@ -67,8 +82,30 @@ func applyDeployFlags(cmd *cobra.Command, b *bundle.Bundle, opts bundleDeployOpt // BundleDeployOverrideWithWrapper creates a deploy override function that uses // the provided error wrapper for API fallback errors. func BundleDeployOverrideWithWrapper(wrapError ErrorWrapper) func(*cobra.Command, *apps.CreateAppDeploymentRequest) { - return func(deployCmd *cobra.Command, deployReq *apps.CreateAppDeploymentRequest) { + return func(deployCmd *cobra.Command, _ *apps.CreateAppDeploymentRequest) { var opts bundleDeployOptions + flagNames := func(flags *pflag.FlagSet) map[string]struct{} { + names := make(map[string]struct{}) + flags.VisitAll(func(flag *pflag.Flag) { + names[flag.Name] = struct{}{} + }) + return names + } + // Generated API flags and API-specific overrides, including the Git source + // override, are registered before the bundle override. + apiFlagNames := flagNames(deployCmd.Flags()) + // Control flags must not bypass the bundle pipeline just because they are + // implemented by the generated API command. + requestFlagNames := map[string]struct{}{ + "deployment-id": {}, + "git-branch": {}, + "git-commit": {}, + "git-source-code-path": {}, + "git-tag": {}, + "json": {}, + "mode": {}, + "source-code-path": {}, + } deployCmd.Flags().BoolVar(&opts.force, "force", false, "Force-override Git branch validation.") deployCmd.Flags().BoolVar(&opts.forceLock, "force-lock", false, "Force acquisition of deployment lock.") @@ -83,20 +120,65 @@ func BundleDeployOverrideWithWrapper(wrapError ErrorWrapper) func(*cobra.Command deployCmd.Flags().MarkHidden("verbose") deployCmd.Flags().BoolVar(&opts.skipValidation, "skip-validation", false, "Skip project validation (build, typecheck, lint)") deployCmd.Flags().BoolVar(&opts.skipTests, "skip-tests", true, "Skip running tests during validation") + bundleFlagNames := flagNames(deployCmd.Flags()) + for name := range apiFlagNames { + delete(bundleFlagNames, name) + } + // --var is inherited from the apps command after this override runs. + bundleFlagNames["var"] = struct{}{} + validateFlags := func(cmd *cobra.Command, args []string) error { + apiFlags := changedFlagNames(cmd, apiFlagNames) + bundleFlags := changedFlagNames(cmd, bundleFlagNames) + requestFlags := changedFlagNames(cmd, requestFlagNames) + if len(apiFlags) > 0 && len(bundleFlags) > 0 { + return fmt.Errorf("API deploy flags %s cannot be combined with bundle deploy flags %s", strings.Join(apiFlags, ", "), strings.Join(bundleFlags, ", ")) + } + if len(args) > 0 && len(bundleFlags) > 0 { + return fmt.Errorf("bundle deploy flags %s cannot be used when APP_NAME is provided; omit APP_NAME to use bundle deploy", strings.Join(bundleFlags, ", ")) + } + if len(args) == 0 && len(apiFlags) > 0 && len(requestFlags) == 0 { + return fmt.Errorf("API deploy flags %s do not select API mode; provide APP_NAME or an API request flag such as --source-code-path, or omit them to use bundle deploy", strings.Join(apiFlags, ", ")) + } + return nil + } makeArgsOptionalWithBundle(deployCmd, "deploy [APP_NAME]") + originalPreRunE := deployCmd.PreRunE + deployCmd.PreRunE = func(cmd *cobra.Command, args []string) error { + if err := validateFlags(cmd, args); err != nil { + return err + } + if originalPreRunE != nil { + return originalPreRunE(cmd, args) + } + return nil + } + originalRunE := deployCmd.RunE deployCmd.RunE = func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { + if err := validateFlags(cmd, args); err != nil { + return err + } + requestFlags := changedFlagNames(cmd, requestFlagNames) + + if len(args) == 0 && len(requestFlags) == 0 { b := root.TryConfigureBundle(cmd) if b != nil { return runBundleDeploy(cmd, opts) } } + if len(args) == 0 { + appName, _, err := getAppNameFromArgs(cmd, args) + if err != nil { + return err + } + args = []string{appName} + } + err := originalRunE(cmd, args) - return wrapError(cmd, deployReq.AppName, err) + return wrapError(cmd, args[0], err) } deployCmd.Long = `Create an app deployment. @@ -110,6 +192,11 @@ without an APP_NAME argument, this command runs an enhanced deployment pipeline: When an APP_NAME argument is provided (or when not in a project directory), creates an app deployment using the API directly. +When an API request flag is provided without APP_NAME in a project directory, +the app name is inferred from databricks.yml and the API is used directly. +Control flags such as --no-wait and --timeout do not select API mode by themselves. +API deploy flags cannot be combined with bundle deploy flags. + Arguments: 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. @@ -124,6 +211,9 @@ Examples: # Deploy a specific app using the API (even from a project directory) databricks apps deploy my-app + # Infer the app name and deploy a workspace source path using the API + databricks apps deploy --source-code-path /Workspace/Users/me/my-app --no-wait + # Deploy from project with validation skip databricks apps deploy --skip-validation diff --git a/cmd/apps/deploy_bundle_test.go b/cmd/apps/deploy_bundle_test.go index 86f0535d99c..679d2b5e19f 100644 --- a/cmd/apps/deploy_bundle_test.go +++ b/cmd/apps/deploy_bundle_test.go @@ -116,6 +116,8 @@ func TestBundleDeployOverrideHelpText(t *testing.T) { assert.Contains(t, cmd.Long, "databricks.yml") assert.Contains(t, cmd.Long, "--auto-approve") assert.Contains(t, cmd.Long, "--force-lock") + assert.Contains(t, cmd.Long, "--source-code-path") + assert.Contains(t, cmd.Long, "do not select API mode") } func TestApplyDeployFlags(t *testing.T) { @@ -238,3 +240,81 @@ func TestBundleDeployOverrideErrorWrapping(t *testing.T) { assert.Error(t, err) assert.True(t, wrapperCalled) } + +func TestBundleDeployOverrideRejectsIncompatibleInputs(t *testing.T) { + tests := []struct { + name string + before func(*cobra.Command) + after func(*cobra.Command) + flags []string + args []string + want string + }{ + { + name: "API and bundle flags", + before: func(cmd *cobra.Command) { + cmd.Flags().String("source-code-path", "", "") + }, + flags: []string{"--source-code-path=/Workspace/app", "--force"}, + want: "API deploy flags --source-code-path cannot be combined with bundle deploy flags --force", + }, + { + name: "Git API and bundle flags", + before: func(cmd *cobra.Command) { + cmd.Flags().String("git-branch", "", "") + }, + flags: []string{"--git-branch=release", "--skip-validation"}, + want: "API deploy flags --git-branch cannot be combined with bundle deploy flags --skip-validation", + }, + { + name: "API control flag without app name", + before: func(cmd *cobra.Command) { + cmd.Flags().Bool("no-wait", false, "") + }, + flags: []string{"--no-wait"}, + want: "API deploy flags --no-wait do not select API mode; provide APP_NAME or an API request flag such as --source-code-path, or omit them to use bundle deploy", + }, + { + name: "bundle flag with app name", + flags: []string{"--force"}, + args: []string{"my-app"}, + want: "bundle deploy flags --force cannot be used when APP_NAME is provided; omit APP_NAME to use bundle deploy", + }, + { + name: "bundle variable with app name", + after: func(cmd *cobra.Command) { + cmd.Flags().StringSlice("var", nil, "") + }, + flags: []string{"--var=app_name=my-app"}, + args: []string{"my-app"}, + want: "bundle deploy flags --var cannot be used when APP_NAME is provided; omit APP_NAME to use bundle deploy", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + preRunCalled := false + cmd := &cobra.Command{ + PreRunE: func(cmd *cobra.Command, args []string) error { + preRunCalled = true + return nil + }, + } + if tc.before != nil { + tc.before(cmd) + } + BundleDeployOverrideWithWrapper(func(cmd *cobra.Command, appName string, err error) error { + return err + })(cmd, &apps.CreateAppDeploymentRequest{}) + if tc.after != nil { + tc.after(cmd) + } + + require.NoError(t, cmd.ParseFlags(tc.flags)) + err := cmd.PreRunE(cmd, tc.args) + + require.EqualError(t, err, tc.want) + assert.False(t, preRunCalled) + }) + } +} From 7fa9510bcfd15790c16b4035730e38736b6b23e6 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Mon, 21 Sep 2026 17:37:01 +0200 Subject: [PATCH 2/2] acceptance: fix Windows path conversion in bundle-with-appname test Git Bash on Windows converts leading-/ paths to Windows filesystem paths before the CLI binary sees them, so --source-code-path /Workspace/apps/inferred became C:/Program Files/Git/Workspace/apps/inferred. Set MSYS_NO_PATHCONV=1 to disable the conversion. Co-authored-by: Isaac --- acceptance/apps/deploy/bundle-with-appname/test.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/acceptance/apps/deploy/bundle-with-appname/test.toml b/acceptance/apps/deploy/bundle-with-appname/test.toml index 498ae1ab787..ce28d9064ec 100644 --- a/acceptance/apps/deploy/bundle-with-appname/test.toml +++ b/acceptance/apps/deploy/bundle-with-appname/test.toml @@ -1,5 +1,6 @@ Cloud = false RecordRequests = true +Env.MSYS_NO_PATHCONV = "1" [[Server]] Pattern = "POST /api/2.0/apps/test-app/deployments"