From c6b4b3a7ac189c91414f3ffd2710f898840e0935 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 4 Aug 2026 11:14:57 +0000 Subject: [PATCH 1/6] testserver: reject empty catalog and model names The fake server stored a catalog or registered model created with an empty name, which the CLI could then never find again, so a deploy appeared to succeed and the next plan saw the resource as missing. Both real backends reject it. Return the same 400 they do, verified against a real workspace. --- libs/testserver/catalogs.go | 12 +++++++++++ libs/testserver/catalogs_test.go | 23 +++++++++++++++++++++ libs/testserver/models.go | 12 +++++++++++ libs/testserver/models_test.go | 34 ++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+) create mode 100644 libs/testserver/models_test.go diff --git a/libs/testserver/catalogs.go b/libs/testserver/catalogs.go index 4d80152373d..96fff02bdf3 100644 --- a/libs/testserver/catalogs.go +++ b/libs/testserver/catalogs.go @@ -24,6 +24,18 @@ func (s *FakeWorkspace) CatalogsCreate(req Request) Response { } } + // An empty name would store a catalog the CLI cannot find again. UC rejects it; verified + // against a real workspace. + if createRequest.Name == "" { + return Response{ + StatusCode: 400, + Body: map[string]string{ + "error_code": "INVALID_PARAMETER_VALUE", + "message": `Invalid input: RPC CreateCatalog Field managedcatalog.CatalogInfo.name: name "" is not a valid name. Valid names cannot contain spaces, periods, forward slashes, or control characters.`, + }, + } + } + // Echo back every field create accepts: a dropped one makes the next plan see a // phantom change. catalogInfo := catalog.CatalogInfo{ diff --git a/libs/testserver/catalogs_test.go b/libs/testserver/catalogs_test.go index 4606fdf28b5..70fbee14ad6 100644 --- a/libs/testserver/catalogs_test.go +++ b/libs/testserver/catalogs_test.go @@ -11,6 +11,29 @@ import ( "github.com/stretchr/testify/require" ) +func TestCatalogsCreate_RejectsEmptyName(t *testing.T) { + workspace := NewFakeWorkspace("http://test", "dbapi123") + + response := workspace.CatalogsCreate(Request{Body: []byte(`{"name": ""}`)}) + assert.Equal(t, 400, response.StatusCode) + + body, ok := response.Body.(map[string]string) + require.True(t, ok) + assert.Equal(t, "INVALID_PARAMETER_VALUE", body["error_code"]) + assert.Contains(t, body["message"], "is not a valid name") +} + +func TestCatalogsCreate_AllowsNonEmptyName(t *testing.T) { + workspace := NewFakeWorkspace("http://test", "dbapi123") + + response := workspace.CatalogsCreate(Request{Body: []byte(`{"name": "my_catalog"}`)}) + assert.Equal(t, 0, response.StatusCode) + + body, ok := response.Body.(catalog.CatalogInfo) + require.True(t, ok) + assert.Equal(t, "my_catalog", body.Name) +} + // createCatalogRequest sets every field CreateCatalog accepts. Tests below assert // the fake echoes all of them back and that the request stays exhaustive. const createCatalogRequest = `{ diff --git a/libs/testserver/models.go b/libs/testserver/models.go index febfd8bf8a8..4f7cebcd79b 100644 --- a/libs/testserver/models.go +++ b/libs/testserver/models.go @@ -19,6 +19,18 @@ func (s *FakeWorkspace) ModelRegistryCreateModel(req Request) any { } } + // An empty name would store a model the CLI cannot find again. MLflow rejects it; verified + // against a real workspace. + if request.Name == "" { + return Response{ + StatusCode: 400, + Body: map[string]string{ + "error_code": "INVALID_PARAMETER_VALUE", + "message": "Got an invalid name ''. Registered Model names cannot be empty strings.", + }, + } + } + // Create the model with a numeric ID (matching real API behavior) modelId := strconv.FormatInt(nextID(), 10) model := ml.Model{ diff --git a/libs/testserver/models_test.go b/libs/testserver/models_test.go new file mode 100644 index 00000000000..6927b1cad63 --- /dev/null +++ b/libs/testserver/models_test.go @@ -0,0 +1,34 @@ +package testserver + +import ( + "testing" + + "github.com/databricks/databricks-sdk-go/service/ml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestModelRegistryCreateModel_RejectsEmptyName(t *testing.T) { + workspace := NewFakeWorkspace("http://test", "dbapi123") + + response, ok := workspace.ModelRegistryCreateModel(Request{Body: []byte(`{"name": ""}`)}).(Response) + require.True(t, ok) + assert.Equal(t, 400, response.StatusCode) + + body, ok := response.Body.(map[string]string) + require.True(t, ok) + assert.Equal(t, "INVALID_PARAMETER_VALUE", body["error_code"]) + assert.Contains(t, body["message"], "cannot be empty strings") +} + +func TestModelRegistryCreateModel_AllowsNonEmptyName(t *testing.T) { + workspace := NewFakeWorkspace("http://test", "dbapi123") + + response, ok := workspace.ModelRegistryCreateModel(Request{Body: []byte(`{"name": "my_model"}`)}).(Response) + require.True(t, ok) + assert.Equal(t, 0, response.StatusCode) + + body, ok := response.Body.(ml.CreateModelResponse) + require.True(t, ok) + assert.Equal(t, "my_model", body.RegisteredModel.Name) +} From 413f75608c98d41503bdf1cb96e24e4a9acdcf7c Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 4 Aug 2026 12:56:43 +0000 Subject: [PATCH 2/6] testserver: cover empty-name rejection with acceptance tests Add acceptance tests showing that bundle deploy with an empty catalog or MLflow model name now fails with the backend error instead of silently succeeding, and use http.StatusBadRequest in catalogs.go for consistency with the rest of the file. --- .../resources/catalogs/empty-name/databricks.yml | 7 +++++++ .../resources/catalogs/empty-name/out.test.toml | 3 +++ .../bundle/resources/catalogs/empty-name/output.txt | 11 +++++++++++ .../bundle/resources/catalogs/empty-name/script | 1 + .../bundle/resources/catalogs/empty-name/test.toml | 8 ++++++++ .../bundle/resources/models/empty-name/databricks.yml | 7 +++++++ .../bundle/resources/models/empty-name/out.test.toml | 3 +++ .../bundle/resources/models/empty-name/output.txt | 11 +++++++++++ acceptance/bundle/resources/models/empty-name/script | 1 + .../bundle/resources/models/empty-name/test.toml | 8 ++++++++ libs/testserver/catalogs.go | 6 +++--- libs/testserver/catalogs_test.go | 1 + libs/testserver/models.go | 4 ++-- libs/testserver/models_test.go | 1 + 14 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 acceptance/bundle/resources/catalogs/empty-name/databricks.yml create mode 100644 acceptance/bundle/resources/catalogs/empty-name/out.test.toml create mode 100644 acceptance/bundle/resources/catalogs/empty-name/output.txt create mode 100644 acceptance/bundle/resources/catalogs/empty-name/script create mode 100644 acceptance/bundle/resources/catalogs/empty-name/test.toml create mode 100644 acceptance/bundle/resources/models/empty-name/databricks.yml create mode 100644 acceptance/bundle/resources/models/empty-name/out.test.toml create mode 100644 acceptance/bundle/resources/models/empty-name/output.txt create mode 100644 acceptance/bundle/resources/models/empty-name/script create mode 100644 acceptance/bundle/resources/models/empty-name/test.toml diff --git a/acceptance/bundle/resources/catalogs/empty-name/databricks.yml b/acceptance/bundle/resources/catalogs/empty-name/databricks.yml new file mode 100644 index 00000000000..527f0362292 --- /dev/null +++ b/acceptance/bundle/resources/catalogs/empty-name/databricks.yml @@ -0,0 +1,7 @@ +bundle: + name: catalog-empty-name + +resources: + catalogs: + mycatalog: + name: "" diff --git a/acceptance/bundle/resources/catalogs/empty-name/out.test.toml b/acceptance/bundle/resources/catalogs/empty-name/out.test.toml new file mode 100644 index 00000000000..e90b6d5d1ba --- /dev/null +++ b/acceptance/bundle/resources/catalogs/empty-name/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/catalogs/empty-name/output.txt b/acceptance/bundle/resources/catalogs/empty-name/output.txt new file mode 100644 index 00000000000..12843b409cb --- /dev/null +++ b/acceptance/bundle/resources/catalogs/empty-name/output.txt @@ -0,0 +1,11 @@ + +>>> musterr [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/catalog-empty-name/default/files... +Deploying resources... +Error: cannot create resources.catalogs.mycatalog: Invalid input: RPC CreateCatalog Field managedcatalog.CatalogInfo.name: name "" is not a valid name. Valid names cannot contain spaces, periods, forward slashes, or control characters. (400 INVALID_PARAMETER_VALUE) + +Endpoint: POST [DATABRICKS_URL]/api/2.1/unity-catalog/catalogs +HTTP Status: 400 Bad Request +API error_code: INVALID_PARAMETER_VALUE +API message: Invalid input: RPC CreateCatalog Field managedcatalog.CatalogInfo.name: name "" is not a valid name. Valid names cannot contain spaces, periods, forward slashes, or control characters. + diff --git a/acceptance/bundle/resources/catalogs/empty-name/script b/acceptance/bundle/resources/catalogs/empty-name/script new file mode 100644 index 00000000000..32f10d10630 --- /dev/null +++ b/acceptance/bundle/resources/catalogs/empty-name/script @@ -0,0 +1 @@ +trace musterr $CLI bundle deploy diff --git a/acceptance/bundle/resources/catalogs/empty-name/test.toml b/acceptance/bundle/resources/catalogs/empty-name/test.toml new file mode 100644 index 00000000000..b65798e51f6 --- /dev/null +++ b/acceptance/bundle/resources/catalogs/empty-name/test.toml @@ -0,0 +1,8 @@ +Local = true +Cloud = false +RecordRequests = false +Ignore = [".databricks"] + +# The error surface is the same on both engines, but terraform wraps it in its +# own output; keep a single golden. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/models/empty-name/databricks.yml b/acceptance/bundle/resources/models/empty-name/databricks.yml new file mode 100644 index 00000000000..35e130d3c6e --- /dev/null +++ b/acceptance/bundle/resources/models/empty-name/databricks.yml @@ -0,0 +1,7 @@ +bundle: + name: model-empty-name + +resources: + models: + mymodel: + name: "" diff --git a/acceptance/bundle/resources/models/empty-name/out.test.toml b/acceptance/bundle/resources/models/empty-name/out.test.toml new file mode 100644 index 00000000000..e90b6d5d1ba --- /dev/null +++ b/acceptance/bundle/resources/models/empty-name/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/models/empty-name/output.txt b/acceptance/bundle/resources/models/empty-name/output.txt new file mode 100644 index 00000000000..009296c27f3 --- /dev/null +++ b/acceptance/bundle/resources/models/empty-name/output.txt @@ -0,0 +1,11 @@ + +>>> musterr [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/model-empty-name/default/files... +Deploying resources... +Error: cannot create resources.models.mymodel: Got an invalid name ''. Registered Model names cannot be empty strings. (400 INVALID_PARAMETER_VALUE) + +Endpoint: POST [DATABRICKS_URL]/api/2.0/mlflow/registered-models/create +HTTP Status: 400 Bad Request +API error_code: INVALID_PARAMETER_VALUE +API message: Got an invalid name ''. Registered Model names cannot be empty strings. + diff --git a/acceptance/bundle/resources/models/empty-name/script b/acceptance/bundle/resources/models/empty-name/script new file mode 100644 index 00000000000..32f10d10630 --- /dev/null +++ b/acceptance/bundle/resources/models/empty-name/script @@ -0,0 +1 @@ +trace musterr $CLI bundle deploy diff --git a/acceptance/bundle/resources/models/empty-name/test.toml b/acceptance/bundle/resources/models/empty-name/test.toml new file mode 100644 index 00000000000..b65798e51f6 --- /dev/null +++ b/acceptance/bundle/resources/models/empty-name/test.toml @@ -0,0 +1,8 @@ +Local = true +Cloud = false +RecordRequests = false +Ignore = [".databricks"] + +# The error surface is the same on both engines, but terraform wraps it in its +# own output; keep a single golden. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/libs/testserver/catalogs.go b/libs/testserver/catalogs.go index 96fff02bdf3..a0f8fd46434 100644 --- a/libs/testserver/catalogs.go +++ b/libs/testserver/catalogs.go @@ -24,11 +24,11 @@ func (s *FakeWorkspace) CatalogsCreate(req Request) Response { } } - // An empty name would store a catalog the CLI cannot find again. UC rejects it; verified - // against a real workspace. + // UC rejects an empty name (message verbatim from a real workspace); the fake would + // otherwise store a catalog the CLI can never read back. if createRequest.Name == "" { return Response{ - StatusCode: 400, + StatusCode: http.StatusBadRequest, Body: map[string]string{ "error_code": "INVALID_PARAMETER_VALUE", "message": `Invalid input: RPC CreateCatalog Field managedcatalog.CatalogInfo.name: name "" is not a valid name. Valid names cannot contain spaces, periods, forward slashes, or control characters.`, diff --git a/libs/testserver/catalogs_test.go b/libs/testserver/catalogs_test.go index 70fbee14ad6..00dd70527c2 100644 --- a/libs/testserver/catalogs_test.go +++ b/libs/testserver/catalogs_test.go @@ -27,6 +27,7 @@ func TestCatalogsCreate_AllowsNonEmptyName(t *testing.T) { workspace := NewFakeWorkspace("http://test", "dbapi123") response := workspace.CatalogsCreate(Request{Body: []byte(`{"name": "my_catalog"}`)}) + // StatusCode 0 gets converted to 200 by normalizeResponse in the server assert.Equal(t, 0, response.StatusCode) body, ok := response.Body.(catalog.CatalogInfo) diff --git a/libs/testserver/models.go b/libs/testserver/models.go index 4f7cebcd79b..5629d1d0adb 100644 --- a/libs/testserver/models.go +++ b/libs/testserver/models.go @@ -19,8 +19,8 @@ func (s *FakeWorkspace) ModelRegistryCreateModel(req Request) any { } } - // An empty name would store a model the CLI cannot find again. MLflow rejects it; verified - // against a real workspace. + // MLflow rejects an empty name (message verbatim from a real workspace); the fake would + // otherwise store a model the CLI can never read back. if request.Name == "" { return Response{ StatusCode: 400, diff --git a/libs/testserver/models_test.go b/libs/testserver/models_test.go index 6927b1cad63..7840d750ec3 100644 --- a/libs/testserver/models_test.go +++ b/libs/testserver/models_test.go @@ -26,6 +26,7 @@ func TestModelRegistryCreateModel_AllowsNonEmptyName(t *testing.T) { response, ok := workspace.ModelRegistryCreateModel(Request{Body: []byte(`{"name": "my_model"}`)}).(Response) require.True(t, ok) + // StatusCode 0 gets converted to 200 by normalizeResponse in the server assert.Equal(t, 0, response.StatusCode) body, ok := response.Body.(ml.CreateModelResponse) From eaeea9c43a0d8a5da2307d36e98286532739de1e Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 4 Aug 2026 14:37:27 +0000 Subject: [PATCH 3/6] testserver: assert the empty-name fix through a read, not the create echo The positive tests asserted the name echoed back in the create response, which holds even if create stores the resource under a key nothing can look up - the bug this change exists to prevent. Read the resource back instead, through the same MapGet the catalogs GET route uses and through ModelRegistryGetModel, so a mis-keyed store fails the test. The rejection tests now also assert the collection stays empty, pinning the symptom directly: a stored-but-unreadable entry is what made a deploy look successful and the next plan see the resource as missing. --- libs/testserver/catalogs_test.go | 14 ++++++++++++-- libs/testserver/models_test.go | 20 +++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/libs/testserver/catalogs_test.go b/libs/testserver/catalogs_test.go index 00dd70527c2..c20f5632bb5 100644 --- a/libs/testserver/catalogs_test.go +++ b/libs/testserver/catalogs_test.go @@ -21,6 +21,10 @@ func TestCatalogsCreate_RejectsEmptyName(t *testing.T) { require.True(t, ok) assert.Equal(t, "INVALID_PARAMETER_VALUE", body["error_code"]) assert.Contains(t, body["message"], "is not a valid name") + + // The rejected catalog must not be stored: that is the original bug, where a + // deploy appeared to succeed and the next plan saw the resource as missing. + assert.Empty(t, workspace.Catalogs) } func TestCatalogsCreate_AllowsNonEmptyName(t *testing.T) { @@ -28,11 +32,17 @@ func TestCatalogsCreate_AllowsNonEmptyName(t *testing.T) { response := workspace.CatalogsCreate(Request{Body: []byte(`{"name": "my_catalog"}`)}) // StatusCode 0 gets converted to 200 by normalizeResponse in the server - assert.Equal(t, 0, response.StatusCode) + require.Equal(t, 0, response.StatusCode) + + // Read the catalog back through the same helper the GET route uses, so the + // test fails if create stores it under a key the CLI cannot look up. + getResponse := MapGet(workspace, workspace.Catalogs, "my_catalog") + require.Equal(t, 0, getResponse.StatusCode) - body, ok := response.Body.(catalog.CatalogInfo) + body, ok := getResponse.Body.(catalog.CatalogInfo) require.True(t, ok) assert.Equal(t, "my_catalog", body.Name) + assert.Equal(t, "my_catalog", body.FullName) } // createCatalogRequest sets every field CreateCatalog accepts. Tests below assert diff --git a/libs/testserver/models_test.go b/libs/testserver/models_test.go index 7840d750ec3..2d4808fef92 100644 --- a/libs/testserver/models_test.go +++ b/libs/testserver/models_test.go @@ -1,6 +1,7 @@ package testserver import ( + "net/url" "testing" "github.com/databricks/databricks-sdk-go/service/ml" @@ -19,6 +20,10 @@ func TestModelRegistryCreateModel_RejectsEmptyName(t *testing.T) { require.True(t, ok) assert.Equal(t, "INVALID_PARAMETER_VALUE", body["error_code"]) assert.Contains(t, body["message"], "cannot be empty strings") + + // The rejected model must not be stored: that is the original bug, where a + // deploy appeared to succeed and the next plan saw the resource as missing. + assert.Empty(t, workspace.ModelRegistryModels) } func TestModelRegistryCreateModel_AllowsNonEmptyName(t *testing.T) { @@ -27,9 +32,18 @@ func TestModelRegistryCreateModel_AllowsNonEmptyName(t *testing.T) { response, ok := workspace.ModelRegistryCreateModel(Request{Body: []byte(`{"name": "my_model"}`)}).(Response) require.True(t, ok) // StatusCode 0 gets converted to 200 by normalizeResponse in the server - assert.Equal(t, 0, response.StatusCode) + require.Equal(t, 0, response.StatusCode) + + // Read the model back through the GET handler, so the test fails if create + // stores it under a key the CLI cannot look up. + getResponse, ok := workspace.ModelRegistryGetModel(Request{ + URL: &url.URL{RawQuery: "name=my_model"}, + }).(Response) + require.True(t, ok) + require.Equal(t, 0, getResponse.StatusCode) - body, ok := response.Body.(ml.CreateModelResponse) + body, ok := getResponse.Body.(ml.GetModelResponse) require.True(t, ok) - assert.Equal(t, "my_model", body.RegisteredModel.Name) + assert.Equal(t, "my_model", body.RegisteredModelDatabricks.Name) + assert.NotEmpty(t, body.RegisteredModelDatabricks.Id) } From fbaeadde17a29f58aefa93c3fc098db0e50d9648 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 4 Aug 2026 14:37:39 +0000 Subject: [PATCH 4/6] testserver: run the empty-name acceptance tests against cloud too These goldens assert the UC and MLflow rejection messages verbatim, so their value depends on those strings still matching the real backends. With Cloud = false nothing ever checked that, and the fake could drift out of sync silently. Enable cloud runs so the integration suite catches it, matching the catalogs/with-schemas sibling. The local goldens are unchanged; only out.test.toml moves. --- .../bundle/resources/catalogs/empty-name/out.test.toml | 3 ++- acceptance/bundle/resources/catalogs/empty-name/test.toml | 5 ++++- acceptance/bundle/resources/models/empty-name/out.test.toml | 2 +- acceptance/bundle/resources/models/empty-name/test.toml | 4 +++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/acceptance/bundle/resources/catalogs/empty-name/out.test.toml b/acceptance/bundle/resources/catalogs/empty-name/out.test.toml index e90b6d5d1ba..fe4076cdf9b 100644 --- a/acceptance/bundle/resources/catalogs/empty-name/out.test.toml +++ b/acceptance/bundle/resources/catalogs/empty-name/out.test.toml @@ -1,3 +1,4 @@ Local = true -Cloud = false +Cloud = true +RequiresUnityCatalog = true EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/catalogs/empty-name/test.toml b/acceptance/bundle/resources/catalogs/empty-name/test.toml index b65798e51f6..b50c1271800 100644 --- a/acceptance/bundle/resources/catalogs/empty-name/test.toml +++ b/acceptance/bundle/resources/catalogs/empty-name/test.toml @@ -1,5 +1,8 @@ Local = true -Cloud = false +# Run against a real workspace too, so the golden keeps the message the fake +# returns honest: the fake is only useful here if it matches what UC says. +Cloud = true +RequiresUnityCatalog = true RecordRequests = false Ignore = [".databricks"] diff --git a/acceptance/bundle/resources/models/empty-name/out.test.toml b/acceptance/bundle/resources/models/empty-name/out.test.toml index e90b6d5d1ba..9cfad3fb0d5 100644 --- a/acceptance/bundle/resources/models/empty-name/out.test.toml +++ b/acceptance/bundle/resources/models/empty-name/out.test.toml @@ -1,3 +1,3 @@ Local = true -Cloud = false +Cloud = true EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/models/empty-name/test.toml b/acceptance/bundle/resources/models/empty-name/test.toml index b65798e51f6..37c9e235865 100644 --- a/acceptance/bundle/resources/models/empty-name/test.toml +++ b/acceptance/bundle/resources/models/empty-name/test.toml @@ -1,5 +1,7 @@ Local = true -Cloud = false +# Run against a real workspace too, so the golden keeps the message the fake +# returns honest: the fake is only useful here if it matches what MLflow says. +Cloud = true RecordRequests = false Ignore = [".databricks"] From 6a0285c0f7474005dcff5675b634ef4a91d52a9f Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Wed, 5 Aug 2026 08:06:02 +0000 Subject: [PATCH 5/6] testserver: make the empty-name tests cloud-safe and cover terraform The cloud runs deployed to a fixed ~/.bundle//default, so matrix legs sharing a workspace raced on one root path, and the uploaded files carried no run prefix for cleanBundles to find. Move the bundle name into a template with $UNIQUE_NAME the way the sibling cloud tests do; the deploy fails, but only after the files are uploaded, so there is something to sweep. The models deploy reaches the create call on terraform too and surfaces the same backend message wrapped in terraform's own output, so run both engines and split the deploy output into per-engine goldens rather than dropping terraform. Catalogs stay direct-only, but for the actual reason: terraform rejects catalog resources before any API call, rather than merely formatting the error differently. Assert that the rejected resource was not stored before casting the error body. The cast aborts the test when the rejection is missing, which is exactly the case where the mis-keyed entry is worth reporting. --- .../{databricks.yml => databricks.yml.tmpl} | 2 +- .../resources/catalogs/empty-name/output.txt | 2 +- .../bundle/resources/catalogs/empty-name/script | 5 +++++ .../resources/catalogs/empty-name/test.toml | 4 ++-- .../{databricks.yml => databricks.yml.tmpl} | 2 +- .../models/empty-name/out.deploy.direct.txt | 11 +++++++++++ .../models/empty-name/out.deploy.terraform.txt | 15 +++++++++++++++ .../resources/models/empty-name/out.test.toml | 2 +- .../bundle/resources/models/empty-name/output.txt | 11 ----------- .../bundle/resources/models/empty-name/script | 7 ++++++- .../bundle/resources/models/empty-name/test.toml | 5 ++--- libs/testserver/catalogs.go | 3 ++- libs/testserver/catalogs_test.go | 10 ++++++---- libs/testserver/models_test.go | 10 ++++++---- 14 files changed, 59 insertions(+), 30 deletions(-) rename acceptance/bundle/resources/catalogs/empty-name/{databricks.yml => databricks.yml.tmpl} (60%) rename acceptance/bundle/resources/models/empty-name/{databricks.yml => databricks.yml.tmpl} (60%) create mode 100644 acceptance/bundle/resources/models/empty-name/out.deploy.direct.txt create mode 100644 acceptance/bundle/resources/models/empty-name/out.deploy.terraform.txt diff --git a/acceptance/bundle/resources/catalogs/empty-name/databricks.yml b/acceptance/bundle/resources/catalogs/empty-name/databricks.yml.tmpl similarity index 60% rename from acceptance/bundle/resources/catalogs/empty-name/databricks.yml rename to acceptance/bundle/resources/catalogs/empty-name/databricks.yml.tmpl index 527f0362292..290f0ef2d0d 100644 --- a/acceptance/bundle/resources/catalogs/empty-name/databricks.yml +++ b/acceptance/bundle/resources/catalogs/empty-name/databricks.yml.tmpl @@ -1,5 +1,5 @@ bundle: - name: catalog-empty-name + name: catalog-empty-name-$UNIQUE_NAME resources: catalogs: diff --git a/acceptance/bundle/resources/catalogs/empty-name/output.txt b/acceptance/bundle/resources/catalogs/empty-name/output.txt index 12843b409cb..0a21066c99b 100644 --- a/acceptance/bundle/resources/catalogs/empty-name/output.txt +++ b/acceptance/bundle/resources/catalogs/empty-name/output.txt @@ -1,6 +1,6 @@ >>> musterr [CLI] bundle deploy -Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/catalog-empty-name/default/files... +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/catalog-empty-name-[UNIQUE_NAME]/default/files... Deploying resources... Error: cannot create resources.catalogs.mycatalog: Invalid input: RPC CreateCatalog Field managedcatalog.CatalogInfo.name: name "" is not a valid name. Valid names cannot contain spaces, periods, forward slashes, or control characters. (400 INVALID_PARAMETER_VALUE) diff --git a/acceptance/bundle/resources/catalogs/empty-name/script b/acceptance/bundle/resources/catalogs/empty-name/script index 32f10d10630..11089578fd1 100644 --- a/acceptance/bundle/resources/catalogs/empty-name/script +++ b/acceptance/bundle/resources/catalogs/empty-name/script @@ -1 +1,6 @@ +# The deploy fails, but only after the bundle files are uploaded, so the bundle +# name carries $UNIQUE_NAME to keep concurrent cloud legs off each other's root +# path and let the run-wide sweeper find what is left behind. +envsubst < databricks.yml.tmpl > databricks.yml + trace musterr $CLI bundle deploy diff --git a/acceptance/bundle/resources/catalogs/empty-name/test.toml b/acceptance/bundle/resources/catalogs/empty-name/test.toml index b50c1271800..d9ecae2ded6 100644 --- a/acceptance/bundle/resources/catalogs/empty-name/test.toml +++ b/acceptance/bundle/resources/catalogs/empty-name/test.toml @@ -6,6 +6,6 @@ RequiresUnityCatalog = true RecordRequests = false Ignore = [".databricks"] -# The error surface is the same on both engines, but terraform wraps it in its -# own output; keep a single golden. +# Catalog resources are only supported by the direct engine, which rejects them +# before any API call on terraform, so there is nothing to assert there. EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/models/empty-name/databricks.yml b/acceptance/bundle/resources/models/empty-name/databricks.yml.tmpl similarity index 60% rename from acceptance/bundle/resources/models/empty-name/databricks.yml rename to acceptance/bundle/resources/models/empty-name/databricks.yml.tmpl index 35e130d3c6e..a5fd377721f 100644 --- a/acceptance/bundle/resources/models/empty-name/databricks.yml +++ b/acceptance/bundle/resources/models/empty-name/databricks.yml.tmpl @@ -1,5 +1,5 @@ bundle: - name: model-empty-name + name: model-empty-name-$UNIQUE_NAME resources: models: diff --git a/acceptance/bundle/resources/models/empty-name/out.deploy.direct.txt b/acceptance/bundle/resources/models/empty-name/out.deploy.direct.txt new file mode 100644 index 00000000000..ee04aaab7dc --- /dev/null +++ b/acceptance/bundle/resources/models/empty-name/out.deploy.direct.txt @@ -0,0 +1,11 @@ + +>>> musterr [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/model-empty-name-[UNIQUE_NAME]/default/files... +Deploying resources... +Error: cannot create resources.models.mymodel: Got an invalid name ''. Registered Model names cannot be empty strings. (400 INVALID_PARAMETER_VALUE) + +Endpoint: POST [DATABRICKS_URL]/api/2.0/mlflow/registered-models/create +HTTP Status: 400 Bad Request +API error_code: INVALID_PARAMETER_VALUE +API message: Got an invalid name ''. Registered Model names cannot be empty strings. + diff --git a/acceptance/bundle/resources/models/empty-name/out.deploy.terraform.txt b/acceptance/bundle/resources/models/empty-name/out.deploy.terraform.txt new file mode 100644 index 00000000000..80c84c93bf3 --- /dev/null +++ b/acceptance/bundle/resources/models/empty-name/out.deploy.terraform.txt @@ -0,0 +1,15 @@ + +>>> musterr [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/model-empty-name-[UNIQUE_NAME]/default/files... +Deploying resources... +Error: terraform apply: exit status 1 + +Error: cannot create mlflow model: Got an invalid name ''. Registered Model names cannot be empty strings. + + with databricks_mlflow_model.mymodel, + on bundle.tf.json line 17, in resource.databricks_mlflow_model.mymodel: + 17: } + + + +Updating deployment state... diff --git a/acceptance/bundle/resources/models/empty-name/out.test.toml b/acceptance/bundle/resources/models/empty-name/out.test.toml index 9cfad3fb0d5..bbc7fcfd1bd 100644 --- a/acceptance/bundle/resources/models/empty-name/out.test.toml +++ b/acceptance/bundle/resources/models/empty-name/out.test.toml @@ -1,3 +1,3 @@ Local = true Cloud = true -EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/resources/models/empty-name/output.txt b/acceptance/bundle/resources/models/empty-name/output.txt index 009296c27f3..e69de29bb2d 100644 --- a/acceptance/bundle/resources/models/empty-name/output.txt +++ b/acceptance/bundle/resources/models/empty-name/output.txt @@ -1,11 +0,0 @@ - ->>> musterr [CLI] bundle deploy -Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/model-empty-name/default/files... -Deploying resources... -Error: cannot create resources.models.mymodel: Got an invalid name ''. Registered Model names cannot be empty strings. (400 INVALID_PARAMETER_VALUE) - -Endpoint: POST [DATABRICKS_URL]/api/2.0/mlflow/registered-models/create -HTTP Status: 400 Bad Request -API error_code: INVALID_PARAMETER_VALUE -API message: Got an invalid name ''. Registered Model names cannot be empty strings. - diff --git a/acceptance/bundle/resources/models/empty-name/script b/acceptance/bundle/resources/models/empty-name/script index 32f10d10630..28a1b669259 100644 --- a/acceptance/bundle/resources/models/empty-name/script +++ b/acceptance/bundle/resources/models/empty-name/script @@ -1 +1,6 @@ -trace musterr $CLI bundle deploy +# The deploy fails, but only after the bundle files are uploaded, so the bundle +# name carries $UNIQUE_NAME to keep concurrent cloud legs off each other's root +# path and let the run-wide sweeper find what is left behind. +envsubst < databricks.yml.tmpl > databricks.yml + +trace musterr $CLI bundle deploy &> out.deploy.$DATABRICKS_BUNDLE_ENGINE.txt diff --git a/acceptance/bundle/resources/models/empty-name/test.toml b/acceptance/bundle/resources/models/empty-name/test.toml index 37c9e235865..c3c82795256 100644 --- a/acceptance/bundle/resources/models/empty-name/test.toml +++ b/acceptance/bundle/resources/models/empty-name/test.toml @@ -5,6 +5,5 @@ Cloud = true RecordRequests = false Ignore = [".databricks"] -# The error surface is the same on both engines, but terraform wraps it in its -# own output; keep a single golden. -EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +# Both engines reach the create call and surface the backend message, so both are +# worth covering; terraform wraps it in its own output, hence the per-engine goldens. diff --git a/libs/testserver/catalogs.go b/libs/testserver/catalogs.go index a0f8fd46434..3e69649acab 100644 --- a/libs/testserver/catalogs.go +++ b/libs/testserver/catalogs.go @@ -25,7 +25,8 @@ func (s *FakeWorkspace) CatalogsCreate(req Request) Response { } // UC rejects an empty name (message verbatim from a real workspace); the fake would - // otherwise store a catalog the CLI can never read back. + // otherwise store a catalog the CLI can never read back. Only the empty name trips + // this check, though the backend's canned error also lists other invalid characters. if createRequest.Name == "" { return Response{ StatusCode: http.StatusBadRequest, diff --git a/libs/testserver/catalogs_test.go b/libs/testserver/catalogs_test.go index c20f5632bb5..624b813cf55 100644 --- a/libs/testserver/catalogs_test.go +++ b/libs/testserver/catalogs_test.go @@ -17,14 +17,16 @@ func TestCatalogsCreate_RejectsEmptyName(t *testing.T) { response := workspace.CatalogsCreate(Request{Body: []byte(`{"name": ""}`)}) assert.Equal(t, 400, response.StatusCode) + // The rejected catalog must not be stored: that is the original bug, where a + // deploy appeared to succeed and the next plan saw the resource as missing. + // Checked before the require below so it is still reported when the rejection + // is missing altogether. + assert.Empty(t, workspace.Catalogs) + body, ok := response.Body.(map[string]string) require.True(t, ok) assert.Equal(t, "INVALID_PARAMETER_VALUE", body["error_code"]) assert.Contains(t, body["message"], "is not a valid name") - - // The rejected catalog must not be stored: that is the original bug, where a - // deploy appeared to succeed and the next plan saw the resource as missing. - assert.Empty(t, workspace.Catalogs) } func TestCatalogsCreate_AllowsNonEmptyName(t *testing.T) { diff --git a/libs/testserver/models_test.go b/libs/testserver/models_test.go index 2d4808fef92..00b4c52458f 100644 --- a/libs/testserver/models_test.go +++ b/libs/testserver/models_test.go @@ -16,14 +16,16 @@ func TestModelRegistryCreateModel_RejectsEmptyName(t *testing.T) { require.True(t, ok) assert.Equal(t, 400, response.StatusCode) + // The rejected model must not be stored: that is the original bug, where a + // deploy appeared to succeed and the next plan saw the resource as missing. + // Checked before the require below so it is still reported when the rejection + // is missing altogether. + assert.Empty(t, workspace.ModelRegistryModels) + body, ok := response.Body.(map[string]string) require.True(t, ok) assert.Equal(t, "INVALID_PARAMETER_VALUE", body["error_code"]) assert.Contains(t, body["message"], "cannot be empty strings") - - // The rejected model must not be stored: that is the original bug, where a - // deploy appeared to succeed and the next plan saw the resource as missing. - assert.Empty(t, workspace.ModelRegistryModels) } func TestModelRegistryCreateModel_AllowsNonEmptyName(t *testing.T) { From 2b277c089281c520495fce6012c5460b6d6f5e99 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Wed, 5 Aug 2026 10:44:48 +0000 Subject: [PATCH 6/6] testserver: tighten the comments added by this branch Trim every comment this branch adds to at most two lines, keeping the reason and dropping the restatement. Two are more than trimming. The catalog engine comment claimed catalogs are "only supported by the direct engine, which rejects them before any API call on terraform" - the relative clause pointed at the wrong engine. And the trailing block in the models test.toml explained an EnvMatrix that file does not set, so it moves to the script, next to the per-engine redirect it actually justifies. --- acceptance/bundle/resources/catalogs/empty-name/script | 5 ++--- .../bundle/resources/catalogs/empty-name/test.toml | 7 +++---- acceptance/bundle/resources/models/empty-name/script | 7 ++++--- acceptance/bundle/resources/models/empty-name/test.toml | 6 +----- libs/testserver/catalogs.go | 5 ++--- libs/testserver/catalogs_test.go | 9 +++------ libs/testserver/models.go | 4 ++-- libs/testserver/models_test.go | 9 +++------ 8 files changed, 20 insertions(+), 32 deletions(-) diff --git a/acceptance/bundle/resources/catalogs/empty-name/script b/acceptance/bundle/resources/catalogs/empty-name/script index 11089578fd1..dc9e56639a9 100644 --- a/acceptance/bundle/resources/catalogs/empty-name/script +++ b/acceptance/bundle/resources/catalogs/empty-name/script @@ -1,6 +1,5 @@ -# The deploy fails, but only after the bundle files are uploaded, so the bundle -# name carries $UNIQUE_NAME to keep concurrent cloud legs off each other's root -# path and let the run-wide sweeper find what is left behind. +# The deploy fails only after the files are uploaded, so $UNIQUE_NAME in the bundle +# name keeps concurrent cloud legs apart and lets the sweeper find what is left. envsubst < databricks.yml.tmpl > databricks.yml trace musterr $CLI bundle deploy diff --git a/acceptance/bundle/resources/catalogs/empty-name/test.toml b/acceptance/bundle/resources/catalogs/empty-name/test.toml index d9ecae2ded6..857f971d215 100644 --- a/acceptance/bundle/resources/catalogs/empty-name/test.toml +++ b/acceptance/bundle/resources/catalogs/empty-name/test.toml @@ -1,11 +1,10 @@ Local = true -# Run against a real workspace too, so the golden keeps the message the fake -# returns honest: the fake is only useful here if it matches what UC says. +# The golden asserts UC's message verbatim, so run on cloud to catch it drifting. Cloud = true RequiresUnityCatalog = true RecordRequests = false Ignore = [".databricks"] -# Catalog resources are only supported by the direct engine, which rejects them -# before any API call on terraform, so there is nothing to assert there. +# Terraform rejects catalog resources before any API call, so there is nothing to +# assert on that engine. EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/models/empty-name/script b/acceptance/bundle/resources/models/empty-name/script index 28a1b669259..0336c9c6ca0 100644 --- a/acceptance/bundle/resources/models/empty-name/script +++ b/acceptance/bundle/resources/models/empty-name/script @@ -1,6 +1,7 @@ -# The deploy fails, but only after the bundle files are uploaded, so the bundle -# name carries $UNIQUE_NAME to keep concurrent cloud legs off each other's root -# path and let the run-wide sweeper find what is left behind. +# The deploy fails only after the files are uploaded, so $UNIQUE_NAME in the bundle +# name keeps concurrent cloud legs apart and lets the sweeper find what is left. envsubst < databricks.yml.tmpl > databricks.yml +# Both engines reach the create call, but terraform wraps the message in its own +# output, so the goldens are per-engine. trace musterr $CLI bundle deploy &> out.deploy.$DATABRICKS_BUNDLE_ENGINE.txt diff --git a/acceptance/bundle/resources/models/empty-name/test.toml b/acceptance/bundle/resources/models/empty-name/test.toml index c3c82795256..94abe3cfaed 100644 --- a/acceptance/bundle/resources/models/empty-name/test.toml +++ b/acceptance/bundle/resources/models/empty-name/test.toml @@ -1,9 +1,5 @@ Local = true -# Run against a real workspace too, so the golden keeps the message the fake -# returns honest: the fake is only useful here if it matches what MLflow says. +# The golden asserts MLflow's message verbatim, so run on cloud to catch it drifting. Cloud = true RecordRequests = false Ignore = [".databricks"] - -# Both engines reach the create call and surface the backend message, so both are -# worth covering; terraform wraps it in its own output, hence the per-engine goldens. diff --git a/libs/testserver/catalogs.go b/libs/testserver/catalogs.go index 3e69649acab..1d0bc065681 100644 --- a/libs/testserver/catalogs.go +++ b/libs/testserver/catalogs.go @@ -24,9 +24,8 @@ func (s *FakeWorkspace) CatalogsCreate(req Request) Response { } } - // UC rejects an empty name (message verbatim from a real workspace); the fake would - // otherwise store a catalog the CLI can never read back. Only the empty name trips - // this check, though the backend's canned error also lists other invalid characters. + // UC rejects an empty name; the fake would otherwise store a catalog under a key + // nothing can look up. Message is UC's canned error, which names more than we check. if createRequest.Name == "" { return Response{ StatusCode: http.StatusBadRequest, diff --git a/libs/testserver/catalogs_test.go b/libs/testserver/catalogs_test.go index 624b813cf55..eb05ab8ec56 100644 --- a/libs/testserver/catalogs_test.go +++ b/libs/testserver/catalogs_test.go @@ -17,10 +17,8 @@ func TestCatalogsCreate_RejectsEmptyName(t *testing.T) { response := workspace.CatalogsCreate(Request{Body: []byte(`{"name": ""}`)}) assert.Equal(t, 400, response.StatusCode) - // The rejected catalog must not be stored: that is the original bug, where a - // deploy appeared to succeed and the next plan saw the resource as missing. - // Checked before the require below so it is still reported when the rejection - // is missing altogether. + // A stored-but-unreadable catalog is the original bug. Asserted before the + // require below so it is still reported when the rejection is missing. assert.Empty(t, workspace.Catalogs) body, ok := response.Body.(map[string]string) @@ -36,8 +34,7 @@ func TestCatalogsCreate_AllowsNonEmptyName(t *testing.T) { // StatusCode 0 gets converted to 200 by normalizeResponse in the server require.Equal(t, 0, response.StatusCode) - // Read the catalog back through the same helper the GET route uses, so the - // test fails if create stores it under a key the CLI cannot look up. + // Read back through the same helper the GET route uses: a mis-keyed store must fail here. getResponse := MapGet(workspace, workspace.Catalogs, "my_catalog") require.Equal(t, 0, getResponse.StatusCode) diff --git a/libs/testserver/models.go b/libs/testserver/models.go index 5629d1d0adb..3314327d8a7 100644 --- a/libs/testserver/models.go +++ b/libs/testserver/models.go @@ -19,8 +19,8 @@ func (s *FakeWorkspace) ModelRegistryCreateModel(req Request) any { } } - // MLflow rejects an empty name (message verbatim from a real workspace); the fake would - // otherwise store a model the CLI can never read back. + // MLflow rejects an empty name; the fake would otherwise store a model under a key + // nothing can look up. if request.Name == "" { return Response{ StatusCode: 400, diff --git a/libs/testserver/models_test.go b/libs/testserver/models_test.go index 00b4c52458f..e72d4930133 100644 --- a/libs/testserver/models_test.go +++ b/libs/testserver/models_test.go @@ -16,10 +16,8 @@ func TestModelRegistryCreateModel_RejectsEmptyName(t *testing.T) { require.True(t, ok) assert.Equal(t, 400, response.StatusCode) - // The rejected model must not be stored: that is the original bug, where a - // deploy appeared to succeed and the next plan saw the resource as missing. - // Checked before the require below so it is still reported when the rejection - // is missing altogether. + // A stored-but-unreadable model is the original bug. Asserted before the + // require below so it is still reported when the rejection is missing. assert.Empty(t, workspace.ModelRegistryModels) body, ok := response.Body.(map[string]string) @@ -36,8 +34,7 @@ func TestModelRegistryCreateModel_AllowsNonEmptyName(t *testing.T) { // StatusCode 0 gets converted to 200 by normalizeResponse in the server require.Equal(t, 0, response.StatusCode) - // Read the model back through the GET handler, so the test fails if create - // stores it under a key the CLI cannot look up. + // Read back through the GET handler: a mis-keyed store must fail here. getResponse, ok := workspace.ModelRegistryGetModel(Request{ URL: &url.URL{RawQuery: "name=my_model"}, }).(Response)