Skip to content
Merged
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
36 changes: 5 additions & 31 deletions bundle/direct/bundle_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"strings"

"github.com/databricks/cli/bundle/config"
"github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/cli/bundle/deployplan"
"github.com/databricks/cli/bundle/direct/dresources"
"github.com/databricks/cli/bundle/direct/dstate"
Expand Down Expand Up @@ -976,37 +975,12 @@ func (b *DeploymentBundle) makePlan(ctx context.Context, configRoot *config.Root
return nil, fmt.Errorf("%s: %w", prefix, err)
}

baseRefs := map[string]string{}

if strings.HasSuffix(node, ".permissions") {
var inputConfigStructVar *structvar.StructVar
var err error

if strings.HasPrefix(node, "resources.secret_scopes.") {
typedConfig, ok := inputConfig.(*[]resources.SecretScopePermission)
if !ok {
return nil, fmt.Errorf("%s: expected *[]resources.SecretScopePermission, got %T", prefix, inputConfig)
}
inputConfigStructVar, err = dresources.PrepareSecretScopeAclsInputConfig(*typedConfig, node)
} else {
inputConfigStructVar, err = dresources.PreparePermissionsInputConfig(inputConfig, node)
}

if err != nil {
return nil, err
}
inputConfig = inputConfigStructVar.Value
baseRefs = inputConfigStructVar.Refs
} else if strings.HasSuffix(node, ".grants") {
inputConfigStructVar, err := dresources.PrepareGrantsInputConfig(inputConfig, node)
if err != nil {
return nil, err
}
inputConfig = inputConfigStructVar.Value
baseRefs = inputConfigStructVar.Refs
inputStructVar, err := adapter.PrepareInputConfig(inputConfig, node)
if err != nil {
return nil, fmt.Errorf("%s: %w", prefix, err)
}

newStateConfig, err := adapter.PrepareState(inputConfig)
newStateConfig, err := adapter.PrepareState(inputStructVar.Value)
if err != nil {
return nil, fmt.Errorf("%s: %w", prefix, err)
}
Expand All @@ -1032,7 +1006,7 @@ func (b *DeploymentBundle) makePlan(ctx context.Context, configRoot *config.Root
return nil, fmt.Errorf("failed to read references from config for %s: %w", node, err)
}

maps.Copy(refs, baseRefs)
maps.Copy(refs, inputStructVar.Refs)

var dependsOn []deployplan.DependsOnEntry
for _, reference := range refs {
Expand Down
11 changes: 11 additions & 0 deletions bundle/direct/dresources/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ If a desired state describes no resource at all (e.g. an empty grants list), imp

The planner only consults it for nodes without a state entry: once state exists the node stays in the plan, so emptying it still plans an update, and the update is what removes the entry.

## Config that is not the state: Configure and PrepareInputConfig

Most resources take their bundle config straight into `PrepareState`. Sub-resources (permissions, grants) cannot: their state is assembled from the parent's config section plus a reference to the parent's ID, which has no source in the config.

Two optional hooks cover this, and nothing outside the resource needs to know a node is a sub-resource:

- `Configure(resourceType string) error` is called once per registered type at adapter construction, with the key from `SupportedResources` (e.g. `"jobs.permissions"`). Resolve type-level lookups here — one instance exists per type, so the result can be stored on the receiver, and an unsupported type fails at init rather than at plan time.
- `PrepareInputConfig(inputConfig, resourceKey string) (*structvar.StructVar, error)` converts the node's config into the value handed to `PrepareState`, together with the references needed to complete it. `resourceKey` is the full node (`resources.jobs.foo.permissions`), so the parent node is available for building `${...}` references.

Declare `inputConfig` with its concrete type when every parent shares one (grants: `*[]catalog.PrivilegeAssignment`); use `any` only where the config types genuinely differ per parent, as with permissions.

## State backward compatibility

The state struct is serialized to JSON and persisted between deploys. Backward incompatible changes will result in a drift, which depending
Expand Down
56 changes: 56 additions & 0 deletions bundle/direct/dresources/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/databricks/cli/bundle/deployplan"
"github.com/databricks/cli/libs/calladapt"
"github.com/databricks/cli/libs/structs/structpath"
"github.com/databricks/cli/libs/structs/structvar"
"github.com/databricks/databricks-sdk-go"
)

Expand All @@ -28,6 +29,21 @@ type IResource interface {
// Single instance is reused across all instances, so it must not store any resource-specific state.
New(client *databricks.WorkspaceClient) any

// [Optional] Configure passes the resource type this instance is registered under in SupportedResources
// (e.g. "jobs.permissions"). One instance is created per resource type, so type-level lookups belong here
// rather than in per-node methods, and an unsupported type fails at init instead of at plan time.
// Example: func (r *ResourcePermissions) Configure(resourceType string) error
Configure(resourceType string) error

// [Optional] PrepareInputConfig converts the bundle config for a node into the value passed to PrepareState,
// plus references that complete it but have no source in the config. resourceKey is the full node,
// e.g. "resources.jobs.foo.permissions". Sub-resources use it to reference their parent's id.
// Resources that don't implement it receive their config unchanged and contribute no references.
// Like the other resource methods, inputConfig is never nil, so it may be declared as a concrete
// pointer and dereferenced: nodes are discovered from the config tree, so the key always exists.
// Example: func (r *ResourceGrants) PrepareInputConfig(inputConfig *[]catalog.PrivilegeAssignment, resourceKey string) (*structvar.StructVar, error)
PrepareInputConfig(inputConfig any, resourceKey string) (*structvar.StructVar, error)

// PrepareState converts resource's config as defined by bundle schema to the concrete type used by create/update and persisted in the state.
// Example: func (*ResourceJob) PrepareState(input *resources.Job) *jobs.JobSettings
PrepareState(input any) any
Expand Down Expand Up @@ -109,6 +125,7 @@ type Adapter struct {
doCreate *calladapt.BoundCaller

// Optional:
prepareInputConfig *calladapt.BoundCaller
isEmptyState *calladapt.BoundCaller
doUpdate *calladapt.BoundCaller
doUpdateWithID *calladapt.BoundCaller
Expand Down Expand Up @@ -137,12 +154,19 @@ func NewAdapter(typedNil any, resourceType string, client *databricks.WorkspaceC
return nil, fmt.Errorf("internal error: New returned %d values, expected 1", len(outs))
}
impl := outs[0]

err = configureImpl(impl, resourceType)
if err != nil {
return nil, err
}

adapter := &Adapter{
prepareState: nil,
remapState: nil,
doRefresh: nil,
doDelete: nil,
doCreate: nil,
prepareInputConfig: nil,
isEmptyState: nil,
doUpdate: nil,
doUpdateWithID: nil,
Expand Down Expand Up @@ -170,6 +194,19 @@ func NewAdapter(typedNil any, resourceType string, client *databricks.WorkspaceC
return adapter, nil
}

// configureImpl calls the resource's Configure method, if it has one.
func configureImpl(impl any, resourceType string) error {
call, err := calladapt.PrepareCall(impl, reflect.TypeFor[IResource](), "Configure")
if err != nil {
return err
}
if call == nil {
return nil
}
_, err = call.Call(resourceType)
return err
}

// loadKeyedSlices validates and calls KeyedSlices method, returning the resulting map.
func loadKeyedSlices(call *calladapt.BoundCaller) (map[string]any, error) {
outs, err := call.Call()
Expand Down Expand Up @@ -213,6 +250,11 @@ func (a *Adapter) initMethods(resource any) error {

// Optional methods with varying signatures:

a.prepareInputConfig, err = calladapt.PrepareCall(resource, reflect.TypeFor[IResource](), "PrepareInputConfig")
if err != nil {
return err
}

a.isEmptyState, err = calladapt.PrepareCall(resource, reflect.TypeFor[IResource](), "IsEmptyState")
if err != nil {
return err
Expand Down Expand Up @@ -440,6 +482,20 @@ func (a *Adapter) FieldTriggersRecreate(path *structpath.PathNode) bool {
return false
}

// PrepareInputConfig converts the node's bundle config into the input for PrepareState and the
// references needed to complete it. Resources without PrepareInputConfig pass their config through.
func (a *Adapter) PrepareInputConfig(inputConfig any, resourceKey string) (*structvar.StructVar, error) {
if a.prepareInputConfig == nil {
return &structvar.StructVar{Value: inputConfig, Refs: nil}, nil
}

outs, err := a.prepareInputConfig.Call(inputConfig, resourceKey)
if err != nil {
return nil, err
}
return outs[0].(*structvar.StructVar), nil
}

func (a *Adapter) PrepareState(input any) (any, error) {
outs, err := a.prepareState.Call(input)
if err != nil {
Expand Down
60 changes: 25 additions & 35 deletions bundle/direct/dresources/grants.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,51 +28,53 @@ type GrantsState struct {
EmbeddedSlice []catalog.PrivilegeAssignment `json:"__embed__,omitempty"`
}

func PrepareGrantsInputConfig(inputConfig any, node string) (*structvar.StructVar, error) {
baseNode, ok := strings.CutSuffix(node, ".grants")
if !ok {
return nil, fmt.Errorf("internal error: node %q does not end with .grants", node)
}
type ResourceGrants struct {
client *databricks.WorkspaceClient

resourceType, err := extractGrantResourceType(node)
if err != nil {
return nil, err
// securableType is the UC securable type of the parent resource, e.g. "schema".
securableType string
}

func (*ResourceGrants) New(client *databricks.WorkspaceClient) *ResourceGrants {
return &ResourceGrants{client: client, securableType: ""}
}

func (r *ResourceGrants) Configure(resourceType string) error {
parentType, ok := strings.CutSuffix(resourceType, ".grants")
if !ok {
return fmt.Errorf("internal error: resource type %q does not end with .grants", resourceType)
}

securableType, ok := grantResourceToSecurableType[resourceType]
r.securableType, ok = grantResourceToSecurableType[parentType]
if !ok {
return nil, fmt.Errorf("unsupported grants resource type: %s", resourceType)
return fmt.Errorf("unsupported grants resource type: %s", parentType)
}

grantsPtr, ok := inputConfig.(*[]catalog.PrivilegeAssignment)
return nil
}

func (r *ResourceGrants) PrepareInputConfig(inputConfig *[]catalog.PrivilegeAssignment, resourceKey string) (*structvar.StructVar, error) {
baseNode, ok := strings.CutSuffix(resourceKey, ".grants")
if !ok {
return nil, fmt.Errorf("expected *[]catalog.PrivilegeAssignment, got %T", inputConfig)
return nil, fmt.Errorf("internal error: node %q does not end with .grants", resourceKey)
}

// Normalize the same way as DoRead (sort, collapse ALL_PRIVILEGES) so the
// config and the value read back compare equal.
normalizeAssignments(*grantsPtr)
normalizeAssignments(*inputConfig)

return &structvar.StructVar{
Value: &GrantsState{
SecurableType: securableType,
SecurableType: r.securableType,
FullName: "",
EmbeddedSlice: *grantsPtr,
EmbeddedSlice: *inputConfig,
},
Refs: map[string]string{
"full_name": "${" + baseNode + ".id}",
},
}, nil
}

type ResourceGrants struct {
client *databricks.WorkspaceClient
}

func (*ResourceGrants) New(client *databricks.WorkspaceClient) *ResourceGrants {
return &ResourceGrants{client: client}
}

func (*ResourceGrants) PrepareState(state *GrantsState) *GrantsState {
return state
}
Expand Down Expand Up @@ -251,18 +253,6 @@ func normalizeAssignments(assignments []catalog.PrivilegeAssignment) {
}
}

func extractGrantResourceType(node string) (string, error) {
rest, ok := strings.CutPrefix(node, "resources.")
if !ok {
return "", fmt.Errorf("cannot extract resource type from %q", node)
}
parts := strings.Split(rest, ".")
if len(parts) < 2 {
return "", fmt.Errorf("cannot extract resource type from %q", node)
}
return parts[0], nil
}

func parseGrantsID(id string) (string, string, error) {
parts := strings.SplitN(id, "/", 2)
if len(parts) != 2 {
Expand Down
50 changes: 29 additions & 21 deletions bundle/direct/dresources/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ var permissionResourceToObjectType = map[string]string{

type ResourcePermissions struct {
client *databricks.WorkspaceClient

// objectType is the permissions API prefix of the parent resource, e.g. "/jobs/".
objectType string

// idField is the parent field holding the permissions object ID.
idField string
}

// StatePermission represents a permission entry in deployment state.
Expand All @@ -57,37 +63,43 @@ type PermissionsState struct {
}

// permissionIDFields maps resource types that use a non-standard ID field for
// the permissions API (most resources use "id").
// the permissions API; resources absent from it use defaultPermissionIDField.
var permissionIDFields = map[string]string{
"model_serving_endpoints": "endpoint_id", // internal numeric ID, not the name used in CRUD APIs
"models": "model_id", // numeric model ID, not the model name used as CRUD state ID
"postgres_projects": "project_id", // bare project_id, not the hierarchical "projects/{id}" state ID
"vector_search_endpoints": "endpoint_uuid", // endpoint UUID, not the endpoint name used as deployment ID
}

// objectIDRef returns the reference expression for the permissions object ID.
func objectIDRef(prefix, baseNode, resourceType string) string {
if field, ok := permissionIDFields[resourceType]; ok {
return prefix + "${" + baseNode + "." + field + "}"
}
return prefix + "${" + baseNode + ".id}"
const defaultPermissionIDField = "id"

func (*ResourcePermissions) New(client *databricks.WorkspaceClient) *ResourcePermissions {
return &ResourcePermissions{client: client, objectType: "", idField: ""}
}

func PreparePermissionsInputConfig(inputConfig any, node string) (*structvar.StructVar, error) {
baseNode, ok := strings.CutSuffix(node, ".permissions")
func (r *ResourcePermissions) Configure(resourceType string) error {
parentType, ok := strings.CutSuffix(resourceType, ".permissions")
if !ok {
return nil, fmt.Errorf("internal error: node %q does not end with .permissions", node)
return fmt.Errorf("internal error: resource type %q does not end with .permissions", resourceType)
}

parts := strings.Split(baseNode, ".")
if len(parts) < 2 {
return nil, fmt.Errorf("internal error: unexpected node format %q", baseNode)
r.objectType, ok = permissionResourceToObjectType[parentType]
if !ok {
return fmt.Errorf("unsupported permissions resource type: %s", parentType)
}
resourceType := parts[1]

prefix, ok := permissionResourceToObjectType[resourceType]
r.idField, ok = permissionIDFields[parentType]
if !ok {
return nil, fmt.Errorf("unsupported permissions resource type: %s", resourceType)
r.idField = defaultPermissionIDField
}

return nil
}

func (r *ResourcePermissions) PrepareInputConfig(inputConfig any, resourceKey string) (*structvar.StructVar, error) {
baseNode, ok := strings.CutSuffix(resourceKey, ".permissions")
if !ok {
return nil, fmt.Errorf("internal error: node %q does not end with .permissions", resourceKey)
}

permissions, err := toStatePermissions(inputConfig)
Expand All @@ -101,15 +113,11 @@ func PreparePermissionsInputConfig(inputConfig any, node string) (*structvar.Str
EmbeddedSlice: permissions,
},
Refs: map[string]string{
"object_id": objectIDRef(prefix, baseNode, resourceType),
"object_id": r.objectType + "${" + baseNode + "." + r.idField + "}",
},
}, nil
}

func (*ResourcePermissions) New(client *databricks.WorkspaceClient) *ResourcePermissions {
return &ResourcePermissions{client: client}
}

func (*ResourcePermissions) PrepareState(s *PermissionsState) *PermissionsState {
return s
}
Expand Down
Loading
Loading