From 058330cba46acc5d236bd1f2502a6d7e3b414310 Mon Sep 17 00:00:00 2001 From: James Broadhead Date: Tue, 21 Apr 2026 16:15:22 +0000 Subject: [PATCH 1/2] bundle: add --deploy-apps to push source code during `bundle deploy` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the DEPLOY-01 "bundle deploy does not actually deploy apps" gap. Today `bundle deploy` reconciles resources via terraform/direct, but the app source code is pushed by a separate `bundle run ` step that users routinely miss — they run the documented deploy command, see their app unchanged, and conclude the platform is broken. With `--deploy-apps`, the Deploy phase calls `w.Apps.Deploy()` for every app in the bundle after the resource CRUD path completes. The flag defaults to false to preserve existing behavior for users and CI pipelines relying on the two-step flow; once the flag proves out in the field we can flip the default. Changes ------- * bundle/appdeploy/config.go: factor the ${resources.*} reference resolution out of appRunner.resolvedConfig() into a package-level ResolveAppConfig. Takes *config.Root (not *bundle.Bundle) to avoid the bundle -> direct -> dresources -> appdeploy import cycle. * bundle/phases/deploy_apps.go: new DeployApps phase. Iterates b.Config.Resources.Apps, resolves config, calls appdeploy.Deploy per app. Accumulates per-app errors via errors.Join so one failed app does not mask the others. * bundle/phases/deploy.go: call DeployApps at the end of the deploy phase (after the core mutator, before ScriptPostDeploy), gated on b.DeployApps. * bundle/bundle.go: new DeployApps bool alongside AutoApprove. * cmd/bundle/deploy.go: wire the --deploy-apps flag through ProcessOptions.InitFunc. * bundle/run/app.go: replace the in-line resolvedConfig method with a call to the new package-level helper so both code paths share the same resolution behavior. * bundle/appdeploy/config_test.go: unit coverage for the nil / no-config cases. A ${resources.*} interpolation test is deferred to follow-up (needs a populated config.Root via the standard bundle load path). Follow-ups ---------- * Integration test that deploys a bundle with an app and asserts the source code ends up at the workspace path. * Decide default flip (likely tied to a v0.N release where the behavior is documented as the new expectation). * A deeper dyn.Value-driven unit test for ResolveAppConfig to cover interpolation. Co-authored-by: Isaac --- bundle/appdeploy/config.go | 59 +++++++++++++++++++++++++++++++++ bundle/appdeploy/config_test.go | 31 +++++++++++++++++ bundle/bundle.go | 6 ++++ bundle/phases/deploy.go | 7 ++++ bundle/phases/deploy_apps.go | 59 +++++++++++++++++++++++++++++++++ bundle/run/app.go | 56 ++++--------------------------- cmd/bundle/deploy.go | 3 ++ 7 files changed, 172 insertions(+), 49 deletions(-) create mode 100644 bundle/appdeploy/config.go create mode 100644 bundle/appdeploy/config_test.go create mode 100644 bundle/phases/deploy_apps.go diff --git a/bundle/appdeploy/config.go b/bundle/appdeploy/config.go new file mode 100644 index 0000000000..69467c5df0 --- /dev/null +++ b/bundle/appdeploy/config.go @@ -0,0 +1,59 @@ +package appdeploy + +import ( + "github.com/databricks/cli/bundle/config" + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/dyn/convert" + "github.com/databricks/cli/libs/dyn/dynvar" +) + +// ResolveAppConfig returns the app config with `${resources.*}` variable references +// resolved against the current bundle state. The app runtime configuration (env vars, +// command) can reference other bundle resources whose properties are known only after +// the initialization phase — so this has to be called after resources have been +// created and the bundle Config reflects their current state. +// +// appKey is the map key under `resources.apps` (usually equal to `app.Name` but not +// guaranteed — resources may be keyed by a local alias). +// +// The function takes a pointer to config.Root rather than *bundle.Bundle to avoid an +// import cycle between bundle and appdeploy (bundle → direct → dresources → appdeploy). +func ResolveAppConfig(cfg *config.Root, appKey string, app *resources.App) (*resources.AppConfig, error) { + if app == nil || app.Config == nil { + return nil, nil + } + + root := cfg.Value() + + // Normalize the full config so that all typed fields are present, even those + // not explicitly set. This allows looking up resource properties by path. + normalized, _ := convert.Normalize(cfg, root, convert.IncludeMissingFields) + + configPath := dyn.MustPathFromString("resources.apps." + appKey + ".config") + configV, err := dyn.GetByPath(root, configPath) + if err != nil || !configV.IsValid() { + return app.Config, nil //nolint:nilerr // missing config path means use default config + } + + resourcesPrefix := dyn.MustPathFromString("resources") + + // Resolve ${resources.*} references in the app config against the full bundle config. + // Other variable types (bundle.*, workspace.*, variables.*) are already resolved + // during the initialization phase and are left in place if encountered here. + resolved, err := dynvar.Resolve(configV, func(path dyn.Path) (dyn.Value, error) { + if !path.HasPrefix(resourcesPrefix) { + return dyn.InvalidValue, dynvar.ErrSkipResolution + } + return dyn.GetByPath(normalized, path) + }) + if err != nil { + return nil, err + } + + var resolvedConfig resources.AppConfig + if err := convert.ToTyped(&resolvedConfig, resolved); err != nil { + return nil, err + } + return &resolvedConfig, nil +} diff --git a/bundle/appdeploy/config_test.go b/bundle/appdeploy/config_test.go new file mode 100644 index 0000000000..2185a46543 --- /dev/null +++ b/bundle/appdeploy/config_test.go @@ -0,0 +1,31 @@ +package appdeploy_test + +import ( + "testing" + + "github.com/databricks/cli/bundle/appdeploy" + "github.com/databricks/cli/bundle/config" + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveAppConfig_NilApp(t *testing.T) { + cfg := &config.Root{} + out, err := appdeploy.ResolveAppConfig(cfg, "my_app", nil) + require.NoError(t, err) + assert.Nil(t, out) +} + +func TestResolveAppConfig_AppWithoutConfig(t *testing.T) { + cfg := &config.Root{} + app := &resources.App{App: apps.App{Name: "my_app"}} + out, err := appdeploy.ResolveAppConfig(cfg, "my_app", app) + require.NoError(t, err) + assert.Nil(t, out) +} + +// TODO: add a test covering `${resources.*}` interpolation — requires setting up a +// config.Root with a populated dyn.Value via the standard bundle load path. Factored +// out to a follow-up to keep this draft PR scoped. diff --git a/bundle/bundle.go b/bundle/bundle.go index e7eef14b90..9ec8dcaae9 100644 --- a/bundle/bundle.go +++ b/bundle/bundle.go @@ -145,6 +145,12 @@ type Bundle struct { // files AutoApprove bool + // DeployApps extends `bundle deploy` to also upload source code and trigger a + // new AppDeployment for every app in the bundle. When false (the default), + // `bundle deploy` only reconciles resources and the user must still run + // `bundle run ` to push source. + DeployApps bool + // SkipLocalFileValidation makes path translation tolerant of missing local files. // When set, TranslatePaths computes workspace paths without verifying files exist. // Used by config-remote-sync: a user may modify resource paths remotely (e.g., diff --git a/bundle/phases/deploy.go b/bundle/phases/deploy.go index 38389b9adb..f32bdcb3a8 100644 --- a/bundle/phases/deploy.go +++ b/bundle/phases/deploy.go @@ -252,6 +252,13 @@ func Deploy(ctx context.Context, b *bundle.Bundle, outputHandler sync.OutputHand return } + // Push app source code as part of `bundle deploy` when the user opts in via + // --deploy-apps. Historically this was a separate step (`bundle run `). + DeployApps(ctx, b) + if logdiag.HasError(ctx) { + return + } + bundle.ApplyContext(ctx, b, scripts.Execute(config.ScriptPostDeploy)) } diff --git a/bundle/phases/deploy_apps.go b/bundle/phases/deploy_apps.go new file mode 100644 index 0000000000..0941664708 --- /dev/null +++ b/bundle/phases/deploy_apps.go @@ -0,0 +1,59 @@ +package phases + +import ( + "context" + "errors" + "fmt" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/appdeploy" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/logdiag" +) + +// DeployApps uploads source code and triggers an AppDeployment for every app in the +// bundle. It is called at the end of the Deploy phase when the user asks for a +// single-command deploy-and-push (via `--deploy-apps` or `b.DeployApps = true`). +// +// The resource CRUD path (terraform/direct) provisions the app compute but does not +// push source code, which is why this exists as a separate phase: `w.Apps.Deploy` +// and file upload to the workspace are not modelled as resources. +func DeployApps(ctx context.Context, b *bundle.Bundle) { + if !b.DeployApps { + return + } + if len(b.Config.Resources.Apps) == 0 { + return + } + + log.Info(ctx, "Phase: deploy apps") + cmdio.LogString(ctx, "Deploying app source code...") + + w := b.WorkspaceClient(ctx) + var failures []error + for key, app := range b.Config.Resources.Apps { + if app == nil { + continue + } + cmdio.LogString(ctx, fmt.Sprintf("✓ Deploying app source for %s", app.Name)) + + config, err := appdeploy.ResolveAppConfig(&b.Config, key, app) + if err != nil { + failures = append(failures, fmt.Errorf("app %s: resolve config: %w", key, err)) + continue + } + + deployment := appdeploy.BuildDeployment(app.SourceCodePath, config, app.GitSource) + if err := appdeploy.Deploy(ctx, w, app.Name, deployment); err != nil { + failures = append(failures, fmt.Errorf("app %s: %w", key, err)) + continue + } + } + + if len(failures) > 0 { + logdiag.LogError(ctx, errors.Join(failures...)) + return + } + cmdio.LogString(ctx, "App source code deployed!") +} diff --git a/bundle/run/app.go b/bundle/run/app.go index 8c0f135cc5..999e379e18 100644 --- a/bundle/run/app.go +++ b/bundle/run/app.go @@ -11,9 +11,6 @@ import ( "github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/bundle/run/output" "github.com/databricks/cli/libs/cmdio" - "github.com/databricks/cli/libs/dyn" - "github.com/databricks/cli/libs/dyn/convert" - "github.com/databricks/cli/libs/dyn/dynvar" "github.com/databricks/databricks-sdk-go/service/apps" "github.com/spf13/cobra" ) @@ -138,7 +135,13 @@ func (a *appRunner) start(ctx context.Context) error { func (a *appRunner) deploy(ctx context.Context) error { w := a.bundle.WorkspaceClient(ctx) - config, err := a.resolvedConfig() + // The runner key is "apps."; the map key under resources.apps is just "". + appKey := a.Key() + const prefix = "apps." + if len(appKey) > len(prefix) && appKey[:len(prefix)] == prefix { + appKey = appKey[len(prefix):] + } + config, err := appdeploy.ResolveAppConfig(&a.bundle.Config, appKey, a.app) if err != nil { return err } @@ -146,51 +149,6 @@ func (a *appRunner) deploy(ctx context.Context) error { return appdeploy.Deploy(ctx, w, a.app.Name, deployment) } -// resolvedConfig returns the app config with any ${resources.*} variable references -// resolved against the current bundle state. This is needed because the app runtime -// configuration (env vars, command) can reference other bundle resources whose -// properties are known only after the initialization phase. -func (a *appRunner) resolvedConfig() (*resources.AppConfig, error) { - if a.app.Config == nil { - return nil, nil - } - - root := a.bundle.Config.Value() - - // Normalize the full config so that all typed fields are present, even those - // not explicitly set. This allows looking up resource properties by path. - normalized, _ := convert.Normalize(a.bundle.Config, root, convert.IncludeMissingFields) - - // Get the app's config section as a dyn.Value to resolve references in it. - // The key is of the form "apps.", so the full path is "resources.apps..config". - configPath := dyn.MustPathFromString("resources." + a.Key() + ".config") - configV, err := dyn.GetByPath(root, configPath) - if err != nil || !configV.IsValid() { - return a.app.Config, nil //nolint:nilerr // missing config path means use default config - } - - resourcesPrefix := dyn.MustPathFromString("resources") - - // Resolve ${resources.*} references in the app config against the full bundle config. - // Other variable types (bundle.*, workspace.*, variables.*) are already resolved - // during the initialization phase and are left in place if encountered here. - resolved, err := dynvar.Resolve(configV, func(path dyn.Path) (dyn.Value, error) { - if !path.HasPrefix(resourcesPrefix) { - return dyn.InvalidValue, dynvar.ErrSkipResolution - } - return dyn.GetByPath(normalized, path) - }) - if err != nil { - return nil, err - } - - var config resources.AppConfig - if err := convert.ToTyped(&config, resolved); err != nil { - return nil, err - } - return &config, nil -} - func (a *appRunner) Cancel(ctx context.Context) error { // We should cancel the app by stopping it. app := a.app diff --git a/cmd/bundle/deploy.go b/cmd/bundle/deploy.go index 31ffe7090d..1d3fba799d 100644 --- a/cmd/bundle/deploy.go +++ b/cmd/bundle/deploy.go @@ -31,6 +31,7 @@ See https://docs.databricks.com/en/dev-tools/bundles/index.html for more informa var autoApprove bool var verbose bool var readPlanPath string + var deployApps bool cmd.Flags().BoolVar(&force, "force", false, "Force-override Git branch validation.") cmd.Flags().BoolVar(&forceLock, "force-lock", false, "Force acquisition of deployment lock.") cmd.Flags().BoolVar(&failOnActiveRuns, "fail-on-active-runs", false, "Fail if there are running jobs or pipelines in the deployment.") @@ -40,6 +41,7 @@ See https://docs.databricks.com/en/dev-tools/bundles/index.html for more informa cmd.Flags().MarkDeprecated("compute-id", "use --cluster-id instead") cmd.Flags().BoolVar(&verbose, "verbose", false, "Enable verbose output.") cmd.Flags().StringVar(&readPlanPath, "plan", "", "Path to a JSON plan file to apply instead of planning (direct engine only).") + cmd.Flags().BoolVar(&deployApps, "deploy-apps", false, "After resources are reconciled, also upload source code and trigger an AppDeployment for every app in the bundle.") // Verbose flag currently only affects file sync output, it's used by the vscode extension cmd.Flags().MarkHidden("verbose") @@ -49,6 +51,7 @@ See https://docs.databricks.com/en/dev-tools/bundles/index.html for more informa b.Config.Bundle.Force = force b.Config.Bundle.Deployment.Lock.Force = forceLock b.AutoApprove = autoApprove + b.DeployApps = deployApps if cmd.Flag("compute-id").Changed { b.Config.Bundle.ClusterId = clusterId From c83e95dd2fc37be08ade51fe17b64632c97168be Mon Sep 17 00:00:00 2001 From: James Broadhead Date: Tue, 21 Apr 2026 16:56:27 +0000 Subject: [PATCH 2/2] bundle: warn when apps exist but --deploy-apps was not set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the bundle has `apps:` resources but the user did not pass `--deploy-apps`, the resource phase succeeds and apps appear "deployed" — yet their source code was never pushed. This is the exact first-five- minutes confusion the flag was introduced to fix; silent skipping made the original problem slightly worse for anyone who didn't read the release notes. Print a line per app pointing at `databricks bundle run `, which remains the current way to push source after a `bundle deploy`. Co-authored-by: Isaac --- bundle/phases/deploy_apps.go | 23 ++++++++++++++++++++++- bundle/phases/deploy_apps_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 bundle/phases/deploy_apps_test.go diff --git a/bundle/phases/deploy_apps.go b/bundle/phases/deploy_apps.go index 0941664708..026a06ef05 100644 --- a/bundle/phases/deploy_apps.go +++ b/bundle/phases/deploy_apps.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "sort" + "strings" "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/appdeploy" @@ -20,10 +22,14 @@ import ( // push source code, which is why this exists as a separate phase: `w.Apps.Deploy` // and file upload to the workspace are not modelled as resources. func DeployApps(ctx context.Context, b *bundle.Bundle) { + appCount := len(b.Config.Resources.Apps) if !b.DeployApps { + if appCount > 0 { + cmdio.LogString(ctx, skippedAppsMessage(b)) + } return } - if len(b.Config.Resources.Apps) == 0 { + if appCount == 0 { return } @@ -57,3 +63,18 @@ func DeployApps(ctx context.Context, b *bundle.Bundle) { } cmdio.LogString(ctx, "App source code deployed!") } + +func skippedAppsMessage(b *bundle.Bundle) string { + keys := make([]string, 0, len(b.Config.Resources.Apps)) + for key := range b.Config.Resources.Apps { + keys = append(keys, key) + } + sort.Strings(keys) + + var lines []string + lines = append(lines, fmt.Sprintf("Bundle contains %d Apps, but --deploy-apps was not set, not deploying apps. To deploy, run:", len(keys))) + for _, key := range keys { + lines = append(lines, " databricks bundle run "+key) + } + return strings.Join(lines, "\n") +} diff --git a/bundle/phases/deploy_apps_test.go b/bundle/phases/deploy_apps_test.go new file mode 100644 index 0000000000..735fd73e11 --- /dev/null +++ b/bundle/phases/deploy_apps_test.go @@ -0,0 +1,30 @@ +package phases + +import ( + "testing" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/config" + "github.com/databricks/cli/bundle/config/resources" + "github.com/stretchr/testify/assert" +) + +func TestSkippedAppsMessage(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + Apps: map[string]*resources.App{ + "my_app": {}, + "other_app": {}, + }, + }, + }, + } + + msg := skippedAppsMessage(b) + + expected := "Bundle contains 2 Apps, but --deploy-apps was not set, not deploying apps. To deploy, run:\n" + + " databricks bundle run my_app\n" + + " databricks bundle run other_app" + assert.Equal(t, expected, msg) +}