Skip to content

Commit 48c9c4e

Browse files
oarbusimarcosumalantolijwilliams-mongo
authored
fix: Handles updates of mongodbatlas_global_cluster_config (#3060)
* handle update * change error message * changelog * docs change * test changes * Update docs/resources/global_cluster_config.md Co-authored-by: Marco Suma <[email protected]> * reference API * add partial and total removal of custom_zone_mappings in test * refactor update * pr suggestions * Update internal/service/globalclusterconfig/resource_global_cluster_config.go Co-authored-by: Leo Antoli <[email protected]> * Update docs/resources/global_cluster_config.md Co-authored-by: John Williams <[email protected]> --------- Co-authored-by: Marco Suma <[email protected]> Co-authored-by: Leo Antoli <[email protected]> Co-authored-by: John Williams <[email protected]>
1 parent aa1531e commit 48c9c4e

File tree

4 files changed

+176
-7
lines changed

4 files changed

+176
-7
lines changed

.changelog/3060.txt

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```release-note:enhancement
2+
resource/mongodbatlas_global_cluster_config: Supports update operation
3+
```

docs/resources/global_cluster_config.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
-> **NOTE:** This resource can only be used with Atlas-managed clusters. See doc for `global_cluster_self_managed_sharding` attribute in [`mongodbatlas_advanced_cluster` resource](https://registry.terraform.io/providers/mongodb/mongodbatlas/latest/docs/resources/advanced_cluster) for more info.
88

9-
~> **IMPORTANT:** A Global Cluster Configuration, once created, can only be deleted. You can recreate the Global Cluster with the same data only in the Atlas UI. This is because the configuration and its related collection with shard key and indexes are managed separately and they would end up in an inconsistent state. [Read more about Global Cluster Configuration](https://www.mongodb.com/docs/atlas/global-clusters/)
9+
~> **IMPORTANT:** You can update a Global Cluster Configuration to add new custom zone mappings and managed namespaces. However, once configured, you can't modify or partially delete custom zone mappings (you must remove them all at once). You can add or remove, but can't modify, managed namespaces. Any update that changes an existing managed namespace results in an error. [Read more about Global Cluster Configuration](https://www.mongodb.com/docs/atlas/global-clusters/). For more details, see [Global Clusters API](https://www.mongodb.com/docs/atlas/reference/api-resources-spec/v2/#tag/Global-Clusters)
1010

1111
## Examples Usage
1212

internal/service/globalclusterconfig/resource_global_cluster_config.go

+133-3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"reflect"
78
"strings"
89
"time"
910

@@ -211,9 +212,38 @@ func resourceRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Di
211212
}
212213

213214
func resourceUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
214-
return diag.Errorf("Updating a global cluster configuration resource is not allowed as it would " +
215-
"leave the index and shard key on the related collection in an inconsistent state.\n" +
216-
"Please read our official documentation for more information.")
215+
connV2 := meta.(*config.MongoDBClient).AtlasV2
216+
ids := conversion.DecodeStateID(d.Id())
217+
projectID := ids["project_id"]
218+
clusterName := ids["cluster_name"]
219+
220+
if d.HasChange("managed_namespaces") {
221+
oldMN, newMN := d.GetChange("managed_namespaces")
222+
oldList := oldMN.(*schema.Set).List()
223+
newList := newMN.(*schema.Set).List()
224+
if err := updateManagedNamespaces(ctx, connV2, projectID, clusterName, oldList, newList); err != nil {
225+
return diag.FromErr(fmt.Errorf(errorGlobalClusterUpdate, clusterName, err))
226+
}
227+
}
228+
229+
if d.HasChange("custom_zone_mappings") {
230+
oldZN, newZN := d.GetChange("custom_zone_mappings")
231+
oldSet := oldZN.(*schema.Set)
232+
newSet := newZN.(*schema.Set)
233+
if err := updateCustomZoneMappings(ctx, connV2, projectID, clusterName, oldSet, newSet); err != nil {
234+
return diag.FromErr(fmt.Errorf(errorGlobalClusterUpdate, clusterName, err))
235+
}
236+
}
237+
return resourceRead(ctx, d, meta)
238+
}
239+
240+
// convertInterfaceSlice is a helper function that converts []map[string]any into []any
241+
func convertInterfaceSlice(input []map[string]any) []any {
242+
var out []any
243+
for _, v := range input {
244+
out = append(out, v)
245+
}
246+
return out
217247
}
218248

219249
func resourceDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
@@ -335,3 +365,103 @@ func newCustomZoneMappings(tfList []any) *[]admin.ZoneMapping {
335365

336366
return &apiObjects
337367
}
368+
369+
func addManagedNamespaces(ctx context.Context, connV2 *admin.APIClient, add []any, projectID, clusterName string) error {
370+
for _, m := range add {
371+
mn := m.(map[string]any)
372+
373+
addManagedNamespace := &admin.ManagedNamespaces{
374+
Collection: mn["collection"].(string),
375+
Db: mn["db"].(string),
376+
CustomShardKey: mn["custom_shard_key"].(string),
377+
}
378+
if isCustomShardKeyHashed, okCustomShard := mn["is_custom_shard_key_hashed"]; okCustomShard {
379+
addManagedNamespace.IsCustomShardKeyHashed = conversion.Pointer[bool](isCustomShardKeyHashed.(bool))
380+
}
381+
if isShardKeyUnique, okShard := mn["is_shard_key_unique"]; okShard {
382+
addManagedNamespace.IsShardKeyUnique = conversion.Pointer[bool](isShardKeyUnique.(bool))
383+
}
384+
_, _, err := connV2.GlobalClustersApi.CreateManagedNamespace(ctx, projectID, clusterName, addManagedNamespace).Execute()
385+
if err != nil {
386+
return err
387+
}
388+
}
389+
return nil
390+
}
391+
392+
// buildManagedNamespacesMap converts a list of managed_namespace entries into a map keyed by "collection:db"
393+
func buildManagedNamespacesMap(list []any) map[string]map[string]any {
394+
namespacesMap := make(map[string]map[string]any)
395+
for _, item := range list {
396+
m := item.(map[string]any)
397+
key := fmt.Sprintf("%s:%s", m["collection"].(string), m["db"].(string))
398+
namespacesMap[key] = m
399+
}
400+
return namespacesMap
401+
}
402+
403+
// diffManagedNamespaces calculates the difference between old and new managed_namespaces.
404+
// Returns slices of namespaces to add and remove; errors out on modifications.
405+
func diffManagedNamespaces(oldList, newList []any) (toAdd, toRemove []map[string]any, err error) {
406+
oldMap := buildManagedNamespacesMap(oldList)
407+
newMap := buildManagedNamespacesMap(newList)
408+
for key, oldEntry := range oldMap {
409+
if newEntry, exists := newMap[key]; exists {
410+
// Modification is not allowed.
411+
if !reflect.DeepEqual(oldEntry, newEntry) {
412+
return nil, nil, fmt.Errorf("managed namespace for collection '%s' in db '%s' cannot be modified", oldEntry["collection"], oldEntry["db"])
413+
}
414+
} else {
415+
toRemove = append(toRemove, oldEntry)
416+
}
417+
}
418+
for key, newEntry := range newMap {
419+
if _, exists := oldMap[key]; !exists {
420+
toAdd = append(toAdd, newEntry)
421+
}
422+
}
423+
return toAdd, toRemove, nil
424+
}
425+
426+
// updateManagedNamespaces encapsulates diffing and applying removals/additions.
427+
func updateManagedNamespaces(ctx context.Context, connV2 *admin.APIClient, projectID, clusterName string, oldList, newList []any) error {
428+
toAdd, toRemove, err := diffManagedNamespaces(oldList, newList)
429+
if err != nil {
430+
return err
431+
}
432+
if len(toRemove) > 0 {
433+
if err := removeManagedNamespaces(ctx, connV2, convertInterfaceSlice(toRemove), projectID, clusterName); err != nil {
434+
return err
435+
}
436+
}
437+
if len(toAdd) > 0 {
438+
if err := addManagedNamespaces(ctx, connV2, convertInterfaceSlice(toAdd), projectID, clusterName); err != nil {
439+
return err
440+
}
441+
}
442+
return nil
443+
}
444+
445+
// updateCustomZoneMappings encapsulates diffing and applying changes for custom_zone_mappings.
446+
func updateCustomZoneMappings(ctx context.Context, connV2 *admin.APIClient, projectID, clusterName string, oldSet, newSet *schema.Set) error {
447+
removed := oldSet.Difference(newSet).List()
448+
added := newSet.Difference(oldSet).List()
449+
450+
if len(removed) > 0 {
451+
// Allow deletion only if all mappings are removed.
452+
if newSet.Len() != 0 {
453+
return fmt.Errorf("partial deletion of custom_zone_mappings is not allowed; remove either all mappings or none")
454+
}
455+
if _, _, err := connV2.GlobalClustersApi.DeleteAllCustomZoneMappings(ctx, projectID, clusterName).Execute(); err != nil {
456+
return err
457+
}
458+
}
459+
if len(added) > 0 {
460+
if _, _, err := connV2.GlobalClustersApi.CreateCustomZoneMapping(ctx, projectID, clusterName, &admin.CustomZoneMappings{
461+
CustomZoneMappings: newCustomZoneMappings(added),
462+
}).Execute(); err != nil {
463+
return err
464+
}
465+
}
466+
return nil
467+
}

internal/service/globalclusterconfig/resource_global_cluster_config_test.go

+39-3
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ func basicTestCase(tb testing.TB, checkZoneID, withBackup bool) *resource.TestCa
6161
},
6262
{
6363
Config: configBasic(&clusterInfo, true, false),
64-
ExpectError: regexp.MustCompile("Updating a global cluster configuration resource is not allowed"),
64+
ExpectError: regexp.MustCompile("managed namespace for collection 'publishers' in db 'mydata' cannot be modified"),
6565
},
6666
},
6767
}
@@ -137,8 +137,44 @@ func TestAccGlobalClusterConfig_database(t *testing.T) {
137137
),
138138
},
139139
{
140-
Config: configWithDBConfig(&clusterInfo, customZoneUpdated),
141-
ExpectError: regexp.MustCompile("Updating a global cluster configuration resource is not allowed"),
140+
Config: configWithDBConfig(&clusterInfo, customZoneUpdated),
141+
Check: resource.ComposeAggregateTestCheckFunc(
142+
checkExists(resourceName),
143+
checkZone(0, "US", clusterInfo.ResourceName, true),
144+
checkZone(1, "IE", clusterInfo.ResourceName, true),
145+
checkZone(2, "DE", clusterInfo.ResourceName, true),
146+
checkZone(3, "JP", clusterInfo.ResourceName, true),
147+
acc.CheckRSAndDS(resourceName, conversion.Pointer(dataSourceName), nil,
148+
[]string{"project_id"},
149+
map[string]string{
150+
"cluster_name": clusterInfo.Name,
151+
"managed_namespaces.#": "5",
152+
"managed_namespaces.0.is_custom_shard_key_hashed": "false",
153+
"managed_namespaces.0.is_shard_key_unique": "false",
154+
"custom_zone_mapping_zone_id.%": "4",
155+
"custom_zone_mapping.%": "4",
156+
}),
157+
),
158+
},
159+
{
160+
Config: configWithDBConfig(&clusterInfo, customZone),
161+
ExpectError: regexp.MustCompile("partial deletion of custom_zone_mappings is not allowed; remove either all mappings or none"),
162+
},
163+
{
164+
Config: configWithDBConfig(&clusterInfo, ""),
165+
Check: resource.ComposeAggregateTestCheckFunc(
166+
checkExists(resourceName),
167+
acc.CheckRSAndDS(resourceName, conversion.Pointer(dataSourceName), nil,
168+
[]string{"project_id"},
169+
map[string]string{
170+
"cluster_name": clusterInfo.Name,
171+
"managed_namespaces.#": "5",
172+
"managed_namespaces.0.is_custom_shard_key_hashed": "false",
173+
"managed_namespaces.0.is_shard_key_unique": "false",
174+
"custom_zone_mapping_zone_id.%": "0",
175+
"custom_zone_mapping.%": "0",
176+
}),
177+
),
142178
},
143179
{
144180
ResourceName: resourceName,

0 commit comments

Comments
 (0)