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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ bundle:

resources:
jobs:
# run_as is not set in config, so the backend defaults it to the deployer.
no_run_as:
name: no-run-as-$UNIQUE_NAME
tasks:
- task_key: main
notebook_task:
notebook_path: /Users/{{workspace_user_name}}/notebook

# The terraform provider pins run_as to the job's identity on every update,
# so a remote run_as that names the creator is a default, not an edit.
no_run_as_creator:
name: no-run-as-creator-$UNIQUE_NAME
tasks:
- task_key: main
notebook_task:
notebook_path: /Users/{{workspace_user_name}}/notebook

user_to_user:
name: user-to-user-$UNIQUE_NAME
run_as:
Expand All @@ -12,6 +29,7 @@ resources:
notebook_task:
notebook_path: /Users/{{workspace_user_name}}/notebook

# The creator default only applies when the config leaves run_as unset.
user_to_creator:
name: user-to-creator-$UNIQUE_NAME
run_as:
Expand Down
28 changes: 21 additions & 7 deletions acceptance/bundle/config-remote-sync/job_run_as/output.txt
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@

>>> [CLI] bundle deploy
Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files...
Created jobs.no_run_as
Created jobs.no_run_as_creator
Created jobs.user_to_creator
Created jobs.user_to_sp
Created jobs.user_to_user
Files: 2 uploaded, 0 deleted
Resources: 3 created, 0 changed, 0 deleted, 0 unchanged
Resources: 5 created, 0 changed, 0 deleted, 0 unchanged

=== Change run_as remotely

=== Detect and save changes

>>> [CLI] bundle config-remote-sync --save
Detected changes in 3 resource(s):
Detected changes in 4 resource(s):

Resource: resources.jobs.no_run_as
run_as: add

Resource: resources.jobs.user_to_creator
run_as.user_name: replace
Expand All @@ -29,21 +34,28 @@ Resource: resources.jobs.user_to_user
>>> diff.py databricks.yml.backup databricks.yml
--- databricks.yml.backup
+++ databricks.yml
@@ -7,5 +7,5 @@
@@ -12,4 +12,6 @@
notebook_path: /Users/{{workspace_user_name}}/notebook

+ run_as:
+ service_principal_name: [UUID]
# The terraform provider pins run_as to the job's identity on every update,
# so a remote run_as that names the creator is a default, not an edit.
@@ -24,5 +26,5 @@
name: user-to-user-[UNIQUE_NAME]
run_as:
- user_name: first-user@example.com
+ user_name: second-user@example.com
tasks:
- task_key: main
@@ -16,5 +16,5 @@
@@ -34,5 +36,5 @@
name: user-to-creator-[UNIQUE_NAME]
run_as:
- user_name: first-user@example.com
+ user_name: [USERNAME]
tasks:
- task_key: main
@@ -25,5 +25,5 @@
@@ -43,5 +45,5 @@
name: user-to-sp-[UNIQUE_NAME]
run_as:
- user_name: first-user@example.com
Expand All @@ -60,14 +72,16 @@ No changes detected.
=== Plan after sync

>>> [CLI] bundle plan
Plan: 0 to add, 0 to change, 0 to delete, 3 unchanged
Plan: 0 to add, 0 to change, 0 to delete, 5 unchanged

>>> [CLI] bundle destroy --auto-approve
The following resources will be deleted:
delete resources.jobs.no_run_as
delete resources.jobs.no_run_as_creator
delete resources.jobs.user_to_creator
delete resources.jobs.user_to_sp
delete resources.jobs.user_to_user

All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default

Destroy: 3 deleted
Destroy: 5 deleted
6 changes: 6 additions & 0 deletions acceptance/bundle/config-remote-sync/job_run_as/script
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ trap cleanup EXIT
trace $CLI bundle deploy

title "Change run_as remotely"
edit_resource.py jobs "$(read_id.py no_run_as)" <<'PY'
r["run_as"] = {"service_principal_name": "11111111-2222-3333-4444-555555555555"}
PY
edit_resource.py jobs "$(read_id.py no_run_as_creator)" <<PY
r["run_as"] = {"user_name": "$CURRENT_USER_NAME"}
PY
edit_resource.py jobs "$(read_id.py user_to_user)" <<'PY'
r["run_as"] = {"user_name": "second-user@example.com"}
PY
Expand Down
3 changes: 3 additions & 0 deletions acceptance/bundle/config-remote-sync/terraform_state/script
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ title "Replay a terraform deployment of the bundle (captured from a real terrafo
trace replay_tfstate.py terraform-requests.json terraform.tfstate
job_id="$(read_id.py test_job)"

# Jobs updated by the terraform engine carry an explicit run_as naming the job's identity; that is
# not a remote edit, so only max_concurrent_runs must be detected.
edit_resource.py jobs $job_id <<EOF2
r["max_concurrent_runs"] = 3
r["run_as"] = {"user_name": "$CURRENT_USER_NAME"}
EOF2

title "config-remote-sync migrates the terraform state in memory and detects the remote change"
Expand Down
3 changes: 0 additions & 3 deletions bundle/configsync/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,6 @@ var serverSideDefaults = map[string]any{
"resources.sql_warehouses.*.min_num_clusters": int64(1),
"resources.sql_warehouses.*.warehouse_type": "CLASSIC",

// Terraform defaults
"resources.jobs.*.run_as": alwaysSkip,

// deployment.* is CLI-managed: metadata.AnnotateJobs / AnnotatePipelines write
// kind + metadata_file_path on every deploy, and the Deployment Metadata Service
// sets deployment_id + version_id. None is user-authored (validate.ValidateDeploymentFields
Expand Down
3 changes: 3 additions & 0 deletions bundle/configsync/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ func ExtractChanges(ctx context.Context, b *bundle.Bundle, plan *deployplan.Plan
!keepSkippedRunAsChange(fullPath, changeDesc) {
continue
}
if skipJobRunAs(fullPath, changeDesc, entry.RemoteState) {
continue
}

change, err := convertChangeDesc(fullPath, changeDesc)
if err != nil {
Expand Down
44 changes: 41 additions & 3 deletions bundle/configsync/runas.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,53 @@
package configsync

import "github.com/databricks/cli/bundle/deployplan"
import (
"cmp"

"github.com/databricks/cli/bundle/deployplan"
"github.com/databricks/cli/bundle/direct/dresources"
"github.com/databricks/cli/libs/structs/structdiff"
"github.com/databricks/databricks-sdk-go/service/jobs"
)

// The plan skips a job's run_as keys that the config lacks as backend defaults.
// run_as holds a single principal key, so when the remote switches kind (for
// example user_name to service_principal_name) the new key is the edit itself,
// and dropping it while removing the old key leaves "run_as: {}".
const jobRunAsChildPattern = "resources.jobs.*.run_as.*"
const (
jobRunAsPattern = "resources.jobs.*.run_as"
jobRunAsChildPattern = "resources.jobs.*.run_as.*"
)

// keepSkippedRunAsChange reports whether a run_as change the plan skipped as a
// backend default should still be considered for syncing.
func keepSkippedRunAsChange(path string, cd *deployplan.ChangeDesc) bool {
return cd.Reason == deployplan.ReasonBackendDefault && matchPattern(jobRunAsChildPattern, path)
return cd.Reason == deployplan.ReasonBackendDefault &&
(matchPattern(jobRunAsPattern, path) || matchPattern(jobRunAsChildPattern, path))
}

// skipJobRunAs reports whether a change to the run_as object as a whole must
// not be synced. The plan diffs a run_as set on both sides key by key, so a
// change to the whole object means one side has none.
func skipJobRunAs(path string, cd *deployplan.ChangeDesc, remoteState any) 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.

minor: skipJobRunAs never checks cd.New, so a config that already has run_as gets a redundant whole-object replace

The comments at runas.go:28-30 and :40 assume a whole-object run_as change means the config has none, but skipJobRunAs never checks cd.New. The plan also records a whole-object change at run_as when the saved state has no run_as and the config now has one. That happens when run_as was added locally (or inherited from a new bundle-level run_as) and not yet deployed. In that case the local diff gives Old=nil, New=<config value>, Remote=<remote value>. If the remote also has a run_as that differs, the remote diff runs key by key (run_as.user_name and so on), so the parent entry stays as an Update.

Before this PR the alwaysSkip entry for resources.jobs.*.run_as in defaults.go dropped this parent entry. Now, when the remote principal is not the creator, skipJobRunAs returns false. convertChangeDesc (diff.go:94) then emits run_as: replace with the whole remote object, next to the existing per-key run_as.<key> changes.

The PR's own flow can reach this: a sync adds run_as to the YAML, nothing is deployed, the run-as is changed again in the Jobs UI, and sync runs again. The final YAML is the same, because ResolveChanges applies deeper paths first (resolve.go:226-246). The cost is a duplicated change in the command output and telemetry, and a whole-node rewrite of the run_as block.

Suggested fix: return true from skipJobRunAs when cd.New != nil, since the per-key entries carry the change. Add a unit case to TestSkipJobRunAs with New and Remote both set (no current row has both).

Agreed by 3 reviewer model(s).

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.

With run_as in config but not in state and a non-creator remote, this emits run_as: replace beside the per-key changes. Can it return true when cd.New != nil?

if !matchPattern(jobRunAsPattern, path) {
return false
}
// A removed remote run_as is not synced, as before: the Jobs UI always
// submits a principal, and removal is unverified on a real workspace.
if cd.Remote == nil {
return true
}
// The config has no run_as. Skip one unchanged since the last deploy.
if structdiff.IsEqual(cd.Old, cd.Remote) {

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.

major: A run_as removed from config and deployed is written back to YAML by the next sync

The structdiff.IsEqual(cd.Old, cd.Remote) check at runas.go:41 is meant to skip a remote run_as that is unchanged since the last deploy. But cd.Old is the saved state, and the direct engine saves the config-derived state (saveState in bundle/direct/apply.go), not what the server holds. The check can therefore only match while the saved state still has a run_as that the config no longer has. That is a local removal that has not been deployed yet.

The Jobs API keeps the previous run_as when jobs/reset omits it. acceptance/bundle/run_as/job_default (Cloud = true) records this as Badness ("run_as is still set even though it's not in bundle and not in reset request"), and libs/testserver/jobs.go:211-218 mirrors it.

Scenario: deploy a job with run_as: {user_name: U}, where U is not the deployer (or a job that inherits a bundle-level run_as). Delete run_as from the YAML and deploy. The saved state now has no run_as, but the remote still has U. On the next bundle config-remote-sync:

  • the plan entry at run_as is Old=nil, New=nil, Remote=U, classified backend_default (bundle/direct/bundle_plan.go shouldSkipBackendDefault; bundle plan reports the job unchanged);
  • keepSkippedRunAsChange now keeps the whole-object path;
  • IsEqual(nil, U) is false, and isCreatorRunAs is false unless U is the job's creator.

The result is run_as: add, and --save writes back the run_as the user just removed. This repeats on every run, because nothing in state records U. Removing a bundle-level run_as (often a service principal that is not the deployer) hits every job that inherited it. Before this PR, the resources.jobs.*.run_as: alwaysSkip entry in defaults.go stopped this.

The PR description lists only ownership transfer as a known gap, and says a run_as unchanged since the last deploy is not synced. Neither the unit tests nor the acceptance tests cover the Old == Remote branch end to end. The terraform_state case reaches the creator check instead, because migrated state is built from the config and has no run_as.

Please decide whether writing the identity back is intended, since the remote job really does run as that principal. Then either filter this case, or document it as a known limitation next to the ownership-transfer gap. Either way, add an acceptance case to config-remote-sync/job_run_as: deploy with run_as, remove it from the YAML, deploy again, then run sync. The fake server already reproduces the cloud behavior.

Agreed by 3 reviewer model(s).

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.

Major: After run_as is removed from YAML and deployed, the job keeps it remotely and state has none, so every sync re-adds it. Intended? Either way, please add a job_run_as acceptance case.

return true
}
return isCreatorRunAs(cd.Remote.(jobs.JobRunAs), remoteState.(*dresources.JobRemote))
}

// isCreatorRunAs reports whether run_as names the job's creator. Jobs updated by
// the terraform engine before it was removed carry an explicit run_as set to the
// job's identity, usually its creator, which is a default rather than an edit:
// https://github.com/databricks/terraform-provider-databricks/blob/76f95e4/jobs/resource_job.go#L844-L857
func isCreatorRunAs(runAs jobs.JobRunAs, job *dresources.JobRemote) bool {
return cmp.Or(runAs.UserName, runAs.ServicePrincipalName, runAs.GroupName) == job.CreatorUserName
}
88 changes: 86 additions & 2 deletions bundle/configsync/runas_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@ import (
"testing"

"github.com/databricks/cli/bundle/deployplan"
"github.com/databricks/cli/bundle/direct/dresources"
"github.com/databricks/databricks-sdk-go/service/jobs"
"github.com/stretchr/testify/assert"
)

const testRunAsPath = "resources.jobs.my_job.run_as"
const (
testRunAsPath = "resources.jobs.my_job.run_as"
testCreator = "creator@example.com"
testSPID = "11111111-2222-3333-4444-555555555555"
)

func TestKeepSkippedRunAsChange(t *testing.T) {
backendDefault := &deployplan.ChangeDesc{Action: deployplan.Skip, Reason: deployplan.ReasonBackendDefault}
Expand All @@ -20,7 +26,7 @@ func TestKeepSkippedRunAsChange(t *testing.T) {
{"service_principal_name backend default", testRunAsPath + ".service_principal_name", backendDefault, true},
{"user_name backend default", testRunAsPath + ".user_name", backendDefault, true},
{"group_name backend default", testRunAsPath + ".group_name", backendDefault, true},
{"whole run_as backend default", testRunAsPath, backendDefault, false},
{"whole run_as backend default", testRunAsPath, backendDefault, true},
{"run_as key already matches the remote", testRunAsPath + ".user_name", &deployplan.ChangeDesc{Action: deployplan.Skip, Reason: deployplan.ReasonRemoteAlreadySet}, false},
{"other job field", "resources.jobs.my_job.tasks[task_key='main'].new_cluster.enable_elastic_disk", backendDefault, false},
{"pipeline run_as key", "resources.pipelines.my_pipeline.run_as.user_name", backendDefault, false},
Expand All @@ -31,3 +37,81 @@ func TestKeepSkippedRunAsChange(t *testing.T) {
})
}
}

func TestSkipJobRunAs(t *testing.T) {
sp := jobs.JobRunAs{ServicePrincipalName: testSPID}
user := jobs.JobRunAs{UserName: "user@example.com"}
creator := jobs.JobRunAs{UserName: testCreator}
group := jobs.JobRunAs{GroupName: "data-engineers"}

tests := []struct {
name string
path string
cd *deployplan.ChangeDesc
creator string
want bool
}{
{
name: "service principal set remotely",
path: testRunAsPath,
cd: &deployplan.ChangeDesc{Remote: sp},
creator: testCreator,
want: false,
},
{
name: "group set remotely",
path: testRunAsPath,
cd: &deployplan.ChangeDesc{Remote: group},
creator: testCreator,
want: false,
},
{
name: "user that is the creator",
path: testRunAsPath,
cd: &deployplan.ChangeDesc{Remote: creator},
creator: testCreator,
want: true,
},
{
name: "service principal that is the creator",
path: testRunAsPath,
cd: &deployplan.ChangeDesc{Remote: sp},
creator: testSPID,
want: true,
},
{
name: "unchanged since the last deploy",
path: testRunAsPath,
cd: &deployplan.ChangeDesc{Old: user, Remote: user},
creator: testCreator,
want: true,
},
{
name: "changed since the last deploy",
path: testRunAsPath,
cd: &deployplan.ChangeDesc{Old: user, Remote: sp},
creator: testCreator,
want: false,
},
{
name: "removed remotely",
path: testRunAsPath,
cd: &deployplan.ChangeDesc{Old: user, New: user},
creator: testCreator,
want: true,
},
{
name: "run_as key",
path: testRunAsPath + ".service_principal_name",
cd: &deployplan.ChangeDesc{Remote: testSPID},
creator: testSPID,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
remoteState := &dresources.JobRemote{CreatorUserName: tt.creator}
assert.Equal(t, tt.want, skipJobRunAs(tt.path, tt.cd, remoteState))
})
}
}
Loading