Skip to content
Open
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
59 changes: 59 additions & 0 deletions bundle/appdeploy/config.go
Original file line number Diff line number Diff line change
@@ -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
}
31 changes: 31 additions & 0 deletions bundle/appdeploy/config_test.go
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions bundle/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <app>` to push source.
DeployApps bool
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We have recently added lifecycle.started field, can you check if it works? Unlike pure config option, it is actually tracked in resource state so it handles transitions between started/stopped/unspecified better.


// 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.,
Expand Down
7 changes: 7 additions & 0 deletions bundle/phases/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <app>`).
DeployApps(ctx, b)
if logdiag.HasError(ctx) {
return
}

bundle.ApplyContext(ctx, b, scripts.Execute(config.ScriptPostDeploy))
}

Expand Down
80 changes: 80 additions & 0 deletions bundle/phases/deploy_apps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package phases

import (
"context"
"errors"
"fmt"
"sort"
"strings"

"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) {
appCount := len(b.Config.Resources.Apps)
if !b.DeployApps {
if appCount > 0 {
cmdio.LogString(ctx, skippedAppsMessage(b))
}
return
}
if appCount == 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))

Check failure on line 45 in bundle/phases/deploy_apps.go

View workflow job for this annotation

GitHub Actions / lint

string-format: fmt.Sprintf can be replaced with string concatenation (perfsprint)

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!")
}

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)

Check failure on line 72 in bundle/phases/deploy_apps.go

View workflow job for this annotation

GitHub Actions / lint

use of `sort.Strings` forbidden because "Use slices.Sort from the standard library instead." (forbidigo)

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")
}
30 changes: 30 additions & 0 deletions bundle/phases/deploy_apps_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
56 changes: 7 additions & 49 deletions bundle/run/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -138,59 +135,20 @@ 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.<name>"; the map key under resources.apps is just "<name>".
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
}
deployment := appdeploy.BuildDeployment(a.app.SourceCodePath, config, a.app.GitSource)
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.<name>", so the full path is "resources.apps.<name>.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
Expand Down
3 changes: 3 additions & 0 deletions cmd/bundle/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand All @@ -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")

Expand All @@ -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
Expand Down
Loading