Skip to content

Commit 3900690

Browse files
mgyuchtalexottnkvuong
authored
[Fix] Only allow authorized_paths to be updated in the options field of databricks_catalog (#4517)
## Changes There was a misunderstanding with the Unity Catalog API in connection with #4476. Apparently, only `authorized_paths` is eligible for update in the UC Catalog API. Otherwise, any other changes to `options` must trigger resource recreation. This PR customizes the logic of `databricks_catalog` to do this. To be specific: * `CustomizeDiff` marks the `options` field as `ForceNew` if there is a key other than `authorized_paths` which was updated. * The `Update` command removes any options that aren't `authorized_paths`. This might be the case in the future if other options are added for HMS catalogs that can be set at create time but not updated. * The `Create` command removes any options from the `UpdateCatalog` RPC. ## Tests Added two integration tests to verify the behavior of `databricks_catalog` update. * Updated `TestUcAccCatalogUpdate` to verify that adding options with a key other than `authorized_paths` to a catalog defined without any options causes the catalog to be recreated. * Added `TestUcAccCatalogHmsConnectionUpdate` which tests the lifecycle of HMS catalogs, including updating the `authorized_paths` option, enforcing that the catalog resource is never deleted during the lifecycle of the test. --------- Co-authored-by: Alex Ott <[email protected]> Co-authored-by: vuong-nguyen <[email protected]>
1 parent 07702db commit 3900690

File tree

4 files changed

+196
-0
lines changed

4 files changed

+196
-0
lines changed

NEXT_CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
### Bug Fixes
1111

12+
* Only allow `authorized_paths` to be updated in the `options` field of `databricks_catalog` ([#4517](https://github.com/databricks/terraform-provider-databricks/pull/4517)).
1213
* Mark `default_catalog_name` attribute in `databricks_metastore_assignment` as deprecated ([#4522](https://github.com/databricks/terraform-provider-databricks/pull/4522))
1314
* Delete `databricks_sql_endpoint` that failed to start ([#4520](https://github.com/databricks/terraform-provider-databricks/pull/4520))
1415

catalog/catalog_test.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
package catalog_test
22

33
import (
4+
"context"
45
"fmt"
6+
"regexp"
7+
"strings"
58
"testing"
69

710
"github.com/databricks/terraform-provider-databricks/internal/acceptance"
11+
"github.com/databricks/terraform-provider-databricks/qa"
12+
tfjson "github.com/hashicorp/terraform-json"
13+
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
14+
"github.com/hashicorp/terraform-plugin-testing/plancheck"
815
)
916

1017
func TestUcAccCatalog(t *testing.T) {
@@ -55,6 +62,35 @@ func TestUcAccCatalogIsolated(t *testing.T) {
5562
})
5663
}
5764

65+
type checkResourceRecreate struct {
66+
address string
67+
}
68+
69+
func (c checkResourceRecreate) CheckPlan(ctx context.Context, req plancheck.CheckPlanRequest, resp *plancheck.CheckPlanResponse) {
70+
var change *tfjson.ResourceChange
71+
for _, resourceChange := range req.Plan.ResourceChanges {
72+
if resourceChange.Address == c.address {
73+
change = resourceChange
74+
break
75+
}
76+
}
77+
if change == nil {
78+
addressesWithPlannedChanges := make([]string, 0, len(req.Plan.ResourceChanges))
79+
for _, change := range req.Plan.ResourceChanges {
80+
addressesWithPlannedChanges = append(addressesWithPlannedChanges, change.Address)
81+
}
82+
resp.Error = fmt.Errorf("address %s not found in resource changes; only planned changes for addresses %s", c.address, strings.Join(addressesWithPlannedChanges, ", "))
83+
return
84+
}
85+
if change.Change.Actions[0] != tfjson.ActionDelete {
86+
plannedActions := make([]string, 0, len(change.Change.Actions))
87+
for _, action := range change.Change.Actions {
88+
plannedActions = append(plannedActions, string(action))
89+
}
90+
resp.Error = fmt.Errorf("no delete is planned for %s; planned actions are: %s", c.address, strings.Join(plannedActions, ", "))
91+
}
92+
}
93+
5894
func TestUcAccCatalogUpdate(t *testing.T) {
5995
acceptance.LoadUcwsEnv(t)
6096
acceptance.UnityWorkspaceLevel(t, acceptance.Step{
@@ -100,5 +136,124 @@ func TestUcAccCatalogUpdate(t *testing.T) {
100136
%s
101137
owner = "{env.TEST_METASTORE_ADMIN_GROUP_NAME}"
102138
}`, getPredictiveOptimizationSetting(t, false)),
139+
}, acceptance.Step{
140+
// Adding options should cause the catalog to be recreated.
141+
Template: fmt.Sprintf(`
142+
resource "databricks_catalog" "sandbox" {
143+
name = "sandbox{var.STICKY_RANDOM}"
144+
comment = "this catalog is managed by terraform - updated comment"
145+
properties = {
146+
purpose = "testing"
147+
}
148+
options = {
149+
user = "miles"
150+
}
151+
%s
152+
owner = "{env.TEST_METASTORE_ADMIN_GROUP_NAME}"
153+
}`, getPredictiveOptimizationSetting(t, false)),
154+
ConfigPlanChecks: resource.ConfigPlanChecks{
155+
PreApply: []plancheck.PlanCheck{
156+
checkResourceRecreate{address: "databricks_catalog.sandbox"},
157+
},
158+
},
159+
})
160+
}
161+
162+
// Create a connection to an HMS catalog and update authorized_paths.
163+
func TestUcAccCatalogHmsConnectionUpdate(t *testing.T) {
164+
authorizedPath := fmt.Sprintf("s3://%s/path/to/authorized", qa.RandomName("hms-bucket-"))
165+
otherAuthorizedPath := fmt.Sprintf("s3://%s/path/to/authorized", qa.RandomName("hms-other-bucket-"))
166+
otherInfra := fmt.Sprintf(`
167+
resource "databricks_connection" "sandbox" {
168+
name = "hms_connection{var.STICKY_RANDOM}"
169+
connection_type = "HIVE_METASTORE"
170+
comment = "created in TestUcAccCatalogHmsConnectionUpdate"
171+
options = {
172+
host = "test.mysql.database.azure.com"
173+
port = "3306"
174+
user = "user"
175+
password = "password"
176+
database = "metastore"
177+
db_type = "MYSQL"
178+
version = "2.3"
179+
}
180+
properties = {
181+
purpose = "testing"
182+
}
183+
}
184+
resource "databricks_storage_credential" "external" {
185+
name = "cred-{var.STICKY_RANDOM}"
186+
aws_iam_role {
187+
role_arn = "{env.TEST_METASTORE_DATA_ACCESS_ARN}"
188+
}
189+
comment = "created in TestUcAccCatalogHmsConnectionUpdate"
190+
}
191+
resource "databricks_external_location" "sandbox" {
192+
name = "sandbox{var.STICKY_RANDOM}"
193+
comment = "created in TestUcAccCatalogHmsConnectionUpdate"
194+
url = "%s"
195+
credential_name = "${databricks_storage_credential.external.name}"
196+
skip_validation = true
197+
}
198+
resource "databricks_external_location" "sandbox-other" {
199+
name = "sandbox-other{var.STICKY_RANDOM}"
200+
comment = "created in TestUcAccCatalogHmsConnectionUpdate"
201+
url = "%s"
202+
credential_name = "${databricks_storage_credential.external.name}"
203+
skip_validation = true
204+
}
205+
`, authorizedPath, otherAuthorizedPath)
206+
acceptance.UnityWorkspaceLevel(t, acceptance.Step{
207+
Template: otherInfra + fmt.Sprintf(`
208+
resource "databricks_catalog" "sandbox" {
209+
name = "sandbox{var.STICKY_RANDOM}"
210+
comment = "created in TestUcAccCatalogHmsConnectionUpdate"
211+
connection_name = "${databricks_connection.sandbox.name}"
212+
options = {
213+
authorized_paths = "%s"
214+
}
215+
lifecycle {
216+
prevent_destroy = true
217+
}
218+
depends_on = [databricks_external_location.sandbox, databricks_external_location.sandbox-other]
219+
}`, authorizedPath),
220+
}, acceptance.Step{
221+
Template: otherInfra + fmt.Sprintf(`
222+
resource "databricks_catalog" "sandbox" {
223+
name = "sandbox{var.STICKY_RANDOM}"
224+
comment = "created in TestUcAccCatalogHmsConnectionUpdate"
225+
connection_name = "${databricks_connection.sandbox.name}"
226+
options = {
227+
authorized_paths = "%s,%s"
228+
}
229+
lifecycle {
230+
prevent_destroy = true
231+
}
232+
}`, authorizedPath, otherAuthorizedPath),
233+
}, acceptance.Step{
234+
Template: otherInfra + fmt.Sprintf(`
235+
resource "databricks_catalog" "sandbox" {
236+
name = "sandbox{var.STICKY_RANDOM}"
237+
comment = "created in TestUcAccCatalogHmsConnectionUpdate"
238+
connection_name = "${databricks_connection.sandbox.name}"
239+
options = {
240+
authorized_paths = "%s,%s"
241+
other_option = "value"
242+
}
243+
lifecycle {
244+
prevent_destroy = true
245+
}
246+
}`, authorizedPath, otherAuthorizedPath),
247+
ExpectError: regexp.MustCompile("Instance cannot be destroyed"),
248+
}, acceptance.Step{
249+
Template: otherInfra + fmt.Sprintf(`
250+
resource "databricks_catalog" "sandbox" {
251+
name = "sandbox{var.STICKY_RANDOM}"
252+
comment = "created in TestUcAccCatalogHmsConnectionUpdate"
253+
connection_name = "${databricks_connection.sandbox.name}"
254+
options = {
255+
authorized_paths = "%s,%s"
256+
}
257+
}`, authorizedPath, otherAuthorizedPath),
103258
})
104259
}

catalog/resource_catalog.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ func ResourceCatalog() common.Resource {
9494
var updateCatalogRequest catalog.UpdateCatalog
9595
common.DataToStructPointer(d, catalogSchema, &updateCatalogRequest)
9696
updateCatalogRequest.Name = d.Id()
97+
// Options must be set in the create request only (aside from HMS-backed catalogs).
98+
updateCatalogRequest.Options = nil
9799
_, err = w.Catalogs.Update(ctx, updateCatalogRequest)
98100
if err != nil {
99101
return err
@@ -151,6 +153,14 @@ func ResourceCatalog() common.Resource {
151153
}
152154

153155
updateCatalogRequest.Owner = ""
156+
// The only option allowed in update is "authorized_paths". All other options must be removed.
157+
if opts := updateCatalogRequest.Options; opts != nil {
158+
if v, ok := opts["authorized_paths"]; ok {
159+
updateCatalogRequest.Options = map[string]string{"authorized_paths": v}
160+
} else {
161+
updateCatalogRequest.Options = nil
162+
}
163+
}
154164
ci, err := w.Catalogs.Update(ctx, updateCatalogRequest)
155165

156166
if err != nil {
@@ -204,5 +214,33 @@ func ResourceCatalog() common.Resource {
204214
}
205215
return w.Catalogs.Delete(ctx, catalog.DeleteCatalogRequest{Force: force, Name: d.Id()})
206216
},
217+
CustomizeDiff: func(ctx context.Context, d *schema.ResourceDiff) error {
218+
// The only scenario in which we can update options is for the `authorized_paths` key. Any
219+
// other changes to the options field will result in an error.
220+
if d.HasChange("options") {
221+
old, new := d.GetChange("options")
222+
oldMap := old.(map[string]interface{})
223+
newMap := new.(map[string]interface{})
224+
delete(oldMap, "authorized_paths")
225+
delete(newMap, "authorized_paths")
226+
// If any attribute other than `authorized_paths` is removed, the resource should be recreated.
227+
for k := range oldMap {
228+
if _, ok := newMap[k]; !ok {
229+
if err := d.ForceNew("options"); err != nil {
230+
return err
231+
}
232+
}
233+
}
234+
// If any attribute other than `authorized_paths` is added or changed, the resource should be recreated.
235+
for k, v := range newMap {
236+
if oldV, ok := oldMap[k]; !ok || oldV != v {
237+
if err := d.ForceNew("options"); err != nil {
238+
return err
239+
}
240+
}
241+
}
242+
}
243+
return nil
244+
},
207245
}
208246
}

internal/acceptance/init.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ type Step struct {
9292
Destroy bool
9393
ExpectNonEmptyPlan bool
9494
ExpectError *regexp.Regexp
95+
ConfigPlanChecks resource.ConfigPlanChecks
9596
PlanOnly bool
9697
PreventDiskCleanup bool
9798
PreventPostDestroyRefresh bool
@@ -219,6 +220,7 @@ func run(t *testing.T, steps []Step) {
219220
Config: stepConfig,
220221
Destroy: s.Destroy,
221222
ExpectNonEmptyPlan: s.ExpectNonEmptyPlan,
223+
ConfigPlanChecks: s.ConfigPlanChecks,
222224
PlanOnly: s.PlanOnly,
223225
PreventDiskCleanup: s.PreventDiskCleanup,
224226
PreventPostDestroyRefresh: s.PreventPostDestroyRefresh,

0 commit comments

Comments
 (0)