Skip to content

Commit de6d36c

Browse files
committed
Merge remote-tracking branch 'origin/main' into bump-sdk
# Conflicts: # libs/structs/structwalk/walktype_test.go
2 parents 524d811 + cb73bba commit de6d36c

19 files changed

Lines changed: 513 additions & 26 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66

77
### CLI
88

9+
* `ssh connect` now accepts a `--base-environment` flag to run a serverless session on a custom base environment. It takes an `env.yaml` path, a `workspace-base-environments/...` resource ID, or a base environment display name, and is rejected together with `--environment-version` or `--cluster` ([#5706](https://github.com/databricks/cli/pull/5706)).
910
* `databricks aitools install` is now plugin-first: it installs the Databricks plugin through each agent's own CLI (Claude Code, Codex, GitHub Copilot) instead of copying raw skill files. Agents without a plugin (OpenCode, Antigravity) still get skill files, and Cursor prints the `/add-plugin databricks` step. Use `--skills-only` to force raw skill files for every agent, or `--path <dir>` to write skills to a directory ([#5738](https://github.com/databricks/cli/pull/5738)).
11+
* `databricks labs list` now only shows projects that can be installed ([#5560](https://github.com/databricks/cli/pull/5560)).
1012

1113
### Bundles
1214

1315
* direct: Fixed persistent drift on `model_serving_endpoints` caused by the `traffic_config` field ([#5708](https://github.com/databricks/cli/pull/5708)).
1416
* direct: Fix spurious update when `apply_policy_default_values: true` is set on job task, for-each-task, or job cluster new_cluster ([#5731](https://github.com/databricks/cli/pull/5731)). Also fix spurious updates for for-each-task clusters due to missing backend defaults for `data_security_mode`, `node_type_id`, `driver_node_type_id`, `driver_instance_pool_id`, `enable_elastic_disk`, and `enable_local_disk_encryption`.
1517
* direct: Cluster resize now falls back to regular update if resize fails due to `INVALID_STATE` ([#5716](https://github.com/databricks/cli/pull/5716)).
18+
* Fixed `bundle deployment migrate` failing on `model_serving_endpoints`/`database_instances` with permissions (regression since v1.5.0) ([#5775](https://github.com/databricks/cli/pull/5775)).
1619

1720
### Dependency updates
1821

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
bundle:
2+
name: test-bundle-$UNIQUE_NAME
3+
4+
resources:
5+
database_instances:
6+
foo:
7+
name: test-db-instance-$UNIQUE_NAME
8+
capacity: CU_1
9+
permissions:
10+
- level: CAN_USE
11+
group_name: users
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
bundle:
2+
name: test-bundle-$UNIQUE_NAME
3+
4+
resources:
5+
model_serving_endpoints:
6+
foo:
7+
name: test-endpoint-$UNIQUE_NAME
8+
permissions:
9+
- level: CAN_VIEW
10+
group_name: users

acceptance/bundle/invariant/continue_293/out.test.toml

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

acceptance/bundle/invariant/migrate/out.test.toml

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

acceptance/bundle/invariant/no_drift/out.test.toml

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

acceptance/bundle/invariant/test.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ EnvMatrix.INPUT_CONFIG = [
3232
"job_apply_policy_default_values_for_each_task.yml.tmpl",
3333
"database_catalog.yml.tmpl",
3434
"database_instance.yml.tmpl",
35+
"database_instance_with_permissions.yml.tmpl",
3536
"experiment.yml.tmpl",
3637
"external_location.yml.tmpl",
3738
"genie_space.yml.tmpl",
@@ -47,6 +48,7 @@ EnvMatrix.INPUT_CONFIG = [
4748
"model.yml.tmpl",
4849
"model_with_permissions.yml.tmpl",
4950
"model_serving_endpoint.yml.tmpl",
51+
"model_serving_endpoint_with_permissions.yml.tmpl",
5052
"pipeline.yml.tmpl",
5153
"pipeline_apply_policy_default_values.yml.tmpl",
5254
"pipeline_config_dots.yml.tmpl",

acceptance/invariant_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package acceptance_test
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"slices"
7+
"strings"
8+
"testing"
9+
10+
"github.com/databricks/cli/bundle/config"
11+
"github.com/databricks/cli/libs/dyn"
12+
"github.com/databricks/cli/libs/dyn/yamlloader"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
const invariantConfigsDir = "bundle/invariant/configs"
18+
19+
// LackingInvariantTest lists keys from config.ResourcesTypes that knowingly lack
20+
// a covering config in invariantConfigsDir. Keys match the ResourcesTypes
21+
// form: "<group>" for the resource itself, "<group>.permissions" / "<group>.grants"
22+
// for permissions/grants coverage. Add a config and remove the entry to close a gap;
23+
// the test fails if an entry here is actually covered, so the list only shrinks.
24+
var LackingInvariantTest = map[string]bool{
25+
"quality_monitors": true,
26+
27+
"alerts.permissions": true,
28+
"apps.permissions": true,
29+
"clusters.permissions": true,
30+
"dashboards.permissions": true,
31+
"experiments.permissions": true,
32+
"pipelines.permissions": true,
33+
"postgres_projects.permissions": true,
34+
"sql_warehouses.permissions": true,
35+
36+
"catalogs.grants": true,
37+
"external_locations.grants": true,
38+
"registered_models.grants": true,
39+
"vector_search_indexes.grants": true,
40+
"volumes.grants": true,
41+
}
42+
43+
// TestInvariantConfigsCoverage ensures that the invariant test configs in
44+
// bundle/invariant/configs cover every bundle resource type, and that resource
45+
// types supporting permissions or grants have at least one config exercising them.
46+
//
47+
// config.ResourcesTypes is the source of truth: it maps each resource group
48+
// (e.g. "jobs") to its Go type and, where the resource struct has a Permissions
49+
// or Grants field, adds derived keys "<group>.permissions" and "<group>.grants".
50+
func TestInvariantConfigsCoverage(t *testing.T) {
51+
present, withPermissions, withGrants := scanInvariantConfigs(t)
52+
53+
keys := make([]string, 0, len(config.ResourcesTypes))
54+
for key := range config.ResourcesTypes {
55+
keys = append(keys, key)
56+
}
57+
slices.Sort(keys)
58+
59+
for _, key := range keys {
60+
var covered bool
61+
var hint string
62+
switch {
63+
case strings.HasSuffix(key, ".permissions"):
64+
group := strings.TrimSuffix(key, ".permissions")
65+
covered = withPermissions[group]
66+
hint = "attaches permissions to a " + group + " resource"
67+
case strings.HasSuffix(key, ".grants"):
68+
group := strings.TrimSuffix(key, ".grants")
69+
covered = withGrants[group]
70+
hint = "attaches grants to a " + group + " resource"
71+
default:
72+
covered = present[key]
73+
hint = "defines a " + key + " resource"
74+
}
75+
76+
if LackingInvariantTest[key] {
77+
assert.False(t, covered,
78+
"%q is covered by a config in %s; remove it from LackingInvariantTest", key, invariantConfigsDir)
79+
continue
80+
}
81+
assert.True(t, covered,
82+
"no config in %s %s; add one or allowlist %q", invariantConfigsDir, hint, key)
83+
}
84+
}
85+
86+
// scanInvariantConfigs parses every config in the invariant configs directory and
87+
// returns the set of resource groups present, the groups with at least one resource
88+
// carrying permissions, and the groups with at least one resource carrying grants.
89+
func scanInvariantConfigs(t *testing.T) (present, withPermissions, withGrants map[string]bool) {
90+
present = map[string]bool{}
91+
withPermissions = map[string]bool{}
92+
withGrants = map[string]bool{}
93+
94+
entries, err := os.ReadDir(invariantConfigsDir)
95+
require.NoError(t, err)
96+
97+
for _, entry := range entries {
98+
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yml.tmpl") {
99+
continue
100+
}
101+
path := filepath.Join(invariantConfigsDir, entry.Name())
102+
contents, err := os.ReadFile(path)
103+
require.NoError(t, err)
104+
105+
v, err := yamlloader.LoadYAML(path, strings.NewReader(string(contents)))
106+
require.NoError(t, err, "failed to parse %s", path)
107+
108+
resources := v.Get("resources")
109+
if resources.Kind() != dyn.KindMap {
110+
// Some configs (e.g. PyDABs) declare resources outside of YAML.
111+
continue
112+
}
113+
114+
for _, group := range resources.MustMap().Pairs() {
115+
groupName := group.Key.MustString()
116+
present[groupName] = true
117+
118+
if group.Value.Kind() != dyn.KindMap {
119+
continue
120+
}
121+
for _, resource := range group.Value.MustMap().Pairs() {
122+
cfg := resource.Value
123+
if cfg.Kind() != dyn.KindMap {
124+
continue
125+
}
126+
if cfg.Get("permissions").Kind() != dyn.KindInvalid {
127+
withPermissions[groupName] = true
128+
}
129+
if cfg.Get("grants").Kind() != dyn.KindInvalid {
130+
withGrants[groupName] = true
131+
}
132+
}
133+
}
134+
}
135+
136+
return present, withPermissions, withGrants
137+
}

bundle/migrate/build_state.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,17 @@ func BuildStateFromTF(
161161
return strings.Compare(a.Label, b.Label)
162162
})
163163

164+
// For a .permissions node, id (tfIDs[node]) is the databricks_permissions resource's
165+
// own ID, which is exactly the object_id (e.g. "/serving-endpoints/<id>"). Use it
166+
// directly: re-deriving it from the parent's TF state fails for types whose id field
167+
// is absent there (model_serving_endpoints, database_instances).
168+
if _, ok := sv.Refs["object_id"]; ok {
169+
if err := structaccess.Set(sv.Value, structpath.NewStringKey(nil, "object_id"), id); err != nil {
170+
return fmt.Errorf("%s: setting object_id: %w", node, err)
171+
}
172+
delete(sv.Refs, "object_id")
173+
}
174+
164175
// Resolve each reference using TF state.
165176
// node format: "resources.<group>.<name>" or "resources.<group>.<name>.permissions"
166177
parts := strings.SplitN(node, ".", 4)

bundle/migrate/build_state_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,58 @@ resources:
195195
// Check raw bytes because json.Unmarshal would silently re-truncate when reading back.
196196
wantStateRaw: `"job_id": 9007199254740993`,
197197
},
198+
{
199+
// model_serving_endpoints permissions reference object_id via the parent's
200+
// "endpoint_id", which is absent from the parent's TF state attributes. The
201+
// object_id must come from the permissions node's own TF state ID instead.
202+
name: "model_serving_endpoints permissions object_id from node ID",
203+
yaml: `
204+
resources:
205+
model_serving_endpoints:
206+
foo:
207+
name: my-endpoint
208+
permissions:
209+
- level: CAN_VIEW
210+
group_name: users
211+
`,
212+
tfAttrs: migrate.TFStateAttrs{
213+
"databricks_model_serving": {"foo": json.RawMessage(`{"id": "my-endpoint", "name": "my-endpoint"}`)},
214+
},
215+
tfIDs: map[string]string{
216+
"resources.model_serving_endpoints.foo": "my-endpoint",
217+
"resources.model_serving_endpoints.foo.permissions": "/serving-endpoints/abc123",
218+
},
219+
wantKey: "resources.model_serving_endpoints.foo.permissions",
220+
wantID: "/serving-endpoints/abc123",
221+
wantState: map[string]any{"object_id": "/serving-endpoints/abc123"},
222+
wantDeps: []deployplan.DependsOnEntry{{Node: "resources.model_serving_endpoints.foo"}},
223+
},
224+
{
225+
// database_instances permissions reference object_id via the parent's "id",
226+
// which is absent from the parent's TF state attributes (it is stored under
227+
// "name"). The object_id must come from the permissions node's own TF state ID.
228+
name: "database_instances permissions object_id from node ID",
229+
yaml: `
230+
resources:
231+
database_instances:
232+
foo:
233+
name: my-db-instance
234+
permissions:
235+
- level: CAN_USE
236+
group_name: users
237+
`,
238+
tfAttrs: migrate.TFStateAttrs{
239+
"databricks_database_instance": {"foo": json.RawMessage(`{"name": "my-db-instance"}`)},
240+
},
241+
tfIDs: map[string]string{
242+
"resources.database_instances.foo": "my-db-instance",
243+
"resources.database_instances.foo.permissions": "/database-instances/my-db-instance",
244+
},
245+
wantKey: "resources.database_instances.foo.permissions",
246+
wantID: "/database-instances/my-db-instance",
247+
wantState: map[string]any{"object_id": "/database-instances/my-db-instance"},
248+
wantDeps: []deployplan.DependsOnEntry{{Node: "resources.database_instances.foo"}},
249+
},
198250
{
199251
name: "dashboard etag stored from TF attributes",
200252
yaml: `

0 commit comments

Comments
 (0)