From 2b057b94af41ed12209a95e339c3100fda3f5a85 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 18 Aug 2026 08:50:49 +0000 Subject: [PATCH] Reject dangling ${resources.*} refs at validate time validate --strict previously accepted references to undefined resources; deploy then failed with an invalid dependency. Fail early during initialize with the same "reference does not exist" error. --- .../bundles/dangling-resource-refs.md | 1 + .../dangling_resource_refs/databricks.yml | 10 +++ .../dangling_resource_refs/out.test.toml | 2 + .../dangling_resource_refs/output.txt | 17 ++++ .../validate/dangling_resource_refs/script | 4 + .../validate/dangling_resource_references.go | 80 +++++++++++++++++++ .../dangling_resource_references_test.go | 73 +++++++++++++++++ bundle/phases/initialize.go | 4 + 8 files changed, 191 insertions(+) create mode 100644 .nextchanges/bundles/dangling-resource-refs.md create mode 100644 acceptance/bundle/validate/dangling_resource_refs/databricks.yml create mode 100644 acceptance/bundle/validate/dangling_resource_refs/out.test.toml create mode 100644 acceptance/bundle/validate/dangling_resource_refs/output.txt create mode 100644 acceptance/bundle/validate/dangling_resource_refs/script create mode 100644 bundle/config/validate/dangling_resource_references.go create mode 100644 bundle/config/validate/dangling_resource_references_test.go diff --git a/.nextchanges/bundles/dangling-resource-refs.md b/.nextchanges/bundles/dangling-resource-refs.md new file mode 100644 index 00000000000..39a0fb4757d --- /dev/null +++ b/.nextchanges/bundles/dangling-resource-refs.md @@ -0,0 +1 @@ +Reject ${resources.*} references to resources that are not defined in the bundle. diff --git a/acceptance/bundle/validate/dangling_resource_refs/databricks.yml b/acceptance/bundle/validate/dangling_resource_refs/databricks.yml new file mode 100644 index 00000000000..db38350d4cd --- /dev/null +++ b/acceptance/bundle/validate/dangling_resource_refs/databricks.yml @@ -0,0 +1,10 @@ +bundle: + name: dangling-resource-refs + +resources: + jobs: + my_job: + name: my_job + permissions: + - level: CAN_VIEW + group_name: ${resources.jobs.does_not_exist.id} diff --git a/acceptance/bundle/validate/dangling_resource_refs/out.test.toml b/acceptance/bundle/validate/dangling_resource_refs/out.test.toml new file mode 100644 index 00000000000..98ea5040486 --- /dev/null +++ b/acceptance/bundle/validate/dangling_resource_refs/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/validate/dangling_resource_refs/output.txt b/acceptance/bundle/validate/dangling_resource_refs/output.txt new file mode 100644 index 00000000000..e1a8a9fbcb9 --- /dev/null +++ b/acceptance/bundle/validate/dangling_resource_refs/output.txt @@ -0,0 +1,17 @@ + +>>> [CLI] bundle validate --strict +Error: reference does not exist: ${resources.jobs.does_not_exist.id} + at resources.jobs.my_job.permissions[0].group_name + +Name: dangling-resource-refs +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/dangling-resource-refs/default + +Found 1 error + +>>> [CLI] bundle deploy +Error: reference does not exist: ${resources.jobs.does_not_exist.id} + at resources.jobs.my_job.permissions[0].group_name + diff --git a/acceptance/bundle/validate/dangling_resource_refs/script b/acceptance/bundle/validate/dangling_resource_refs/script new file mode 100644 index 00000000000..43ea7bbc502 --- /dev/null +++ b/acceptance/bundle/validate/dangling_resource_refs/script @@ -0,0 +1,4 @@ +# Without the fix, validate --strict succeeds and deploy fails with +# "invalid dependency" (direct) or an unresolved terraform reference. +musterr trace $CLI bundle validate --strict +musterr trace $CLI bundle deploy diff --git a/bundle/config/validate/dangling_resource_references.go b/bundle/config/validate/dangling_resource_references.go new file mode 100644 index 00000000000..4812d97b2e9 --- /dev/null +++ b/bundle/config/validate/dangling_resource_references.go @@ -0,0 +1,80 @@ +package validate + +import ( + "context" + "fmt" + "strings" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/libs/diag" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/dyn/dynvar" +) + +type danglingResourceReferences struct{} + +// DanglingResourceReferences rejects ${resources.*} references whose target +// resource is not defined in the bundle. Deploy fails later with an invalid +// dependency (direct) or an unresolved reference (terraform); catch it here. +func DanglingResourceReferences() bundle.Mutator { + return &danglingResourceReferences{} +} + +func (m *danglingResourceReferences) Name() string { + return "validate:dangling_resource_references" +} + +func (m *danglingResourceReferences) Apply(_ context.Context, b *bundle.Bundle) diag.Diagnostics { + var diags diag.Diagnostics + + _ = dyn.WalkReadOnly(b.Config.Value(), func(path dyn.Path, v dyn.Value) error { + ref, ok := dynvar.NewRef(v) + if !ok { + return nil + } + for _, r := range ref.References() { + if !strings.HasPrefix(r, "resources.") { + continue + } + if d := checkDanglingResourceReference(b, r, path, v.Locations()); d != nil { + diags = append(diags, *d) + } + } + return nil + }) + + return diags +} + +// checkDanglingResourceReference checks a reference like +// "resources.jobs.missing.id" and returns a diagnostic when the resource +// (resources.jobs.missing) is not defined. +func checkDanglingResourceReference(b *bundle.Bundle, ref string, path dyn.Path, locs []dyn.Location) *diag.Diagnostic { + p, err := dyn.NewPathFromString(ref) + // resources..[....] + if err != nil || len(p) < 3 || p[0].Key() != "resources" { + return nil + } + + // Identity is resources..; trailing fields (.id, .permissions, …) + // are resolved at deploy time and are not required to exist in config. + resourceKey := p[:3] + v, err := dyn.GetByPath(b.Config.Value(), resourceKey) + if err == nil && v.Kind() != dyn.KindInvalid && v.Kind() != dyn.KindNil { + return nil + } + + d := &diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("reference does not exist: ${%s}", ref), + Paths: []dyn.Path{path}, + } + // ApplyBundlePermissions rewrites permission entries without locations; skip + // empty ones so we don't print "in :0:0". + for _, loc := range locs { + if loc.File != "" { + d.Locations = append(d.Locations, loc) + } + } + return d +} diff --git a/bundle/config/validate/dangling_resource_references_test.go b/bundle/config/validate/dangling_resource_references_test.go new file mode 100644 index 00000000000..703986ce895 --- /dev/null +++ b/bundle/config/validate/dangling_resource_references_test.go @@ -0,0 +1,73 @@ +package validate + +import ( + "testing" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/config" + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/bundle/internal/bundletest" + "github.com/databricks/cli/libs/diag" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/databricks-sdk-go/service/jobs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDanglingResourceReferences_MissingResource(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + Jobs: map[string]*resources.Job{ + "my_job": {JobSettings: jobs.JobSettings{Name: "my_job"}}, + }, + }, + }, + } + bundletest.Mutate(t, b, func(v dyn.Value) (dyn.Value, error) { + return dyn.Set(v, "resources.jobs.my_job.name", dyn.V("${resources.jobs.does_not_exist.id}")) + }) + + diags := DanglingResourceReferences().Apply(t.Context(), b) + require.Len(t, diags, 1) + assert.Equal(t, diag.Error, diags[0].Severity) + assert.Equal(t, "reference does not exist: ${resources.jobs.does_not_exist.id}", diags[0].Summary) +} + +func TestDanglingResourceReferences_ExistingResource(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + Jobs: map[string]*resources.Job{ + "src": {JobSettings: jobs.JobSettings{Name: "src"}}, + "dst": {JobSettings: jobs.JobSettings{Name: "dst"}}, + }, + }, + }, + } + bundletest.Mutate(t, b, func(v dyn.Value) (dyn.Value, error) { + return dyn.Set(v, "resources.jobs.dst.name", dyn.V("${resources.jobs.src.id}")) + }) + + diags := DanglingResourceReferences().Apply(t.Context(), b) + assert.Empty(t, diags) +} + +func TestDanglingResourceReferences_UnknownType(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + Jobs: map[string]*resources.Job{ + "my_job": {JobSettings: jobs.JobSettings{Name: "my_job"}}, + }, + }, + }, + } + bundletest.Mutate(t, b, func(v dyn.Value) (dyn.Value, error) { + return dyn.Set(v, "resources.jobs.my_job.name", dyn.V("${resources.unknown.foo.id}")) + }) + + diags := DanglingResourceReferences().Apply(t.Context(), b) + require.Len(t, diags, 1) + assert.Contains(t, diags[0].Summary, "${resources.unknown.foo.id}") +} diff --git a/bundle/phases/initialize.go b/bundle/phases/initialize.go index a130da820d6..d72a3d0db69 100644 --- a/bundle/phases/initialize.go +++ b/bundle/phases/initialize.go @@ -188,6 +188,10 @@ func Initialize(ctx context.Context, b *bundle.Bundle) { // Reject configured job_runs.idempotency_token; the CLI sets it on run-now. validate.ValidateJobRunIdempotencyToken(), + // Reads (dynamic): * (strings) (searches for ${resources.*} references) + // Errors when a cross-resource reference targets a resource that is not defined. + validate.DanglingResourceReferences(), + // Reads (dynamic): * (strings) (searches for ${resources.*} references) // Warns (TF engine) or errors (direct engine) when a cross-resource reference // points to a Terraform-only field with no DABs equivalent.