Skip to content

Commit 1d3083f

Browse files
Refactor validate_direct_only_resources and approval log loops (#5215)
## Changes Two independent refactors in `bundle/`: 1. **`bundle/config/mutator/validate_direct_only_resources.go`** — drop the local `directOnlyResource` struct (whose `pluralName`/`singularName` fields actually held `SingularTitle`/`SingularName` values) and the per-type `getResources` closures. Iterate `b.Config.Resources.AllResources()` and key off `PluralName` via a small `map[string]bool` instead, reading `SingularTitle`/`SingularName` from the canonical `ResourceDescription`. Adds a unit test covering direct/terraform engines × the three direct-only resource types. 2. **`bundle/phases/{deploy,destroy}.go`** — collapse the eight (deploy) and seven (destroy) near-identical `if len(xActions) != 0 { LogString(message); for _ { Log(action) } }` blocks into a table-driven helper `logApprovalGroups` in a new `bundle/phases/approval.go`. The deploy version also replaces the eight-clause `len(...) == 0 &&` early-return with a single `total == 0` check returned from the helper. The schema child-resource skip (deploy only) and trailing blank lines (destroy only) are preserved via per-group `skipChildren`/`trailingGap` flags. The outer "all deletes" preamble in destroy stays as-is — it's structurally different. Net diff: −215 source LOC, +110 test LOC. ## Why Both files had grown into mechanical repetition. `validate_direct_only_resources.go` re-declared resource metadata that already lives on each resource's `ResourceDescription()` and named the fields incorrectly. The approval functions repeated the same eight-/seven-times pattern inline, with an opaque eight-clause boolean expression for the early-return. There is one minor user-visible wording change: for `external_locations` and `vector_search_endpoints`, the Detail message now reads `... use external_location resources.` / `... use vector_search_endpoint resources.` (snake_case, from `SingularName`) instead of `... use external location resources.` / `... use vector search endpoint resources.` (the old struct's hand-written field with spaces). The catalog message is byte-identical. There are no acceptance tests covering the other two messages. ## Tests - New unit test `validate_direct_only_resources_test.go` (table-driven over the three direct-only types). - Existing acceptance test `bundle/validate/catalog_requires_direct_mode` passes byte-identically. - `bundle/destroy/...`, `bundle/deploy/...` (excluding the pre-existing `spark-jar-task` Java env failure that also fails on `main`), `bundle/resources/grants/schemas/...`, `bundle/config-remote-sync/...`, and `bundle/user_agent/simple` all pass byte-identically. - `./task fmt`, `./task checks`, `./task lint` clean. _This PR was written by Claude Code._
1 parent 3ff74bd commit 1d3083f

5 files changed

Lines changed: 204 additions & 216 deletions

File tree

bundle/config/mutator/validate_direct_only_resources.go

Lines changed: 26 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -6,56 +6,11 @@ import (
66

77
"github.com/databricks/cli/bundle"
88
"github.com/databricks/cli/bundle/config/engine"
9+
"github.com/databricks/cli/bundle/deploy/terraform"
10+
"github.com/databricks/cli/bundle/direct/dresources"
911
"github.com/databricks/cli/libs/diag"
1012
)
1113

12-
type directOnlyResource struct {
13-
resourceType string
14-
pluralName string
15-
singularName string
16-
getResources func(*bundle.Bundle) map[string]any
17-
}
18-
19-
// Resources that are only supported in direct deployment mode
20-
var directOnlyResources = []directOnlyResource{
21-
{
22-
resourceType: "catalogs",
23-
pluralName: "Catalog",
24-
singularName: "catalog",
25-
getResources: func(b *bundle.Bundle) map[string]any {
26-
result := make(map[string]any)
27-
for k, v := range b.Config.Resources.Catalogs {
28-
result[k] = v
29-
}
30-
return result
31-
},
32-
},
33-
{
34-
resourceType: "external_locations",
35-
pluralName: "External Location",
36-
singularName: "external location",
37-
getResources: func(b *bundle.Bundle) map[string]any {
38-
result := make(map[string]any)
39-
for k, v := range b.Config.Resources.ExternalLocations {
40-
result[k] = v
41-
}
42-
return result
43-
},
44-
},
45-
{
46-
resourceType: "vector_search_endpoints",
47-
pluralName: "Vector Search Endpoint",
48-
singularName: "vector search endpoint",
49-
getResources: func(b *bundle.Bundle) map[string]any {
50-
result := make(map[string]any)
51-
for k, v := range b.Config.Resources.VectorSearchEndpoints {
52-
result[k] = v
53-
}
54-
return result
55-
},
56-
},
57-
}
58-
5914
type validateDirectOnlyResources struct {
6015
engine engine.EngineType
6116
}
@@ -70,26 +25,37 @@ func (m *validateDirectOnlyResources) Name() string {
7025
return "ValidateDirectOnlyResources"
7126
}
7227

28+
// isDirectOnly reports whether a resource type (by PluralName) is supported only
29+
// by the direct engine — present in dresources.SupportedResources but absent
30+
// from terraform.GroupToTerraformName.
31+
func isDirectOnly(pluralName string) bool {
32+
_, hasDirect := dresources.SupportedResources[pluralName]
33+
_, hasTerraform := terraform.GroupToTerraformName[pluralName]
34+
return hasDirect && !hasTerraform
35+
}
36+
7337
func (m *validateDirectOnlyResources) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
7438
if m.engine.IsDirect() {
7539
return nil
7640
}
7741

7842
var diags diag.Diagnostics
79-
80-
for _, resource := range directOnlyResources {
81-
resourceMap := resource.getResources(b)
82-
if len(resourceMap) > 0 {
83-
diags = diags.Append(diag.Diagnostic{
84-
Severity: diag.Error,
85-
Summary: resource.pluralName + " resources are only supported with direct deployment mode",
86-
Detail: fmt.Sprintf("%s resources require direct deployment mode. "+
87-
"Please set the DATABRICKS_BUNDLE_ENGINE environment variable to 'direct' to use %s resources.\n"+
88-
"Learn more at https://docs.databricks.com/dev-tools/bundles/direct",
89-
resource.pluralName, resource.singularName),
90-
Locations: b.Config.GetLocations("resources." + resource.resourceType),
91-
})
43+
for _, group := range b.Config.Resources.AllResources() {
44+
if len(group.Resources) == 0 {
45+
continue
46+
}
47+
if !isDirectOnly(group.Description.PluralName) {
48+
continue
9249
}
50+
diags = diags.Append(diag.Diagnostic{
51+
Severity: diag.Error,
52+
Summary: group.Description.SingularTitle + " resources are only supported with direct deployment mode",
53+
Detail: fmt.Sprintf("%s resources require direct deployment mode. "+
54+
"Please set the DATABRICKS_BUNDLE_ENGINE environment variable to 'direct' to use %s resources.\n"+
55+
"Learn more at https://docs.databricks.com/dev-tools/bundles/direct",
56+
group.Description.SingularTitle, group.Description.SingularName),
57+
Locations: b.Config.GetLocations("resources." + group.Description.PluralName),
58+
})
9359
}
9460

9561
return diags
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package mutator_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/databricks/cli/bundle"
7+
"github.com/databricks/cli/bundle/config"
8+
"github.com/databricks/cli/bundle/config/engine"
9+
"github.com/databricks/cli/bundle/config/mutator"
10+
"github.com/databricks/cli/bundle/config/resources"
11+
"github.com/stretchr/testify/assert"
12+
)
13+
14+
func TestValidateDirectOnlyResourcesDirectEngineReturnsNil(t *testing.T) {
15+
b := &bundle.Bundle{
16+
Config: config.Root{
17+
Resources: config.Resources{
18+
Catalogs: map[string]*resources.Catalog{
19+
"my_catalog": {},
20+
},
21+
},
22+
},
23+
}
24+
25+
diags := bundle.Apply(t.Context(), b, mutator.ValidateDirectOnlyResources(engine.EngineDirect))
26+
assert.Empty(t, diags)
27+
}
28+
29+
func TestValidateDirectOnlyResourcesTerraformEngineNoDirectOnlyReturnsNil(t *testing.T) {
30+
b := &bundle.Bundle{
31+
Config: config.Root{
32+
Resources: config.Resources{
33+
Jobs: map[string]*resources.Job{
34+
"my_job": {},
35+
},
36+
},
37+
},
38+
}
39+
40+
diags := bundle.Apply(t.Context(), b, mutator.ValidateDirectOnlyResources(engine.EngineTerraform))
41+
assert.Empty(t, diags)
42+
}
43+
44+
func TestValidateDirectOnlyResourcesTerraformEngineDirectOnlyEmitsError(t *testing.T) {
45+
cases := []struct {
46+
name string
47+
bundle *bundle.Bundle
48+
expectedSummary string
49+
expectedDetail string
50+
}{
51+
{
52+
name: "catalogs",
53+
bundle: &bundle.Bundle{
54+
Config: config.Root{
55+
Resources: config.Resources{
56+
Catalogs: map[string]*resources.Catalog{
57+
"my_catalog": {},
58+
},
59+
},
60+
},
61+
},
62+
expectedSummary: "Catalog resources are only supported with direct deployment mode",
63+
expectedDetail: "Catalog resources require direct deployment mode. " +
64+
"Please set the DATABRICKS_BUNDLE_ENGINE environment variable to 'direct' to use catalog resources.\n" +
65+
"Learn more at https://docs.databricks.com/dev-tools/bundles/direct",
66+
},
67+
{
68+
name: "external_locations",
69+
bundle: &bundle.Bundle{
70+
Config: config.Root{
71+
Resources: config.Resources{
72+
ExternalLocations: map[string]*resources.ExternalLocation{
73+
"my_location": {},
74+
},
75+
},
76+
},
77+
},
78+
expectedSummary: "External Location resources are only supported with direct deployment mode",
79+
expectedDetail: "External Location resources require direct deployment mode. " +
80+
"Please set the DATABRICKS_BUNDLE_ENGINE environment variable to 'direct' to use external_location resources.\n" +
81+
"Learn more at https://docs.databricks.com/dev-tools/bundles/direct",
82+
},
83+
{
84+
name: "vector_search_endpoints",
85+
bundle: &bundle.Bundle{
86+
Config: config.Root{
87+
Resources: config.Resources{
88+
VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{
89+
"my_endpoint": {},
90+
},
91+
},
92+
},
93+
},
94+
expectedSummary: "Vector Search Endpoint resources are only supported with direct deployment mode",
95+
expectedDetail: "Vector Search Endpoint resources require direct deployment mode. " +
96+
"Please set the DATABRICKS_BUNDLE_ENGINE environment variable to 'direct' to use vector_search_endpoint resources.\n" +
97+
"Learn more at https://docs.databricks.com/dev-tools/bundles/direct",
98+
},
99+
}
100+
101+
for _, tc := range cases {
102+
t.Run(tc.name, func(t *testing.T) {
103+
diags := bundle.Apply(t.Context(), tc.bundle, mutator.ValidateDirectOnlyResources(engine.EngineTerraform))
104+
if assert.Len(t, diags, 1) {
105+
assert.Equal(t, tc.expectedSummary, diags[0].Summary)
106+
assert.Equal(t, tc.expectedDetail, diags[0].Detail)
107+
}
108+
})
109+
}
110+
}

bundle/phases/approval.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package phases
2+
3+
import (
4+
"context"
5+
6+
"github.com/databricks/cli/bundle/deployplan"
7+
"github.com/databricks/cli/libs/cmdio"
8+
)
9+
10+
// approvalGroup describes one resource type that needs explicit user consent
11+
// before a destructive action is applied.
12+
type approvalGroup struct {
13+
group string // matches config.GetResourceTypeFromKey, e.g. "schemas"
14+
message string // banner shown above the action list
15+
skipChildren bool // skip actions where IsChildResource() is true
16+
}
17+
18+
// logApprovalGroups filters actions per group and prints non-empty groups.
19+
// If trailingNewline is true, an empty line is printed after each non-empty group.
20+
// Returns the total number of matched actions across all groups.
21+
func logApprovalGroups(ctx context.Context, actions []deployplan.Action, groups []approvalGroup, trailingNewline bool, types ...deployplan.ActionType) int {
22+
total := 0
23+
for _, g := range groups {
24+
matched := filterGroup(actions, g.group, types...)
25+
if len(matched) == 0 {
26+
continue
27+
}
28+
total += len(matched)
29+
cmdio.LogString(ctx, g.message)
30+
for _, a := range matched {
31+
if g.skipChildren && a.IsChildResource() {
32+
continue
33+
}
34+
cmdio.Log(ctx, a)
35+
}
36+
if trailingNewline {
37+
cmdio.LogString(ctx, "")
38+
}
39+
}
40+
return total
41+
}

bundle/phases/deploy.go

Lines changed: 15 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ import (
2626
"github.com/databricks/cli/libs/sync"
2727
)
2828

29+
var deployApprovalGroups = []approvalGroup{
30+
{group: "schemas", message: deleteOrRecreateSchemaMessage, skipChildren: true},
31+
{group: "pipelines", message: deleteOrRecreatePipelineMessage},
32+
{group: "volumes", message: deleteOrRecreateVolumeMessage},
33+
{group: "dashboards", message: deleteOrRecreateDashboardMessage},
34+
{group: "database_instances", message: deleteOrRecreateDatabaseInstanceMessage},
35+
{group: "synced_database_tables", message: deleteOrRecreateSyncedDatabaseTableMessage},
36+
{group: "postgres_projects", message: deleteOrRecreatePostgresProjectMessage},
37+
{group: "postgres_branches", message: deleteOrRecreatePostgresBranchMessage},
38+
}
39+
2940
func approvalForDeploy(ctx context.Context, b *bundle.Bundle, plan *deployplan.Plan) (bool, error) {
3041
actions := plan.GetActions()
3142

@@ -34,90 +45,12 @@ func approvalForDeploy(ctx context.Context, b *bundle.Bundle, plan *deployplan.P
3445
return false, err
3546
}
3647

37-
types := []deployplan.ActionType{deployplan.Recreate, deployplan.Delete}
38-
schemaActions := filterGroup(actions, "schemas", types...)
39-
pipelineActions := filterGroup(actions, "pipelines", types...)
40-
volumeActions := filterGroup(actions, "volumes", types...)
41-
dashboardActions := filterGroup(actions, "dashboards", types...)
42-
databaseInstanceActions := filterGroup(actions, "database_instances", types...)
43-
syncedDatabaseTableActions := filterGroup(actions, "synced_database_tables", types...)
44-
postgresProjectActions := filterGroup(actions, "postgres_projects", types...)
45-
postgresBranchActions := filterGroup(actions, "postgres_branches", types...)
46-
47-
// We don't need to display any prompts in this case.
48-
if len(schemaActions) == 0 && len(pipelineActions) == 0 && len(volumeActions) == 0 && len(dashboardActions) == 0 &&
49-
len(databaseInstanceActions) == 0 && len(syncedDatabaseTableActions) == 0 &&
50-
len(postgresProjectActions) == 0 && len(postgresBranchActions) == 0 {
48+
total := logApprovalGroups(ctx, actions, deployApprovalGroups, false, deployplan.Recreate, deployplan.Delete)
49+
if total == 0 {
50+
// No destructive actions in any tracked group: skip the prompt.
5151
return true, nil
5252
}
5353

54-
// One or more UC schema resources will be deleted or recreated.
55-
if len(schemaActions) != 0 {
56-
cmdio.LogString(ctx, deleteOrRecreateSchemaMessage)
57-
for _, action := range schemaActions {
58-
if action.IsChildResource() {
59-
continue
60-
}
61-
cmdio.Log(ctx, action)
62-
}
63-
}
64-
65-
// One or more pipelines is being recreated.
66-
if len(pipelineActions) != 0 {
67-
cmdio.LogString(ctx, deleteOrRecreatePipelineMessage)
68-
for _, action := range pipelineActions {
69-
cmdio.Log(ctx, action)
70-
}
71-
}
72-
73-
// One or more volumes is being recreated.
74-
if len(volumeActions) != 0 {
75-
cmdio.LogString(ctx, deleteOrRecreateVolumeMessage)
76-
for _, action := range volumeActions {
77-
cmdio.Log(ctx, action)
78-
}
79-
}
80-
81-
// One or more dashboards is being recreated.
82-
if len(dashboardActions) != 0 {
83-
cmdio.LogString(ctx, deleteOrRecreateDashboardMessage)
84-
for _, action := range dashboardActions {
85-
cmdio.Log(ctx, action)
86-
}
87-
}
88-
89-
// One or more database instances is being deleted or recreated.
90-
if len(databaseInstanceActions) != 0 {
91-
cmdio.LogString(ctx, deleteOrRecreateDatabaseInstanceMessage)
92-
for _, action := range databaseInstanceActions {
93-
cmdio.Log(ctx, action)
94-
}
95-
}
96-
97-
// One or more synced database tables is being deleted or recreated.
98-
if len(syncedDatabaseTableActions) != 0 {
99-
cmdio.LogString(ctx, deleteOrRecreateSyncedDatabaseTableMessage)
100-
for _, action := range syncedDatabaseTableActions {
101-
cmdio.Log(ctx, action)
102-
}
103-
}
104-
105-
// One or more Lakebase projects is being deleted or recreated.
106-
if len(postgresProjectActions) != 0 {
107-
cmdio.LogString(ctx, deleteOrRecreatePostgresProjectMessage)
108-
for _, action := range postgresProjectActions {
109-
cmdio.Log(ctx, action)
110-
}
111-
}
112-
113-
// One or more Lakebase branches is being deleted or recreated.
114-
if len(postgresBranchActions) != 0 {
115-
cmdio.LogString(ctx, deleteOrRecreatePostgresBranchMessage)
116-
for _, action := range postgresBranchActions {
117-
cmdio.Log(ctx, action)
118-
}
119-
}
120-
12154
if b.AutoApprove {
12255
return true, nil
12356
}
@@ -127,12 +60,7 @@ func approvalForDeploy(ctx context.Context, b *bundle.Bundle, plan *deployplan.P
12760
}
12861

12962
cmdio.LogString(ctx, "")
130-
approved, err := cmdio.AskYesOrNo(ctx, "Would you like to proceed?")
131-
if err != nil {
132-
return false, err
133-
}
134-
135-
return approved, nil
63+
return cmdio.AskYesOrNo(ctx, "Would you like to proceed?")
13664
}
13765

13866
func deployCore(ctx context.Context, b *bundle.Bundle, plan *deployplan.Plan, targetEngine engine.EngineType) {

0 commit comments

Comments
 (0)