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
1 change: 1 addition & 0 deletions .nextchanges/bundles/exclude-resource-selector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Add `--exclude` to `bundle deploy` and `bundle plan` to defer selected resources. ([#7348](https://github.com/databricks/cli/pull/7348))
1 change: 1 addition & 0 deletions acceptance/bundle/help/bundle-deploy/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Usage:
Flags:
--auto-approve Skip interactive approvals that might be required for deployment.
-c, --cluster-id string Override cluster in the deployment with the given cluster ID.
--exclude strings Deploy all resources except the specified resource (e.g. 'my_job' or 'jobs.my_job'). Can be repeated or comma-separated.
--fail-on-active-runs Fail if there are running jobs or pipelines in the deployment.
--force Force-override Git branch validation.
--force-lock Force acquisition of deployment lock.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
bundle:
name: exclude-$UNIQUE_NAME

resources:
jobs:
deferred:
name: deferred-$UNIQUE_NAME
tasks:
- task_key: run_prerequisite
run_job_task:
job_id: 123
14 changes: 14 additions & 0 deletions acceptance/bundle/select/exclude_basic/databricks.yml.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
bundle:
name: exclude-$UNIQUE_NAME

resources:
jobs:
prerequisite:
name: prerequisite-$UNIQUE_NAME

deferred:
name: deferred-$UNIQUE_NAME
tasks:
- task_key: run_prerequisite
run_job_task:
job_id: ${resources.jobs.prerequisite.id}
3 changes: 3 additions & 0 deletions acceptance/bundle/select/exclude_basic/out.test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Cloud = false
EnvMatrix.DMS = ["", "true"]
EnvMatrix.READPLAN = ["", "1"]
95 changes: 95 additions & 0 deletions acceptance/bundle/select/exclude_basic/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@

>>> [CLI] bundle plan --exclude jobs.deferred
create jobs.prerequisite

Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged, 1 not selected

>>> [CLI] bundle plan --exclude jobs.deferred --exclude deferred
create jobs.prerequisite

Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged, 1 not selected

>>> [CLI] bundle plan --exclude jobs.prerequisite
Error: cannot exclude jobs.prerequisite: jobs.deferred depends on it through ${resources.jobs.prerequisite.id}; also exclude jobs.deferred or remove the dependency


>>> [CLI] bundle deploy --exclude jobs.missing
Error: no such resource: jobs.missing


>>> [CLI] bundle deploy --select jobs.deferred --exclude jobs.prerequisite
Error: --select and --exclude cannot be used together

>>> [CLI] bundle deploy --exclude jobs.deferred --plan plan.json
Error: --exclude cannot be used with --plan; generate a filtered plan with bundle plan --exclude, then apply it with --plan

>>> env DATABRICKS_BUNDLE_ENGINE=terraform [CLI] bundle plan --exclude jobs.deferred
Error: the Terraform deployment engine has been removed in Databricks CLI v1.20.0

Unset the DATABRICKS_BUNDLE_ENGINE environment variable (or set it to "direct") to deploy with the direct engine; existing Terraform state is migrated automatically. To keep using Terraform, revert to Databricks CLI v1.19.x.
See https://docs.databricks.com/dev-tools/bundles/direct for details

=== Deploy prerequisites with --exclude
Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/exclude-[UNIQUE_NAME]/default/files...
Created jobs.prerequisite
Files: 4 uploaded, 0 deleted
Resources: 1 created, 0 changed, 0 deleted, 0 unchanged, 1 not selected

>>> [CLI] bundle summary
Name: exclude-[UNIQUE_NAME]
Target: default
Workspace:
User: [USERNAME]
Path: /Workspace/Users/[USERNAME]/.bundle/exclude-[UNIQUE_NAME]/default
Resources:
Jobs:
deferred:
Name: deferred-[UNIQUE_NAME]
URL: (not deployed)
prerequisite:
Name: prerequisite-[UNIQUE_NAME]
URL: [DATABRICKS_URL]/jobs/[PREREQUISITE_ID]?w=[WORKSPACE_ID]

>>> print_requests.py --nostamp --sort //jobs
{
"method": "POST",
"path": "/api/2.2/jobs/create",
"body": {
"deployment": {
"kind": "BUNDLE",
"metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/exclude-[UNIQUE_NAME]/default/state/metadata.json"
},
"edit_mode": "UI_LOCKED",
"format": "MULTI_TASK",
"max_concurrent_runs": 1,
"name": "prerequisite-[UNIQUE_NAME]",
"queue": {
"enabled": true
}
}
}

=== Deploy deferred resource with --select

>>> [CLI] bundle deploy --select jobs.deferred
Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/exclude-[UNIQUE_NAME]/default/files...
Created jobs.deferred
Files: 0 uploaded, 0 deleted
Resources: 1 created, 0 changed, 0 deleted, 1 unchanged

>>> [CLI] bundle plan --exclude jobs.deferred
Error: cannot exclude jobs.deferred: deployed jobs.deferred still depends on jobs.prerequisite through ${resources.jobs.prerequisite.id}, which is planned for delete; also exclude jobs.prerequisite or update jobs.deferred first


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

=== Destroy
>>> [CLI] bundle destroy --auto-approve
The following resources will be deleted:
delete resources.jobs.deferred
delete resources.jobs.prerequisite

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

Destroy: 2 deleted
34 changes: 34 additions & 0 deletions acceptance/bundle/select/exclude_basic/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
envsubst '$UNIQUE_NAME' < databricks.yml.tmpl > databricks.yml

cleanup() {
title "Destroy"
trace $CLI bundle destroy --auto-approve
}
trap cleanup EXIT

trace $CLI bundle plan --exclude jobs.deferred
trace $CLI bundle plan --exclude jobs.deferred --exclude deferred
musterr trace $CLI bundle plan --exclude jobs.prerequisite
musterr trace $CLI bundle deploy --exclude jobs.missing
musterr trace $CLI bundle deploy --select jobs.deferred --exclude jobs.prerequisite
musterr trace $CLI bundle deploy --exclude jobs.deferred --plan plan.json
musterr trace env DATABRICKS_BUNDLE_ENGINE=terraform $CLI bundle plan --exclude jobs.deferred

$CLI bundle plan --exclude jobs.deferred -o json > plan.json
title "Deploy prerequisites with --exclude\n"
if [ -n "$READPLAN" ]; then
$CLI bundle deploy --plan plan.json
else
$CLI bundle deploy --exclude jobs.deferred
fi
replace_ids.py
trace $CLI bundle summary
trace print_requests.py --nostamp --sort //jobs

title "Deploy deferred resource with --select\n"
trace $CLI bundle deploy --select jobs.deferred
# The deployed job still references its prerequisite until it is updated.
envsubst '$UNIQUE_NAME' < databricks-removed.yml.tmpl > databricks.yml
musterr trace $CLI bundle plan --exclude jobs.deferred
envsubst '$UNIQUE_NAME' < databricks.yml.tmpl > databricks.yml
trace $CLI bundle plan
4 changes: 4 additions & 0 deletions acceptance/bundle/select/exclude_basic/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Cloud = false
RecordRequests = true
EnvMatrix.READPLAN = ["", "1"]
Ignore = [".databricks", ".gitignore", "databricks.yml", "plan.json"]
3 changes: 3 additions & 0 deletions bundle/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ type Bundle struct {
// When non-empty, only the specified resources are included in deployment.
Select []string

// Exclude contains resources omitted from deployment.
Exclude []string

// MigratingToDirect is set when the existing state still uses terraform, so it is
// migrated to the direct engine in memory before the command runs (statemgmt.Migrate).
// Deploy and destroy commit the migration (statemgmt.CommitMigration); other commands
Expand Down
72 changes: 68 additions & 4 deletions bundle/deployplan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,8 @@ type Plan struct {

Plan map[string]*PlanEntry `json:"plan,omitzero"`

// NotSelected is the number of resources removed by FilterToSelected via the
// --select flag. Serialized so the summary survives a deploy from a plan file
// (--plan); used only for summary reporting.
// NotSelected is the number of resources removed by --select or --exclude.
// Serialized so the summary survives a deploy from a plan file (--plan).
NotSelected int `json:"not_selected,omitempty"`

mutex sync.Mutex `json:"-"`
Expand Down Expand Up @@ -282,7 +281,7 @@ func (p *Plan) ReadUnlockEntry(resourceKey string) {
p.lockmap.RUnlock(resourceKey)
}

// ResolveSelectors maps each --select selector to its plan key ("resources.type.name")
// ResolveSelectors maps each resource selector to its plan key ("resources.type.name")
// against resourceKeys of that same form. A selector is a qualified "type.name" or an
// unqualified "name"; it errors on no match or, when unqualified, more than one.
// Duplicate resourceKeys (a resource present in both config and state) collapse, so an
Expand Down Expand Up @@ -423,6 +422,71 @@ func (p *Plan) FilterToSelected(_ context.Context, selected []string) error {
return nil
}

// FilterToExcluded removes the named resources and their child plan nodes. It
// rejects exclusions that would break references in retained or deployed resources.
func (p *Plan) FilterToExcluded(excluded []string, existingDependencies map[string][]DependsOnEntry) error {
resolved, err := ResolveSelectors(maps.Keys(p.Plan), excluded)
if err != nil {
return err
}

toRemove := make(map[string]struct{}, len(resolved))
for _, key := range resolved {
toRemove[key] = struct{}{}
for child := range p.Plan {
if strings.HasPrefix(child, key+".") {
toRemove[child] = struct{}{}
}
}
}

var conflicts []string
for key, entry := range p.Plan {
_, removed := toRemove[key]
dependencies := entry.DependsOn
if removed {
dependencies = existingDependencies[key]
}
for _, dep := range dependencies {
dependencyEntry, exists := p.Plan[dep.Node]
if !exists {
continue
}
_, dependencyRemoved := toRemove[dep.Node]
if removed == dependencyRemoved {
continue
}
dependent := strings.TrimPrefix(key, "resources.")
dependency := strings.TrimPrefix(dep.Node, "resources.")
reference := ""
if dep.Label != "" {
reference = " through " + dep.Label
}
if dependencyRemoved {
if entry.Action == Delete {
continue
}
conflicts = append(conflicts, fmt.Sprintf("cannot exclude %s: %s depends on it%s; also exclude %s or remove the dependency", dependency, dependent, reference, dependent))
} else {
if dependencyEntry.Action.KeepsID() {
continue
}
conflicts = append(conflicts, fmt.Sprintf("cannot exclude %s: deployed %s still depends on %s%s, which is planned for %s; also exclude %s or update %s first", dependent, dependent, dependency, reference, dependencyEntry.Action, dependency, dependent))
}
}
}
if len(conflicts) > 0 {
slices.Sort(conflicts)
return errors.New(strings.Join(conflicts, "\n"))
}

for key := range toRemove {
delete(p.Plan, key)
}
p.NotSelected = len(toRemove)
return nil
}

// reverseEdges returns the reverse of the plan's DependsOn edges: for each node, the
// nodes that depend on it.
func (p *Plan) reverseEdges() map[string][]string {
Expand Down
96 changes: 96 additions & 0 deletions bundle/deployplan/plan_filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,3 +283,99 @@ func TestFilterToSelected_DeletionLeavesDependencies(t *testing.T) {
assert.Contains(t, p.Plan, "resources.jobs.gone")
assert.NotContains(t, p.Plan, "resources.jobs.lib")
}

func TestFilterToExcluded_LeavesOtherResourcesAndDeduplicatesSelectors(t *testing.T) {
p := planWithDeps()
require.NoError(t, p.FilterToExcluded([]string{"jobs.independent", "independent", "jobs.independent"}, nil))
assert.NotContains(t, p.Plan, "resources.jobs.independent")
assert.Contains(t, p.Plan, "resources.jobs.foo")
assert.Contains(t, p.Plan, "resources.jobs.bar")
assert.Contains(t, p.Plan, "resources.jobs.baz")
assert.Equal(t, 1, p.NotSelected)
}

func TestFilterToExcluded_RemovesGrantsAndPermissions(t *testing.T) {
p := planWithGrants()
require.NoError(t, p.FilterToExcluded([]string{"schemas.bronze"}, nil))
assert.NotContains(t, p.Plan, "resources.schemas.bronze")
assert.NotContains(t, p.Plan, "resources.schemas.bronze.grants")
assert.NotContains(t, p.Plan, "resources.schemas.bronze.permissions")
assert.Contains(t, p.Plan, "resources.schemas.silver")
assert.Contains(t, p.Plan, "resources.schemas.silver.grants")
assert.Equal(t, 3, p.NotSelected)
}

func TestFilterToExcluded_RejectsRequiredDependencyWithoutChangingPlan(t *testing.T) {
p := planWithDeps()
p.Plan["resources.jobs.foo"].DependsOn[0].Label = "${resources.jobs.bar.id}"
err := p.FilterToExcluded([]string{"jobs.bar"}, nil)
require.Error(t, err)
assert.ErrorContains(t, err, "cannot exclude jobs.bar: jobs.foo depends on it")
assert.ErrorContains(t, err, "${resources.jobs.bar.id}")
assert.Len(t, p.Plan, 4)
assert.Zero(t, p.NotSelected)
}

func TestFilterToExcluded_RejectsUnknownAndAmbiguousSelectors(t *testing.T) {
p := deployplan.NewPlanDirect()
p.Plan["resources.jobs.same"] = &deployplan.PlanEntry{}
p.Plan["resources.pipelines.same"] = &deployplan.PlanEntry{}
assert.ErrorContains(t, p.FilterToExcluded([]string{"unknown"}, nil), "no such resource: unknown")
assert.ErrorContains(t, p.FilterToExcluded([]string{"same"}, nil), "ambiguous resource: same")
assert.Len(t, p.Plan, 2)
}

func TestFilterToExcluded_OmitsSelectedDeletion(t *testing.T) {
p := deployplan.NewPlanDirect()
p.Plan["resources.jobs.keep"] = &deployplan.PlanEntry{Action: deployplan.Create}
p.Plan["resources.jobs.gone"] = &deployplan.PlanEntry{Action: deployplan.Delete}
require.NoError(t, p.FilterToExcluded([]string{"jobs.gone"}, nil))
assert.NotContains(t, p.Plan, "resources.jobs.gone")
assert.Equal(t, deployplan.Create, p.Plan["resources.jobs.keep"].Action)
assert.Equal(t, 1, p.NotSelected)
}

func TestFilterToExcluded_RejectsIDChangesNeededByExcludedResource(t *testing.T) {
for _, action := range []deployplan.ActionType{deployplan.Delete, deployplan.Recreate, deployplan.UpdateWithID} {
t.Run(string(action), func(t *testing.T) {
p := deployplan.NewPlanDirect()
p.Plan["resources.jobs.bar"] = &deployplan.PlanEntry{Action: action}
p.Plan["resources.jobs.foo"] = &deployplan.PlanEntry{Action: deployplan.Update}
existingDependencies := map[string][]deployplan.DependsOnEntry{
"resources.jobs.foo": {{Node: "resources.jobs.bar", Label: "${resources.jobs.bar.id}"}},
}

err := p.FilterToExcluded([]string{"jobs.foo"}, existingDependencies)
require.Error(t, err)
assert.ErrorContains(t, err, "deployed jobs.foo still depends on jobs.bar through ${resources.jobs.bar.id}")
assert.ErrorContains(t, err, "planned for "+string(action))
assert.Len(t, p.Plan, 2)
assert.Zero(t, p.NotSelected)
})
}
}

func TestFilterToExcluded_AllowsStableDependencyOfExcludedResource(t *testing.T) {
p := deployplan.NewPlanDirect()
p.Plan["resources.jobs.bar"] = &deployplan.PlanEntry{Action: deployplan.Update}
p.Plan["resources.jobs.foo"] = &deployplan.PlanEntry{Action: deployplan.Update}
existingDependencies := map[string][]deployplan.DependsOnEntry{
"resources.jobs.foo": {{Node: "resources.jobs.bar"}},
}

require.NoError(t, p.FilterToExcluded([]string{"jobs.foo"}, existingDependencies))
assert.NotContains(t, p.Plan, "resources.jobs.foo")
assert.Contains(t, p.Plan, "resources.jobs.bar")
}

func TestFilterToExcluded_AllowsDeletingDependentOfExcludedResource(t *testing.T) {
p := deployplan.NewPlanDirect()
p.Plan["resources.jobs.bar"] = &deployplan.PlanEntry{Action: deployplan.Skip}
p.Plan["resources.jobs.foo"] = &deployplan.PlanEntry{
Action: deployplan.Delete,
DependsOn: []deployplan.DependsOnEntry{{Node: "resources.jobs.bar"}},
}
require.NoError(t, p.FilterToExcluded([]string{"jobs.bar"}, nil))
assert.NotContains(t, p.Plan, "resources.jobs.bar")
assert.Equal(t, deployplan.Delete, p.Plan["resources.jobs.foo"].Action)
}
Loading
Loading