diff --git a/acceptance/bundle/resource_deps/implicit_deps_vector_search_index/databricks.yml b/acceptance/bundle/resource_deps/implicit_deps_vector_search_index/databricks.yml index 97c420f1a5..77114e26e3 100644 --- a/acceptance/bundle/resource_deps/implicit_deps_vector_search_index/databricks.yml +++ b/acceptance/bundle/resource_deps/implicit_deps_vector_search_index/databricks.yml @@ -2,7 +2,9 @@ bundle: name: test-bundle # Tests implicit dependency detection for vector search indexes: -# - name (format "catalog.schema.index") should resolve the catalog and schema parts +# - name (format "catalog.schema.index") resolves the catalog and schema parts (my_index) +# - a name that already contains a ${resources...} reference is left untouched; a mix of +# references and literals is not supported (mixed_ref_index, full_ref_index) resources: catalogs: my_catalog: @@ -19,6 +21,20 @@ resources: index_type: DIRECT_ACCESS direct_access_index_spec: schema_json: '{"id":"integer","vector":"array"}' + mixed_ref_index: + name: ${resources.catalogs.my_catalog.name}.myschema.myindex + endpoint_name: my-endpoint + primary_key: id + index_type: DIRECT_ACCESS + direct_access_index_spec: + schema_json: '{"id":"integer","vector":"array"}' + full_ref_index: + name: ${resources.catalogs.my_catalog.name}.${resources.schemas.my_schema.name}.myindex + endpoint_name: my-endpoint + primary_key: id + index_type: DIRECT_ACCESS + direct_access_index_spec: + schema_json: '{"id":"integer","vector":"array"}' targets: dev: diff --git a/acceptance/bundle/resource_deps/implicit_deps_vector_search_index/output.txt b/acceptance/bundle/resource_deps/implicit_deps_vector_search_index/output.txt index 862b7e25ea..3cdf0b6522 100644 --- a/acceptance/bundle/resource_deps/implicit_deps_vector_search_index/output.txt +++ b/acceptance/bundle/resource_deps/implicit_deps_vector_search_index/output.txt @@ -13,6 +13,24 @@ } }, "vector_search_indexes": { + "full_ref_index": { + "direct_access_index_spec": { + "schema_json": "{\"id\":\"integer\",\"vector\":\"array\"}" + }, + "endpoint_name": "my-endpoint", + "index_type": "DIRECT_ACCESS", + "name": "${resources.catalogs.my_catalog.name}.${resources.schemas.my_schema.name}.myindex", + "primary_key": "id" + }, + "mixed_ref_index": { + "direct_access_index_spec": { + "schema_json": "{\"id\":\"integer\",\"vector\":\"array\"}" + }, + "endpoint_name": "my-endpoint", + "index_type": "DIRECT_ACCESS", + "name": "${resources.catalogs.my_catalog.name}.myschema.myindex", + "primary_key": "id" + }, "my_index": { "direct_access_index_spec": { "schema_json": "{\"id\":\"integer\",\"vector\":\"array\"}" diff --git a/bundle/config/mutator/resourcemutator/capture_uc_dependencies.go b/bundle/config/mutator/resourcemutator/capture_uc_dependencies.go index 61b7467df9..88b3853925 100644 --- a/bundle/config/mutator/resourcemutator/capture_uc_dependencies.go +++ b/bundle/config/mutator/resourcemutator/capture_uc_dependencies.go @@ -8,6 +8,7 @@ import ( "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/libs/diag" + "github.com/databricks/cli/libs/dyn/dynvar" ) type captureUCDependencies struct{} @@ -88,6 +89,18 @@ func resolveCatalog(b *bundle.Bundle, catalogName string) string { return catalogName } +// splitUCName splits a compound UC identifier into exactly n dot-separated +// components, returning false if it has a different number of components. Callers +// skip names containing a ${...} reference beforehand, so this is a plain split: +// a reference would push the component count past n and be rejected here anyway. +func splitUCName(name string, n int) ([]string, bool) { + parts := strings.Split(name, ".") + if len(parts) != n { + return nil, false + } + return parts, true +} + // resolveParent rewrites a `schemas/{catalog}.{schema}` parent reference so that // a catalog or schema defined in the same bundle becomes an explicit deploy-time // dependency. AI Gateway securables address their parent schema with this @@ -97,8 +110,8 @@ func resolveParent(b *bundle.Bundle, parent string) string { if !ok { return parent } - parts := strings.SplitN(rest, ".", 2) - if len(parts) != 2 { + parts, ok := splitUCName(rest, 2) + if !ok { return parent } catalogName, schemaName := parts[0], parts[1] @@ -144,9 +157,14 @@ func (m *captureUCDependencies) Apply(ctx context.Context, b *bundle.Bundle) dia if qm == nil || qm.OutputSchemaName == "" { continue } + // A name that already contains a reference is left as is: we only rewrite a + // fully literal name and do not support a mix of references and literals. + if dynvar.ContainsVariableReference(qm.OutputSchemaName) { + continue + } // OutputSchemaName is a compound "catalog.schema" string. - parts := strings.SplitN(qm.OutputSchemaName, ".", 2) - if len(parts) != 2 { + parts, ok := splitUCName(qm.OutputSchemaName, 2) + if !ok { continue } catalogName, schemaName := parts[0], parts[1] @@ -159,9 +177,14 @@ func (m *captureUCDependencies) Apply(ctx context.Context, b *bundle.Bundle) dia if idx == nil { continue } + // A name that already contains a reference is left as is; a mix of + // references and literals is not supported. + if dynvar.ContainsVariableReference(idx.Name) { + continue + } // Name is a three-part "catalog.schema.index" UC identifier. - parts := strings.SplitN(idx.Name, ".", 3) - if len(parts) != 3 { + parts, ok := splitUCName(idx.Name, 3) + if !ok { continue } catalogName, schemaName := parts[0], parts[1] @@ -187,6 +210,11 @@ func (m *captureUCDependencies) Apply(ctx context.Context, b *bundle.Bundle) dia if ms == nil { continue } + // A parent that already contains a reference is left as is; a mix of + // references and literals is not supported. + if dynvar.ContainsVariableReference(ms.Parent) { + continue + } ms.Parent = resolveParent(b, ms.Parent) } diff --git a/bundle/config/mutator/resourcemutator/capture_uc_dependencies_test.go b/bundle/config/mutator/resourcemutator/capture_uc_dependencies_test.go index db4f888c7c..481df5d214 100644 --- a/bundle/config/mutator/resourcemutator/capture_uc_dependencies_test.go +++ b/bundle/config/mutator/resourcemutator/capture_uc_dependencies_test.go @@ -347,3 +347,88 @@ func TestCaptureUCDependenciesNilResources(t *testing.T) { d := bundle.Apply(t.Context(), b, CaptureUCDependencies()) require.Nil(t, d) } + +func TestSplitUCName(t *testing.T) { + tests := []struct { + name string + input string + n int + expected []string + ok bool + }{ + {"three_part", "catalog.schema.index", 3, []string{"catalog", "schema", "index"}, true}, + {"two_part", "catalog.schema", 2, []string{"catalog", "schema"}, true}, + {"too_many_components", "a.b.c.d", 3, nil, false}, + {"too_few_components", "a.b", 3, nil, false}, + {"single_component", "a", 3, nil, false}, + {"empty", "", 3, nil, false}, + {"reference_over_counts", "${resources.catalogs.c.name}.schema.index", 3, nil, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + parts, ok := splitUCName(tc.input, tc.n) + assert.Equal(t, tc.ok, ok) + assert.Equal(t, tc.expected, parts) + }) + } +} + +// A vector search index name that already contains a ${resources...} reference is left +// untouched: a mix of references and literals is not supported. Only a fully literal name +// with exactly three components has its catalog and schema captured. +func TestCaptureUCDependenciesVectorSearchIndexReferences(t *testing.T) { + newBundle := func(indexName string) *bundle.Bundle { + return &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + Catalogs: map[string]*resources.Catalog{ + "dev_catalog": {CreateCatalog: catalog.CreateCatalog{Name: "catalog1"}}, + }, + Schemas: map[string]*resources.Schema{ + "my_schema": {CreateSchema: catalog.CreateSchema{CatalogName: "catalog1", Name: "myschema"}}, + }, + VectorSearchIndexes: map[string]*resources.VectorSearchIndex{ + "idx": {CreateVectorIndexRequest: vectorsearch.CreateVectorIndexRequest{Name: indexName}}, + }, + }, + }, + } + } + + tests := []struct { + name string + input string + expected string + }{ + { + "all_literal_captured", + "catalog1.myschema.myindex", + "${resources.catalogs.dev_catalog.name}.${resources.schemas.my_schema.name}.myindex", + }, + { + "catalog_reference_with_literal_schema_unsupported", + "${resources.catalogs.dev_catalog.name}.myschema.myindex", + "${resources.catalogs.dev_catalog.name}.myschema.myindex", + }, + { + "fully_referenced_unchanged", + "${resources.catalogs.dev_catalog.name}.${resources.schemas.my_schema.name}.myindex", + "${resources.catalogs.dev_catalog.name}.${resources.schemas.my_schema.name}.myindex", + }, + { + "more_than_three_components_skipped", + "catalog1.myschema.my.index", + "catalog1.myschema.my.index", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + b := newBundle(tc.input) + d := bundle.Apply(t.Context(), b, CaptureUCDependencies()) + require.Nil(t, d) + assert.Equal(t, tc.expected, b.Config.Resources.VectorSearchIndexes["idx"].Name) + }) + } +}