diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index c79b0d3533c..5a55ba006ee 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -3042,7 +3042,7 @@ resources.vector_search_endpoints.*.endpoint_status *vectorsearch.EndpointStatus resources.vector_search_endpoints.*.endpoint_status.message string REMOTE resources.vector_search_endpoints.*.endpoint_status.state vectorsearch.EndpointStatusState REMOTE resources.vector_search_endpoints.*.endpoint_type vectorsearch.EndpointType ALL -resources.vector_search_endpoints.*.endpoint_uuid string REMOTE +resources.vector_search_endpoints.*.endpoint_uuid string REMOTE STATE resources.vector_search_endpoints.*.id string INPUT REMOTE resources.vector_search_endpoints.*.last_updated_timestamp int64 REMOTE resources.vector_search_endpoints.*.last_updated_user string REMOTE diff --git a/acceptance/bundle/resources/vector_search_endpoints/drift/min_qps/out.plan.direct.json b/acceptance/bundle/resources/vector_search_endpoints/drift/min_qps/out.plan.direct.json index 93aa4f1a24d..dbd1364b122 100644 --- a/acceptance/bundle/resources/vector_search_endpoints/drift/min_qps/out.plan.direct.json +++ b/acceptance/bundle/resources/vector_search_endpoints/drift/min_qps/out.plan.direct.json @@ -3,6 +3,12 @@ "resources.vector_search_endpoints.my_endpoint": { "action": "update", "changes": { + "endpoint_uuid": { + "action": "skip", + "reason": "custom", + "old": "[MY_ENDPOINT_UUID]", + "remote": "[MY_ENDPOINT_UUID]" + }, "min_qps": { "action": "update", "old": 1, diff --git a/acceptance/bundle/resources/vector_search_endpoints/drift/min_qps/output.txt b/acceptance/bundle/resources/vector_search_endpoints/drift/min_qps/output.txt index 294d7061a4e..9f8c49adba5 100644 --- a/acceptance/bundle/resources/vector_search_endpoints/drift/min_qps/output.txt +++ b/acceptance/bundle/resources/vector_search_endpoints/drift/min_qps/output.txt @@ -15,7 +15,7 @@ Deployment complete! "state":"ONLINE" }, "endpoint_type":"STANDARD", - "id":"[UUID]", + "id":"[MY_ENDPOINT_UUID]", "last_updated_timestamp":[UNIX_TIME_MILLIS][1], "last_updated_user":"[USERNAME]", "name":"vs-endpoint-[UNIQUE_NAME]", diff --git a/acceptance/bundle/resources/vector_search_endpoints/drift/min_qps/script b/acceptance/bundle/resources/vector_search_endpoints/drift/min_qps/script index 81e86fefcb2..3c2062e4747 100644 --- a/acceptance/bundle/resources/vector_search_endpoints/drift/min_qps/script +++ b/acceptance/bundle/resources/vector_search_endpoints/drift/min_qps/script @@ -11,6 +11,11 @@ trace $CLI bundle deploy endpoint_name="vs-endpoint-${UNIQUE_NAME}" +# Register a stable label for the endpoint UUID so the plan output shows the +# same token for both saved (old) and remote, confirming they match. +endpoint_uuid=$($CLI vector-search-endpoints get-endpoint "${endpoint_name}" | jq -r '.id') +add_repl.py "$endpoint_uuid" "MY_ENDPOINT_UUID" + title "Simulate remote drift: change min_qps to 5 outside the bundle" trace $CLI vector-search-endpoints patch-endpoint "${endpoint_name}" --min-qps 5 diff --git a/acceptance/bundle/resources/vector_search_endpoints/drift/recreated_same_name/databricks.yml.tmpl b/acceptance/bundle/resources/vector_search_endpoints/drift/recreated_same_name/databricks.yml.tmpl index 914f4af6e3d..b67caeebad6 100644 --- a/acceptance/bundle/resources/vector_search_endpoints/drift/recreated_same_name/databricks.yml.tmpl +++ b/acceptance/bundle/resources/vector_search_endpoints/drift/recreated_same_name/databricks.yml.tmpl @@ -9,3 +9,6 @@ resources: my_endpoint: name: vs-endpoint-$UNIQUE_NAME endpoint_type: STANDARD + permissions: + - level: CAN_USE + group_name: admins diff --git a/acceptance/bundle/resources/vector_search_endpoints/drift/recreated_same_name/output.txt b/acceptance/bundle/resources/vector_search_endpoints/drift/recreated_same_name/output.txt index 0da720312a1..08afd3157e1 100644 --- a/acceptance/bundle/resources/vector_search_endpoints/drift/recreated_same_name/output.txt +++ b/acceptance/bundle/resources/vector_search_endpoints/drift/recreated_same_name/output.txt @@ -13,6 +13,9 @@ Deployment complete! "endpoint_type": "STANDARD" } +>>> print_state.py +"/vector-search-endpoints/[ORIGINAL_ENDPOINT_UUID]" + === Delete and recreate remotely with the same name >>> [CLI] vector-search-endpoints delete-endpoint vs-endpoint-[UNIQUE_NAME] @@ -32,10 +35,14 @@ Deployment complete! Original endpoint UUID: [ORIGINAL_ENDPOINT_UUID] Remote recreated endpoint UUID: [REMOTE_RECREATED_ENDPOINT_UUID] -=== Badness: bundle should recreate after remote replacement, but currently sees no drift +=== Plan detects the UUID change and proposes recreate >>> [CLI] bundle plan -Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged +recreate vector_search_endpoints.my_endpoint +update vector_search_endpoints.my_endpoint.permissions + +Plan: 1 to add, 1 to change, 1 to delete, 0 unchanged +=== Deploy recreates the endpoint and rebinds permissions to the new UUID >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/drift-vs-endpoint-recreated-same-name-[UNIQUE_NAME]/default/files... Deploying resources... @@ -44,11 +51,13 @@ Deployment complete! >>> [CLI] vector-search-endpoints get-endpoint vs-endpoint-[UNIQUE_NAME] { - "id": "[REMOTE_RECREATED_ENDPOINT_UUID]", "name": "vs-endpoint-[UNIQUE_NAME]", "endpoint_type": "STANDARD" } +>>> print_state.py +"/vector-search-endpoints/[UUID]" + >>> [CLI] bundle destroy --auto-approve The following resources will be deleted: delete resources.vector_search_endpoints.my_endpoint diff --git a/acceptance/bundle/resources/vector_search_endpoints/drift/recreated_same_name/script b/acceptance/bundle/resources/vector_search_endpoints/drift/recreated_same_name/script index 48de644a9a5..0a17aa3152a 100644 --- a/acceptance/bundle/resources/vector_search_endpoints/drift/recreated_same_name/script +++ b/acceptance/bundle/resources/vector_search_endpoints/drift/recreated_same_name/script @@ -14,6 +14,7 @@ trace $CLI bundle deploy original_endpoint_uuid=$($CLI vector-search-endpoints get-endpoint "${endpoint_name}" | jq -r '.id') add_repl.py "$original_endpoint_uuid" "ORIGINAL_ENDPOINT_UUID" trace $CLI vector-search-endpoints get-endpoint "${endpoint_name}" | jq '{id, name, endpoint_type}' +trace print_state.py | jq '.state."resources.vector_search_endpoints.my_endpoint.permissions".state.object_id' title "Delete and recreate remotely with the same name" trace $CLI vector-search-endpoints delete-endpoint "${endpoint_name}" @@ -31,8 +32,10 @@ if [ "$original_endpoint_uuid" = "$remote_recreated_endpoint_uuid" ]; then exit 1 fi -title "Badness: bundle should recreate after remote replacement, but currently sees no drift" -trace $CLI bundle plan | contains.py "Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged" +title "Plan detects the UUID change and proposes recreate" +trace $CLI bundle plan | contains.py "recreate vector_search_endpoints.my_endpoint" "update vector_search_endpoints.my_endpoint.permissions" +title "Deploy recreates the endpoint and rebinds permissions to the new UUID" trace $CLI bundle deploy -trace $CLI vector-search-endpoints get-endpoint "${endpoint_name}" | jq '{id, name, endpoint_type}' +trace $CLI vector-search-endpoints get-endpoint "${endpoint_name}" | jq '{name, endpoint_type}' +trace print_state.py | jq '.state."resources.vector_search_endpoints.my_endpoint.permissions".state.object_id' diff --git a/bundle/direct/dresources/vector_search_endpoint.go b/bundle/direct/dresources/vector_search_endpoint.go index 24bbd1a6e74..1322f474a15 100644 --- a/bundle/direct/dresources/vector_search_endpoint.go +++ b/bundle/direct/dresources/vector_search_endpoint.go @@ -5,9 +5,11 @@ import ( "time" "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/libs/structs/structpath" "github.com/databricks/cli/libs/utils" "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/service/vectorsearch" ) @@ -16,6 +18,23 @@ var ( pathMinQps = structpath.MustParsePath("min_qps") ) +// VectorSearchEndpointState is persisted in deployment state. endpoint_uuid is +// tracked so out-of-band replacement of an endpoint with the same name can be +// detected: when saved UUID differs from remote UUID, the endpoint is recreated. +type VectorSearchEndpointState struct { + vectorsearch.CreateEndpoint + EndpointUuid string `json:"endpoint_uuid,omitempty"` +} + +// Custom marshalers required because embedded CreateEndpoint has its own. +func (s *VectorSearchEndpointState) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, s) +} + +func (s VectorSearchEndpointState) MarshalJSON() ([]byte, error) { + return marshal.Marshal(s) +} + // VectorSearchEndpointRemote is remote state for a vector search endpoint. It embeds API response // fields for drift comparison and adds endpoint_uuid for permissions; deployment state id remains the endpoint name. type VectorSearchEndpointRemote struct { @@ -41,22 +60,28 @@ func (*ResourceVectorSearchEndpoint) New(client *databricks.WorkspaceClient) *Re return &ResourceVectorSearchEndpoint{client: client} } -func (*ResourceVectorSearchEndpoint) PrepareState(input *resources.VectorSearchEndpoint) *vectorsearch.CreateEndpoint { - return &input.CreateEndpoint +func (*ResourceVectorSearchEndpoint) PrepareState(input *resources.VectorSearchEndpoint) *VectorSearchEndpointState { + return &VectorSearchEndpointState{ + CreateEndpoint: input.CreateEndpoint, + EndpointUuid: "", + } } -func (*ResourceVectorSearchEndpoint) RemapState(remote *VectorSearchEndpointRemote) *vectorsearch.CreateEndpoint { +func (*ResourceVectorSearchEndpoint) RemapState(remote *VectorSearchEndpointRemote) *VectorSearchEndpointState { var minQps int64 if remote.ScalingInfo != nil { minQps = remote.ScalingInfo.RequestedMinQps } - return &vectorsearch.CreateEndpoint{ - Name: remote.Name, - EndpointType: remote.EndpointType, - BudgetPolicyId: remote.BudgetPolicyId, - UsagePolicyId: "", // Missing in remote - MinQps: minQps, - ForceSendFields: utils.FilterFields[vectorsearch.CreateEndpoint](remote.ForceSendFields, "UsagePolicyId"), + return &VectorSearchEndpointState{ + CreateEndpoint: vectorsearch.CreateEndpoint{ + Name: remote.Name, + EndpointType: remote.EndpointType, + BudgetPolicyId: remote.BudgetPolicyId, + UsagePolicyId: "", // Missing in remote + MinQps: minQps, + ForceSendFields: utils.FilterFields[vectorsearch.CreateEndpoint](remote.ForceSendFields, "UsagePolicyId"), + }, + EndpointUuid: remote.EndpointUuid, } } @@ -68,16 +93,19 @@ func (r *ResourceVectorSearchEndpoint) DoRead(ctx context.Context, id string) (* return newVectorSearchEndpointRemote(info), nil } -func (r *ResourceVectorSearchEndpoint) DoCreate(ctx context.Context, config *vectorsearch.CreateEndpoint) (string, *VectorSearchEndpointRemote, error) { - waiter, err := r.client.VectorSearchEndpoints.CreateEndpoint(ctx, *config) +func (r *ResourceVectorSearchEndpoint) DoCreate(ctx context.Context, config *VectorSearchEndpointState) (string, *VectorSearchEndpointRemote, error) { + waiter, err := r.client.VectorSearchEndpoints.CreateEndpoint(ctx, config.CreateEndpoint) if err != nil { return "", nil, err } id := config.Name + if waiter.Response != nil { + config.EndpointUuid = waiter.Response.Id + } return id, newVectorSearchEndpointRemote(waiter.Response), nil } -func (r *ResourceVectorSearchEndpoint) WaitAfterCreate(ctx context.Context, config *vectorsearch.CreateEndpoint) (*VectorSearchEndpointRemote, error) { +func (r *ResourceVectorSearchEndpoint) WaitAfterCreate(ctx context.Context, config *VectorSearchEndpointState) (*VectorSearchEndpointRemote, error) { info, err := r.client.VectorSearchEndpoints.WaitGetEndpointVectorSearchEndpointOnline(ctx, config.Name, 60*time.Minute, nil) if err != nil { return nil, err @@ -85,7 +113,7 @@ func (r *ResourceVectorSearchEndpoint) WaitAfterCreate(ctx context.Context, conf return newVectorSearchEndpointRemote(info), nil } -func (r *ResourceVectorSearchEndpoint) DoUpdate(ctx context.Context, id string, config *vectorsearch.CreateEndpoint, entry *PlanEntry) (*VectorSearchEndpointRemote, error) { +func (r *ResourceVectorSearchEndpoint) DoUpdate(ctx context.Context, id string, config *VectorSearchEndpointState, entry *PlanEntry) (*VectorSearchEndpointRemote, error) { if entry.Changes.HasChange(pathBudgetPolicyId) { _, err := r.client.VectorSearchEndpoints.UpdateEndpointBudgetPolicy(ctx, vectorsearch.PatchEndpointBudgetPolicyRequest{ EndpointName: id, @@ -107,9 +135,36 @@ func (r *ResourceVectorSearchEndpoint) DoUpdate(ctx context.Context, id string, } } + // Preserve endpoint_uuid in saved state: PrepareState leaves it empty because + // it isn't in config, so copy from remote before SaveState writes newState. + if remote, ok := entry.RemoteState.(*VectorSearchEndpointRemote); ok && remote != nil { + config.EndpointUuid = remote.EndpointUuid + } + return nil, nil } func (r *ResourceVectorSearchEndpoint) DoDelete(ctx context.Context, id string) error { return r.client.VectorSearchEndpoints.DeleteEndpointByEndpointName(ctx, id) } + +// OverrideChangeDesc classifies endpoint_uuid drift: Recreate when saved UUID +// differs from remote (endpoint replaced out-of-band), Skip otherwise. The +// field is not in config, so a synthetic diff between saved state and an empty +// newState is expected on every plan. +func (*ResourceVectorSearchEndpoint) OverrideChangeDesc(_ context.Context, path *structpath.PathNode, change *ChangeDesc, remote *VectorSearchEndpointRemote) error { + if path.String() != "endpoint_uuid" { + return nil + } + savedUuid, _ := change.Old.(string) + var remoteUuid string + if remote != nil { + remoteUuid = remote.EndpointUuid + } + if savedUuid != "" && remoteUuid != "" && savedUuid != remoteUuid { + change.Action = deployplan.Recreate + } else { + change.Action = deployplan.Skip + } + return nil +}