From c74d12abbf276d1d74307948e5c5a809a34ceb81 Mon Sep 17 00:00:00 2001 From: Modular Magician Date: Wed, 20 May 2020 23:34:22 +0000 Subject: [PATCH] Data Catalog EntryGroup (#3485) Signed-off-by: Modular Magician --- .changelog/3485.txt | 12 + google/config.go | 3 + google/iam_data_catalog_entry_group.go | 200 +++++++++++++ ...data_catalog_entry_group_generated_test.go | 195 ++++++++++++ google/provider.go | 19 +- google/resource_data_catalog_entry_group.go | 281 ++++++++++++++++++ ...data_catalog_entry_group_generated_test.go | 120 ++++++++ ...e_data_catalog_entry_group_sweeper_test.go | 124 ++++++++ .../resource_data_catalog_entry_group_test.go | 50 ++++ .../r/data_catalog_entry_group.html.markdown | 129 ++++++++ ...data_catalog_entry_group_iam.html.markdown | 144 +++++++++ website/google.erb | 20 ++ 12 files changed, 1294 insertions(+), 3 deletions(-) create mode 100644 .changelog/3485.txt create mode 100644 google/iam_data_catalog_entry_group.go create mode 100644 google/iam_data_catalog_entry_group_generated_test.go create mode 100644 google/resource_data_catalog_entry_group.go create mode 100644 google/resource_data_catalog_entry_group_generated_test.go create mode 100644 google/resource_data_catalog_entry_group_sweeper_test.go create mode 100644 google/resource_data_catalog_entry_group_test.go create mode 100644 website/docs/r/data_catalog_entry_group.html.markdown create mode 100644 website/docs/r/data_catalog_entry_group_iam.html.markdown diff --git a/.changelog/3485.txt b/.changelog/3485.txt new file mode 100644 index 00000000000..aac882c5381 --- /dev/null +++ b/.changelog/3485.txt @@ -0,0 +1,12 @@ +```release-note:new-resource +`google_data_catalog_entry_group` +``` +```release-note:new-resource +`google_data_catalog_entry_group_iam_member` +``` +```release-note:new-resource +`google_data_catalog_entry_group_iam_binding` +``` +```release-note:new-resource +`google_data_catalog_entry_group_iam_policy` +``` diff --git a/google/config.go b/google/config.go index bcd7fe8c2f8..d2f7a33a210 100644 --- a/google/config.go +++ b/google/config.go @@ -92,6 +92,7 @@ type Config struct { CloudTasksBasePath string ComputeBasePath string ContainerAnalysisBasePath string + DataCatalogBasePath string DataprocBasePath string DatastoreBasePath string DeploymentManagerBasePath string @@ -231,6 +232,7 @@ var CloudSchedulerDefaultBasePath = "https://cloudscheduler.googleapis.com/v1/" var CloudTasksDefaultBasePath = "https://cloudtasks.googleapis.com/v2/" var ComputeDefaultBasePath = "https://www.googleapis.com/compute/v1/" var ContainerAnalysisDefaultBasePath = "https://containeranalysis.googleapis.com/v1/" +var DataCatalogDefaultBasePath = "https://datacatalog.googleapis.com/v1/" var DataprocDefaultBasePath = "https://dataproc.googleapis.com/v1/" var DatastoreDefaultBasePath = "https://datastore.googleapis.com/v1/" var DeploymentManagerDefaultBasePath = "https://www.googleapis.com/deploymentmanager/v2/" @@ -745,6 +747,7 @@ func ConfigureBasePaths(c *Config) { c.CloudTasksBasePath = CloudTasksDefaultBasePath c.ComputeBasePath = ComputeDefaultBasePath c.ContainerAnalysisBasePath = ContainerAnalysisDefaultBasePath + c.DataCatalogBasePath = DataCatalogDefaultBasePath c.DataprocBasePath = DataprocDefaultBasePath c.DatastoreBasePath = DatastoreDefaultBasePath c.DeploymentManagerBasePath = DeploymentManagerDefaultBasePath diff --git a/google/iam_data_catalog_entry_group.go b/google/iam_data_catalog_entry_group.go new file mode 100644 index 00000000000..fea9217f2c4 --- /dev/null +++ b/google/iam_data_catalog_entry_group.go @@ -0,0 +1,200 @@ +// ---------------------------------------------------------------------------- +// +// *** AUTO GENERATED CODE *** AUTO GENERATED CODE *** +// +// ---------------------------------------------------------------------------- +// +// This file is automatically generated by Magic Modules and manual +// changes will be clobbered when the file is regenerated. +// +// Please read more about how to change this file in +// .github/CONTRIBUTING.md. +// +// ---------------------------------------------------------------------------- +package google + +import ( + "fmt" + + "github.com/hashicorp/errwrap" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "google.golang.org/api/cloudresourcemanager/v1" +) + +var DataCatalogEntryGroupIamSchema = map[string]*schema.Schema{ + "project": { + Type: schema.TypeString, + Computed: true, + Optional: true, + ForceNew: true, + }, + "region": { + Type: schema.TypeString, + Computed: true, + Optional: true, + ForceNew: true, + }, + "entry_group": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + DiffSuppressFunc: compareSelfLinkOrResourceName, + }, +} + +type DataCatalogEntryGroupIamUpdater struct { + project string + region string + entryGroup string + d *schema.ResourceData + Config *Config +} + +func DataCatalogEntryGroupIamUpdaterProducer(d *schema.ResourceData, config *Config) (ResourceIamUpdater, error) { + values := make(map[string]string) + + project, _ := getProject(d, config) + if project != "" { + d.Set("project", project) + } + values["project"] = project + region, _ := getRegion(d, config) + if region != "" { + d.Set("region", region) + } + values["region"] = region + if v, ok := d.GetOk("entry_group"); ok { + values["entry_group"] = v.(string) + } + + // We may have gotten either a long or short name, so attempt to parse long name if possible + m, err := getImportIdQualifiers([]string{"projects/(?P[^/]+)/locations/(?P[^/]+)/entryGroups/(?P[^/]+)", "(?P[^/]+)/(?P[^/]+)/(?P[^/]+)", "(?P[^/]+)/(?P[^/]+)", "(?P[^/]+)"}, d, config, d.Get("entry_group").(string)) + if err != nil { + return nil, err + } + + for k, v := range m { + values[k] = v + } + + u := &DataCatalogEntryGroupIamUpdater{ + project: values["project"], + region: values["region"], + entryGroup: values["entry_group"], + d: d, + Config: config, + } + + d.Set("project", u.project) + d.Set("region", u.region) + d.Set("entry_group", u.GetResourceId()) + + return u, nil +} + +func DataCatalogEntryGroupIdParseFunc(d *schema.ResourceData, config *Config) error { + values := make(map[string]string) + + project, _ := getProject(d, config) + if project != "" { + values["project"] = project + } + + region, _ := getRegion(d, config) + if region != "" { + values["region"] = region + } + + m, err := getImportIdQualifiers([]string{"projects/(?P[^/]+)/locations/(?P[^/]+)/entryGroups/(?P[^/]+)", "(?P[^/]+)/(?P[^/]+)/(?P[^/]+)", "(?P[^/]+)/(?P[^/]+)", "(?P[^/]+)"}, d, config, d.Id()) + if err != nil { + return err + } + + for k, v := range m { + values[k] = v + } + + u := &DataCatalogEntryGroupIamUpdater{ + project: values["project"], + region: values["region"], + entryGroup: values["entry_group"], + d: d, + Config: config, + } + d.Set("entry_group", u.GetResourceId()) + d.SetId(u.GetResourceId()) + return nil +} + +func (u *DataCatalogEntryGroupIamUpdater) GetResourceIamPolicy() (*cloudresourcemanager.Policy, error) { + url, err := u.qualifyEntryGroupUrl("getIamPolicy") + if err != nil { + return nil, err + } + + project, err := getProject(u.d, u.Config) + if err != nil { + return nil, err + } + var obj map[string]interface{} + + policy, err := sendRequest(u.Config, "POST", project, url, obj) + if err != nil { + return nil, errwrap.Wrapf(fmt.Sprintf("Error retrieving IAM policy for %s: {{err}}", u.DescribeResource()), err) + } + + out := &cloudresourcemanager.Policy{} + err = Convert(policy, out) + if err != nil { + return nil, errwrap.Wrapf("Cannot convert a policy to a resource manager policy: {{err}}", err) + } + + return out, nil +} + +func (u *DataCatalogEntryGroupIamUpdater) SetResourceIamPolicy(policy *cloudresourcemanager.Policy) error { + json, err := ConvertToMap(policy) + if err != nil { + return err + } + + obj := make(map[string]interface{}) + obj["policy"] = json + + url, err := u.qualifyEntryGroupUrl("setIamPolicy") + if err != nil { + return err + } + project, err := getProject(u.d, u.Config) + if err != nil { + return err + } + + _, err = sendRequestWithTimeout(u.Config, "POST", project, url, obj, u.d.Timeout(schema.TimeoutCreate)) + if err != nil { + return errwrap.Wrapf(fmt.Sprintf("Error setting IAM policy for %s: {{err}}", u.DescribeResource()), err) + } + + return nil +} + +func (u *DataCatalogEntryGroupIamUpdater) qualifyEntryGroupUrl(methodIdentifier string) (string, error) { + urlTemplate := fmt.Sprintf("{{DataCatalogBasePath}}%s:%s", fmt.Sprintf("projects/%s/locations/%s/entryGroups/%s", u.project, u.region, u.entryGroup), methodIdentifier) + url, err := replaceVars(u.d, u.Config, urlTemplate) + if err != nil { + return "", err + } + return url, nil +} + +func (u *DataCatalogEntryGroupIamUpdater) GetResourceId() string { + return fmt.Sprintf("projects/%s/locations/%s/entryGroups/%s", u.project, u.region, u.entryGroup) +} + +func (u *DataCatalogEntryGroupIamUpdater) GetMutexKey() string { + return fmt.Sprintf("iam-datacatalog-entrygroup-%s", u.GetResourceId()) +} + +func (u *DataCatalogEntryGroupIamUpdater) DescribeResource() string { + return fmt.Sprintf("datacatalog entrygroup %q", u.GetResourceId()) +} diff --git a/google/iam_data_catalog_entry_group_generated_test.go b/google/iam_data_catalog_entry_group_generated_test.go new file mode 100644 index 00000000000..8768368e58e --- /dev/null +++ b/google/iam_data_catalog_entry_group_generated_test.go @@ -0,0 +1,195 @@ +// ---------------------------------------------------------------------------- +// +// *** AUTO GENERATED CODE *** AUTO GENERATED CODE *** +// +// ---------------------------------------------------------------------------- +// +// This file is automatically generated by Magic Modules and manual +// changes will be clobbered when the file is regenerated. +// +// Please read more about how to change this file in +// .github/CONTRIBUTING.md. +// +// ---------------------------------------------------------------------------- + +package google + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccDataCatalogEntryGroupIamBindingGenerated(t *testing.T) { + t.Parallel() + + context := map[string]interface{}{ + "random_suffix": randString(t, 10), + "role": "roles/viewer", + } + + vcrTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccDataCatalogEntryGroupIamBinding_basicGenerated(context), + }, + { + ResourceName: "google_data_catalog_entry_group_iam_binding.foo", + ImportStateId: fmt.Sprintf("projects/%s/locations/%s/entryGroups/%s roles/viewer", getTestProjectFromEnv(), getTestRegionFromEnv(), fmt.Sprintf("tf_test_my_group%s", context["random_suffix"])), + ImportState: true, + ImportStateVerify: true, + }, + { + // Test Iam Binding update + Config: testAccDataCatalogEntryGroupIamBinding_updateGenerated(context), + }, + { + ResourceName: "google_data_catalog_entry_group_iam_binding.foo", + ImportStateId: fmt.Sprintf("projects/%s/locations/%s/entryGroups/%s roles/viewer", getTestProjectFromEnv(), getTestRegionFromEnv(), fmt.Sprintf("tf_test_my_group%s", context["random_suffix"])), + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccDataCatalogEntryGroupIamMemberGenerated(t *testing.T) { + t.Parallel() + + context := map[string]interface{}{ + "random_suffix": randString(t, 10), + "role": "roles/viewer", + } + + vcrTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + // Test Iam Member creation (no update for member, no need to test) + Config: testAccDataCatalogEntryGroupIamMember_basicGenerated(context), + }, + { + ResourceName: "google_data_catalog_entry_group_iam_member.foo", + ImportStateId: fmt.Sprintf("projects/%s/locations/%s/entryGroups/%s roles/viewer user:admin@hashicorptest.com", getTestProjectFromEnv(), getTestRegionFromEnv(), fmt.Sprintf("tf_test_my_group%s", context["random_suffix"])), + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccDataCatalogEntryGroupIamPolicyGenerated(t *testing.T) { + t.Parallel() + + context := map[string]interface{}{ + "random_suffix": randString(t, 10), + "role": "roles/viewer", + } + + vcrTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccDataCatalogEntryGroupIamPolicy_basicGenerated(context), + }, + { + ResourceName: "google_data_catalog_entry_group_iam_policy.foo", + ImportStateId: fmt.Sprintf("projects/%s/locations/%s/entryGroups/%s", getTestProjectFromEnv(), getTestRegionFromEnv(), fmt.Sprintf("tf_test_my_group%s", context["random_suffix"])), + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccDataCatalogEntryGroupIamPolicy_emptyBinding(context), + }, + { + ResourceName: "google_data_catalog_entry_group_iam_policy.foo", + ImportStateId: fmt.Sprintf("projects/%s/locations/%s/entryGroups/%s", getTestProjectFromEnv(), getTestRegionFromEnv(), fmt.Sprintf("tf_test_my_group%s", context["random_suffix"])), + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccDataCatalogEntryGroupIamMember_basicGenerated(context map[string]interface{}) string { + return Nprintf(` +resource "google_data_catalog_entry_group" "basic_entry_group" { + entry_group_id = "tf_test_my_group%{random_suffix}" +} + +resource "google_data_catalog_entry_group_iam_member" "foo" { + entry_group = google_data_catalog_entry_group.basic_entry_group.name + role = "%{role}" + member = "user:admin@hashicorptest.com" +} +`, context) +} + +func testAccDataCatalogEntryGroupIamPolicy_basicGenerated(context map[string]interface{}) string { + return Nprintf(` +resource "google_data_catalog_entry_group" "basic_entry_group" { + entry_group_id = "tf_test_my_group%{random_suffix}" +} + +data "google_iam_policy" "foo" { + binding { + role = "%{role}" + members = ["user:admin@hashicorptest.com"] + } +} + +resource "google_data_catalog_entry_group_iam_policy" "foo" { + entry_group = google_data_catalog_entry_group.basic_entry_group.name + policy_data = data.google_iam_policy.foo.policy_data +} +`, context) +} + +func testAccDataCatalogEntryGroupIamPolicy_emptyBinding(context map[string]interface{}) string { + return Nprintf(` +resource "google_data_catalog_entry_group" "basic_entry_group" { + entry_group_id = "tf_test_my_group%{random_suffix}" +} + +data "google_iam_policy" "foo" { +} + +resource "google_data_catalog_entry_group_iam_policy" "foo" { + entry_group = google_data_catalog_entry_group.basic_entry_group.name + policy_data = data.google_iam_policy.foo.policy_data +} +`, context) +} + +func testAccDataCatalogEntryGroupIamBinding_basicGenerated(context map[string]interface{}) string { + return Nprintf(` +resource "google_data_catalog_entry_group" "basic_entry_group" { + entry_group_id = "tf_test_my_group%{random_suffix}" +} + +resource "google_data_catalog_entry_group_iam_binding" "foo" { + entry_group = google_data_catalog_entry_group.basic_entry_group.name + role = "%{role}" + members = ["user:admin@hashicorptest.com"] +} +`, context) +} + +func testAccDataCatalogEntryGroupIamBinding_updateGenerated(context map[string]interface{}) string { + return Nprintf(` +resource "google_data_catalog_entry_group" "basic_entry_group" { + entry_group_id = "tf_test_my_group%{random_suffix}" +} + +resource "google_data_catalog_entry_group_iam_binding" "foo" { + entry_group = google_data_catalog_entry_group.basic_entry_group.name + role = "%{role}" + members = ["user:admin@hashicorptest.com", "user:paddy@hashicorp.com"] +} +`, context) +} diff --git a/google/provider.go b/google/provider.go index 2b4ce1c1706..9f5e04ac74b 100644 --- a/google/provider.go +++ b/google/provider.go @@ -213,6 +213,14 @@ func Provider() terraform.ResourceProvider { "GOOGLE_CONTAINER_ANALYSIS_CUSTOM_ENDPOINT", }, ContainerAnalysisDefaultBasePath), }, + "data_catalog_custom_endpoint": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validateCustomEndpoint, + DefaultFunc: schema.MultiEnvDefaultFunc([]string{ + "GOOGLE_DATA_CATALOG_CUSTOM_ENDPOINT", + }, DataCatalogDefaultBasePath), + }, "dataproc_custom_endpoint": { Type: schema.TypeString, Optional: true, @@ -548,9 +556,9 @@ func Provider() terraform.ResourceProvider { return provider } -// Generated resources: 128 -// Generated IAM resources: 54 -// Total generated resources: 182 +// Generated resources: 129 +// Generated IAM resources: 57 +// Total generated resources: 186 func ResourceMap() map[string]*schema.Resource { resourceMap, _ := ResourceMapWithErrors() return resourceMap @@ -650,6 +658,10 @@ func ResourceMapWithErrors() (map[string]*schema.Resource, error) { "google_compute_url_map": resourceComputeUrlMap(), "google_compute_vpn_tunnel": resourceComputeVpnTunnel(), "google_container_analysis_note": resourceContainerAnalysisNote(), + "google_data_catalog_entry_group": resourceDataCatalogEntryGroup(), + "google_data_catalog_entry_group_iam_binding": ResourceIamBinding(DataCatalogEntryGroupIamSchema, DataCatalogEntryGroupIamUpdaterProducer, DataCatalogEntryGroupIdParseFunc), + "google_data_catalog_entry_group_iam_member": ResourceIamMember(DataCatalogEntryGroupIamSchema, DataCatalogEntryGroupIamUpdaterProducer, DataCatalogEntryGroupIdParseFunc), + "google_data_catalog_entry_group_iam_policy": ResourceIamPolicy(DataCatalogEntryGroupIamSchema, DataCatalogEntryGroupIamUpdaterProducer, DataCatalogEntryGroupIdParseFunc), "google_dataproc_autoscaling_policy": resourceDataprocAutoscalingPolicy(), "google_datastore_index": resourceDatastoreIndex(), "google_deployment_manager_deployment": resourceDeploymentManagerDeployment(), @@ -920,6 +932,7 @@ func providerConfigure(d *schema.ResourceData, p *schema.Provider, terraformVers config.CloudTasksBasePath = d.Get("cloud_tasks_custom_endpoint").(string) config.ComputeBasePath = d.Get("compute_custom_endpoint").(string) config.ContainerAnalysisBasePath = d.Get("container_analysis_custom_endpoint").(string) + config.DataCatalogBasePath = d.Get("data_catalog_custom_endpoint").(string) config.DataprocBasePath = d.Get("dataproc_custom_endpoint").(string) config.DatastoreBasePath = d.Get("datastore_custom_endpoint").(string) config.DeploymentManagerBasePath = d.Get("deployment_manager_custom_endpoint").(string) diff --git a/google/resource_data_catalog_entry_group.go b/google/resource_data_catalog_entry_group.go new file mode 100644 index 00000000000..656e347c5a3 --- /dev/null +++ b/google/resource_data_catalog_entry_group.go @@ -0,0 +1,281 @@ +// ---------------------------------------------------------------------------- +// +// *** AUTO GENERATED CODE *** AUTO GENERATED CODE *** +// +// ---------------------------------------------------------------------------- +// +// This file is automatically generated by Magic Modules and manual +// changes will be clobbered when the file is regenerated. +// +// Please read more about how to change this file in +// .github/CONTRIBUTING.md. +// +// ---------------------------------------------------------------------------- + +package google + +import ( + "fmt" + "log" + "reflect" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func resourceDataCatalogEntryGroup() *schema.Resource { + return &schema.Resource{ + Create: resourceDataCatalogEntryGroupCreate, + Read: resourceDataCatalogEntryGroupRead, + Update: resourceDataCatalogEntryGroupUpdate, + Delete: resourceDataCatalogEntryGroupDelete, + + Importer: &schema.ResourceImporter{ + State: resourceDataCatalogEntryGroupImport, + }, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(4 * time.Minute), + Update: schema.DefaultTimeout(4 * time.Minute), + Delete: schema.DefaultTimeout(4 * time.Minute), + }, + + Schema: map[string]*schema.Schema{ + "entry_group_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validateRegexp(`^[A-z_][A-z0-9_]{0,63}$`), + Description: `The id of the entry group to create. The id must begin with a letter or underscore, +contain only English letters, numbers and underscores, and be at most 64 characters.`, + }, + "description": { + Type: schema.TypeString, + Optional: true, + Description: `Entry group description, which can consist of several sentences or paragraphs that describe entry group contents.`, + }, + "display_name": { + Type: schema.TypeString, + Optional: true, + Description: `A short name to identify the entry group, for example, "analytics data - jan 2011".`, + }, + "region": { + Type: schema.TypeString, + Computed: true, + Optional: true, + ForceNew: true, + Description: `EntryGroup location region.`, + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: `The resource name of the entry group in URL format. Example: projects/{project}/locations/{location}/entryGroups/{entryGroupId}`, + }, + "project": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + }, + }, + } +} + +func resourceDataCatalogEntryGroupCreate(d *schema.ResourceData, meta interface{}) error { + config := meta.(*Config) + + obj := make(map[string]interface{}) + displayNameProp, err := expandDataCatalogEntryGroupDisplayName(d.Get("display_name"), d, config) + if err != nil { + return err + } else if v, ok := d.GetOkExists("display_name"); !isEmptyValue(reflect.ValueOf(displayNameProp)) && (ok || !reflect.DeepEqual(v, displayNameProp)) { + obj["displayName"] = displayNameProp + } + descriptionProp, err := expandDataCatalogEntryGroupDescription(d.Get("description"), d, config) + if err != nil { + return err + } else if v, ok := d.GetOkExists("description"); !isEmptyValue(reflect.ValueOf(descriptionProp)) && (ok || !reflect.DeepEqual(v, descriptionProp)) { + obj["description"] = descriptionProp + } + + url, err := replaceVars(d, config, "{{DataCatalogBasePath}}projects/{{project}}/locations/{{region}}/entryGroups?entryGroupId={{entry_group_id}}") + if err != nil { + return err + } + + log.Printf("[DEBUG] Creating new EntryGroup: %#v", obj) + project, err := getProject(d, config) + if err != nil { + return err + } + res, err := sendRequestWithTimeout(config, "POST", project, url, obj, d.Timeout(schema.TimeoutCreate)) + if err != nil { + return fmt.Errorf("Error creating EntryGroup: %s", err) + } + if err := d.Set("name", flattenDataCatalogEntryGroupName(res["name"], d, config)); err != nil { + return fmt.Errorf(`Error setting computed identity field "name": %s`, err) + } + + // Store the ID now + id, err := replaceVars(d, config, "{{name}}") + if err != nil { + return fmt.Errorf("Error constructing id: %s", err) + } + d.SetId(id) + + log.Printf("[DEBUG] Finished creating EntryGroup %q: %#v", d.Id(), res) + + return resourceDataCatalogEntryGroupRead(d, meta) +} + +func resourceDataCatalogEntryGroupRead(d *schema.ResourceData, meta interface{}) error { + config := meta.(*Config) + + url, err := replaceVars(d, config, "{{DataCatalogBasePath}}{{name}}") + if err != nil { + return err + } + + project, err := getProject(d, config) + if err != nil { + return err + } + res, err := sendRequest(config, "GET", project, url, nil) + if err != nil { + return handleNotFoundError(err, d, fmt.Sprintf("DataCatalogEntryGroup %q", d.Id())) + } + + if err := d.Set("project", project); err != nil { + return fmt.Errorf("Error reading EntryGroup: %s", err) + } + + region, err := getRegion(d, config) + if err != nil { + return err + } + if err := d.Set("region", region); err != nil { + return fmt.Errorf("Error reading EntryGroup: %s", err) + } + + if err := d.Set("name", flattenDataCatalogEntryGroupName(res["name"], d, config)); err != nil { + return fmt.Errorf("Error reading EntryGroup: %s", err) + } + if err := d.Set("display_name", flattenDataCatalogEntryGroupDisplayName(res["displayName"], d, config)); err != nil { + return fmt.Errorf("Error reading EntryGroup: %s", err) + } + if err := d.Set("description", flattenDataCatalogEntryGroupDescription(res["description"], d, config)); err != nil { + return fmt.Errorf("Error reading EntryGroup: %s", err) + } + + return nil +} + +func resourceDataCatalogEntryGroupUpdate(d *schema.ResourceData, meta interface{}) error { + config := meta.(*Config) + + project, err := getProject(d, config) + if err != nil { + return err + } + + obj := make(map[string]interface{}) + displayNameProp, err := expandDataCatalogEntryGroupDisplayName(d.Get("display_name"), d, config) + if err != nil { + return err + } else if v, ok := d.GetOkExists("display_name"); !isEmptyValue(reflect.ValueOf(v)) && (ok || !reflect.DeepEqual(v, displayNameProp)) { + obj["displayName"] = displayNameProp + } + descriptionProp, err := expandDataCatalogEntryGroupDescription(d.Get("description"), d, config) + if err != nil { + return err + } else if v, ok := d.GetOkExists("description"); !isEmptyValue(reflect.ValueOf(v)) && (ok || !reflect.DeepEqual(v, descriptionProp)) { + obj["description"] = descriptionProp + } + + url, err := replaceVars(d, config, "{{DataCatalogBasePath}}{{name}}") + if err != nil { + return err + } + + log.Printf("[DEBUG] Updating EntryGroup %q: %#v", d.Id(), obj) + updateMask := []string{} + + if d.HasChange("display_name") { + updateMask = append(updateMask, "displayName") + } + + if d.HasChange("description") { + updateMask = append(updateMask, "description") + } + // updateMask is a URL parameter but not present in the schema, so replaceVars + // won't set it + url, err = addQueryParams(url, map[string]string{"updateMask": strings.Join(updateMask, ",")}) + if err != nil { + return err + } + _, err = sendRequestWithTimeout(config, "PATCH", project, url, obj, d.Timeout(schema.TimeoutUpdate)) + + if err != nil { + return fmt.Errorf("Error updating EntryGroup %q: %s", d.Id(), err) + } + + return resourceDataCatalogEntryGroupRead(d, meta) +} + +func resourceDataCatalogEntryGroupDelete(d *schema.ResourceData, meta interface{}) error { + config := meta.(*Config) + + project, err := getProject(d, config) + if err != nil { + return err + } + + url, err := replaceVars(d, config, "{{DataCatalogBasePath}}{{name}}") + if err != nil { + return err + } + + var obj map[string]interface{} + log.Printf("[DEBUG] Deleting EntryGroup %q", d.Id()) + + res, err := sendRequestWithTimeout(config, "DELETE", project, url, obj, d.Timeout(schema.TimeoutDelete)) + if err != nil { + return handleNotFoundError(err, d, "EntryGroup") + } + + log.Printf("[DEBUG] Finished deleting EntryGroup %q: %#v", d.Id(), res) + return nil +} + +func resourceDataCatalogEntryGroupImport(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + + config := meta.(*Config) + + // current import_formats can't import fields with forward slashes in their value + if err := parseImportId([]string{"(?P[^ ]+) (?P[^ ]+)", "(?P[^ ]+)"}, d, config); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} + +func flattenDataCatalogEntryGroupName(v interface{}, d *schema.ResourceData, config *Config) interface{} { + return v +} + +func flattenDataCatalogEntryGroupDisplayName(v interface{}, d *schema.ResourceData, config *Config) interface{} { + return v +} + +func flattenDataCatalogEntryGroupDescription(v interface{}, d *schema.ResourceData, config *Config) interface{} { + return v +} + +func expandDataCatalogEntryGroupDisplayName(v interface{}, d TerraformResourceData, config *Config) (interface{}, error) { + return v, nil +} + +func expandDataCatalogEntryGroupDescription(v interface{}, d TerraformResourceData, config *Config) (interface{}, error) { + return v, nil +} diff --git a/google/resource_data_catalog_entry_group_generated_test.go b/google/resource_data_catalog_entry_group_generated_test.go new file mode 100644 index 00000000000..389683f64b1 --- /dev/null +++ b/google/resource_data_catalog_entry_group_generated_test.go @@ -0,0 +1,120 @@ +// ---------------------------------------------------------------------------- +// +// *** AUTO GENERATED CODE *** AUTO GENERATED CODE *** +// +// ---------------------------------------------------------------------------- +// +// This file is automatically generated by Magic Modules and manual +// changes will be clobbered when the file is regenerated. +// +// Please read more about how to change this file in +// .github/CONTRIBUTING.md. +// +// ---------------------------------------------------------------------------- + +package google + +import ( + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/terraform" +) + +func TestAccDataCatalogEntryGroup_dataCatalogEntryGroupBasicExample(t *testing.T) { + t.Parallel() + + context := map[string]interface{}{ + "random_suffix": randString(t, 10), + } + + vcrTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckDataCatalogEntryGroupDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccDataCatalogEntryGroup_dataCatalogEntryGroupBasicExample(context), + }, + { + ResourceName: "google_data_catalog_entry_group.basic_entry_group", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"region", "entry_group_id"}, + }, + }, + }) +} + +func testAccDataCatalogEntryGroup_dataCatalogEntryGroupBasicExample(context map[string]interface{}) string { + return Nprintf(` +resource "google_data_catalog_entry_group" "basic_entry_group" { + entry_group_id = "tf_test_my_group%{random_suffix}" +} +`, context) +} + +func TestAccDataCatalogEntryGroup_dataCatalogEntryGroupFullExample(t *testing.T) { + t.Parallel() + + context := map[string]interface{}{ + "random_suffix": randString(t, 10), + } + + vcrTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckDataCatalogEntryGroupDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccDataCatalogEntryGroup_dataCatalogEntryGroupFullExample(context), + }, + { + ResourceName: "google_data_catalog_entry_group.basic_entry_group", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"region", "entry_group_id"}, + }, + }, + }) +} + +func testAccDataCatalogEntryGroup_dataCatalogEntryGroupFullExample(context map[string]interface{}) string { + return Nprintf(` +resource "google_data_catalog_entry_group" "basic_entry_group" { + entry_group_id = "tf_test_my_group%{random_suffix}" + + display_name = "terraform entry group" + description = "entry group created by Terraform" +} +`, context) +} + +func testAccCheckDataCatalogEntryGroupDestroyProducer(t *testing.T) func(s *terraform.State) error { + return func(s *terraform.State) error { + for name, rs := range s.RootModule().Resources { + if rs.Type != "google_data_catalog_entry_group" { + continue + } + if strings.HasPrefix(name, "data.") { + continue + } + + config := googleProviderConfig(t) + + url, err := replaceVarsForTest(config, rs, "{{DataCatalogBasePath}}{{name}}") + if err != nil { + return err + } + + _, err = sendRequest(config, "GET", "", url, nil) + if err == nil { + return fmt.Errorf("DataCatalogEntryGroup still exists at %s", url) + } + } + + return nil + } +} diff --git a/google/resource_data_catalog_entry_group_sweeper_test.go b/google/resource_data_catalog_entry_group_sweeper_test.go new file mode 100644 index 00000000000..f3f0f4b5007 --- /dev/null +++ b/google/resource_data_catalog_entry_group_sweeper_test.go @@ -0,0 +1,124 @@ +// ---------------------------------------------------------------------------- +// +// *** AUTO GENERATED CODE *** AUTO GENERATED CODE *** +// +// ---------------------------------------------------------------------------- +// +// This file is automatically generated by Magic Modules and manual +// changes will be clobbered when the file is regenerated. +// +// Please read more about how to change this file in +// .github/CONTRIBUTING.md. +// +// ---------------------------------------------------------------------------- + +package google + +import ( + "context" + "log" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func init() { + resource.AddTestSweepers("DataCatalogEntryGroup", &resource.Sweeper{ + Name: "DataCatalogEntryGroup", + F: testSweepDataCatalogEntryGroup, + }) +} + +// At the time of writing, the CI only passes us-central1 as the region +func testSweepDataCatalogEntryGroup(region string) error { + resourceName := "DataCatalogEntryGroup" + log.Printf("[INFO][SWEEPER_LOG] Starting sweeper for %s", resourceName) + + config, err := sharedConfigForRegion(region) + if err != nil { + log.Printf("[INFO][SWEEPER_LOG] error getting shared config for region: %s", err) + return err + } + + err = config.LoadAndValidate(context.Background()) + if err != nil { + log.Printf("[INFO][SWEEPER_LOG] error loading: %s", err) + return err + } + + t := &testing.T{} + billingId := getTestBillingAccountFromEnv(t) + + // Setup variables to replace in list template + d := &ResourceDataMock{ + FieldsInSchema: map[string]interface{}{ + "project": config.Project, + "region": region, + "location": region, + "zone": "-", + "billing_account": billingId, + }, + } + + listTemplate := strings.Split("https://datacatalog.googleapis.com/v1/projects/{{project}}/locations/{{region}}/entryGroups", "?")[0] + listUrl, err := replaceVars(d, config, listTemplate) + if err != nil { + log.Printf("[INFO][SWEEPER_LOG] error preparing sweeper list url: %s", err) + return nil + } + + res, err := sendRequest(config, "GET", config.Project, listUrl, nil) + if err != nil { + log.Printf("[INFO][SWEEPER_LOG] Error in response from request %s: %s", listUrl, err) + return nil + } + + resourceList, ok := res["entryGroups"] + if !ok { + log.Printf("[INFO][SWEEPER_LOG] Nothing found in response.") + return nil + } + + rl := resourceList.([]interface{}) + + log.Printf("[INFO][SWEEPER_LOG] Found %d items in %s list response.", len(rl), resourceName) + // Keep count of items that aren't sweepable for logging. + nonPrefixCount := 0 + for _, ri := range rl { + obj := ri.(map[string]interface{}) + if obj["name"] == nil { + log.Printf("[INFO][SWEEPER_LOG] %s resource name was nil", resourceName) + return nil + } + + name := GetResourceNameFromSelfLink(obj["name"].(string)) + // Skip resources that shouldn't be sweeped + if !isSweepableTestResource(name) { + nonPrefixCount++ + continue + } + + deleteTemplate := "https://datacatalog.googleapis.com/v1/{{name}}" + deleteUrl, err := replaceVars(d, config, deleteTemplate) + if err != nil { + log.Printf("[INFO][SWEEPER_LOG] error preparing delete url: %s", err) + return nil + } + deleteUrl = deleteUrl + name + + // Don't wait on operations as we may have a lot to delete + _, err = sendRequest(config, "DELETE", config.Project, deleteUrl, nil) + if err != nil { + log.Printf("[INFO][SWEEPER_LOG] Error deleting for url %s : %s", deleteUrl, err) + } else { + log.Printf("[INFO][SWEEPER_LOG] Sent delete request for %s resource: %s", resourceName, name) + } + } + + if nonPrefixCount > 0 { + log.Printf("[INFO][SWEEPER_LOG] %d items were non-sweepable and skipped.", nonPrefixCount) + } + + return nil +} diff --git a/google/resource_data_catalog_entry_group_test.go b/google/resource_data_catalog_entry_group_test.go new file mode 100644 index 00000000000..e4f237fe38c --- /dev/null +++ b/google/resource_data_catalog_entry_group_test.go @@ -0,0 +1,50 @@ +package google + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccDataCatalogEntryGroup_update(t *testing.T) { + t.Parallel() + + context := map[string]interface{}{ + "random_suffix": randString(t, 10), + } + + vcrTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckDataCatalogEntryGroupDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccDataCatalogEntryGroup_dataCatalogEntryGroupBasicExample(context), + }, + { + ResourceName: "google_data_catalog_entry_group.basic_entry_group", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"region", "entry_group_id"}, + }, + { + Config: testAccDataCatalogEntryGroup_dataCatalogEntryGroupFullExample(context), + }, + { + ResourceName: "google_data_catalog_entry_group.basic_entry_group", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"region", "entry_group_id"}, + }, + { + Config: testAccDataCatalogEntryGroup_dataCatalogEntryGroupBasicExample(context), + }, + { + ResourceName: "google_data_catalog_entry_group.basic_entry_group", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"region", "entry_group_id"}, + }, + }, + }) +} diff --git a/website/docs/r/data_catalog_entry_group.html.markdown b/website/docs/r/data_catalog_entry_group.html.markdown new file mode 100644 index 00000000000..bcaec9894ed --- /dev/null +++ b/website/docs/r/data_catalog_entry_group.html.markdown @@ -0,0 +1,129 @@ +--- +# ---------------------------------------------------------------------------- +# +# *** AUTO GENERATED CODE *** AUTO GENERATED CODE *** +# +# ---------------------------------------------------------------------------- +# +# This file is automatically generated by Magic Modules and manual +# changes will be clobbered when the file is regenerated. +# +# Please read more about how to change this file in +# .github/CONTRIBUTING.md. +# +# ---------------------------------------------------------------------------- +subcategory: "Data catalog" +layout: "google" +page_title: "Google: google_data_catalog_entry_group" +sidebar_current: "docs-google-data-catalog-entry-group" +description: |- + An EntryGroup resource represents a logical grouping of zero or more Data Catalog Entry resources. +--- + +# google\_data\_catalog\_entry\_group + +An EntryGroup resource represents a logical grouping of zero or more Data Catalog Entry resources. + + +To get more information about EntryGroup, see: + +* [API documentation](https://cloud.google.com/data-catalog/docs/reference/rest/v1/projects.locations.entryGroups) +* How-to Guides + * [Official Documentation](https://cloud.google.com/data-catalog/docs) + + +## Example Usage - Data Catalog Entry Group Basic + + +```hcl +resource "google_data_catalog_entry_group" "basic_entry_group" { + entry_group_id = "my_group" +} +``` + +## Example Usage - Data Catalog Entry Group Full + + +```hcl +resource "google_data_catalog_entry_group" "basic_entry_group" { + entry_group_id = "my_group" + + display_name = "terraform entry group" + description = "entry group created by Terraform" +} +``` + +## Argument Reference + +The following arguments are supported: + + +* `entry_group_id` - + (Required) + The id of the entry group to create. The id must begin with a letter or underscore, + contain only English letters, numbers and underscores, and be at most 64 characters. + + +- - - + + +* `display_name` - + (Optional) + A short name to identify the entry group, for example, "analytics data - jan 2011". + +* `description` - + (Optional) + Entry group description, which can consist of several sentences or paragraphs that describe entry group contents. + +* `region` - + (Optional) + EntryGroup location region. + +* `project` - (Optional) The ID of the project in which the resource belongs. + If it is not provided, the provider project is used. + + +## Attributes Reference + +In addition to the arguments listed above, the following computed attributes are exported: + +* `id` - an identifier for the resource with format `{{name}}` + +* `name` - + The resource name of the entry group in URL format. Example: projects/{project}/locations/{location}/entryGroups/{entryGroupId} + + +## Timeouts + +This resource provides the following +[Timeouts](/docs/configuration/resources.html#timeouts) configuration options: + +- `create` - Default is 4 minutes. +- `update` - Default is 4 minutes. +- `delete` - Default is 4 minutes. + +## Import + +EntryGroup can be imported using any of these accepted formats: + +``` +$ terraform import google_data_catalog_entry_group.default projects/{{project}}/locations/{{region}}/entryGroups/{{name}} +$ terraform import google_data_catalog_entry_group.default {{project}}/{{region}}/{{name}} +$ terraform import google_data_catalog_entry_group.default {{region}}/{{name}} +$ terraform import google_data_catalog_entry_group.default {{name}} +``` + +-> If you're importing a resource with beta features, make sure to include `-provider=google-beta` +as an argument so that Terraform uses the correct provider to import your resource. + +## User Project Overrides + +This resource supports [User Project Overrides](https://www.terraform.io/docs/providers/google/guides/provider_reference.html#user_project_override). diff --git a/website/docs/r/data_catalog_entry_group_iam.html.markdown b/website/docs/r/data_catalog_entry_group_iam.html.markdown new file mode 100644 index 00000000000..b7174038838 --- /dev/null +++ b/website/docs/r/data_catalog_entry_group_iam.html.markdown @@ -0,0 +1,144 @@ +--- +# ---------------------------------------------------------------------------- +# +# *** AUTO GENERATED CODE *** AUTO GENERATED CODE *** +# +# ---------------------------------------------------------------------------- +# +# This file is automatically generated by Magic Modules and manual +# changes will be clobbered when the file is regenerated. +# +# Please read more about how to change this file in +# .github/CONTRIBUTING.md. +# +# ---------------------------------------------------------------------------- +subcategory: "Data catalog" +layout: "google" +page_title: "Google: google_data_catalog_entry_group_iam" +sidebar_current: "docs-google-data-catalog-entry-group-iam" +description: |- + Collection of resources to manage IAM policy for Data catalog EntryGroup +--- + +# IAM policy for Data catalog EntryGroup +Three different resources help you manage your IAM policy for Data catalog EntryGroup. Each of these resources serves a different use case: + +* `google_data_catalog_entry_group_iam_policy`: Authoritative. Sets the IAM policy for the entrygroup and replaces any existing policy already attached. +* `google_data_catalog_entry_group_iam_binding`: Authoritative for a given role. Updates the IAM policy to grant a role to a list of members. Other roles within the IAM policy for the entrygroup are preserved. +* `google_data_catalog_entry_group_iam_member`: Non-authoritative. Updates the IAM policy to grant a role to a new member. Other members for the role for the entrygroup are preserved. + +~> **Note:** `google_data_catalog_entry_group_iam_policy` **cannot** be used in conjunction with `google_data_catalog_entry_group_iam_binding` and `google_data_catalog_entry_group_iam_member` or they will fight over what your policy should be. + +~> **Note:** `google_data_catalog_entry_group_iam_binding` resources **can be** used in conjunction with `google_data_catalog_entry_group_iam_member` resources **only if** they do not grant privilege to the same role. + + + +## google\_data\_catalog\_entry\_group\_iam\_policy + +```hcl +data "google_iam_policy" "admin" { + binding { + role = "roles/viewer" + members = [ + "user:jane@example.com", + ] + } +} + +resource "google_data_catalog_entry_group_iam_policy" "policy" { + entry_group = google_data_catalog_entry_group.basic_entry_group.name + policy_data = data.google_iam_policy.admin.policy_data +} +``` + +## google\_data\_catalog\_entry\_group\_iam\_binding + +```hcl +resource "google_data_catalog_entry_group_iam_binding" "binding" { + entry_group = google_data_catalog_entry_group.basic_entry_group.name + role = "roles/viewer" + members = [ + "user:jane@example.com", + ] +} +``` + +## google\_data\_catalog\_entry\_group\_iam\_member + +```hcl +resource "google_data_catalog_entry_group_iam_member" "member" { + entry_group = google_data_catalog_entry_group.basic_entry_group.name + role = "roles/viewer" + member = "user:jane@example.com" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `entry_group` - (Required) Used to find the parent resource to bind the IAM policy to + +* `project` - (Optional) The ID of the project in which the resource belongs. + If it is not provided, the project will be parsed from the identifier of the parent resource. If no project is provided in the parent identifier and no project is specified, the provider project is used. + +* `member/members` - (Required) Identities that will be granted the privilege in `role`. + Each entry can have one of the following values: + * **allUsers**: A special identifier that represents anyone who is on the internet; with or without a Google account. + * **allAuthenticatedUsers**: A special identifier that represents anyone who is authenticated with a Google account or a service account. + * **user:{emailid}**: An email address that represents a specific Google account. For example, alice@gmail.com or joe@example.com. + * **serviceAccount:{emailid}**: An email address that represents a service account. For example, my-other-app@appspot.gserviceaccount.com. + * **group:{emailid}**: An email address that represents a Google group. For example, admins@example.com. + * **domain:{domain}**: A G Suite domain (primary, instead of alias) name that represents all the users of that domain. For example, google.com or example.com. + +* `role` - (Required) The role that should be applied. Only one + `google_data_catalog_entry_group_iam_binding` can be used per role. Note that custom roles must be of the format + `[projects|organizations]/{parent-name}/roles/{role-name}`. + +* `policy_data` - (Required only by `google_data_catalog_entry_group_iam_policy`) The policy data generated by + a `google_iam_policy` data source. + +## Attributes Reference + +In addition to the arguments listed above, the following computed attributes are +exported: + +* `etag` - (Computed) The etag of the IAM policy. + +## Import + +For all import syntaxes, the "resource in question" can take any of the following forms: + +* projects/{{project}}/locations/{{region}}/entryGroups/{{entry_group}} +* {{project}}/{{region}}/{{entry_group}} +* {{region}}/{{entry_group}} +* {{entry_group}} + +Any variables not passed in the import command will be taken from the provider configuration. + +Data catalog entrygroup IAM resources can be imported using the resource identifiers, role, and member. + +IAM member imports use space-delimited identifiers: the resource in question, the role, and the member identity, e.g. +``` +$ terraform import google_data_catalog_entry_group_iam_member.editor "{{entry_group}} roles/viewer jane@example.com" +``` + +IAM binding imports use space-delimited identifiers: the resource in question and the role, e.g. +``` +$ terraform import google_data_catalog_entry_group_iam_binding.editor "projects/{{project}}/locations/{{region}}/entryGroups/{{entry_group}} roles/viewer" +``` + +IAM policy imports use the identifier of the resource in question, e.g. +``` +$ terraform import google_data_catalog_entry_group_iam_policy.editor projects/{{project}}/locations/{{region}}/entryGroups/{{entry_group}} +``` + +-> If you're importing a resource with beta features, make sure to include `-provider=google-beta` +as an argument so that Terraform uses the correct provider to import your resource. + +-> **Custom Roles**: If you're importing a IAM resource with a custom role, make sure to use the + full name of the custom role, e.g. `[projects/my-project|organizations/my-org]/roles/my-custom-role`. + +## User Project Overrides + +This resource supports [User Project Overrides](https://www.terraform.io/docs/providers/google/guides/provider_reference.html#user_project_override). diff --git a/website/google.erb b/website/google.erb index ee6d08a87d2..9be5c7d9272 100644 --- a/website/google.erb +++ b/website/google.erb @@ -1480,6 +1480,26 @@ +
  • + Data catalog + +
  • +
  • Dataflow