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..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" @@ -14,3 +15,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) + }) + } +}