Skip to content

Commit 22be781

Browse files
Persist endpoint UUID for vector_search_endpoints drift detection (#5127)
## Changes Persist `endpoint_uuid` in state and detect identity drift on `vector_search_endpoints`. The endpoint name is stable but its UUID changes if the endpoint is deleted and recreated by name (e.g. via the workspace UI). Without persisting the UUID: - The bundle silently rebound permissions to a different backing endpoint without recreating the endpoint resource. - Anything else referencing `endpoint_uuid` (most importantly the permissions object_id, but also indexes added on top in the next PR) raced the recreate. `VectorSearchEndpointState` now embeds `vectorsearch.CreateEndpoint` and adds `EndpointUuid`. `DoCreate` records the UUID from the create response; `DoUpdate` copies it from `entry.RemoteState` so unrelated updates (e.g. `min_qps`) don't blank it out. `OverrideChangeDesc` classifies `endpoint_uuid` drift as `Recreate` when saved differs from remote, `Skip` otherwise. `drift/recreated_same_name` flips from a "badness snapshot" (which captured the old behavior of permissions silently rebinding) to the recreate behavior, with a permissions block on the endpoint to verify the cascade rebinds correctly. `drift/min_qps/out.plan.direct.json` regenerates to include the new `endpoint_uuid` skip entry in the detailed plan. ## Why Splitting this out of the larger `vector_search_indexes` PR ([#5123](#5123)) so it can land independently. The index PR builds on the persisted UUID for orphan detection, but the endpoint UUID work stands on its own and is useful regardless. ## Tests - `make fmtfull`, `make checks`, `make lintfull` — clean. - `make test` — green (`libs/apps/runlocal` needed `NODE_OPTIONS=` for the harness leak; unrelated). `bundle/internal/schema TestRequiredAnnotationsForNewFields` panics, which is failing on `main` for unrelated reasons. - `go test ./acceptance -run 'TestAccept/bundle/resources/vector_search_endpoints'` — all green, including the flipped `drift/recreated_same_name`. _This PR was written by Claude Code._
1 parent 3c622e9 commit 22be781

8 files changed

Lines changed: 103 additions & 22 deletions

File tree

acceptance/bundle/refschema/out.fields.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3042,7 +3042,7 @@ resources.vector_search_endpoints.*.endpoint_status *vectorsearch.EndpointStatus
30423042
resources.vector_search_endpoints.*.endpoint_status.message string REMOTE
30433043
resources.vector_search_endpoints.*.endpoint_status.state vectorsearch.EndpointStatusState REMOTE
30443044
resources.vector_search_endpoints.*.endpoint_type vectorsearch.EndpointType ALL
3045-
resources.vector_search_endpoints.*.endpoint_uuid string REMOTE
3045+
resources.vector_search_endpoints.*.endpoint_uuid string REMOTE STATE
30463046
resources.vector_search_endpoints.*.id string INPUT REMOTE
30473047
resources.vector_search_endpoints.*.last_updated_timestamp int64 REMOTE
30483048
resources.vector_search_endpoints.*.last_updated_user string REMOTE

acceptance/bundle/resources/vector_search_endpoints/drift/min_qps/out.plan.direct.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
"resources.vector_search_endpoints.my_endpoint": {
44
"action": "update",
55
"changes": {
6+
"endpoint_uuid": {
7+
"action": "skip",
8+
"reason": "custom",
9+
"old": "[MY_ENDPOINT_UUID]",
10+
"remote": "[MY_ENDPOINT_UUID]"
11+
},
612
"min_qps": {
713
"action": "update",
814
"old": 1,

acceptance/bundle/resources/vector_search_endpoints/drift/min_qps/output.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Deployment complete!
1515
"state":"ONLINE"
1616
},
1717
"endpoint_type":"STANDARD",
18-
"id":"[UUID]",
18+
"id":"[MY_ENDPOINT_UUID]",
1919
"last_updated_timestamp":[UNIX_TIME_MILLIS][1],
2020
"last_updated_user":"[USERNAME]",
2121
"name":"vs-endpoint-[UNIQUE_NAME]",

acceptance/bundle/resources/vector_search_endpoints/drift/min_qps/script

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ trace $CLI bundle deploy
1111

1212
endpoint_name="vs-endpoint-${UNIQUE_NAME}"
1313

14+
# Register a stable label for the endpoint UUID so the plan output shows the
15+
# same token for both saved (old) and remote, confirming they match.
16+
endpoint_uuid=$($CLI vector-search-endpoints get-endpoint "${endpoint_name}" | jq -r '.id')
17+
add_repl.py "$endpoint_uuid" "MY_ENDPOINT_UUID"
18+
1419
title "Simulate remote drift: change min_qps to 5 outside the bundle"
1520
trace $CLI vector-search-endpoints patch-endpoint "${endpoint_name}" --min-qps 5
1621

acceptance/bundle/resources/vector_search_endpoints/drift/recreated_same_name/databricks.yml.tmpl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,6 @@ resources:
99
my_endpoint:
1010
name: vs-endpoint-$UNIQUE_NAME
1111
endpoint_type: STANDARD
12+
permissions:
13+
- level: CAN_USE
14+
group_name: admins

acceptance/bundle/resources/vector_search_endpoints/drift/recreated_same_name/output.txt

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ Deployment complete!
1313
"endpoint_type": "STANDARD"
1414
}
1515

16+
>>> print_state.py
17+
"/vector-search-endpoints/[ORIGINAL_ENDPOINT_UUID]"
18+
1619
=== Delete and recreate remotely with the same name
1720
>>> [CLI] vector-search-endpoints delete-endpoint vs-endpoint-[UNIQUE_NAME]
1821

@@ -32,10 +35,14 @@ Deployment complete!
3235
Original endpoint UUID: [ORIGINAL_ENDPOINT_UUID]
3336
Remote recreated endpoint UUID: [REMOTE_RECREATED_ENDPOINT_UUID]
3437

35-
=== Badness: bundle should recreate after remote replacement, but currently sees no drift
38+
=== Plan detects the UUID change and proposes recreate
3639
>>> [CLI] bundle plan
37-
Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged
40+
recreate vector_search_endpoints.my_endpoint
41+
update vector_search_endpoints.my_endpoint.permissions
42+
43+
Plan: 1 to add, 1 to change, 1 to delete, 0 unchanged
3844

45+
=== Deploy recreates the endpoint and rebinds permissions to the new UUID
3946
>>> [CLI] bundle deploy
4047
Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/drift-vs-endpoint-recreated-same-name-[UNIQUE_NAME]/default/files...
4148
Deploying resources...
@@ -44,11 +51,13 @@ Deployment complete!
4451

4552
>>> [CLI] vector-search-endpoints get-endpoint vs-endpoint-[UNIQUE_NAME]
4653
{
47-
"id": "[REMOTE_RECREATED_ENDPOINT_UUID]",
4854
"name": "vs-endpoint-[UNIQUE_NAME]",
4955
"endpoint_type": "STANDARD"
5056
}
5157

58+
>>> print_state.py
59+
"/vector-search-endpoints/[UUID]"
60+
5261
>>> [CLI] bundle destroy --auto-approve
5362
The following resources will be deleted:
5463
delete resources.vector_search_endpoints.my_endpoint

acceptance/bundle/resources/vector_search_endpoints/drift/recreated_same_name/script

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ trace $CLI bundle deploy
1414
original_endpoint_uuid=$($CLI vector-search-endpoints get-endpoint "${endpoint_name}" | jq -r '.id')
1515
add_repl.py "$original_endpoint_uuid" "ORIGINAL_ENDPOINT_UUID"
1616
trace $CLI vector-search-endpoints get-endpoint "${endpoint_name}" | jq '{id, name, endpoint_type}'
17+
trace print_state.py | jq '.state."resources.vector_search_endpoints.my_endpoint.permissions".state.object_id'
1718

1819
title "Delete and recreate remotely with the same name"
1920
trace $CLI vector-search-endpoints delete-endpoint "${endpoint_name}"
@@ -31,8 +32,10 @@ if [ "$original_endpoint_uuid" = "$remote_recreated_endpoint_uuid" ]; then
3132
exit 1
3233
fi
3334

34-
title "Badness: bundle should recreate after remote replacement, but currently sees no drift"
35-
trace $CLI bundle plan | contains.py "Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged"
35+
title "Plan detects the UUID change and proposes recreate"
36+
trace $CLI bundle plan | contains.py "recreate vector_search_endpoints.my_endpoint" "update vector_search_endpoints.my_endpoint.permissions"
3637

38+
title "Deploy recreates the endpoint and rebinds permissions to the new UUID"
3739
trace $CLI bundle deploy
38-
trace $CLI vector-search-endpoints get-endpoint "${endpoint_name}" | jq '{id, name, endpoint_type}'
40+
trace $CLI vector-search-endpoints get-endpoint "${endpoint_name}" | jq '{name, endpoint_type}'
41+
trace print_state.py | jq '.state."resources.vector_search_endpoints.my_endpoint.permissions".state.object_id'

bundle/direct/dresources/vector_search_endpoint.go

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import (
55
"time"
66

77
"github.com/databricks/cli/bundle/config/resources"
8+
"github.com/databricks/cli/bundle/deployplan"
89
"github.com/databricks/cli/libs/structs/structpath"
910
"github.com/databricks/cli/libs/utils"
1011
"github.com/databricks/databricks-sdk-go"
12+
"github.com/databricks/databricks-sdk-go/marshal"
1113
"github.com/databricks/databricks-sdk-go/service/vectorsearch"
1214
)
1315

@@ -16,6 +18,23 @@ var (
1618
pathMinQps = structpath.MustParsePath("min_qps")
1719
)
1820

21+
// VectorSearchEndpointState is persisted in deployment state. endpoint_uuid is
22+
// tracked so out-of-band replacement of an endpoint with the same name can be
23+
// detected: when saved UUID differs from remote UUID, the endpoint is recreated.
24+
type VectorSearchEndpointState struct {
25+
vectorsearch.CreateEndpoint
26+
EndpointUuid string `json:"endpoint_uuid,omitempty"`
27+
}
28+
29+
// Custom marshalers required because embedded CreateEndpoint has its own.
30+
func (s *VectorSearchEndpointState) UnmarshalJSON(b []byte) error {
31+
return marshal.Unmarshal(b, s)
32+
}
33+
34+
func (s VectorSearchEndpointState) MarshalJSON() ([]byte, error) {
35+
return marshal.Marshal(s)
36+
}
37+
1938
// VectorSearchEndpointRemote is remote state for a vector search endpoint. It embeds API response
2039
// fields for drift comparison and adds endpoint_uuid for permissions; deployment state id remains the endpoint name.
2140
type VectorSearchEndpointRemote struct {
@@ -41,22 +60,28 @@ func (*ResourceVectorSearchEndpoint) New(client *databricks.WorkspaceClient) *Re
4160
return &ResourceVectorSearchEndpoint{client: client}
4261
}
4362

44-
func (*ResourceVectorSearchEndpoint) PrepareState(input *resources.VectorSearchEndpoint) *vectorsearch.CreateEndpoint {
45-
return &input.CreateEndpoint
63+
func (*ResourceVectorSearchEndpoint) PrepareState(input *resources.VectorSearchEndpoint) *VectorSearchEndpointState {
64+
return &VectorSearchEndpointState{
65+
CreateEndpoint: input.CreateEndpoint,
66+
EndpointUuid: "",
67+
}
4668
}
4769

48-
func (*ResourceVectorSearchEndpoint) RemapState(remote *VectorSearchEndpointRemote) *vectorsearch.CreateEndpoint {
70+
func (*ResourceVectorSearchEndpoint) RemapState(remote *VectorSearchEndpointRemote) *VectorSearchEndpointState {
4971
var minQps int64
5072
if remote.ScalingInfo != nil {
5173
minQps = remote.ScalingInfo.RequestedMinQps
5274
}
53-
return &vectorsearch.CreateEndpoint{
54-
Name: remote.Name,
55-
EndpointType: remote.EndpointType,
56-
BudgetPolicyId: remote.BudgetPolicyId,
57-
UsagePolicyId: "", // Missing in remote
58-
MinQps: minQps,
59-
ForceSendFields: utils.FilterFields[vectorsearch.CreateEndpoint](remote.ForceSendFields, "UsagePolicyId"),
75+
return &VectorSearchEndpointState{
76+
CreateEndpoint: vectorsearch.CreateEndpoint{
77+
Name: remote.Name,
78+
EndpointType: remote.EndpointType,
79+
BudgetPolicyId: remote.BudgetPolicyId,
80+
UsagePolicyId: "", // Missing in remote
81+
MinQps: minQps,
82+
ForceSendFields: utils.FilterFields[vectorsearch.CreateEndpoint](remote.ForceSendFields, "UsagePolicyId"),
83+
},
84+
EndpointUuid: remote.EndpointUuid,
6085
}
6186
}
6287

@@ -68,24 +93,27 @@ func (r *ResourceVectorSearchEndpoint) DoRead(ctx context.Context, id string) (*
6893
return newVectorSearchEndpointRemote(info), nil
6994
}
7095

71-
func (r *ResourceVectorSearchEndpoint) DoCreate(ctx context.Context, config *vectorsearch.CreateEndpoint) (string, *VectorSearchEndpointRemote, error) {
72-
waiter, err := r.client.VectorSearchEndpoints.CreateEndpoint(ctx, *config)
96+
func (r *ResourceVectorSearchEndpoint) DoCreate(ctx context.Context, config *VectorSearchEndpointState) (string, *VectorSearchEndpointRemote, error) {
97+
waiter, err := r.client.VectorSearchEndpoints.CreateEndpoint(ctx, config.CreateEndpoint)
7398
if err != nil {
7499
return "", nil, err
75100
}
76101
id := config.Name
102+
if waiter.Response != nil {
103+
config.EndpointUuid = waiter.Response.Id
104+
}
77105
return id, newVectorSearchEndpointRemote(waiter.Response), nil
78106
}
79107

80-
func (r *ResourceVectorSearchEndpoint) WaitAfterCreate(ctx context.Context, config *vectorsearch.CreateEndpoint) (*VectorSearchEndpointRemote, error) {
108+
func (r *ResourceVectorSearchEndpoint) WaitAfterCreate(ctx context.Context, config *VectorSearchEndpointState) (*VectorSearchEndpointRemote, error) {
81109
info, err := r.client.VectorSearchEndpoints.WaitGetEndpointVectorSearchEndpointOnline(ctx, config.Name, 60*time.Minute, nil)
82110
if err != nil {
83111
return nil, err
84112
}
85113
return newVectorSearchEndpointRemote(info), nil
86114
}
87115

88-
func (r *ResourceVectorSearchEndpoint) DoUpdate(ctx context.Context, id string, config *vectorsearch.CreateEndpoint, entry *PlanEntry) (*VectorSearchEndpointRemote, error) {
116+
func (r *ResourceVectorSearchEndpoint) DoUpdate(ctx context.Context, id string, config *VectorSearchEndpointState, entry *PlanEntry) (*VectorSearchEndpointRemote, error) {
89117
if entry.Changes.HasChange(pathBudgetPolicyId) {
90118
_, err := r.client.VectorSearchEndpoints.UpdateEndpointBudgetPolicy(ctx, vectorsearch.PatchEndpointBudgetPolicyRequest{
91119
EndpointName: id,
@@ -107,9 +135,36 @@ func (r *ResourceVectorSearchEndpoint) DoUpdate(ctx context.Context, id string,
107135
}
108136
}
109137

138+
// Preserve endpoint_uuid in saved state: PrepareState leaves it empty because
139+
// it isn't in config, so copy from remote before SaveState writes newState.
140+
if remote, ok := entry.RemoteState.(*VectorSearchEndpointRemote); ok && remote != nil {
141+
config.EndpointUuid = remote.EndpointUuid
142+
}
143+
110144
return nil, nil
111145
}
112146

113147
func (r *ResourceVectorSearchEndpoint) DoDelete(ctx context.Context, id string) error {
114148
return r.client.VectorSearchEndpoints.DeleteEndpointByEndpointName(ctx, id)
115149
}
150+
151+
// OverrideChangeDesc classifies endpoint_uuid drift: Recreate when saved UUID
152+
// differs from remote (endpoint replaced out-of-band), Skip otherwise. The
153+
// field is not in config, so a synthetic diff between saved state and an empty
154+
// newState is expected on every plan.
155+
func (*ResourceVectorSearchEndpoint) OverrideChangeDesc(_ context.Context, path *structpath.PathNode, change *ChangeDesc, remote *VectorSearchEndpointRemote) error {
156+
if path.String() != "endpoint_uuid" {
157+
return nil
158+
}
159+
savedUuid, _ := change.Old.(string)
160+
var remoteUuid string
161+
if remote != nil {
162+
remoteUuid = remote.EndpointUuid
163+
}
164+
if savedUuid != "" && remoteUuid != "" && savedUuid != remoteUuid {
165+
change.Action = deployplan.Recreate
166+
} else {
167+
change.Action = deployplan.Skip
168+
}
169+
return nil
170+
}

0 commit comments

Comments
 (0)