From 61d7b1c0009738fe1838fd41ee923dd2bb56e9c7 Mon Sep 17 00:00:00 2001 From: Bradley Jamrozik Date: Wed, 24 Jun 2026 19:55:50 -0500 Subject: [PATCH 1/2] BEJ: allow apps alongside top-level run_as MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bundle: allow apps alongside top-level run_as; apps ignore the setting ## Changes - Remove the validation error that blocked bundles with app resources from having a top-level `run_as` identity configured. - Apps are not mutated by run_as (the API does not support it), so having run_as in the bundle is valid — the setting is simply skipped. - Add unit tests confirming apps are not mutated and can coexist with jobs that do get run_as applied. - Add acceptance test covering a bundle with an app and a service principal run_as identity. ## Why The previous validation was too strict: it rejected a valid configuration where a user wants run_as on their jobs/pipelines and also has an app in the same bundle. The app just ignores run_as, so there's no reason to block the bundle from validating. ## Tests New unit tests in run_as_test.go and acceptance test in acceptance/bundle/run_as/app_different/. --- .../bundle/run_as/app_different/app/app.py | 1 + .../run_as/app_different/databricks.yml | 12 ++ .../bundle/run_as/app_different/out.test.toml | 3 + .../bundle/run_as/app_different/output.txt | 7 + acceptance/bundle/run_as/app_different/script | 1 + .../config/mutator/resourcemutator/run_as.go | 10 - .../mutator/resourcemutator/run_as_test.go | 172 ++++++++++++++++++ 7 files changed, 196 insertions(+), 10 deletions(-) create mode 100644 acceptance/bundle/run_as/app_different/app/app.py create mode 100644 acceptance/bundle/run_as/app_different/databricks.yml create mode 100644 acceptance/bundle/run_as/app_different/out.test.toml create mode 100644 acceptance/bundle/run_as/app_different/output.txt create mode 100644 acceptance/bundle/run_as/app_different/script diff --git a/acceptance/bundle/run_as/app_different/app/app.py b/acceptance/bundle/run_as/app_different/app/app.py new file mode 100644 index 00000000000..1da984a7963 --- /dev/null +++ b/acceptance/bundle/run_as/app_different/app/app.py @@ -0,0 +1 @@ +# Minimal app stub for acceptance testing diff --git a/acceptance/bundle/run_as/app_different/databricks.yml b/acceptance/bundle/run_as/app_different/databricks.yml new file mode 100644 index 00000000000..180b426b689 --- /dev/null +++ b/acceptance/bundle/run_as/app_different/databricks.yml @@ -0,0 +1,12 @@ +bundle: + name: "run_as" + +run_as: + service_principal_name: "my_service_principal" + +resources: + apps: + foo: + name: "my_app" + description: "An app with a differing run_as identity" + source_code_path: ./app diff --git a/acceptance/bundle/run_as/app_different/out.test.toml b/acceptance/bundle/run_as/app_different/out.test.toml new file mode 100644 index 00000000000..f784a183258 --- /dev/null +++ b/acceptance/bundle/run_as/app_different/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/run_as/app_different/output.txt b/acceptance/bundle/run_as/app_different/output.txt new file mode 100644 index 00000000000..e1d564f2095 --- /dev/null +++ b/acceptance/bundle/run_as/app_different/output.txt @@ -0,0 +1,7 @@ +Name: run_as +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/run_as/default + +Validation OK! diff --git a/acceptance/bundle/run_as/app_different/script b/acceptance/bundle/run_as/app_different/script new file mode 100644 index 00000000000..72555b332a4 --- /dev/null +++ b/acceptance/bundle/run_as/app_different/script @@ -0,0 +1 @@ +$CLI bundle validate diff --git a/bundle/config/mutator/resourcemutator/run_as.go b/bundle/config/mutator/resourcemutator/run_as.go index 4f5e3ce9036..9a3aa470e6b 100644 --- a/bundle/config/mutator/resourcemutator/run_as.go +++ b/bundle/config/mutator/resourcemutator/run_as.go @@ -116,16 +116,6 @@ func validateRunAs(b *bundle.Bundle) diag.Diagnostics { } } - // Apps do not support run_as in the API. - if len(b.Config.Resources.Apps) > 0 { - diags = diags.Extend(reportRunAsNotSupported( - "apps", - b.Config.GetLocation("resources.apps"), - b.Config.Workspace.CurrentUser.UserName, - identity, - )) - } - return diags } diff --git a/bundle/config/mutator/resourcemutator/run_as_test.go b/bundle/config/mutator/resourcemutator/run_as_test.go index 5f98190ecb3..b6a323c34ca 100644 --- a/bundle/config/mutator/resourcemutator/run_as_test.go +++ b/bundle/config/mutator/resourcemutator/run_as_test.go @@ -9,6 +9,7 @@ import ( "github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/libs/dyn" "github.com/databricks/cli/libs/dyn/convert" + "github.com/databricks/databricks-sdk-go/service/apps" "github.com/databricks/databricks-sdk-go/service/iam" "github.com/databricks/databricks-sdk-go/service/jobs" "github.com/databricks/databricks-sdk-go/service/sql" @@ -169,6 +170,7 @@ func TestRunAsWorksForAllowedResources(t *testing.T) { // they are not on the allow list below. var allowList = []string{ "alerts", + "apps", "catalogs", "clusters", "dashboards", @@ -301,3 +303,173 @@ func TestRunAsNoErrorForSupportedResources(t *testing.T) { require.NoError(t, diags.Error()) } } + +func TestRunAsAppNotMutated(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Workspace: config.Workspace{ + CurrentUser: &config.User{ + User: &iam.User{UserName: "alice"}, + }, + }, + RunAs: &jobs.JobRunAs{UserName: "bob"}, + Resources: config.Resources{ + Apps: map[string]*resources.App{ + "my_app": { + App: apps.App{ + Name: "my_app", + Description: "desc", + }, + SourceCodePath: "./src", + }, + }, + }, + }, + } + + diags := bundle.Apply(t.Context(), b, SetRunAs()) + assert.NoError(t, diags.Error()) + + app := b.Config.Resources.Apps["my_app"] + assert.Equal(t, "my_app", app.Name) + assert.Equal(t, "./src", app.SourceCodePath) + assert.Equal(t, "desc", app.Description) +} + +func TestRunAsNoOpForApps(t *testing.T) { + tests := []struct { + name string + runAs *jobs.JobRunAs + }{ + { + name: "user identity", + runAs: &jobs.JobRunAs{UserName: "bob"}, + }, + { + name: "service principal", + runAs: &jobs.JobRunAs{ServicePrincipalName: "sp-acme"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Workspace: config.Workspace{ + CurrentUser: &config.User{ + User: &iam.User{UserName: "alice"}, + }, + }, + RunAs: tc.runAs, + Resources: config.Resources{ + Apps: map[string]*resources.App{ + "my_app": {App: apps.App{Name: "my_app"}}, + }, + }, + }, + } + + diags := bundle.Apply(t.Context(), b, SetRunAs()) + assert.NoError(t, diags.Error()) + }) + } +} + +func TestRunAsNoRunAs_AppUnchanged(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Workspace: config.Workspace{ + CurrentUser: &config.User{ + User: &iam.User{UserName: "alice"}, + }, + }, + Resources: config.Resources{ + Apps: map[string]*resources.App{ + "my_app": {App: apps.App{Name: "my_app"}}, + }, + }, + }, + } + + diags := bundle.Apply(t.Context(), b, SetRunAs()) + assert.Nil(t, diags) + assert.Equal(t, "my_app", b.Config.Resources.Apps["my_app"].Name) +} + +func TestRunAsAppAndJobTogether(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Workspace: config.Workspace{ + CurrentUser: &config.User{ + User: &iam.User{UserName: "alice"}, + }, + }, + RunAs: &jobs.JobRunAs{UserName: "bob"}, + Resources: config.Resources{ + Apps: map[string]*resources.App{ + "my_app": {App: apps.App{Name: "my_app"}}, + }, + Jobs: map[string]*resources.Job{ + "my_job": { + JobSettings: jobs.JobSettings{Name: "my_job"}, + }, + }, + }, + }, + } + + diags := bundle.Apply(t.Context(), b, SetRunAs()) + require.NoError(t, diags.Error()) + assert.Equal(t, "bob", b.Config.Resources.Jobs["my_job"].RunAs.UserName) + assert.Equal(t, "my_app", b.Config.Resources.Apps["my_app"].Name) +} + +func TestRunAsAppWithDeniedResourceStillErrors(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Workspace: config.Workspace{ + CurrentUser: &config.User{ + User: &iam.User{UserName: "alice"}, + }, + }, + RunAs: &jobs.JobRunAs{UserName: "bob"}, + Resources: config.Resources{ + Apps: map[string]*resources.App{ + "my_app": {App: apps.App{Name: "my_app"}}, + }, + ModelServingEndpoints: map[string]*resources.ModelServingEndpoint{ + "my_endpoint": {}, + }, + }, + }, + } + + diags := bundle.Apply(t.Context(), b, SetRunAs()) + require.True(t, diags.HasError()) + assert.Contains(t, diags.Error().Error(), "model_serving_endpoints do not support a setting a run_as user") + assert.NotContains(t, diags.Error().Error(), "apps do not support") +} + +func TestRunAsAppSameIdentity_NoError(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Workspace: config.Workspace{ + CurrentUser: &config.User{ + User: &iam.User{UserName: "alice"}, + }, + }, + RunAs: &jobs.JobRunAs{UserName: "alice"}, + Resources: config.Resources{ + Apps: map[string]*resources.App{ + "my_app": {App: apps.App{Name: "my_app"}}, + }, + ModelServingEndpoints: map[string]*resources.ModelServingEndpoint{ + "my_endpoint": {}, + }, + }, + }, + } + + diags := bundle.Apply(t.Context(), b, SetRunAs()) + assert.NoError(t, diags.Error()) +} From affc22f1cf338ae151934733d15171b098702358 Mon Sep 17 00:00:00 2001 From: Bradley Jamrozik Date: Wed, 24 Jun 2026 20:04:10 -0500 Subject: [PATCH 2/2] BEJ: add NEXT_CHANGELOG entry for run_as app change --- NEXT_CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 13efb33eb74..bf15d423f56 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -9,6 +9,7 @@ ### Bundles * direct: Cluster resize now falls back to regular update if resize fails due to `INVALID_STATE` ([#5716](https://github.com/databricks/cli/pull/5716)). +* Allow bundles with `apps` resources to have a top-level `run_as` identity configured. Apps do not support `run_as` via the API and are simply skipped; other resources (jobs, pipelines, etc.) continue to have `run_as` applied as before. ### Dependency updates