Skip to content
3 changes: 3 additions & 0 deletions .changelog/3060.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:enhancement
resource/mongodbatlas_global_cluster_config: Supports update operation
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be easier to review if this was done in two PRs, one with the revert without any additional change, next PR with the changes, so we can spot better the real changes after the revert

```
2 changes: 1 addition & 1 deletion docs/resources/global_cluster_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

-> **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.

~> **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/)
~> **IMPORTANT:** A Global Cluster Configuration can be updated to add new custom zone mappings and managed namespaces. However, once configured, custom zone mappings cannot be modified or partially deleted (you must remove them all at once), and managed namespaces can be added or removed but cannot be modified. Any update that would change an existing managed namespace will result 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)

## Examples Usage

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"reflect"
"strings"
"time"

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

func resourceUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
return diag.Errorf("Updating a global cluster configuration resource is not allowed as it would " +
"leave the index and shard key on the related collection in an inconsistent state.\n" +
"Please read our official documentation for more information.")
connV2 := meta.(*config.MongoDBClient).AtlasV2
ids := conversion.DecodeStateID(d.Id())
projectID := ids["project_id"]
clusterName := ids["cluster_name"]

if d.HasChange("managed_namespaces") {
oldMN, newMN := d.GetChange("managed_namespaces")
oldList := oldMN.(*schema.Set).List()
newList := newMN.(*schema.Set).List()
if err := updateManagedNamespaces(ctx, connV2, projectID, clusterName, oldList, newList); err != nil {
return diag.FromErr(fmt.Errorf(errorGlobalClusterUpdate, clusterName, err))
}
}

if d.HasChange("custom_zone_mappings") {
oldZN, newZN := d.GetChange("custom_zone_mappings")
oldSet := oldZN.(*schema.Set)
newSet := newZN.(*schema.Set)
if err := updateCustomZoneMappings(ctx, connV2, projectID, clusterName, oldSet, newSet); err != nil {
return diag.FromErr(fmt.Errorf(errorGlobalClusterUpdate, clusterName, err))
}
}
return resourceRead(ctx, d, meta)
}

// convertInterfaceSlice is a helper function that converts []map[string]any into []any
func convertInterfaceSlice(input []map[string]any) []any {
var out []any
for _, v := range input {
out = append(out, v)
}
return out
}

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

return &apiObjects
}

func addManagedNamespaces(ctx context.Context, connV2 *admin.APIClient, add []any, projectID, clusterName string) error {
for _, m := range add {
mn := m.(map[string]any)

addManagedNamespace := &admin.ManagedNamespaces{
Collection: mn["collection"].(string),
Db: mn["db"].(string),
CustomShardKey: mn["custom_shard_key"].(string),
}
if isCustomShardKeyHashed, okCustomShard := mn["is_custom_shard_key_hashed"]; okCustomShard {
addManagedNamespace.IsCustomShardKeyHashed = conversion.Pointer[bool](isCustomShardKeyHashed.(bool))
}
if isShardKeyUnique, okShard := mn["is_shard_key_unique"]; okShard {
addManagedNamespace.IsShardKeyUnique = conversion.Pointer[bool](isShardKeyUnique.(bool))
}
_, _, err := connV2.GlobalClustersApi.CreateManagedNamespace(ctx, projectID, clusterName, addManagedNamespace).Execute()
Copy link
Collaborator

@maastha maastha Feb 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens in case this errors out for a particular namespace? Right now I think it would just fail right away and not complete the loop. Should we instead capture errors from all calls and fail once?
Same for deleting namespaces and adding custom zone mappings

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think failing right away is okay, but I haven't thought about the alternative or if we should complete the loop in case of a single failure. No strong opinion from me on this

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm okay failing right away. Only thing to consider is users might run follow-up updates in those cases, and as long as that works as expected and doesn't run into any state inconsistencies this should be fine.

if err != nil {
return err
}
}
return nil
}

// buildManagedNamespacesMap converts a list of managed_namespace entries into a map keyed by "collection:db"
func buildManagedNamespacesMap(list []any) map[string]map[string]any {
namespacesMap := make(map[string]map[string]any)
for _, item := range list {
m := item.(map[string]any)
key := fmt.Sprintf("%s:%s", m["collection"].(string), m["db"].(string))
namespacesMap[key] = m
}
return namespacesMap
}

// diffManagedNamespaces calculates the difference between old and new managed_namespaces.
// Returns slices of namespaces to add and remove; errors out on modifications.
func diffManagedNamespaces(oldList, newList []any) (toAdd, toRemove []map[string]any, err error) {
oldMap := buildManagedNamespacesMap(oldList)
newMap := buildManagedNamespacesMap(newList)
for key, oldEntry := range oldMap {
if newEntry, exists := newMap[key]; exists {
// Modification is not allowed.
if !reflect.DeepEqual(oldEntry, newEntry) {
return nil, nil, fmt.Errorf("managed namespace for collection '%s' in db '%s' cannot be modified", oldEntry["collection"], oldEntry["db"])
}
} else {
toRemove = append(toRemove, oldEntry)
}
}
for key, newEntry := range newMap {
if _, exists := oldMap[key]; !exists {
toAdd = append(toAdd, newEntry)
}
}
return toAdd, toRemove, nil
}

// updateManagedNamespaces encapsulates diffing and applying removals/additions.
func updateManagedNamespaces(ctx context.Context, connV2 *admin.APIClient, projectID, clusterName string, oldList, newList []any) error {
toAdd, toRemove, err := diffManagedNamespaces(oldList, newList)
if err != nil {
return err
}
if len(toRemove) > 0 {
if err := removeManagedNamespaces(ctx, connV2, convertInterfaceSlice(toRemove), projectID, clusterName); err != nil {
return err
}
}
if len(toAdd) > 0 {
if err := addManagedNamespaces(ctx, connV2, convertInterfaceSlice(toAdd), projectID, clusterName); err != nil {
return err
}
}
return nil
}

// updateCustomZoneMappings encapsulates diffing and applying changes for custom_zone_mappings.
func updateCustomZoneMappings(ctx context.Context, connV2 *admin.APIClient, projectID, clusterName string, oldSet, newSet *schema.Set) error {
removed := oldSet.Difference(newSet).List()
added := newSet.Difference(oldSet).List()

if len(removed) > 0 {
// Allow deletion only if all mappings are removed.
if newSet.Len() != 0 {
return fmt.Errorf("partial deletion of custom_zone_mappings is not allowed; remove either all mappings or none")
}
if _, _, err := connV2.GlobalClustersApi.DeleteAllCustomZoneMappings(ctx, projectID, clusterName).Execute(); err != nil {
return err
}
}
if len(added) > 0 {
if _, _, err := connV2.GlobalClustersApi.CreateCustomZoneMapping(ctx, projectID, clusterName, &admin.CustomZoneMappings{
CustomZoneMappings: newCustomZoneMappings(added),
}).Execute(); err != nil {
return err
}
}
return nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func basicTestCase(tb testing.TB, checkZoneID, withBackup bool) *resource.TestCa
},
{
Config: configBasic(&clusterInfo, true, false),
ExpectError: regexp.MustCompile("Updating a global cluster configuration resource is not allowed"),
ExpectError: regexp.MustCompile("managed namespace for collection 'publishers' in db 'mydata' cannot be modified"),
},
},
}
Expand Down Expand Up @@ -137,8 +137,44 @@ func TestAccGlobalClusterConfig_database(t *testing.T) {
),
},
{
Config: configWithDBConfig(&clusterInfo, customZoneUpdated),
ExpectError: regexp.MustCompile("Updating a global cluster configuration resource is not allowed"),
Config: configWithDBConfig(&clusterInfo, customZoneUpdated),
Check: resource.ComposeAggregateTestCheckFunc(
checkExists(resourceName),
checkZone(0, "US", clusterInfo.ResourceName, true),
checkZone(1, "IE", clusterInfo.ResourceName, true),
checkZone(2, "DE", clusterInfo.ResourceName, true),
checkZone(3, "JP", clusterInfo.ResourceName, true),
acc.CheckRSAndDS(resourceName, conversion.Pointer(dataSourceName), nil,
[]string{"project_id"},
map[string]string{
"cluster_name": clusterInfo.Name,
"managed_namespaces.#": "5",
"managed_namespaces.0.is_custom_shard_key_hashed": "false",
"managed_namespaces.0.is_shard_key_unique": "false",
"custom_zone_mapping_zone_id.%": "4",
"custom_zone_mapping.%": "4",
}),
),
},
{
Config: configWithDBConfig(&clusterInfo, customZone),
ExpectError: regexp.MustCompile("partial deletion of custom_zone_mappings is not allowed; remove either all mappings or none"),
},
{
Config: configWithDBConfig(&clusterInfo, ""),
Check: resource.ComposeAggregateTestCheckFunc(
checkExists(resourceName),
acc.CheckRSAndDS(resourceName, conversion.Pointer(dataSourceName), nil,
[]string{"project_id"},
map[string]string{
"cluster_name": clusterInfo.Name,
"managed_namespaces.#": "5",
"managed_namespaces.0.is_custom_shard_key_hashed": "false",
"managed_namespaces.0.is_shard_key_unique": "false",
"custom_zone_mapping_zone_id.%": "0",
"custom_zone_mapping.%": "0",
}),
),
},
{
ResourceName: resourceName,
Expand Down