Skip to content
Draft
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/dangling-resource-refs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Reject ${resources.*} references to resources that are not defined in the bundle.
10 changes: 10 additions & 0 deletions acceptance/bundle/validate/dangling_resource_refs/databricks.yml
Original file line number Diff line number Diff line change
@@ -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}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions acceptance/bundle/validate/dangling_resource_refs/output.txt
Original file line number Diff line number Diff line change
@@ -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

4 changes: 4 additions & 0 deletions acceptance/bundle/validate/dangling_resource_refs/script
Original file line number Diff line number Diff line change
@@ -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
80 changes: 80 additions & 0 deletions bundle/config/validate/dangling_resource_references.go
Original file line number Diff line number Diff line change
@@ -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.<group>.<name>[.<field>...]
if err != nil || len(p) < 3 || p[0].Key() != "resources" {
return nil
}

// Identity is resources.<group>.<name>; 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)

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.

This checks dynamic tree but references can be to things that are not in the config (remote references in direct and terraform-specific fields).

So it's too strict.

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.

I think once we remove terraform we can think about validating these but for now not worth it.

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
}
73 changes: 73 additions & 0 deletions bundle/config/validate/dangling_resource_references_test.go
Original file line number Diff line number Diff line change
@@ -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}")
}
4 changes: 4 additions & 0 deletions bundle/phases/initialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading