diff --git a/restapi/delta_checker.go b/restapi/delta_checker.go index 207a800a..077804e4 100644 --- a/restapi/delta_checker.go +++ b/restapi/delta_checker.go @@ -8,9 +8,10 @@ import ( /* * Performs a deep comparison of two maps - the resource as recorded in state, and the resource as returned by the API. * Accepts a third argument that is a set of fields that are to be ignored when looking for differences. + * Accepts a fourth argument ignoreServerAdditions - when true, fields added by the server (but not in recordedResource) will be ignored. * Returns 1. the recordedResource overlaid with fields that have been modified in actualResource but not ignored, and 2. a bool true if there were any changes. */ -func getDelta(recordedResource map[string]interface{}, actualResource map[string]interface{}, ignoreList []string) (modifiedResource map[string]interface{}, hasChanges bool) { +func getDelta(recordedResource map[string]interface{}, actualResource map[string]interface{}, ignoreList []string, ignoreServerAdditions bool) (modifiedResource map[string]interface{}, hasChanges bool) { modifiedResource = map[string]interface{}{} hasChanges = false @@ -46,7 +47,7 @@ func getDelta(recordedResource map[string]interface{}, actualResource map[string } // Recursively compare deeperIgnoreList := _descendIgnoreList(key, ignoreList) - if modifiedSubResource, hasChange := getDelta(subMapA, subMapB, deeperIgnoreList); hasChange { + if modifiedSubResource, hasChange := getDelta(subMapA, subMapB, deeperIgnoreList, ignoreServerAdditions); hasChange { modifiedResource[key] = modifiedSubResource hasChanges = true } else { @@ -85,6 +86,10 @@ func getDelta(recordedResource map[string]interface{}, actualResource map[string } // If we've gotten here, that means actualResource has an additional key that wasn't in recordedResource + // When ignoreServerAdditions is true, we don't consider server-added fields as changes + if ignoreServerAdditions { + continue + } modifiedResource[key] = valActual hasChanges = true } diff --git a/restapi/delta_checker_test.go b/restapi/delta_checker_test.go index 12cdfebd..c0e65f8e 100644 --- a/restapi/delta_checker_test.go +++ b/restapi/delta_checker_test.go @@ -296,7 +296,7 @@ func generateTypeConversionTests() []deltaTestCase { func TestHasDelta(t *testing.T) { // Run the main test cases for _, testCase := range deltaTestCases { - _, result := getDelta(testCase.o1, testCase.o2, testCase.ignoreList) + _, result := getDelta(testCase.o1, testCase.o2, testCase.ignoreList, false) if result != testCase.resultHasDelta { t.Errorf("delta_checker_test.go: Test Case [%s] wanted [%v] got [%v]", testCase.testCase, testCase.resultHasDelta, result) } @@ -304,7 +304,7 @@ func TestHasDelta(t *testing.T) { // Test type changes for _, testCase := range generateTypeConversionTests() { - _, result := getDelta(testCase.o1, testCase.o2, testCase.ignoreList) + _, result := getDelta(testCase.o1, testCase.o2, testCase.ignoreList, false) if result != testCase.resultHasDelta { t.Errorf("delta_checker_test.go: TYPE CONVERSION Test Case [%d:%s] wanted [%v] got [%v]", testCase.testId, testCase.testCase, testCase.resultHasDelta, result) } @@ -345,8 +345,80 @@ func TestHasDeltaModifiedResource(t *testing.T) { ignoreList := []string{"hairball", "hobbies.sleeping", "name"} - modified, _ := getDelta(recordedInput, actualInput, ignoreList) + modified, _ := getDelta(recordedInput, actualInput, ignoreList, false) if !reflect.DeepEqual(expectedOutput, modified) { t.Errorf("delta_checker_test.go: Unexpected delta: expected %v but got %v", expectedOutput, modified) } } + +func TestIgnoreServerAdditions(t *testing.T) { + testCases := []struct { + name string + recorded map[string]interface{} + actual map[string]interface{} + ignoreServerAdditions bool + expectedHasChanges bool + expectedResult map[string]interface{} + }{ + { + name: "Server adds field - not ignored", + recorded: MapAny{"foo": "bar"}, + actual: MapAny{"foo": "bar", "status": "active"}, + ignoreServerAdditions: false, + expectedHasChanges: true, + expectedResult: MapAny{"foo": "bar", "status": "active"}, + }, + { + name: "Server adds field - ignored", + recorded: MapAny{"foo": "bar"}, + actual: MapAny{"foo": "bar", "status": "active"}, + ignoreServerAdditions: true, + expectedHasChanges: false, + expectedResult: MapAny{"foo": "bar"}, + }, + { + name: "Server adds multiple fields - ignored", + recorded: MapAny{"name": "test"}, + actual: MapAny{"name": "test", "created_at": "2025-01-01", "updated_at": "2025-01-02", "status": "active"}, + ignoreServerAdditions: true, + expectedHasChanges: false, + expectedResult: MapAny{"name": "test"}, + }, + { + name: "Server changes configured field - not affected by ignoreServerAdditions", + recorded: MapAny{"name": "original"}, + actual: MapAny{"name": "changed", "status": "active"}, + ignoreServerAdditions: true, + expectedHasChanges: true, + expectedResult: MapAny{"name": "changed"}, + }, + { + name: "Server adds nested field - ignored", + recorded: MapAny{"config": MapAny{"enabled": true}}, + actual: MapAny{"config": MapAny{"enabled": true, "timestamp": "2025-01-01"}}, + ignoreServerAdditions: true, + expectedHasChanges: false, + expectedResult: MapAny{"config": MapAny{"enabled": true}}, + }, + { + name: "Server adds nested field - not ignored", + recorded: MapAny{"config": MapAny{"enabled": true}}, + actual: MapAny{"config": MapAny{"enabled": true, "timestamp": "2025-01-01"}}, + ignoreServerAdditions: false, + expectedHasChanges: true, + expectedResult: MapAny{"config": MapAny{"enabled": true, "timestamp": "2025-01-01"}}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, hasChanges := getDelta(tc.recorded, tc.actual, []string{}, tc.ignoreServerAdditions) + if hasChanges != tc.expectedHasChanges { + t.Errorf("Expected hasChanges=%v, got %v", tc.expectedHasChanges, hasChanges) + } + if !reflect.DeepEqual(result, tc.expectedResult) { + t.Errorf("Expected result=%v, got %v", tc.expectedResult, result) + } + }) + } +} diff --git a/restapi/import_api_object_test.go b/restapi/import_api_object_test.go index 8eb59001..55ba5ef0 100644 --- a/restapi/import_api_object_test.go +++ b/restapi/import_api_object_test.go @@ -52,7 +52,7 @@ func TestAccRestApiObject_importBasic(t *testing.T) { ImportStateIdPrefix: "/api/objects/", ImportStateVerify: true, /* create_response isn't populated during import (we don't know the API response from creation) */ - ImportStateVerifyIgnore: []string{"debug", "data", "create_response", "ignore_all_server_changes"}, + ImportStateVerifyIgnore: []string{"debug", "data", "create_response", "ignore_all_server_changes", "ignore_server_additions"}, }, }, }) diff --git a/restapi/resource_api_object.go b/restapi/resource_api_object.go index 331e8c03..ae5ebf42 100644 --- a/restapi/resource_api_object.go +++ b/restapi/resource_api_object.go @@ -209,6 +209,12 @@ func resourceRestAPI() *schema.Resource { Optional: true, Default: false, }, + "ignore_server_additions": { + Type: schema.TypeBool, + Description: "When set to 'true', fields added by the server (but not present in your configuration) will be ignored for drift detection. This prevents resource recreation when the API returns additional fields like defaults or metadata. Default: false", + Optional: true, + Default: false, + }, }, /* End schema */ } @@ -325,9 +331,11 @@ func resourceRestAPIRead(d *schema.ResourceData, meta interface{}) error { } } + ignoreServerAdditions := d.Get("ignore_server_additions").(bool) + // This checks if there were any changes to the remote resource that will need to be corrected // by comparing the current state with the response returned by the api. - modifiedResource, hasDifferences := getDelta(obj.data, obj.apiData, ignoreList) + modifiedResource, hasDifferences := getDelta(obj.data, obj.apiData, ignoreList, ignoreServerAdditions) if hasDifferences { log.Printf("resource_api_object.go: Found differences in remote resource\n")