From a8b4bdd7d7c13fec1bc7506d913a91eb3db87a5c Mon Sep 17 00:00:00 2001 From: Andrew Nester Date: Thu, 22 Oct 2026 16:31:15 +0200 Subject: [PATCH] Manage registered model aliases on deploy --- .../bundles/registered-model-aliases.md | 1 + .../databricks.yml.tmpl | 15 + .../registered-model-aliases/out.test.toml | 2 + .../bind/registered-model-aliases/output.txt | 93 ++++ .../bind/registered-model-aliases/script | 31 ++ .../bind/registered-model-aliases/test.toml | 7 + .../aliases/databricks.yml.tmpl | 9 + .../registered_models/aliases/out.test.toml | 3 + .../registered_models/aliases/output.txt | 462 ++++++++++++++++++ .../registered_models/aliases/script | 82 ++++ .../registered_models/aliases/test.toml | 13 + .../aliases_converge/databricks.yml.tmpl | 8 +- .../aliases_converge/output.txt | 58 ++- .../registered_models/aliases_converge/script | 17 +- .../aliases_converge/test.toml | 5 +- .../registered_models/script.prepare | 11 + .../dresources/configs/registered_models.yml | 14 +- bundle/direct/dresources/registered_model.go | 93 +++- libs/testserver/fake_workspace.go | 4 + libs/testserver/handlers.go | 17 +- libs/testserver/registered_models.go | 136 ++++++ 21 files changed, 1036 insertions(+), 45 deletions(-) create mode 100644 .nextchanges/bundles/registered-model-aliases.md create mode 100644 acceptance/bundle/deployment/bind/registered-model-aliases/databricks.yml.tmpl create mode 100644 acceptance/bundle/deployment/bind/registered-model-aliases/out.test.toml create mode 100644 acceptance/bundle/deployment/bind/registered-model-aliases/output.txt create mode 100644 acceptance/bundle/deployment/bind/registered-model-aliases/script create mode 100644 acceptance/bundle/deployment/bind/registered-model-aliases/test.toml create mode 100644 acceptance/bundle/resources/registered_models/aliases/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/registered_models/aliases/out.test.toml create mode 100644 acceptance/bundle/resources/registered_models/aliases/output.txt create mode 100644 acceptance/bundle/resources/registered_models/aliases/script create mode 100644 acceptance/bundle/resources/registered_models/aliases/test.toml create mode 100644 acceptance/bundle/resources/registered_models/script.prepare diff --git a/.nextchanges/bundles/registered-model-aliases.md b/.nextchanges/bundles/registered-model-aliases.md new file mode 100644 index 00000000000..d95cf413d08 --- /dev/null +++ b/.nextchanges/bundles/registered-model-aliases.md @@ -0,0 +1 @@ +* Fixes [#4012](https://github.com/databricks/cli/issues/4012): registered model `aliases` are now set in Unity Catalog on deploy instead of being silently ignored. A model without an `aliases` section keeps the aliases set outside the bundle (for example by MLflow), and `aliases: []` removes them. diff --git a/acceptance/bundle/deployment/bind/registered-model-aliases/databricks.yml.tmpl b/acceptance/bundle/deployment/bind/registered-model-aliases/databricks.yml.tmpl new file mode 100644 index 00000000000..91d5e81ae2b --- /dev/null +++ b/acceptance/bundle/deployment/bind/registered-model-aliases/databricks.yml.tmpl @@ -0,0 +1,15 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + registered_models: + model1: + name: $MODEL_NAME + catalog_name: main + schema_name: default + comment: Managed by the bundle + aliases: + - alias_name: test + version_num: 1 + - alias_name: staging + version_num: 2 diff --git a/acceptance/bundle/deployment/bind/registered-model-aliases/out.test.toml b/acceptance/bundle/deployment/bind/registered-model-aliases/out.test.toml new file mode 100644 index 00000000000..19b5b15d11f --- /dev/null +++ b/acceptance/bundle/deployment/bind/registered-model-aliases/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = [""] diff --git a/acceptance/bundle/deployment/bind/registered-model-aliases/output.txt b/acceptance/bundle/deployment/bind/registered-model-aliases/output.txt new file mode 100644 index 00000000000..8d8d43b39a2 --- /dev/null +++ b/acceptance/bundle/deployment/bind/registered-model-aliases/output.txt @@ -0,0 +1,93 @@ + +=== A model created outside the bundle, with versions 1 and 2 registered the way mlflow.register_model does +>>> [CLI] registered-models create --json {"name": "test-registered-model-[UNIQUE_NAME]", "catalog_name": "main", "schema_name": "default"} +{ + "full_name": "main.default.test-registered-model-[UNIQUE_NAME]" +} + +>>> [CLI] api post /api/2.0/mlflow/unity-catalog/model-versions/create --json {"name": "main.default.test-registered-model-[UNIQUE_NAME]", "source": "dbfs:/tmp/model"} +{ + "version": "1" +} + +>>> [CLI] api post /api/2.0/mlflow/unity-catalog/model-versions/create --json {"name": "main.default.test-registered-model-[UNIQUE_NAME]", "source": "dbfs:/tmp/model"} +{ + "version": "2" +} + +=== Bind reports the comment and aliases the deploy will set +>>> musterr [CLI] bundle deployment bind model1 main.default.test-registered-model-[UNIQUE_NAME] +Plan: update resources.registered_models.model1 + +Changes detected: + ~ aliases: null -> [{"alias_name":"test","version_num":1},{"alias_name":"staging","version_num":2}] + ~ comment: null -> "Managed by the bundle" + +Error: this bind operation requires user confirmation, but the current console does not support prompting. +To proceed, use --auto-approve after reviewing the plan above. + + +>>> [CLI] bundle deployment bind model1 main.default.test-registered-model-[UNIQUE_NAME] --auto-approve +Successfully bound registered_model with an id 'main.default.test-registered-model-[UNIQUE_NAME]' +Run 'bundle deploy' to deploy changes to your workspace + +=== Deploy sets them +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default/files... +Updated registered_models.model1 +Files: 2 uploaded, 0 deleted +Resources: 0 created, 1 changed, 0 deleted, 0 unchanged + +>>> print_requests.py //unity-catalog/models +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/models/main.default.test-registered-model-[UNIQUE_NAME]", + "body": { + "catalog_name": "main", + "comment": "Managed by the bundle", + "name": "test-registered-model-[UNIQUE_NAME]", + "schema_name": "default" + } +} +{ + "method": "PUT", + "path": "/api/2.1/unity-catalog/models/main.default.test-registered-model-[UNIQUE_NAME]/aliases/test", + "body": { + "version_num": 1 + } +} +{ + "method": "PUT", + "path": "/api/2.1/unity-catalog/models/main.default.test-registered-model-[UNIQUE_NAME]/aliases/staging", + "body": { + "version_num": 2 + } +} + +>>> [CLI] registered-models get main.default.test-registered-model-[UNIQUE_NAME] --include-aliases +{ + "comment": "Managed by the bundle", + "aliases": [ + { + "alias_name": "test", + "version_num": 1 + }, + { + "alias_name": "staging", + "version_num": 2 + } + ] +} + +=== Redeploy is a no-op +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged + +>>> [CLI] bundle deployment unbind model1 + +>>> [CLI] bundle destroy --auto-approve +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle-[UNIQUE_NAME]/default + +Destroy: 0 deleted + +>>> [CLI] registered-models delete main.default.test-registered-model-[UNIQUE_NAME] diff --git a/acceptance/bundle/deployment/bind/registered-model-aliases/script b/acceptance/bundle/deployment/bind/registered-model-aliases/script new file mode 100644 index 00000000000..96adef52a94 --- /dev/null +++ b/acceptance/bundle/deployment/bind/registered-model-aliases/script @@ -0,0 +1,31 @@ +MODEL_NAME="test-registered-model-$UNIQUE_NAME" +export MODEL_NAME +envsubst < databricks.yml.tmpl > databricks.yml +MODEL="main.default.$MODEL_NAME" + +title "A model created outside the bundle, with versions 1 and 2 registered the way mlflow.register_model does" +trace $CLI registered-models create --json "{\"name\": \"$MODEL_NAME\", \"catalog_name\": \"main\", \"schema_name\": \"default\"}" | jq '{full_name}' +for _ in 1 2; do + trace $CLI api post /api/2.0/mlflow/unity-catalog/model-versions/create --json "{\"name\": \"$MODEL\", \"source\": \"dbfs:/tmp/model\"}" | jq '.model_version | {version}' +done + +cleanup() { + trace $CLI registered-models delete "$MODEL" +} +trap cleanup EXIT + +title "Bind reports the comment and aliases the deploy will set" +trace musterr $CLI bundle deployment bind model1 "$MODEL" +trace $CLI bundle deployment bind model1 "$MODEL" --auto-approve + +title "Deploy sets them" +rm -f "$OUT_REQUESTS" +trace $CLI bundle deploy +trace print_requests.py //unity-catalog/models +trace $CLI registered-models get "$MODEL" --include-aliases | jq '{comment, aliases: [.aliases[]? | {alias_name, version_num}]}' + +title "Redeploy is a no-op" +trace $CLI bundle plan + +trace $CLI bundle deployment unbind model1 +trace $CLI bundle destroy --auto-approve diff --git a/acceptance/bundle/deployment/bind/registered-model-aliases/test.toml b/acceptance/bundle/deployment/bind/registered-model-aliases/test.toml new file mode 100644 index 00000000000..c0fb7eb168a --- /dev/null +++ b/acceptance/bundle/deployment/bind/registered-model-aliases/test.toml @@ -0,0 +1,7 @@ +# Binding an existing model and deploying sets the aliases the config declares (#4012). +# Local only: the aliases need registered model versions, which the test registers through the +# fake server rather than with MLflow. +Cloud = false +RecordRequests = true + +Env.MSYS_NO_PATHCONV = "1" diff --git a/acceptance/bundle/resources/registered_models/aliases/databricks.yml.tmpl b/acceptance/bundle/resources/registered_models/aliases/databricks.yml.tmpl new file mode 100644 index 00000000000..48b4bec2828 --- /dev/null +++ b/acceptance/bundle/resources/registered_models/aliases/databricks.yml.tmpl @@ -0,0 +1,9 @@ +bundle: + name: registered-model-aliases-$UNIQUE_NAME + +resources: + registered_models: + model1: + name: model-aliases-$UNIQUE_NAME + catalog_name: main + schema_name: default diff --git a/acceptance/bundle/resources/registered_models/aliases/out.test.toml b/acceptance/bundle/resources/registered_models/aliases/out.test.toml new file mode 100644 index 00000000000..f7fafbcc731 --- /dev/null +++ b/acceptance/bundle/resources/registered_models/aliases/out.test.toml @@ -0,0 +1,3 @@ +Cloud = false +EnvMatrix.DMS = ["", "true"] +EnvMatrix.READPLAN = ["", "1"] diff --git a/acceptance/bundle/resources/registered_models/aliases/output.txt b/acceptance/bundle/resources/registered_models/aliases/output.txt new file mode 100644 index 00000000000..2fbc017e1f8 --- /dev/null +++ b/acceptance/bundle/resources/registered_models/aliases/output.txt @@ -0,0 +1,462 @@ + +=== Deploy a model that declares no aliases +>>> print_requests.py //unity-catalog/models +{ + "method": "POST", + "path": "/api/2.1/unity-catalog/models", + "body": { + "catalog_name": "main", + "name": "model-aliases-[UNIQUE_NAME]", + "schema_name": "default" + } +} + +=== Out of band: a training run registers two versions and points 'champion' at version 1 +>>> register_model_version main.default.model-aliases-[UNIQUE_NAME] +1 + +>>> register_model_version main.default.model-aliases-[UNIQUE_NAME] +2 + +>>> print_requests.py //unity-catalog/models +{ + "method": "PUT", + "path": "/api/2.1/unity-catalog/models/main.default.model-aliases-[UNIQUE_NAME]/aliases/champion", + "body": { + "version_num": 1 + } +} + +=== No aliases section: 'champion' is skipped as unmanaged, not drift +>>> plan_aliases +{ + "aliases": { + "action": "skip", + "reason": "unmanaged", + "remote": [ + { + "alias_name": "champion", + "catalog_name": "main", + "id": "[UUID]", + "model_name": "model-aliases-[UNIQUE_NAME]", + "schema_name": "default", + "version_num": 1 + } + ] + } +} + +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged + +=== Declare aliases: 'champion' moves to version 2 and 'challenger' points at version 1 +>>> plan_aliases +{ + "aliases": { + "action": "update", + "new": [ + { + "alias_name": "champion", + "version_num": 2 + }, + { + "alias_name": "challenger", + "version_num": 1 + } + ], + "remote": [ + { + "alias_name": "champion", + "catalog_name": "main", + "id": "[UUID]", + "model_name": "model-aliases-[UNIQUE_NAME]", + "schema_name": "default", + "version_num": 1 + } + ] + }, + "aliases[alias_name='challenger']": { + "action": "update", + "new": { + "alias_name": "challenger", + "version_num": 1 + } + }, + "aliases[alias_name='champion'].catalog_name": { + "action": "skip", + "reason": "output_only", + "remote": "main" + }, + "aliases[alias_name='champion'].id": { + "action": "skip", + "reason": "output_only", + "remote": "[UUID]" + }, + "aliases[alias_name='champion'].model_name": { + "action": "skip", + "reason": "output_only", + "remote": "model-aliases-[UNIQUE_NAME]" + }, + "aliases[alias_name='champion'].schema_name": { + "action": "skip", + "reason": "output_only", + "remote": "default" + }, + "aliases[alias_name='champion'].version_num": { + "action": "update", + "new": 2, + "remote": 1 + } +} + +>>> print_requests.py //unity-catalog/models +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/models/main.default.model-aliases-[UNIQUE_NAME]", + "body": { + "catalog_name": "main", + "comment": "", + "name": "model-aliases-[UNIQUE_NAME]", + "schema_name": "default" + } +} +{ + "method": "PUT", + "path": "/api/2.1/unity-catalog/models/main.default.model-aliases-[UNIQUE_NAME]/aliases/champion", + "body": { + "version_num": 2 + } +} +{ + "method": "PUT", + "path": "/api/2.1/unity-catalog/models/main.default.model-aliases-[UNIQUE_NAME]/aliases/challenger", + "body": { + "version_num": 1 + } +} + +>>> [CLI] registered-models get main.default.model-aliases-[UNIQUE_NAME] --include-aliases +[ + { + "alias_name": "champion", + "version_num": 2 + }, + { + "alias_name": "challenger", + "version_num": 1 + } +] + +=== Redeploy is a no-op: the fields the backend fills in on each alias are skipped as output_only +>>> plan_aliases +{ + "aliases[alias_name='challenger'].catalog_name": { + "action": "skip", + "reason": "output_only", + "remote": "main" + }, + "aliases[alias_name='challenger'].id": { + "action": "skip", + "reason": "output_only", + "remote": "[UUID]" + }, + "aliases[alias_name='challenger'].model_name": { + "action": "skip", + "reason": "output_only", + "remote": "model-aliases-[UNIQUE_NAME]" + }, + "aliases[alias_name='challenger'].schema_name": { + "action": "skip", + "reason": "output_only", + "remote": "default" + }, + "aliases[alias_name='champion'].catalog_name": { + "action": "skip", + "reason": "output_only", + "remote": "main" + }, + "aliases[alias_name='champion'].id": { + "action": "skip", + "reason": "output_only", + "remote": "[UUID]" + }, + "aliases[alias_name='champion'].model_name": { + "action": "skip", + "reason": "output_only", + "remote": "model-aliases-[UNIQUE_NAME]" + }, + "aliases[alias_name='champion'].schema_name": { + "action": "skip", + "reason": "output_only", + "remote": "default" + } +} + +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged + +=== Drift: out of band, 'champion' moves back to version 1 and 'challenger' is deleted; deploy restores both +>>> print_requests.py //unity-catalog/models +{ + "method": "PUT", + "path": "/api/2.1/unity-catalog/models/main.default.model-aliases-[UNIQUE_NAME]/aliases/champion", + "body": { + "version_num": 1 + } +} +{ + "method": "DELETE", + "path": "/api/2.1/unity-catalog/models/main.default.model-aliases-[UNIQUE_NAME]/aliases/challenger" +} + +>>> plan_aliases +{ + "aliases[alias_name='challenger']": { + "action": "update", + "old": { + "alias_name": "challenger", + "version_num": 1 + }, + "new": { + "alias_name": "challenger", + "version_num": 1 + } + }, + "aliases[alias_name='champion'].catalog_name": { + "action": "skip", + "reason": "output_only", + "remote": "main" + }, + "aliases[alias_name='champion'].id": { + "action": "skip", + "reason": "output_only", + "remote": "[UUID]" + }, + "aliases[alias_name='champion'].model_name": { + "action": "skip", + "reason": "output_only", + "remote": "model-aliases-[UNIQUE_NAME]" + }, + "aliases[alias_name='champion'].schema_name": { + "action": "skip", + "reason": "output_only", + "remote": "default" + }, + "aliases[alias_name='champion'].version_num": { + "action": "update", + "old": 2, + "new": 2, + "remote": 1 + } +} + +>>> print_requests.py //unity-catalog/models +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/models/main.default.model-aliases-[UNIQUE_NAME]", + "body": { + "catalog_name": "main", + "comment": "", + "name": "model-aliases-[UNIQUE_NAME]", + "schema_name": "default" + } +} +{ + "method": "PUT", + "path": "/api/2.1/unity-catalog/models/main.default.model-aliases-[UNIQUE_NAME]/aliases/champion", + "body": { + "version_num": 2 + } +} +{ + "method": "PUT", + "path": "/api/2.1/unity-catalog/models/main.default.model-aliases-[UNIQUE_NAME]/aliases/challenger", + "body": { + "version_num": 1 + } +} + +>>> [CLI] registered-models get main.default.model-aliases-[UNIQUE_NAME] --include-aliases +[ + { + "alias_name": "champion", + "version_num": 2 + }, + { + "alias_name": "challenger", + "version_num": 1 + } +] + +=== Drop 'challenger' from the config: it is deleted, and so is 'staging', set out of band meanwhile +>>> print_requests.py //unity-catalog/models +{ + "method": "PUT", + "path": "/api/2.1/unity-catalog/models/main.default.model-aliases-[UNIQUE_NAME]/aliases/staging", + "body": { + "version_num": 1 + } +} + +>>> plan_aliases +{ + "aliases[alias_name='challenger']": { + "action": "update", + "old": { + "alias_name": "challenger", + "version_num": 1 + }, + "remote": { + "alias_name": "challenger", + "catalog_name": "main", + "id": "[UUID]", + "model_name": "model-aliases-[UNIQUE_NAME]", + "schema_name": "default", + "version_num": 1 + } + }, + "aliases[alias_name='champion'].catalog_name": { + "action": "skip", + "reason": "output_only", + "remote": "main" + }, + "aliases[alias_name='champion'].id": { + "action": "skip", + "reason": "output_only", + "remote": "[UUID]" + }, + "aliases[alias_name='champion'].model_name": { + "action": "skip", + "reason": "output_only", + "remote": "model-aliases-[UNIQUE_NAME]" + }, + "aliases[alias_name='champion'].schema_name": { + "action": "skip", + "reason": "output_only", + "remote": "default" + }, + "aliases[alias_name='staging']": { + "action": "update", + "remote": { + "alias_name": "staging", + "catalog_name": "main", + "id": "[UUID]", + "model_name": "model-aliases-[UNIQUE_NAME]", + "schema_name": "default", + "version_num": 1 + } + } +} + +>>> print_requests.py //unity-catalog/models +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/models/main.default.model-aliases-[UNIQUE_NAME]", + "body": { + "catalog_name": "main", + "comment": "", + "name": "model-aliases-[UNIQUE_NAME]", + "schema_name": "default" + } +} +{ + "method": "DELETE", + "path": "/api/2.1/unity-catalog/models/main.default.model-aliases-[UNIQUE_NAME]/aliases/challenger" +} +{ + "method": "DELETE", + "path": "/api/2.1/unity-catalog/models/main.default.model-aliases-[UNIQUE_NAME]/aliases/staging" +} + +>>> [CLI] registered-models get main.default.model-aliases-[UNIQUE_NAME] --include-aliases +[ + { + "alias_name": "champion", + "version_num": 2 + } +] + +=== An empty aliases list keeps the aliases managed and deletes them all +>>> plan_aliases +{ + "aliases[alias_name='champion']": { + "action": "update", + "old": { + "alias_name": "champion", + "version_num": 2 + }, + "remote": { + "alias_name": "champion", + "catalog_name": "main", + "id": "[UUID]", + "model_name": "model-aliases-[UNIQUE_NAME]", + "schema_name": "default", + "version_num": 2 + } + } +} + +>>> print_requests.py //unity-catalog/models +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/models/main.default.model-aliases-[UNIQUE_NAME]", + "body": { + "catalog_name": "main", + "comment": "", + "name": "model-aliases-[UNIQUE_NAME]", + "schema_name": "default" + } +} +{ + "method": "DELETE", + "path": "/api/2.1/unity-catalog/models/main.default.model-aliases-[UNIQUE_NAME]/aliases/champion" +} + +>>> [CLI] registered-models get main.default.model-aliases-[UNIQUE_NAME] --include-aliases +[] + +=== Remove the aliases section: aliases are unmanaged again, so one set out of band stays +>>> print_requests.py //unity-catalog/models +{ + "method": "PUT", + "path": "/api/2.1/unity-catalog/models/main.default.model-aliases-[UNIQUE_NAME]/aliases/champion", + "body": { + "version_num": 1 + } +} + +>>> plan_aliases +{ + "aliases": { + "action": "skip", + "reason": "unmanaged", + "remote": [ + { + "alias_name": "champion", + "catalog_name": "main", + "id": "[UUID]", + "model_name": "model-aliases-[UNIQUE_NAME]", + "schema_name": "default", + "version_num": 1 + } + ] + } +} + +>>> print_requests.py //unity-catalog/models + +>>> [CLI] registered-models get main.default.model-aliases-[UNIQUE_NAME] --include-aliases +[ + { + "alias_name": "champion", + "version_num": 1 + } +] + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.registered_models.model1 + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/registered-model-aliases-[UNIQUE_NAME]/default + +Destroy: 1 deleted diff --git a/acceptance/bundle/resources/registered_models/aliases/script b/acceptance/bundle/resources/registered_models/aliases/script new file mode 100644 index 00000000000..b388c672c25 --- /dev/null +++ b/acceptance/bundle/resources/registered_models/aliases/script @@ -0,0 +1,82 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +MODEL="main.default.model-aliases-$UNIQUE_NAME" + +cleanup() { + trace $CLI bundle destroy --auto-approve +} +trap cleanup EXIT + +# Show the model's alias plan entries; reading the saved plan keeps output identical across READPLAN. +plan_aliases() { + jq '.plan["resources.registered_models.model1"].changes // {} | with_entries(select(.key | startswith("aliases")))' tmp.plan.json +} + +# Plan and deploy, then show the alias requests the deploy sent and the aliases the model ends up with. +# Deploy output is verbose and differs plan/no-plan; send it to LOG and assert the key line. +deploy_and_show() { + $CLI bundle plan -o json > tmp.plan.json 2>>LOG.plan + trace plan_aliases + $CLI bundle deploy $(readplanarg tmp.plan.json) &> LOG.deploy + cat LOG.deploy | contains.py "$1" > /dev/null + trace print_requests.py //unity-catalog/models + trace $CLI registered-models get "$MODEL" --include-aliases | alias_fields +} + +title "Deploy a model that declares no aliases" +$CLI bundle plan -o json > tmp.plan.json 2>>LOG.plan +$CLI bundle deploy $(readplanarg tmp.plan.json) &> LOG.deploy +cat LOG.deploy | contains.py "Created registered_models.model1" > /dev/null +trace print_requests.py //unity-catalog/models + +title "Out of band: a training run registers two versions and points 'champion' at version 1" +trace register_model_version "$MODEL" +trace register_model_version "$MODEL" +$CLI registered-models set-alias "$MODEL" champion 1 > /dev/null +trace print_requests.py //unity-catalog/models + +title "No aliases section: 'champion' is skipped as unmanaged, not drift" +$CLI bundle plan -o json > tmp.plan.json 2>>LOG.plan +trace plan_aliases +trace $CLI bundle plan + +title "Declare aliases: 'champion' moves to version 2 and 'challenger' points at version 1" +update_file.py databricks.yml " schema_name: default" " schema_name: default + aliases: + - alias_name: champion + version_num: 2 + - alias_name: challenger + version_num: 1" +deploy_and_show "Updated registered_models.model1" + +title "Redeploy is a no-op: the fields the backend fills in on each alias are skipped as output_only" +$CLI bundle plan -o json > tmp.plan.json 2>>LOG.plan +trace plan_aliases +trace $CLI bundle plan + +title "Drift: out of band, 'champion' moves back to version 1 and 'challenger' is deleted; deploy restores both" +$CLI registered-models set-alias "$MODEL" champion 1 > /dev/null +$CLI registered-models delete-alias "$MODEL" challenger +trace print_requests.py //unity-catalog/models +deploy_and_show "Updated registered_models.model1" + +title "Drop 'challenger' from the config: it is deleted, and so is 'staging', set out of band meanwhile" +$CLI registered-models set-alias "$MODEL" staging 1 > /dev/null +trace print_requests.py //unity-catalog/models +update_file.py databricks.yml " + - alias_name: challenger + version_num: 1" "" +deploy_and_show "Updated registered_models.model1" + +title "An empty aliases list keeps the aliases managed and deletes them all" +update_file.py databricks.yml " aliases: + - alias_name: champion + version_num: 2" " aliases: []" +deploy_and_show "Updated registered_models.model1" + +title "Remove the aliases section: aliases are unmanaged again, so one set out of band stays" +update_file.py databricks.yml " + aliases: []" "" +$CLI registered-models set-alias "$MODEL" champion 1 > /dev/null +trace print_requests.py //unity-catalog/models +deploy_and_show "1 unchanged" diff --git a/acceptance/bundle/resources/registered_models/aliases/test.toml b/acceptance/bundle/resources/registered_models/aliases/test.toml new file mode 100644 index 00000000000..59cd6a293e3 --- /dev/null +++ b/acceptance/bundle/resources/registered_models/aliases/test.toml @@ -0,0 +1,13 @@ +# Without an aliases section the model's aliases are unmanaged (skipped with reason `unmanaged`), so +# aliases set by MLflow clients are not drift. With a section, the config lists every alias of the +# model: declared ones are set, others deleted, and drift is corrected. +# Local only: aliases need registered model versions, which the test registers through the fake +# server rather than with MLflow. +RecordRequests = true + +# READPLAN=1 feeds the serialized plan to `bundle deploy --plan`. +EnvMatrix.READPLAN = ["", "1"] + +Env.MSYS_NO_PATHCONV = "1" + +Ignore = ["tmp.plan.json"] diff --git a/acceptance/bundle/resources/registered_models/aliases_converge/databricks.yml.tmpl b/acceptance/bundle/resources/registered_models/aliases_converge/databricks.yml.tmpl index 43550247edc..bab0117657e 100644 --- a/acceptance/bundle/resources/registered_models/aliases_converge/databricks.yml.tmpl +++ b/acceptance/bundle/resources/registered_models/aliases_converge/databricks.yml.tmpl @@ -8,9 +8,9 @@ resources: comment: $COMMENT catalog_name: main schema_name: default - # Aliases live on model versions and GET does not echo them back - # (DoRead uses IncludeAliases=false), so a config that sets them used to - # report a perpetual in-place update and never converge. + # The backend fills in more fields on each alias than the config sets + # (id, catalog_name, schema_name, model_name), so a config that sets + # aliases must still converge instead of reporting a perpetual update. aliases: - alias_name: champion - id: alias-champion + version_num: 1 diff --git a/acceptance/bundle/resources/registered_models/aliases_converge/output.txt b/acceptance/bundle/resources/registered_models/aliases_converge/output.txt index af7b9aae061..11e41cc81c1 100644 --- a/acceptance/bundle/resources/registered_models/aliases_converge/output.txt +++ b/acceptance/bundle/resources/registered_models/aliases_converge/output.txt @@ -1,35 +1,45 @@ >>> export COMMENT=original comment -=== Initial deployment with aliases set ->>> [CLI] bundle deploy +=== Initial deployment with aliases set: the model is created, but has no version 1 for the alias yet +>>> musterr [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-registered-models-aliases-[UNIQUE_NAME]/default/files... -Created registered_models.my_registered_model +Error: cannot create resources.registered_models.my_registered_model: waiting after creating id=main.default.my-registered-model-aliases-[UNIQUE_NAME]: Model version 1 of registered model main.default.my-registered-model-aliases-[UNIQUE_NAME] does not exist. (404 RESOURCE_DOES_NOT_EXIST) + +Endpoint: PUT [DATABRICKS_URL]/api/2.1/unity-catalog/models/main.default.my-registered-model-aliases-[UNIQUE_NAME]/aliases/champion +HTTP Status: 404 Not Found +API error_code: RESOURCE_DOES_NOT_EXIST +API message: Model version 1 of registered model main.default.my-registered-model-aliases-[UNIQUE_NAME] does not exist. + Files: 2 uploaded, 0 deleted -Resources: 1 created, 0 changed, 0 deleted, 0 unchanged -=== Plan is a no-op: GET never echoes aliases, so they must not drift +=== The model is tracked: the next plan retries the alias as an update instead of creating the model again >>> [CLI] bundle plan -Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged +update registered_models.my_registered_model + +Plan: 0 to add, 1 to change, 0 to delete, 0 unchanged + +=== A training run registers version 1, and the deploy sets the alias +>>> register_model_version main.default.my-registered-model-aliases-[UNIQUE_NAME] +1 -=== The config-set aliases are skipped as input_only (confirms the matched rule) ->>> [CLI] bundle plan --output json -{ - "action": "skip", - "reason": "input_only", - "old": [ - { - "alias_name": "champion", - "id": "alias-champion" - } - ], - "new": [ - { - "alias_name": "champion", - "id": "alias-champion" - } - ] -} +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-registered-models-aliases-[UNIQUE_NAME]/default/files... +Updated registered_models.my_registered_model +Files: 0 uploaded, 0 deleted +Resources: 0 created, 1 changed, 0 deleted, 0 unchanged + +>>> [CLI] registered-models get main.default.my-registered-model-aliases-[UNIQUE_NAME] --include-aliases +[ + { + "alias_name": "champion", + "version_num": 1 + } +] + +=== Plan is a no-op: the fields the backend fills in on the alias do not drift +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged === Edit the comment: an in-place update, not a recreate >>> [CLI] bundle plan diff --git a/acceptance/bundle/resources/registered_models/aliases_converge/script b/acceptance/bundle/resources/registered_models/aliases_converge/script index 5e5f70f5cc7..04d958c0248 100644 --- a/acceptance/bundle/resources/registered_models/aliases_converge/script +++ b/acceptance/bundle/resources/registered_models/aliases_converge/script @@ -2,20 +2,27 @@ trace export COMMENT="original comment" envsubst < databricks.yml.tmpl > databricks.yml +MODEL="main.default.my-registered-model-aliases-$UNIQUE_NAME" + cleanup() { trace $CLI bundle destroy --auto-approve } trap cleanup EXIT -title "Initial deployment with aliases set" +title "Initial deployment with aliases set: the model is created, but has no version 1 for the alias yet" +trace musterr $CLI bundle deploy + +title "The model is tracked: the next plan retries the alias as an update instead of creating the model again" +trace $CLI bundle plan + +title "A training run registers version 1, and the deploy sets the alias" +trace register_model_version "$MODEL" trace $CLI bundle deploy +trace $CLI registered-models get "$MODEL" --include-aliases | alias_fields -title "Plan is a no-op: GET never echoes aliases, so they must not drift" +title "Plan is a no-op: the fields the backend fills in on the alias do not drift" trace $CLI bundle plan | contains.py "Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged" -title "The config-set aliases are skipped as input_only (confirms the matched rule)" -trace $CLI bundle plan --output json | nostamp | jq '.plan[].changes.aliases' - title "Edit the comment: an in-place update, not a recreate" export COMMENT="updated comment" envsubst < databricks.yml.tmpl > databricks.yml diff --git a/acceptance/bundle/resources/registered_models/aliases_converge/test.toml b/acceptance/bundle/resources/registered_models/aliases_converge/test.toml index 85af31f8076..d59a640344c 100644 --- a/acceptance/bundle/resources/registered_models/aliases_converge/test.toml +++ b/acceptance/bundle/resources/registered_models/aliases_converge/test.toml @@ -1,4 +1,5 @@ +# Local only: the alias needs a registered model version, which the test registers through the +# fake server rather than with MLflow. RecordRequests = false -# The aliases input_only classification lives in the direct engine, so scope -# this regression there. +Env.MSYS_NO_PATHCONV = "1" diff --git a/acceptance/bundle/resources/registered_models/script.prepare b/acceptance/bundle/resources/registered_models/script.prepare new file mode 100644 index 00000000000..cbab596663c --- /dev/null +++ b/acceptance/bundle/resources/registered_models/script.prepare @@ -0,0 +1,11 @@ +# alias_fields is the allow-list of alias fields the tests assert on. The backend fills in +# more per alias (id, catalog_name, schema_name, model_name), which the plan ignores. +alias_fields() { + jq '[.aliases[]? | {alias_name, version_num}]' +} + +# register_model_version registers the next version of a model out of band and prints its +# number, as a training run's mlflow.register_model does. An alias can only point at one. +register_model_version() { + $CLI api post /api/2.0/mlflow/unity-catalog/model-versions/create --json "{\"name\": \"$1\", \"source\": \"dbfs:/tmp/model\"}" | jq -r '.model_version.version' +} diff --git a/bundle/direct/dresources/configs/registered_models.yml b/bundle/direct/dresources/configs/registered_models.yml index 5d6bd9fd8de..03bcbcc6079 100644 --- a/bundle/direct/dresources/configs/registered_models.yml +++ b/bundle/direct/dresources/configs/registered_models.yml @@ -10,11 +10,15 @@ ignore_remote_changes: reason: output_only - field: updated_by reason: output_only - # Aliases are managed on model versions through a separate API, and DoRead - # passes IncludeAliases=false, so GET never echoes them back. Without this a - # config that sets aliases reports a perpetual update (remote stays empty). - - field: aliases - reason: input_only + # An alias is set by name and version; the backend fills in the rest on read. + - field: aliases[*].id + reason: output_only + - field: aliases[*].catalog_name + reason: output_only + - field: aliases[*].schema_name + reason: output_only + - field: aliases[*].model_name + reason: output_only provided_id_fields: # The name can technically be updated without recreate. We recreate for now though # to match TF implementation. diff --git a/bundle/direct/dresources/registered_model.go b/bundle/direct/dresources/registered_model.go index 1a2fd468ebb..9dd97965bce 100644 --- a/bundle/direct/dresources/registered_model.go +++ b/bundle/direct/dresources/registered_model.go @@ -2,14 +2,20 @@ package dresources import ( "context" + "fmt" "slices" "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/service/catalog" ) +// registeredModelAliasesPath is the state path of the aliases slice, used to detect alias changes. +var registeredModelAliasesPath = structpath.MustParsePath("aliases") + type ResourceRegisteredModel struct { client *databricks.WorkspaceClient } @@ -51,12 +57,21 @@ func (*ResourceRegisteredModel) RemapState(model *catalog.RegisteredModelInfo) * func (r *ResourceRegisteredModel) DoRead(ctx context.Context, id string) (*catalog.RegisteredModelInfo, error) { return r.client.RegisteredModels.Get(ctx, catalog.GetRegisteredModelRequest{ FullName: id, - IncludeAliases: false, + IncludeAliases: true, IncludeBrowse: false, ForceSendFields: nil, }) } +func registeredModelAliasKey(alias catalog.RegisteredModelAlias) (string, string) { + return "alias_name", alias.AliasName +} + +// KeyedSlices compares aliases by name, so the order the backend lists them in does not produce phantom diffs. +func (*ResourceRegisteredModel) KeyedSlices() map[string]any { + return map[string]any{"aliases": registeredModelAliasKey} +} + func (r *ResourceRegisteredModel) DoCreate(ctx context.Context, config *catalog.CreateRegisteredModelRequest) (string, *catalog.RegisteredModelInfo, error) { response, err := r.client.RegisteredModels.Create(ctx, *config) if err != nil { @@ -66,10 +81,17 @@ func (r *ResourceRegisteredModel) DoCreate(ctx context.Context, config *catalog. return response.FullName, response, nil } +// WaitAfterCreate sets the configured aliases through the alias API. It is not a wait, but unlike +// DoCreate it runs after the model is saved to state: an alias can only point at a registered version, +// so a failure here leaves the model tracked and the next deploy plans the alias again. +func (r *ResourceRegisteredModel) WaitAfterCreate(ctx context.Context, id string, config *catalog.CreateRegisteredModelRequest) (*catalog.RegisteredModelInfo, error) { + return nil, r.syncAliases(ctx, id, config.Aliases, nil) +} + // See schemaForceSend. Verified against a real workspace: {"comment": ""} clears it. var registeredModelForceSend = []string{"Comment"} -func (r *ResourceRegisteredModel) DoUpdate(ctx context.Context, id string, config *catalog.CreateRegisteredModelRequest, _ *PlanEntry) (*catalog.RegisteredModelInfo, error) { +func (r *ResourceRegisteredModel) DoUpdate(ctx context.Context, id string, config *catalog.CreateRegisteredModelRequest, entry *PlanEntry) (*catalog.RegisteredModelInfo, error) { updateRequest := catalog.UpdateRegisteredModelRequest{ FullName: id, Comment: config.Comment, @@ -84,7 +106,9 @@ func (r *ResourceRegisteredModel) DoUpdate(ctx context.Context, id string, confi // Note: TF also does not support changing name without a recreate so the current behavior matches TF. NewName: "", - Aliases: config.Aliases, + // The update API ignores aliases; syncAliases applies them through the alias API. + Aliases: nil, + BrowseOnly: config.BrowseOnly, CreatedAt: config.CreatedAt, CreatedBy: config.CreatedBy, @@ -102,9 +126,72 @@ func (r *ResourceRegisteredModel) DoUpdate(ctx context.Context, id string, confi return nil, err } + if entry.Changes.HasChange(registeredModelAliasesPath) { + remote, ok := entry.RemoteState.(*catalog.RegisteredModelInfo) + if !ok { + return nil, fmt.Errorf("internal error: unexpected remote state type %T", entry.RemoteState) + } + err = r.syncAliases(ctx, id, config.Aliases, remote.Aliases) + if err != nil { + return nil, err + } + } + return response, nil } +// syncAliases makes the model's aliases match desired, given the ones it has remotely. The alias API +// sets or deletes one alias per call. Sets go first, so no alias is deleted unless every set succeeded. +func (r *ResourceRegisteredModel) syncAliases(ctx context.Context, fullName string, desired, remote []catalog.RegisteredModelAlias) error { + remoteVersions := make(map[string]int, len(remote)) + for _, alias := range remote { + remoteVersions[alias.AliasName] = alias.VersionNum + } + + desiredNames := make(map[string]bool, len(desired)) + for _, alias := range desired { + desiredNames[alias.AliasName] = true + if version, ok := remoteVersions[alias.AliasName]; ok && version == alias.VersionNum { + continue + } + _, err := r.client.RegisteredModels.SetAlias(ctx, catalog.SetRegisteredModelAliasRequest{ + FullName: fullName, + Alias: alias.AliasName, + VersionNum: alias.VersionNum, + }) + if err != nil { + return err + } + } + + for _, alias := range remote { + if desiredNames[alias.AliasName] { + continue + } + err := r.client.RegisteredModels.DeleteAlias(ctx, catalog.DeleteAliasRequest{ + FullName: fullName, + Alias: alias.AliasName, + }) + if err != nil { + return err + } + } + + return nil +} + +// OverrideChangeDesc leaves aliases unmanaged when the config has no aliases section. MLflow clients +// commonly assign aliases to the versions they register (e.g. a "champion" picked by an evaluation +// job), so without a section what the model reports is not drift. The difference then arrives as a +// single whole-field change with New nil; `aliases: []` keeps New non-nil, so it stays managed. +func (*ResourceRegisteredModel) OverrideChangeDesc(_ context.Context, path *structpath.PathNode, change *ChangeDesc, _ *catalog.RegisteredModelInfo) error { + if change.Action == deployplan.Update && change.New == nil && path.String() == "aliases" { + change.Action = deployplan.Skip + change.Reason = deployplan.ReasonUnmanaged + } + return nil +} + func (r *ResourceRegisteredModel) DoDelete(ctx context.Context, id string, _ *catalog.CreateRegisteredModelRequest) error { return r.client.RegisteredModels.Delete(ctx, catalog.DeleteRegisteredModelRequest{ FullName: id, diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index 78234c5ebfa..ae4effffa3c 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -228,6 +228,9 @@ type FakeWorkspace struct { McpServices map[string]catalog.McpService ModelProviderServices map[string]catalog.ModelProviderService + // RegisteredModelVersions counts the versions registered on each model, keyed by its full name. + RegisteredModelVersions map[string]int + Features map[string]ml.Feature ServingEndpoints map[string]serving.ServingEndpointDetailed VectorSearchEndpoints map[string]vectorsearch.EndpointInfo @@ -576,6 +579,7 @@ func NewFakeWorkspace(url, token string) *FakeWorkspace { "9876": {PolicyId: "9876", Name: "some-test-cluster-policy"}, }, VectorSearchIndexesPendingDeletion: map[string]int{}, + RegisteredModelVersions: map[string]int{}, } } diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index e1a14b9800b..319a56e6efa 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -788,7 +788,7 @@ func AddDefaultHandlers(server *Server) { // Registered Models: server.Handle("GET", "/api/2.1/unity-catalog/models/{full_name}", func(req Request) any { - return MapGet(req.Workspace, req.Workspace.RegisteredModels, req.Vars["full_name"]) + return req.Workspace.RegisteredModelsGet(req, req.Vars["full_name"]) }) server.Handle("POST", "/api/2.1/unity-catalog/models", func(req Request) any { @@ -800,7 +800,20 @@ func AddDefaultHandlers(server *Server) { }) server.Handle("DELETE", "/api/2.1/unity-catalog/models/{full_name}", func(req Request) any { - return MapDelete(req.Workspace, req.Workspace.RegisteredModels, req.Vars["full_name"]) + return req.Workspace.RegisteredModelsDelete(req.Vars["full_name"]) + }) + + server.Handle("PUT", "/api/2.1/unity-catalog/models/{full_name}/aliases/{alias}", func(req Request) any { + return req.Workspace.RegisteredModelsSetAlias(req, req.Vars["full_name"], req.Vars["alias"]) + }) + + server.Handle("DELETE", "/api/2.1/unity-catalog/models/{full_name}/aliases/{alias}", func(req Request) any { + return req.Workspace.RegisteredModelsDeleteAlias(req.Vars["full_name"], req.Vars["alias"]) + }) + + // Model versions of Unity Catalog models are registered through the MLflow registry API. + server.Handle("POST", "/api/2.0/mlflow/unity-catalog/model-versions/create", func(req Request) any { + return req.Workspace.RegisteredModelsCreateVersion(req) }) // Model Services (AI Gateway): diff --git a/libs/testserver/registered_models.go b/libs/testserver/registered_models.go index b6fb800a623..7e178e4b29b 100644 --- a/libs/testserver/registered_models.go +++ b/libs/testserver/registered_models.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "net/http" + "slices" + "strconv" "github.com/databricks/databricks-sdk-go/service/catalog" ) @@ -75,6 +77,8 @@ func (s *FakeWorkspace) RegisteredModelsUpdate(req Request, fullName string) Res } } + // The update API ignores aliases; they are set through the alias endpoints. + delete(fields, "aliases") applyUpdatedFields(&existing, updateRequest, fields) if updateRequest.NewName != "" { @@ -91,3 +95,135 @@ func (s *FakeWorkspace) RegisteredModelsUpdate(req Request, fullName string) Res Body: existing, } } + +// RegisteredModelsGet mirrors UC, which lists a model's aliases only when include_aliases is set. +func (s *FakeWorkspace) RegisteredModelsGet(req Request, fullName string) Response { + defer s.LockUnlock()() + + model, ok := s.RegisteredModels[fullName] + if !ok { + return registeredModelNotFound(fullName) + } + if req.URL.Query().Get("include_aliases") != "true" { + model.Aliases = nil + } + return Response{ + Body: model, + } +} + +func (s *FakeWorkspace) RegisteredModelsDelete(fullName string) Response { + defer s.LockUnlock()() + + if _, ok := s.RegisteredModels[fullName]; !ok { + return registeredModelNotFound(fullName) + } + delete(s.RegisteredModels, fullName) + // The model's versions are deleted with it, so a model created later under the same name starts with none. + delete(s.RegisteredModelVersions, fullName) + return Response{} +} + +// RegisteredModelsCreateVersion registers the model's next version. The MLflow client calls this endpoint of the +// MLflow Unity Catalog registry when registering a model; tests call it to give aliases a version to point at. +func (s *FakeWorkspace) RegisteredModelsCreateVersion(req Request) Response { + defer s.LockUnlock()() + + var request struct { + Name string `json:"name"` + } + if err := json.Unmarshal(req.Body, &request); err != nil { + return Response{ + Body: fmt.Sprintf("internal error: %s", err), + StatusCode: http.StatusInternalServerError, + } + } + if _, ok := s.RegisteredModels[request.Name]; !ok { + return registeredModelNotFound(request.Name) + } + + s.RegisteredModelVersions[request.Name]++ + return Response{ + Body: map[string]any{ + "model_version": map[string]string{ + "name": request.Name, + "version": strconv.Itoa(s.RegisteredModelVersions[request.Name]), + }, + }, + } +} + +func (s *FakeWorkspace) RegisteredModelsSetAlias(req Request, fullName, aliasName string) Response { + defer s.LockUnlock()() + + model, ok := s.RegisteredModels[fullName] + if !ok { + return registeredModelNotFound(fullName) + } + + var request catalog.SetRegisteredModelAliasRequest + if err := json.Unmarshal(req.Body, &request); err != nil { + return Response{ + Body: fmt.Sprintf("internal error: %s", err), + StatusCode: http.StatusInternalServerError, + } + } + + // An alias can only point at a registered version. + if request.VersionNum < 1 || request.VersionNum > s.RegisteredModelVersions[fullName] { + return Response{ + StatusCode: http.StatusNotFound, + Body: map[string]string{ + "error_code": "RESOURCE_DOES_NOT_EXIST", + "message": fmt.Sprintf("Model version %d of registered model %s does not exist.", request.VersionNum, fullName), + }, + } + } + + alias := catalog.RegisteredModelAlias{ + AliasName: aliasName, + VersionNum: request.VersionNum, + Id: nextUUID(), + CatalogName: model.CatalogName, + SchemaName: model.SchemaName, + ModelName: model.Name, + } + + // Copy rather than edit the stored slice in place: a response being written may still reference it. + aliases := slices.Clone(model.Aliases) + if i := slices.IndexFunc(aliases, func(a catalog.RegisteredModelAlias) bool { return a.AliasName == aliasName }); i >= 0 { + aliases[i] = alias + } else { + aliases = append(aliases, alias) + } + model.Aliases = aliases + s.RegisteredModels[fullName] = model + return Response{ + Body: alias, + } +} + +func (s *FakeWorkspace) RegisteredModelsDeleteAlias(fullName, aliasName string) Response { + defer s.LockUnlock()() + + model, ok := s.RegisteredModels[fullName] + if !ok { + return registeredModelNotFound(fullName) + } + + model.Aliases = slices.DeleteFunc(slices.Clone(model.Aliases), func(a catalog.RegisteredModelAlias) bool { + return a.AliasName == aliasName + }) + s.RegisteredModels[fullName] = model + return Response{} +} + +func registeredModelNotFound(fullName string) Response { + return Response{ + StatusCode: http.StatusNotFound, + Body: map[string]string{ + "error_code": "RESOURCE_DOES_NOT_EXIST", + "message": fmt.Sprintf("Registered model '%s' does not exist.", fullName), + }, + } +}