From 57c294816f209c166e3d85020cb0c5965e12e188 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 22 Sep 2026 12:25:28 +0200 Subject: [PATCH 1/2] direct: force-send genie space description and secret comment on update Clearing a genie space's `description` or a secret's `comment` in config left the old value in place: the field is omitempty, so it was dropped from the update and the next plan re-proposed the change forever. Force-send both (as #6343 did for the UC `comment`), and teach the testserver's genie/secret update handlers to honor an explicit empty value as a clear. Verified against a real workspace: UpdateSpace accepts {"description": ""} and UpdateSecret accepts {"comment": ""} as clears. Flips genie_spaces.description and secrets.comment from CANNOT_REMOVE to CAN_REMOVE in the invariant configs. Co-authored-by: Isaac --- .../bundle/invariant/configs/genie_space.yml.tmpl | 4 +--- acceptance/bundle/invariant/configs/secret.yml.tmpl | 4 +--- .../genie_spaces/change-serialized-space/output.txt | 1 + .../resources/secrets/remove-expire-time/output.txt | 1 + .../resources/secrets/update-expire-time/output.txt | 1 + bundle/direct/dresources/genie_space.go | 8 +++++++- bundle/direct/dresources/genie_space_test.go | 6 ++++-- bundle/direct/dresources/secret.go | 10 +++++++++- libs/testserver/genie_spaces.go | 6 +++++- libs/testserver/uc_secrets.go | 6 +++++- 10 files changed, 35 insertions(+), 12 deletions(-) diff --git a/acceptance/bundle/invariant/configs/genie_space.yml.tmpl b/acceptance/bundle/invariant/configs/genie_space.yml.tmpl index 8b5756d50ab..b4ca88801bf 100644 --- a/acceptance/bundle/invariant/configs/genie_space.yml.tmpl +++ b/acceptance/bundle/invariant/configs/genie_space.yml.tmpl @@ -6,9 +6,7 @@ resources: foo: warehouse_id: $TEST_DEFAULT_WAREHOUSE_ID title: test-genie-space-$UNIQUE_NAME - # CANNOT_REMOVE: description is not force-sent on update, so clearing it drops the - # field from the request and the old value drifts back on every subsequent plan. - description: This is a test genie space + description: This is a test genie space # CAN_REMOVE # Structured (inline) serialized_space is marshalled to a JSON string by # ConfigureGenieSpaceSerializedSpace; this config doubles as a regression # guard that the normalization produces a drift-free deploy. Kept minimal diff --git a/acceptance/bundle/invariant/configs/secret.yml.tmpl b/acceptance/bundle/invariant/configs/secret.yml.tmpl index ed8ba82eb32..14fa93b6ce1 100644 --- a/acceptance/bundle/invariant/configs/secret.yml.tmpl +++ b/acceptance/bundle/invariant/configs/secret.yml.tmpl @@ -12,9 +12,7 @@ resources: schema_name: default name: test-secret-$UNIQUE_NAME value: ${var.secret_value} - # CANNOT_REMOVE: comment is not force-sent on update, so clearing it drops the field - # from the request and the old value drifts back on every subsequent plan. - comment: This is a test secret + comment: This is a test secret # CAN_REMOVE grants: - principal: account users privileges: diff --git a/acceptance/bundle/resources/genie_spaces/change-serialized-space/output.txt b/acceptance/bundle/resources/genie_spaces/change-serialized-space/output.txt index 35bab921268..897e67860a9 100644 --- a/acceptance/bundle/resources/genie_spaces/change-serialized-space/output.txt +++ b/acceptance/bundle/resources/genie_spaces/change-serialized-space/output.txt @@ -36,6 +36,7 @@ Resources: 0 created, 1 changed, 0 deleted, 0 unchanged "method": "PATCH", "path": "/api/2.0/genie/spaces/[MY_SPACE_ID]", "body": { + "description": "", "parent_path": "/Workspace/Users/[USERNAME]/.bundle/change-serialized-space-[UNIQUE_NAME]/default/resources", "serialized_space": "{\"version\":1,\"instructions\":{\"text_instructions\":[{\"id\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\",\"content\":[\"Summarize sales revenue by month\"]}]}}\n", "title": "test genie space", diff --git a/acceptance/bundle/resources/secrets/remove-expire-time/output.txt b/acceptance/bundle/resources/secrets/remove-expire-time/output.txt index 2e88809ebcf..a4963ac944f 100644 --- a/acceptance/bundle/resources/secrets/remove-expire-time/output.txt +++ b/acceptance/bundle/resources/secrets/remove-expire-time/output.txt @@ -44,6 +44,7 @@ Resources: 0 created, 1 changed, 0 deleted, 0 unchanged }, "body": { "catalog_name": "main", + "comment": "", "name": "test_secret_[UNIQUE_NAME]", "schema_name": "default", "value": "secret-value" diff --git a/acceptance/bundle/resources/secrets/update-expire-time/output.txt b/acceptance/bundle/resources/secrets/update-expire-time/output.txt index f99e32fcd31..04f5220d9ff 100644 --- a/acceptance/bundle/resources/secrets/update-expire-time/output.txt +++ b/acceptance/bundle/resources/secrets/update-expire-time/output.txt @@ -44,6 +44,7 @@ Resources: 0 created, 1 changed, 0 deleted, 0 unchanged }, "body": { "catalog_name": "main", + "comment": "", "expire_time": "[EXPIRE_TIME_AFTER]", "name": "test_secret_[UNIQUE_NAME]", "schema_name": "default", diff --git a/bundle/direct/dresources/genie_space.go b/bundle/direct/dresources/genie_space.go index 21d2c632409..f2fbf0f53f0 100644 --- a/bundle/direct/dresources/genie_space.go +++ b/bundle/direct/dresources/genie_space.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "slices" "strings" "github.com/databricks/cli/bundle/config/resources" @@ -177,6 +178,11 @@ func (r *ResourceGenieSpace) DoCreate(ctx context.Context, config *resources.Gen return createResp.SpaceId, responseToGenieSpaceConfig(createResp, serializedSpace), nil } +// description is force-sent on update so that clearing it in config clears it on the space. +// Without this the omitempty field is dropped from the request and the old value drifts back on +// every plan. Verified against a real workspace: UpdateSpace accepts {"description": ""} as a clear. +var genieSpaceForceSend = []string{"Description"} + func (r *ResourceGenieSpace) DoUpdate(ctx context.Context, id string, config *resources.GenieSpaceConfig, _ *PlanEntry) (*resources.GenieSpaceConfig, error) { serializedSpace, err := prepareGenieSpaceRequest(config) if err != nil { @@ -198,7 +204,7 @@ func (r *ResourceGenieSpace) DoUpdate(ctx context.Context, id string, config *re SerializedSpace: serializedSpace, Etag: "", - ForceSendFields: utils.FilterFields[dashboards.GenieUpdateSpaceRequest](config.ForceSendFields), + ForceSendFields: utils.FilterFields[dashboards.GenieUpdateSpaceRequest](append(slices.Clone(genieSpaceForceSend), config.ForceSendFields...)), }) if err != nil { return nil, err diff --git a/bundle/direct/dresources/genie_space_test.go b/bundle/direct/dresources/genie_space_test.go index f77289083d3..a7bd8b0c2ed 100644 --- a/bundle/direct/dresources/genie_space_test.go +++ b/bundle/direct/dresources/genie_space_test.go @@ -122,8 +122,9 @@ func TestGenieSpaceDoUpdateRoundTripsEtag(t *testing.T) { // the response is persisted, for drift detection on the next plan. m.GetMockGenieAPI().EXPECT(). UpdateSpace(ctx, dashboards.GenieUpdateSpaceRequest{ - SpaceId: "space-id", - Title: "new", + SpaceId: "space-id", + Title: "new", + ForceSendFields: []string{"Description"}, }). Return(&dashboards.GenieSpace{ SpaceId: "space-id", @@ -159,6 +160,7 @@ func TestGenieSpaceDoUpdateAlwaysSendsSerializedSpace(t *testing.T) { SpaceId: "space-id", Title: "new", SerializedSpace: "{\"converge\":\"me\"}", + ForceSendFields: []string{"Description"}, }). Return(&dashboards.GenieSpace{ SpaceId: "space-id", diff --git a/bundle/direct/dresources/secret.go b/bundle/direct/dresources/secret.go index 38c8caf84c9..5e6e01b0f95 100644 --- a/bundle/direct/dresources/secret.go +++ b/bundle/direct/dresources/secret.go @@ -2,6 +2,7 @@ package dresources import ( "context" + "slices" "github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/bundle/deployplan" @@ -86,11 +87,18 @@ func (r *ResourceSecret) DoCreate(ctx context.Context, state *catalog.Secret) (s return response.FullName, response, nil } +// comment is force-sent on update so that clearing it in config clears it on the secret. The +// update_mask is "*" and the backend merges, so an omitempty comment would be dropped from the +// body and the old value would drift back. Verified against a real workspace: {"comment": ""} clears it. +var secretForceSend = []string{"Comment"} + // DoUpdate updates the secret in place and returns remote state. func (r *ResourceSecret) DoUpdate(ctx context.Context, id string, state *catalog.Secret, _ *PlanEntry) (*catalog.Secret, error) { + secret := *state + secret.ForceSendFields = utils.FilterFields[catalog.Secret](append(slices.Clone(secretForceSend), state.ForceSendFields...)) response, err := r.client.SecretsUc.UpdateSecret(ctx, catalog.UpdateSecretRequest{ FullName: id, - Secret: *state, + Secret: secret, UpdateMask: fieldmask.FieldMask{ Paths: []string{"*"}, }, diff --git a/libs/testserver/genie_spaces.go b/libs/testserver/genie_spaces.go index b60df650dfd..1ca6118427e 100644 --- a/libs/testserver/genie_spaces.go +++ b/libs/testserver/genie_spaces.go @@ -159,6 +159,10 @@ func (s *FakeWorkspace) GenieSpaceUpdate(req Request) Response { } } + // Which fields the caller actually sent, so an explicit "description": "" (the CLI + // force-sends it) clears the value instead of being treated as "not provided". + fields, _ := parseUpdateFields(req.Body) + // Optimistic concurrency: if the caller sent an etag, it must match the // current one. Empty etag means apply unconditionally. if updateReq.Etag != "" && updateReq.Etag != genieSpace.Etag { @@ -174,7 +178,7 @@ func (s *FakeWorkspace) GenieSpaceUpdate(req Request) Response { if updateReq.Title != "" { genieSpace.Title = updateReq.Title } - if updateReq.Description != "" { + if _, ok := fields["description"]; ok { genieSpace.Description = updateReq.Description } if updateReq.WarehouseId != "" { diff --git a/libs/testserver/uc_secrets.go b/libs/testserver/uc_secrets.go index dda512ebe6e..28bacf1fbed 100644 --- a/libs/testserver/uc_secrets.go +++ b/libs/testserver/uc_secrets.go @@ -133,6 +133,10 @@ func (s *FakeWorkspace) SecretsUcUpdateSecret(req Request) Response { } } + // Which fields the caller actually sent, so an explicit "comment": "" (the CLI force-sends it + // under update_mask=*) clears the value instead of being treated as "not provided". + fields, _ := parseUpdateFields(req.Body) + secret, exists := s.UCSecrets[fullName] if !exists { return Response{ @@ -148,7 +152,7 @@ func (s *FakeWorkspace) SecretsUcUpdateSecret(req Request) Response { if updateSecret.Value != "" { secret.Value = updateSecret.Value } - if updateSecret.Comment != "" { + if _, ok := fields["comment"]; ok { secret.Comment = updateSecret.Comment } if updateSecret.Owner != "" { From 490c0eeaba5156101dae3319275b3e5c2c87154c Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 22 Sep 2026 12:29:29 +0200 Subject: [PATCH 2/2] Add changelog fragment Co-authored-by: Isaac --- .nextchanges/bundles/clear-genie-description-secret-comment.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .nextchanges/bundles/clear-genie-description-secret-comment.md diff --git a/.nextchanges/bundles/clear-genie-description-secret-comment.md b/.nextchanges/bundles/clear-genie-description-secret-comment.md new file mode 100644 index 00000000000..c1909cf7fec --- /dev/null +++ b/.nextchanges/bundles/clear-genie-description-secret-comment.md @@ -0,0 +1 @@ +* direct: Allow clearing a genie space's `description` and a secret's `comment` by removing them from configuration. ([#6789](https://github.com/databricks/cli/pull/6789))