Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions acceptance/apps/deploy/bundle-with-appname/output.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@

=== API deploy with an explicit app name
>>> [CLI] apps deploy test-app --no-wait
{
"deployment_id": "dep-123",
Expand All @@ -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"
}
}
14 changes: 12 additions & 2 deletions acceptance/apps/deploy/bundle-with-appname/script
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions acceptance/apps/deploy/bundle-with-appname/test.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
Cloud = false
RecordRequests = true
Env.MSYS_NO_PATHCONV = "1"

[[Server]]
Pattern = "POST /api/2.0/apps/test-app/deployments"
Expand All @@ -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"
}
}
'''
96 changes: 93 additions & 3 deletions cmd/apps/deploy_bundle.go

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High-level comment: Can we make the logic a bit simpler?

  1. Route by app name only, error instead of infer. No app name → bundle. App name → API. API-only flag with no app name → error "provide APP_NAME". This deletes requestFlagNames entirely, deletes the request-vs-control distinction, and deletes the databricks.yml name-inference path (getAppNameFromArgs in the API branch). It also makes deploy match start/stop/delete.
  2. Detect bundle flags without a name list. API set = the local-flag snapshot (already automatic). Bundle set = the override's own flags (automatic) plus the inherited ones. For the inherited three, either reference the producer's definition or annotate them at registration.

What that removes: the requestFlagNames map, the control-flag special-casing, the double validateFlags call, and the app-name inference. What it keeps: the two genuinely useful errors ("can't combine bundle + API flags", "provide APP_NAME") — which are the part that satisfies the repo's "never silently ignore a flag" rule. Result: one mental model, no hardcoded flag lists, --target and the init-order trap fixed as a side effect.

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"errors"
"fmt"
"os"
"slices"
"strings"

"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/config"
Expand All @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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{} {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we extract the full flag validation logic as a separate function, to keep the code easier to read?

all the logic: including collecting the flags and validating them against the known flags

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())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only works because git_flags.go sorts before overrides.go, so its init() registers the git flags before this snapshot runs.
Can we avoid that? The easier way would be to use PreRunE/RunE where all the flags would be present.

// Control flags must not bypass the bundle pipeline just because they are
// implemented by the generated API command.
requestFlagNames := map[string]struct{}{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a way to avoid hardcoding those flags here? Can't we use some existing references?

If not, can we move this definition to the actual producer side, so that it's easier to keep the map up to date?

"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.")
Expand All @@ -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{}{}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My agent reported that the --target is missing:

Verified facts:

  • --target / -t (and deprecated --environment / -e) is registered as a persistent flag on the root command (cmd/root/bundle.go:260, via initTargetFlag at root.go:52). Persistent-on-root = inherited by every subcommand, including apps deploy.
  • It's a bundle-only flag — it selects the bundle target and feeds TryConfigureBundle / configureBundle. Meaningless in the raw API path (no bundle there).

Why the PR misses it: the override builds apiFlagNames / bundleFlagNames by snapshotting deployCmd.Flags() at construction time. Inherited flags from root/parent aren't merged into that snapshot yet, so target is in neither set. changedFlagNames only loops over snapshot names, so --target is never checked. Result: apps deploy my-app --target prod → 0 api-flags, 0 bundle-flags → no "can't combine" error → routes to API deploy → --target silently ignored.

Anyway, that's similar issue as in the hardcoded flag map above - it's an error prone approach and it might get out of sync easily. Can we do something about it?

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it run twice?

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.
Expand All @@ -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.
Expand All @@ -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

Expand Down
80 changes: 80 additions & 0 deletions cmd/apps/deploy_bundle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
})
}
}
Loading