diff --git a/mmv1/products/dataplex/EntryLink.yaml b/mmv1/products/dataplex/EntryLink.yaml index 9e396ab529e5..9bb25c5c068b 100644 --- a/mmv1/products/dataplex/EntryLink.yaml +++ b/mmv1/products/dataplex/EntryLink.yaml @@ -17,7 +17,7 @@ description: | EntryLink represents a link between two Entries. references: guides: - 'Official Documentation': 'https://cloud.google.com/dataplex/docs' # Update with specific EntryLink docs when available + 'Official Documentation': 'https://cloud.google.com/dataplex/docs' # Update with specific EntryLink docs when available api: 'https://cloud.google.com/dataplex/docs/reference/rest/v1/projects.locations.entryGroups.entryLinks' docs: base_url: 'projects/{{project}}/locations/{{location}}/entryGroups/{{entry_group_id}}/entryLinks/{{entry_link_id}}' @@ -26,6 +26,12 @@ create_url: 'projects/{{project}}/locations/{{location}}/entryGroups/{{entry_gro immutable: true import_format: - 'projects/{{project}}/locations/{{location}}/entryGroups/{{entry_group_id}}/entryLinks/{{entry_link_id}}' +custom_code: + constants: templates/terraform/constants/dataplex_entry_link.go.tmpl + decoder: templates/terraform/decoders/dataplex_entry_link.go.tmpl + encoder: templates/terraform/encoders/dataplex_entry_link.go.tmpl +error_retry_predicates: + - 'transport_tpg.IsDataplex1PEntryNotFoundError' timeouts: insert_minutes: 20 delete_minutes: 20 @@ -43,6 +49,16 @@ examples: test_env_vars: project_number: 'PROJECT_NUMBER' external_providers: ["time"] + - name: 'dataplex_entry_link_with_aspect' + primary_resource_id: 'full_entry_link_with_aspect' + primary_resource_name: 'fmt.Sprintf("tf-test-entry-link-full-with-aspect%s", context["random_suffix"])' + ignore_read_extra: + - 'aspects' + vars: + entry_link_name: 'my_entry_link_full_with_aspect' + test_env_vars: + project_number: 'PROJECT_NUMBER' + project_id: 'PROJECT_NAME' parameters: - name: 'entryGroupId' type: String @@ -110,3 +126,55 @@ properties: enum_values: - 'SOURCE' - 'TARGET' + - name: 'aspects' + type: Array + custom_flatten: 'templates/terraform/custom_flatten/dataplex_entry_link_aspects.go.tmpl' + description: | + The Aspects attached to the Entry Link. + item_type: + type: NestedObject + properties: + - name: 'aspectKey' + type: String + required: true + description: | + The map keys of the Aspects which the service should modify. + It should be the aspect type reference in the format `{project_number}.{location_id}.{aspect_type_id}`. + - name: 'aspect' + type: NestedObject + required: true + properties: + - name: 'aspectType' + type: String + output: true + description: | + The resource name of the type used to create this Aspect. + + - name: 'path' + type: String + output: true + description: | + The path in the entry link under which the aspect is attached. + + - name: 'createTime' + type: Time + output: true + description: | + The time when the Aspect was created. + + - name: 'updateTime' + type: Time + output: true + description: | + The time when the Aspect was last modified. + + - name: 'data' + type: String + required: true + state_func: 'func(v interface{}) string { s, _ := structure.NormalizeJsonString(v); return s }' + custom_flatten: 'templates/terraform/custom_flatten/json_schema.tmpl' + custom_expand: 'templates/terraform/custom_expand/json_schema.tmpl' + validation: + function: 'validation.StringIsJSON' + description: | + The content of the aspect in JSON form, according to its aspect type schema. The maximum size of the field is 120KB (encoded as UTF-8). diff --git a/mmv1/templates/terraform/constants/dataplex_entry_link.go.tmpl b/mmv1/templates/terraform/constants/dataplex_entry_link.go.tmpl new file mode 100644 index 000000000000..43b7d78d1636 --- /dev/null +++ b/mmv1/templates/terraform/constants/dataplex_entry_link.go.tmpl @@ -0,0 +1,152 @@ +var ( + entryLinkProjectNumberRegex = regexp.MustCompile(`^projects\/[1-9]\d*\/.+$`) +) + +// EntryLinkProjectNumberValidation checks if the input string conforms to the pattern: +// "projects//" +func EntryLinkProjectNumberValidation(i interface{}, k string) (warnings []string, errors []error) { + v, ok := i.(string) + + if !ok { + errors = append(errors, fmt.Errorf("expected type of field %q to be string, but got %T", k, i)) + return warnings, errors + } + + if !entryLinkProjectNumberRegex.MatchString(v) { + errors = append(errors, fmt.Errorf( + "field %q has an invalid format: %q. Expected format: 'projects//'. Please note that project IDs are not supported.", + k, v, + )) + } + + return warnings, errors +} + +// FilterEntryLinkAspects filters the aspects in res based on aspectKeySet. +// It returns an error if type assertions fail. +func FilterEntryLinkAspects(aspectKeySet map[string]struct{}, res map[string]interface{}) error { + aspectsRaw, ok := res["aspects"] + if !ok || aspectsRaw == nil { + return nil + } + + aspectsMap, ok := aspectsRaw.(map[string]interface{}) + if !ok { + return fmt.Errorf("FilterEntryLinkAspects: 'aspects' field is not a map[string]interface{}, got %T", aspectsRaw) + } + + for key := range aspectsMap { + if _, keep := aspectKeySet[key]; !keep { + delete(aspectsMap, key) + } + } + return nil +} + +// AddEntryLinkAspectsToSet adds aspect keys from the aspects interface to the aspectKeySet. +// It returns an error if type assertions fail or expected keys are missing. +func AddEntryLinkAspectsToSet(aspectKeySet map[string]struct{}, aspects interface{}) error { + if aspects == nil { + return nil + } + aspectsSlice, ok := aspects.([]interface{}) + if !ok { + return fmt.Errorf("AddEntryLinkAspectsToSet: input 'aspects' is not a []interface{}, got %T", aspects) + } + + for i, aspectItemRaw := range aspectsSlice { + aspectMap, ok := aspectItemRaw.(map[string]interface{}) + if !ok { + return fmt.Errorf("AddEntryLinkAspectsToSet: item at index %d is not a map[string]interface{}, got %T", i, aspectItemRaw) + } + + keyRaw, keyExists := aspectMap["aspect_key"] + if !keyExists { + return fmt.Errorf("AddEntryLinkAspectsToSet: 'aspect_key' not found in aspect item at index %d", i) + } + + keyString, ok := keyRaw.(string) + if !ok { + return fmt.Errorf("AddEntryLinkAspectsToSet: 'aspect_key' in item at index %d is not a string, got %T", i, keyRaw) + } + aspectKeySet[keyString] = struct{}{} + } + return nil +} + +// InverseTransformEntryLinkAspects converts the "aspects" map back to a slice of maps, +// re-inserting the "aspectKey". Modifies obj in-place. +// It returns an error if type assertions fail. +func InverseTransformEntryLinkAspects(res map[string]interface{}) error { + aspectsRaw, ok := res["aspects"] + if !ok || aspectsRaw == nil { + return nil + } + + originalMap, ok := aspectsRaw.(map[string]interface{}) + if !ok { + return fmt.Errorf("InverseTransformEntryLinkAspects: 'aspects' field is not a map[string]interface{}, got %T", aspectsRaw) + } + + newSlice := make([]interface{}, 0, len(originalMap)) + + for key, value := range originalMap { + innerMap, ok := value.(map[string]interface{}) + if !ok { + return fmt.Errorf("InverseTransformEntryLinkAspects: value for key '%s' is not a map[string]interface{}, got %T", key, value) + } + box := make(map[string]interface{}, 2) + box["aspectKey"] = key + box["aspect"] = innerMap + newSlice = append(newSlice, box) + } + res["aspects"] = newSlice + return nil +} + +// TransformEntryLinkAspects concisely transforms the "aspects" slice within obj into a map. +// Modifies obj in-place. +// It returns an error if type assertions fail or expected keys are missing. +func TransformEntryLinkAspects(obj map[string]interface{}) error { + aspectsRaw, ok := obj["aspects"] + if !ok || aspectsRaw == nil { + return nil + } + + originalSlice, ok := aspectsRaw.([]interface{}) + if !ok { + return fmt.Errorf("TransformEntryLinkAspects: 'aspects' field is not a []interface{}, got %T", aspectsRaw) + } + + newMap := make(map[string]interface{}, len(originalSlice)) + for i, item := range originalSlice { + aspectMap, ok := item.(map[string]interface{}) + if !ok { + return fmt.Errorf("TransformEntryLinkAspects: item in 'aspects' slice at index %d is not a map[string]interface{}, got %T", i, item) + } + + keyRaw, keyExists := aspectMap["aspectKey"] + if !keyExists { + return fmt.Errorf("TransformEntryLinkAspects: 'aspectKey' not found in aspect item at index %d", i) + } + key, ok := keyRaw.(string) + if !ok { + return fmt.Errorf("TransformEntryLinkAspects: 'aspectKey' in item at index %d is not a string, got %T", i, keyRaw) + } + + valueRaw, valueExists := aspectMap["aspect"] + if !valueExists { + newMap[key] = map[string]interface{}{"data": map[string]interface{}{}} + continue + } + + value, ok := valueRaw.(map[string]interface{}) + if ok { + newMap[key] = value + } else { + newMap[key] = map[string]interface{}{"data": map[string]interface{}{}} + } + } + obj["aspects"] = newMap + return nil +} \ No newline at end of file diff --git a/mmv1/templates/terraform/custom_flatten/dataplex_entry_link_aspects.go.tmpl b/mmv1/templates/terraform/custom_flatten/dataplex_entry_link_aspects.go.tmpl new file mode 100644 index 000000000000..fe70172db3d9 --- /dev/null +++ b/mmv1/templates/terraform/custom_flatten/dataplex_entry_link_aspects.go.tmpl @@ -0,0 +1,44 @@ +// This file is a transposition of mmv1/templates/terraform/flatten_property_method.go.tmpl +// Most of the code is copied from there, with the exception of sorting logic. +func flatten{{$.GetPrefix}}{{$.TitlelizeProperty}}(v interface{}, d *schema.ResourceData, config *transport_tpg.Config) interface{} { + if v == nil { + return v + } + l := v.([]interface{}) + transformed := make([]map[string]interface{}, 0, len(l)) + for _, raw := range l { + original := raw.(map[string]interface{}) + if len(original) < 1 { + // Do not include empty json objects coming back from the api + continue + } + transformed = append(transformed, map[string]interface{}{ + + {{- range $prop := $.ItemType.UserProperties }} + {{- if not (or $prop.IgnoreRead $prop.WriteOnlyLegacy $prop.WriteOnly) }} + "{{ underscore $prop.Name }}": flatten{{$.GetPrefix}}{{$.TitlelizeProperty}}{{$prop.TitlelizeProperty}}(original["{{ $prop.ApiName }}"], d, config), + {{- end }} + {{- end }} + }) + } + + configData := []map[string]interface{}{} + + for _, item := range d.Get("aspects").([]interface{}) { + configData = append(configData, item.(map[string]interface{})) + } + + sorted, err := tpgresource.SortMapsByConfigOrder(configData, transformed, "aspect_key") + if err != nil { + log.Printf("[ERROR] Could not sort API response value: %s", err) + return v + } + + return sorted +} + +{{- if $.NestedProperties }} + {{- range $prop := $.NestedProperties }} + {{ template "flattenPropertyMethod" $prop -}} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/mmv1/templates/terraform/decoders/dataplex_entry_link.go.tmpl b/mmv1/templates/terraform/decoders/dataplex_entry_link.go.tmpl new file mode 100644 index 000000000000..9b8b9d27a0f7 --- /dev/null +++ b/mmv1/templates/terraform/decoders/dataplex_entry_link.go.tmpl @@ -0,0 +1,31 @@ +aspectKeysOfInterest := make(map[string]struct{}) +var err error + +if d.HasChange("aspects") { + currentAspects, futureAspects := d.GetChange("aspects") + err = AddEntryLinkAspectsToSet(aspectKeysOfInterest, currentAspects) + if err != nil { + return nil, err + } + err = AddEntryLinkAspectsToSet(aspectKeysOfInterest, futureAspects) + if err != nil { + return nil, err + } +} else { + err = AddEntryLinkAspectsToSet(aspectKeysOfInterest, d.Get("aspects")) + if err != nil { + return nil, err + } +} + +err = FilterEntryLinkAspects(aspectKeysOfInterest, res) +if err != nil { + return nil, err +} + +err = InverseTransformEntryLinkAspects(res) +if err != nil { + return nil, err +} + +return res, nil \ No newline at end of file diff --git a/mmv1/templates/terraform/encoders/dataplex_entry_link.go.tmpl b/mmv1/templates/terraform/encoders/dataplex_entry_link.go.tmpl new file mode 100644 index 000000000000..7a1e87737f36 --- /dev/null +++ b/mmv1/templates/terraform/encoders/dataplex_entry_link.go.tmpl @@ -0,0 +1,5 @@ +if err := TransformEntryLinkAspects(obj); err != nil { + return nil, err +} + +return obj, nil \ No newline at end of file diff --git a/mmv1/templates/terraform/examples/dataplex_entry_link_with_aspect.tf.tmpl b/mmv1/templates/terraform/examples/dataplex_entry_link_with_aspect.tf.tmpl new file mode 100644 index 000000000000..caa11d8114d0 --- /dev/null +++ b/mmv1/templates/terraform/examples/dataplex_entry_link_with_aspect.tf.tmpl @@ -0,0 +1,60 @@ +resource "google_bigquery_dataset" "bq_dataset" { + dataset_id = "tf_test_dataset_%{random_suffix}" + project = "{{index $.TestEnvVars "project_number"}}" + location = "us-central1" +} + +resource "google_bigquery_table" "table1" { + deletion_protection = false + dataset_id = google_bigquery_dataset.bq_dataset.dataset_id + table_id = "table1_%{random_suffix}" + project = "{{index $.TestEnvVars "project_number"}}" + schema = jsonencode([ + { + name = "col1" + type = "STRING" + mode = "NULLABLE" + description = "Column 1" + } + ]) +} + +resource "google_bigquery_table" "table2" { + deletion_protection = false + dataset_id = google_bigquery_dataset.bq_dataset.dataset_id + table_id = "table2_%{random_suffix}" + project = "{{index $.TestEnvVars "project_number"}}" + schema = jsonencode([ + { + name = "colA" + type = "STRING" + mode = "NULLABLE" + description = "Column A" + } + ]) +} + +resource "google_dataplex_entry_link" "full_entry_link_with_aspect" { + project = "{{index $.TestEnvVars "project_number"}}" + location = "us-central1" + entry_group_id = "@bigquery" + entry_link_id = "tf-test-full-entry-link%{random_suffix}" + entry_link_type = "projects/655216118709/locations/global/entryLinkTypes/schema-join" + entry_references { + name = "projects/%{project_number}/locations/us-central1/entryGroups/@bigquery/entries/bigquery.googleapis.com/projects/{{index $.TestEnvVars "project_id"}}/datasets/${google_bigquery_dataset.bq_dataset.dataset_id}/tables/${google_bigquery_table.table1.table_id}" + type = "" + } + entry_references { + name = "projects/%{project_number}/locations/us-central1/entryGroups/@bigquery/entries/bigquery.googleapis.com/projects/{{index $.TestEnvVars "project_id"}}/datasets/${google_bigquery_dataset.bq_dataset.dataset_id}/tables/${google_bigquery_table.table2.table_id}" + type = "" + } + aspects { + aspect_key = "655216118709.global.schema-join" + aspect { + data = jsonencode({ + joins = [] + userManaged = true + }) + } + } +} \ No newline at end of file diff --git a/mmv1/third_party/terraform/services/dataplex/resource_dataplex_entry_link_test.go b/mmv1/third_party/terraform/services/dataplex/resource_dataplex_entry_link_test.go index 14d2fdf4d34d..11c440186435 100644 --- a/mmv1/third_party/terraform/services/dataplex/resource_dataplex_entry_link_test.go +++ b/mmv1/third_party/terraform/services/dataplex/resource_dataplex_entry_link_test.go @@ -1,94 +1,334 @@ package dataplex_test import ( + "reflect" + "strings" "testing" - "github.com/hashicorp/terraform-plugin-testing/helper/resource" - - "github.com/hashicorp/terraform-provider-google/google/acctest" - "github.com/hashicorp/terraform-provider-google/google/envvar" + dataplex "github.com/hashicorp/terraform-provider-google/google/services/dataplex" ) -func TestAccDataplexEntryLink_update(t *testing.T) { - t.Parallel() - - context := map[string]interface{}{ - "project_number": envvar.GetTestProjectNumberFromEnv(), - "random_suffix": acctest.RandString(t, 10), +func TestEntryLinkProjectNumberValidation(t *testing.T) { + fieldName := "some_field" + testCases := []struct { + name string + input interface{} + expectError bool + errorMsg string + }{ + {"valid input", "projects/1234567890/locations/us-central1", false, ""}, + {"valid input with only number", "projects/987/stuff", false, ""}, + {"valid input with trailing slash content", "projects/1/a/b/c", false, ""}, + {"valid input minimal", "projects/1/a", false, ""}, + {"invalid input trailing slash only", "projects/555/", true, "has an invalid format"}, + {"invalid type - int", 123, true, `to be string, but got int`}, + {"invalid type - nil", nil, true, `to be string, but got `}, + {"invalid format - missing 'projects/' prefix", "12345/locations/us", true, "has an invalid format"}, + {"invalid format - project number starts with 0", "projects/0123/data", true, "has an invalid format"}, + {"invalid format - no project number", "projects//data", true, "has an invalid format"}, + {"invalid format - letters instead of number", "projects/abc/data", true, "has an invalid format"}, + {"invalid format - missing content after number/", "projects/123", true, "has an invalid format"}, + {"invalid format - empty string", "", true, "has an invalid format"}, } - acctest.VcrTest(t, resource.TestCase{ - PreCheck: func() { acctest.AccTestPreCheck(t) }, - ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), - ExternalProviders: map[string]resource.ExternalProvider{ - "time": {}, - }, - Steps: []resource.TestStep{ - { - Config: testAccDataplexEntryLink_dataplexEntryLinkUpdate(context), - }, - { - ResourceName: "google_dataplex_entry_link.basic_entry_link", - ImportState: true, - ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"entry_group_id", "entry_link_id", "location"}, - }, - }, - }) -} + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, errors := dataplex.EntryLinkProjectNumberValidation(tc.input, fieldName) + hasError := len(errors) > 0 -func testAccDataplexEntryLink_dataplexEntryLinkUpdate(context map[string]interface{}) string { - return acctest.Nprintf(` -resource "google_dataplex_entry_group" "entry-group-basic" { - location = "us-central1" - entry_group_id = "tf-test-entry-group%{random_suffix}" - project = "%{project_number}" -} -resource "google_dataplex_entry" "source" { - location = "us-central1" - entry_group_id = google_dataplex_entry_group.entry-group-basic.entry_group_id - entry_id = "tf-test-source-entry%{random_suffix}" - entry_type = google_dataplex_entry_type.entry-type-basic.name - project = "%{project_number}" -} -resource "google_dataplex_entry_type" "entry-type-basic" { - entry_type_id = "tf-test-entry-type%{random_suffix}" - location = "us-central1" - project = "%{project_number}" -} -resource "google_dataplex_glossary" "term_test_id_full" { - glossary_id = "tf-test-glossary%{random_suffix}" - location = "us-central1" + if hasError != tc.expectError { + t.Fatalf("%s: EntryLinkProjectNumberValidation() error expectation mismatch: got error = %v (%v), want error = %v", tc.name, hasError, errors, tc.expectError) + } + + if tc.expectError && tc.errorMsg != "" { + found := false + for _, err := range errors { + if strings.Contains(err.Error(), tc.errorMsg) { // Check if error message contains the expected substring + found = true + break + } + } + if !found { + t.Errorf("%s: EntryLinkProjectNumberValidation() expected error containing %q, but got: %v", tc.name, tc.errorMsg, errors) + } + } + }) + } } -resource "google_dataplex_glossary_term" "term_test_id_full" { - parent = "projects/${google_dataplex_glossary.term_test_id_full.project}/locations/us-central1/glossaries/${google_dataplex_glossary.term_test_id_full.glossary_id}" - glossary_id = google_dataplex_glossary.term_test_id_full.glossary_id - location = "us-central1" - term_id = "tf-test-term-full%{random_suffix}" - labels = { "tag": "test-tf" } - display_name = "terraform term" - description = "term created by Terraform" + +func TestFilterEntryLinkAspects(t *testing.T) { + testCases := []struct { + name string + aspectKeySet map[string]struct{} + resInput map[string]interface{} + expectedAspects map[string]interface{} + expectError bool + errorMsg string + }{ + {"aspects key is absent", map[string]struct{}{"keep": {}}, map[string]interface{}{"otherKey": "value"}, nil, false, ""}, + {"aspects value is nil", map[string]struct{}{"keep": {}}, map[string]interface{}{"aspects": nil}, nil, false, ""}, + {"empty aspectKeySet", map[string]struct{}{}, map[string]interface{}{"aspects": map[string]interface{}{"one": map[string]interface{}{"data": 1}, "two": map[string]interface{}{"data": 2}}}, map[string]interface{}{}, false, ""}, + {"keep all aspects", map[string]struct{}{"one": {}, "two": {}}, map[string]interface{}{"aspects": map[string]interface{}{"one": map[string]interface{}{"data": 1}, "two": map[string]interface{}{"data": 2}}}, map[string]interface{}{"one": map[string]interface{}{"data": 1}, "two": map[string]interface{}{"data": 2}}, false, ""}, + {"keep some aspects", map[string]struct{}{"two": {}, "three_not_present": {}}, map[string]interface{}{"aspects": map[string]interface{}{"one": map[string]interface{}{"data": 1}, "two": map[string]interface{}{"data": 2}}}, map[string]interface{}{"two": map[string]interface{}{"data": 2}}, false, ""}, + {"input aspects map is empty", map[string]struct{}{"keep": {}}, map[string]interface{}{"aspects": map[string]interface{}{}}, map[string]interface{}{}, false, ""}, + {"aspects is wrong type", map[string]struct{}{"keep": {}}, map[string]interface{}{"aspects": "not a map"}, nil, true, "FilterEntryLinkAspects: 'aspects' field is not a map[string]interface{}, got string"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resCopy := deepCopyMap(tc.resInput) + originalAspectsBeforeCall := deepCopyValue(resCopy["aspects"]) + + err := dataplex.FilterEntryLinkAspects(tc.aspectKeySet, resCopy) + + if tc.expectError { + if err == nil { + t.Fatalf("%s: Expected an error, but got nil", tc.name) + } + if tc.errorMsg != "" && !strings.Contains(err.Error(), tc.errorMsg) { + t.Errorf("%s: Expected error message containing %q, got %q", tc.name, tc.errorMsg, err.Error()) + } + if !reflect.DeepEqual(resCopy["aspects"], originalAspectsBeforeCall) { + t.Errorf("%s: resCopy['aspects'] was modified during error case.\nBefore: %#v\nAfter: %#v", tc.name, originalAspectsBeforeCall, resCopy["aspects"]) + } + return + } + + if err != nil { + t.Fatalf("%s: Did not expect an error, but got: %v", tc.name, err) + } + + actualAspectsRaw, aspectsKeyExists := resCopy["aspects"] + + if tc.expectedAspects == nil { + if aspectsKeyExists && actualAspectsRaw != nil { + if tc.name == "aspects key is absent" { + if aspectsKeyExists { + t.Errorf("%s: Expected 'aspects' key to be absent, but it exists with value: %v", tc.name, actualAspectsRaw) + } + } else { + t.Errorf("%s: Expected 'aspects' value to be nil, but got: %v", tc.name, actualAspectsRaw) + } + } + return + } + + if !aspectsKeyExists { + t.Fatalf("%s: Expected 'aspects' key to exist, but it was absent. Expected value: %#v", tc.name, tc.expectedAspects) + } + + actualAspects, ok := actualAspectsRaw.(map[string]interface{}) + if !ok { + t.Fatalf("%s: Expected 'aspects' to be a map[string]interface{}, but got %T. Value: %#v", tc.name, actualAspectsRaw, actualAspectsRaw) + } + + if !reflect.DeepEqual(actualAspects, tc.expectedAspects) { + t.Errorf("%s: FilterEntryLinkAspects() result mismatch:\ngot: %#v\nwant: %#v", tc.name, actualAspects, tc.expectedAspects) + } + }) + } } -# Introduce a 45-second wait after the glossary resource creation -resource "time_sleep" "wait-for-sync" { - create_duration = "45s" - depends_on = [google_dataplex_glossary_term.term_test_id_full] + +func TestAddEntryLinkAspectsToSet(t *testing.T) { + testCases := []struct { + name string + initialSet map[string]struct{} + aspectsInput interface{} + expectedSet map[string]struct{} + expectError bool + errorMsg string + }{ + {"add to empty set", map[string]struct{}{}, []interface{}{map[string]interface{}{"aspect_key": "key1"}, map[string]interface{}{"aspect_key": "key2"}}, map[string]struct{}{"key1": {}, "key2": {}}, false, ""}, + {"add to existing set", map[string]struct{}{"existing": {}}, []interface{}{map[string]interface{}{"aspect_key": "key1"}}, map[string]struct{}{"existing": {}, "key1": {}}, false, ""}, + {"add duplicate keys", map[string]struct{}{}, []interface{}{map[string]interface{}{"aspect_key": "key1"}, map[string]interface{}{"aspect_key": "key1"}, map[string]interface{}{"aspect_key": "key2"}}, map[string]struct{}{"key1": {}, "key2": {}}, false, ""}, + {"input aspects is empty slice", map[string]struct{}{"existing": {}}, []interface{}{}, map[string]struct{}{"existing": {}}, false, ""}, + {"input aspects is nil", map[string]struct{}{"original": {}}, nil, map[string]struct{}{"original": {}}, false, ""}, + {"input aspects is wrong type", map[string]struct{}{}, "not a slice", map[string]struct{}{}, true, "AddEntryLinkAspectsToSet: input 'aspects' is not a []interface{}, got string"}, + {"item in slice is not a map", map[string]struct{}{}, []interface{}{"not a map"}, map[string]struct{}{}, true, "AddEntryLinkAspectsToSet: item at index 0 is not a map[string]interface{}, got string"}, + {"item map missing aspect_key", map[string]struct{}{}, []interface{}{map[string]interface{}{"wrong_key": "key1"}}, map[string]struct{}{}, true, "AddEntryLinkAspectsToSet: 'aspect_key' not found in aspect item at index 0"}, + {"aspect_key is not a string", map[string]struct{}{}, []interface{}{map[string]interface{}{"aspect_key": 123}}, map[string]struct{}{}, true, "AddEntryLinkAspectsToSet: 'aspect_key' in item at index 0 is not a string, got int"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + currentSet := make(map[string]struct{}) + for k, v := range tc.initialSet { + currentSet[k] = v + } + + err := dataplex.AddEntryLinkAspectsToSet(currentSet, tc.aspectsInput) + + if tc.expectError { + if err == nil { + t.Fatalf("%s: Expected an error, but got nil", tc.name) + } + if tc.errorMsg != "" && !strings.Contains(err.Error(), tc.errorMsg) { + t.Errorf("%s: Expected error message containing %q, got %q", tc.name, tc.errorMsg, err.Error()) + } + } else { + if err != nil { + t.Fatalf("%s: Did not expect an error, but got: %v", tc.name, err) + } + if !reflect.DeepEqual(currentSet, tc.expectedSet) { + t.Errorf("%s: AddEntryLinkAspectsToSet() result mismatch:\ngot: %v\nwant: %v", tc.name, currentSet, tc.expectedSet) + } + } + }) + } } -resource "google_dataplex_entry_link" "basic_entry_link" { - project = "%{project_number}" - location = "us-central1" - entry_group_id = google_dataplex_entry_group.entry-group-basic.entry_group_id - entry_link_id = "tf-test-entry-link%{random_suffix}" - entry_link_type = "projects/655216118709/locations/global/entryLinkTypes/definition" - entry_references { - name = google_dataplex_entry.source.name - type = "SOURCE" - } - entry_references { - name = "projects/${google_dataplex_entry_group.entry-group-basic.project}/locations/us-central1/entryGroups/@dataplex/entries/projects/${google_dataplex_entry_group.entry-group-basic.project}/locations/us-central1/glossaries/${google_dataplex_glossary.term_test_id_full.glossary_id}/terms/${google_dataplex_glossary_term.term_test_id_full.term_id}" - type = "TARGET" - } - depends_on = [time_sleep.wait-for-sync] + +func TestInverseTransformEntryLinkAspects(t *testing.T) { + testCases := []struct { + name string + resInput map[string]interface{} + expectedAspects []interface{} + expectNilAspects bool + expectError bool + errorMsg string + }{ + {"aspects key is absent", map[string]interface{}{"otherKey": "value"}, nil, true, false, ""}, + {"aspects value is nil", map[string]interface{}{"aspects": nil}, nil, true, false, ""}, + {"aspects is empty map", map[string]interface{}{"aspects": map[string]interface{}{}}, []interface{}{}, false, false, ""}, + {"aspects with one entry", map[string]interface{}{"aspects": map[string]interface{}{"key1": map[string]interface{}{"data": "value1"}}}, []interface{}{map[string]interface{}{"aspectKey": "key1", "aspect": map[string]interface{}{"data": "value1"}}}, false, false, ""}, + {"aspects with multiple entries", map[string]interface{}{"aspects": map[string]interface{}{"key2": map[string]interface{}{"data": "value2"}, "key1": map[string]interface{}{"data": "value1"}}}, []interface{}{map[string]interface{}{"aspectKey": "key1", "aspect": map[string]interface{}{"data": "value1"}}, map[string]interface{}{"aspectKey": "key2", "aspect": map[string]interface{}{"data": "value2"}}}, false, false, ""}, + {"aspects is wrong type (not map)", map[string]interface{}{"aspects": "not a map"}, nil, false, true, "InverseTransformEntryLinkAspects: 'aspects' field is not a map[string]interface{}, got string"}, + {"aspect value is not a map", map[string]interface{}{"aspects": map[string]interface{}{"key1": "not a map value"}}, nil, false, true, "InverseTransformEntryLinkAspects: value for key 'key1' is not a map[string]interface{}, got string"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resCopy := deepCopyMap(tc.resInput) + originalAspectsBeforeCall := deepCopyValue(resCopy["aspects"]) + + err := dataplex.InverseTransformEntryLinkAspects(resCopy) + + if tc.expectError { + if err == nil { + t.Fatalf("%s: Expected an error, but got nil", tc.name) + } + if tc.errorMsg != "" && !strings.Contains(err.Error(), tc.errorMsg) { + t.Errorf("%s: Expected error message containing %q, got %q", tc.name, tc.errorMsg, err.Error()) + } + if !reflect.DeepEqual(resCopy["aspects"], originalAspectsBeforeCall) { + t.Errorf("%s: resCopy['aspects'] was modified during error case.\nBefore: %#v\nAfter: %#v", tc.name, originalAspectsBeforeCall, resCopy["aspects"]) + } + return + } + + if err != nil { + t.Fatalf("%s: Did not expect an error, but got: %v", tc.name, err) + } + + actualAspectsRaw, aspectsKeyExists := resCopy["aspects"] + + if tc.expectNilAspects { + if aspectsKeyExists && actualAspectsRaw != nil { + t.Errorf("%s: Expected 'aspects' to be nil or absent, but got: %#v", tc.name, actualAspectsRaw) + } + return + } + + if !aspectsKeyExists { + t.Fatalf("%s: Expected 'aspects' key in result map, but it was missing. Expected value: %#v", tc.name, tc.expectedAspects) + } + if actualAspectsRaw == nil && tc.expectedAspects != nil { + t.Fatalf("%s: Expected 'aspects' to be non-nil, but got nil. Expected value: %#v", tc.name, tc.expectedAspects) + } + + actualAspectsSlice, ok := actualAspectsRaw.([]interface{}) + if !ok { + if tc.expectedAspects != nil || actualAspectsRaw != nil { + t.Fatalf("%s: Expected 'aspects' to be []interface{}, but got %T. Value: %#v", tc.name, actualAspectsRaw, actualAspectsRaw) + } + } + + if actualAspectsSlice != nil { + sortAspectSlice(actualAspectsSlice) + } + if tc.expectedAspects != nil { + sortAspectSlice(tc.expectedAspects) + } + + if !reflect.DeepEqual(actualAspectsSlice, tc.expectedAspects) { + t.Errorf("%s: InverseTransformEntryLinkAspects() result mismatch:\ngot: %#v\nwant: %#v", tc.name, actualAspectsSlice, tc.expectedAspects) + } + }) + } } -`, context) + +func TestTransformEntryLinkAspects(t *testing.T) { + testCases := []struct { + name string + objInput map[string]interface{} + expectedAspects map[string]interface{} + expectNilAspects bool + expectError bool + errorMsg string + }{ + {"aspects key is absent", map[string]interface{}{"otherKey": "value"}, nil, true, false, ""}, + {"aspects value is nil", map[string]interface{}{"aspects": nil}, nil, true, false, ""}, + {"aspects is empty slice", map[string]interface{}{"aspects": []interface{}{}}, map[string]interface{}{}, false, false, ""}, + {"aspects with one item", map[string]interface{}{"aspects": []interface{}{map[string]interface{}{"aspectKey": "key1", "aspect": map[string]interface{}{"data": "value1"}}}}, map[string]interface{}{"key1": map[string]interface{}{"data": "value1"}}, false, false, ""}, + {"aspects with one item that has no aspect", map[string]interface{}{"aspects": []interface{}{map[string]interface{}{"aspectKey": "key1"}}}, map[string]interface{}{"key1": map[string]interface{}{"data": map[string]interface{}{}}}, false, false, ""}, + {"aspects with multiple items", map[string]interface{}{"aspects": []interface{}{map[string]interface{}{"aspectKey": "key1", "aspect": map[string]interface{}{"data": "value1"}}, map[string]interface{}{"aspectKey": "key2", "aspect": map[string]interface{}{"data": "value2"}}}}, map[string]interface{}{"key1": map[string]interface{}{"data": "value1"}, "key2": map[string]interface{}{"data": "value2"}}, false, false, ""}, + {"aspects with duplicate aspectKey", map[string]interface{}{"aspects": []interface{}{map[string]interface{}{"aspectKey": "key1", "aspect": map[string]interface{}{"data": "value_first"}}, map[string]interface{}{"aspectKey": "key2", "aspect": map[string]interface{}{"data": "value2"}}, map[string]interface{}{"aspectKey": "key1", "aspect": map[string]interface{}{"data": "value_last"}}}}, map[string]interface{}{"key1": map[string]interface{}{"data": "value_last"}, "key2": map[string]interface{}{"data": "value2"}}, false, false, ""}, + {"aspects is wrong type (not slice)", map[string]interface{}{"aspects": "not a slice"}, nil, false, true, "TransformEntryLinkAspects: 'aspects' field is not a []interface{}, got string"}, + {"item in slice is not a map", map[string]interface{}{"aspects": []interface{}{"not a map"}}, nil, false, true, "TransformEntryLinkAspects: item in 'aspects' slice at index 0 is not a map[string]interface{}, got string"}, + {"item map missing aspectKey", map[string]interface{}{"aspects": []interface{}{map[string]interface{}{"wrongKey": "k1", "aspect": map[string]interface{}{}}}}, nil, false, true, "TransformEntryLinkAspects: 'aspectKey' not found in aspect item at index 0"}, + {"aspectKey is not a string", map[string]interface{}{"aspects": []interface{}{map[string]interface{}{"aspectKey": 123, "aspect": map[string]interface{}{}}}}, nil, false, true, "TransformEntryLinkAspects: 'aspectKey' in item at index 0 is not a string, got int"}, + {"aspect is present but wrong type", map[string]interface{}{"aspects": []interface{}{map[string]interface{}{"aspectKey": "key1", "aspect": "not a map"}}}, map[string]interface{}{"key1": map[string]interface{}{"data": map[string]interface{}{}}}, false, false, ""}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + objCopy := deepCopyMap(tc.objInput) + originalAspectsBeforeCall := deepCopyValue(objCopy["aspects"]) + + err := dataplex.TransformEntryLinkAspects(objCopy) + + if tc.expectError { + if err == nil { + t.Fatalf("%s: Expected an error, but got nil", tc.name) + } + if tc.errorMsg != "" && !strings.Contains(err.Error(), tc.errorMsg) { + t.Errorf("%s: Expected error message containing %q, got %q", tc.name, tc.errorMsg, err.Error()) + } + if !reflect.DeepEqual(objCopy["aspects"], originalAspectsBeforeCall) { + t.Errorf("%s: objCopy['aspects'] was modified during error case.\nBefore: %#v\nAfter: %#v", tc.name, originalAspectsBeforeCall, objCopy["aspects"]) + } + return + } + + if err != nil { + t.Fatalf("%s: Did not expect an error, but got: %v", tc.name, err) + } + + actualAspectsRaw, aspectsKeyExists := objCopy["aspects"] + + if tc.expectNilAspects { + if aspectsKeyExists && actualAspectsRaw != nil { + t.Errorf("%s: Expected 'aspects' to be nil or absent, but got: %#v", tc.name, actualAspectsRaw) + } + return + } + + if !aspectsKeyExists { + t.Fatalf("%s: Expected 'aspects' key in result map, but it was missing. Expected value: %#v", tc.name, tc.expectedAspects) + } + if actualAspectsRaw == nil && tc.expectedAspects != nil { + t.Fatalf("%s: Expected 'aspects' to be non-nil, but got nil. Expected value: %#v", tc.name, tc.expectedAspects) + } + + actualAspectsMap, ok := actualAspectsRaw.(map[string]interface{}) + if !ok { + if tc.expectedAspects != nil || actualAspectsRaw != nil { + t.Fatalf("%s: Expected 'aspects' to be map[string]interface{}, but got %T. Value: %#v", tc.name, actualAspectsRaw, actualAspectsRaw) + } + } + + if !reflect.DeepEqual(actualAspectsMap, tc.expectedAspects) { + t.Errorf("%s: TransformEntryLinkAspects() result mismatch:\ngot: %#v\nwant: %#v", tc.name, actualAspectsMap, tc.expectedAspects) + } + }) + } } diff --git a/mmv1/third_party/terraform/transport/error_retry_predicates.go b/mmv1/third_party/terraform/transport/error_retry_predicates.go index 86a60b3e25bf..d37afbd8c1e2 100644 --- a/mmv1/third_party/terraform/transport/error_retry_predicates.go +++ b/mmv1/third_party/terraform/transport/error_retry_predicates.go @@ -693,3 +693,13 @@ func IsDataplex1PEntryIngestedError(err error) (bool, string) { } return false, "" } + +// Retry when waiting for a Dataplex target entry to be ingested. +func IsDataplex1PEntryNotFoundError(err error) (bool, string) { + if gerr, ok := err.(*googleapi.Error); ok { + if gerr.Code == 404 && strings.Contains(gerr.Body, "Entry `") && strings.Contains(gerr.Body, "` does not exist.") && (strings.Contains(gerr.Body, "@dataplex/entries/") || strings.Contains(gerr.Body, "@bigquery/entries/")) { + return true, fmt.Sprintf("Retry 404s for Dataplex Entry Ingestion") + } + } + return false, "" +}