From cf9653f1513fadece727ef50dffe066335b83800 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Tue, 10 May 2022 20:53:31 -0500 Subject: [PATCH 01/52] inital commit --- tools/terraform_sync_tool/main.tf | 5 +++++ tools/terraform_sync_tool/variables.tf | 3 +++ 2 files changed, 8 insertions(+) create mode 100644 tools/terraform_sync_tool/main.tf create mode 100644 tools/terraform_sync_tool/variables.tf diff --git a/tools/terraform_sync_tool/main.tf b/tools/terraform_sync_tool/main.tf new file mode 100644 index 000000000..6a798c6d3 --- /dev/null +++ b/tools/terraform_sync_tool/main.tf @@ -0,0 +1,5 @@ +provider "google" { + project = var.project_id + region = "us-central1" + zone = "us-central1-c" +} \ No newline at end of file diff --git a/tools/terraform_sync_tool/variables.tf b/tools/terraform_sync_tool/variables.tf new file mode 100644 index 000000000..e40fa1539 --- /dev/null +++ b/tools/terraform_sync_tool/variables.tf @@ -0,0 +1,3 @@ +variable "project_id" { + description = "The project id." +} \ No newline at end of file From fbca216cfa8270014d1c8567e091815925131fe2 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Wed, 11 May 2022 15:45:26 -0500 Subject: [PATCH 02/52] create sample dataset in BigQeury --- tools/terraform_sync_tool/bq_schema.json | 32 ++++++++++++++++++++++++ tools/terraform_sync_tool/main.tf | 23 +++++++++++++++++ tools/terraform_sync_tool/variables.tf | 4 +++ 3 files changed, 59 insertions(+) create mode 100644 tools/terraform_sync_tool/bq_schema.json diff --git a/tools/terraform_sync_tool/bq_schema.json b/tools/terraform_sync_tool/bq_schema.json new file mode 100644 index 000000000..32d78ef24 --- /dev/null +++ b/tools/terraform_sync_tool/bq_schema.json @@ -0,0 +1,32 @@ +[ + { + "description": "Full visitor ID", + "mode": "NULLABLE", + "name": "fullVisitorId", + "type": "STRING" + }, + { + "description": "Visit number", + "mode": "NULLABLE", + "name": "visitNumber", + "type": "INTEGER" + }, + { + "description": "Visit ID", + "mode": "NULLABLE", + "name": "visitId", + "type": "INTEGER" + }, + { + "description": "Visit Start Time", + "mode": "NULLABLE", + "name": "visitStartTime", + "type": "INTEGER" + }, + { + "description": "Full Date of Visit", + "mode": "NULLABLE", + "name": "fullDate", + "type": "DATE" + } +] \ No newline at end of file diff --git a/tools/terraform_sync_tool/main.tf b/tools/terraform_sync_tool/main.tf index 6a798c6d3..cb88ee477 100644 --- a/tools/terraform_sync_tool/main.tf +++ b/tools/terraform_sync_tool/main.tf @@ -2,4 +2,27 @@ provider "google" { project = var.project_id region = "us-central1" zone = "us-central1-c" +} + +resource "google_bigquery_dataset" "example_dataset" { + dataset_id = var.dataset_id + friendly_name = "test" + description = "This is a description" + location = "US" + default_table_expiration_ms = 3600000 +} + +resource "google_bigquery_dataset_iam_binding" "reader" { + dataset_id = google_bigquery_dataset.example_dataset.dataset_id + role = "roles/bigquery.dataViewer" + + members = [ + "user:candicehou@google.com", + ] +} + +resource "google_bigquery_table" "foo" { + dataset_id = google_bigquery_dataset.example_dataset.dataset_id + table_id = "foo" + schema = file("bq_schema.json") } \ No newline at end of file diff --git a/tools/terraform_sync_tool/variables.tf b/tools/terraform_sync_tool/variables.tf index e40fa1539..581599488 100644 --- a/tools/terraform_sync_tool/variables.tf +++ b/tools/terraform_sync_tool/variables.tf @@ -1,3 +1,7 @@ variable "project_id" { description = "The project id." +} + +variable "dataset_id" { + description = "The BigQuery dataset id." } \ No newline at end of file From 313418fef34e351a6487545545b3486a3a703f27 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Thu, 19 May 2022 15:32:40 -0500 Subject: [PATCH 03/52] add cloudbuild.yaml --- tools/terraform_sync_tool/cloudbuild.yaml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tools/terraform_sync_tool/cloudbuild.yaml diff --git a/tools/terraform_sync_tool/cloudbuild.yaml b/tools/terraform_sync_tool/cloudbuild.yaml new file mode 100644 index 000000000..123575ccd --- /dev/null +++ b/tools/terraform_sync_tool/cloudbuild.yaml @@ -0,0 +1,3 @@ +steps: +- name: 'bash' + args: ['echo', 'I am running a bash command'] \ No newline at end of file From c1ccc8373640e4790622d0d48ec18c14f9091c15 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Sun, 22 May 2022 14:34:28 -0500 Subject: [PATCH 04/52] table json schema --- .../terraform_sync_tool/BatchControlLog.json | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tools/terraform_sync_tool/BatchControlLog.json diff --git a/tools/terraform_sync_tool/BatchControlLog.json b/tools/terraform_sync_tool/BatchControlLog.json new file mode 100644 index 000000000..ed3ea2e17 --- /dev/null +++ b/tools/terraform_sync_tool/BatchControlLog.json @@ -0,0 +1,69 @@ + +[ + { + "description": "Batch_ID", + "mode": "NULLABLE", + "name": "Batch_ID", + "type": "STRING" + }, + { + "description": "Batch_Start_DateTime", + "mode": "NULLABLE", + "name": "Batch_Start_DateTime", + "type": "TIMESTAMP" + }, + { + "description": "Batch_Name", + "mode": "NULLABLE", + "name": "Batch_Name", + "type": "STRING" + }, + { + "description": "Job_ID", + "mode": "NULLABLE", + "name": "Job_ID", + "type": "STRING" + }, + { + "description": "Dataflow_ID", + "mode": "NULLABLE", + "name": "Dataflow_ID", + "type": "STRING" + }, + { + "description": "Batch_End_DateTime", + "mode": "NULLABLE", + "name": "Batch_End_DateTime", + "type": "TIMESTAMP" + }, + { + "description": "Batch_Status_Code", + "mode": "NULLABLE", + "name": "Batch_Status_Code", + "type": "STRING" + }, + { + "description": "Failure_Reason", + "mode": "NULLABLE", + "name": "Failure_Reason", + "type": "STRING" + }, + { + "description": "Source_File_Name", + "mode": "NULLABLE", + "name": "Source_File_Name", + "type": "STRING" + }, + { + "description": "Number_of_Records", + "mode": "NULLABLE", + "name": "Number_of_Records", + "type": "STRING" + }, + { + "description": "Number_of_Records_Failed", + "mode": "NULLABLE", + "name": "Number_of_Records_Failed", + "type": "STRING" + } +] \ No newline at end of file From 1a69c02f73d821d5a6d5c875a21fbba1fc740a81 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 6 Jun 2022 15:42:43 -0500 Subject: [PATCH 05/52] Delete bq_schema.json --- tools/terraform_sync_tool/bq_schema.json | 32 ------------------------ 1 file changed, 32 deletions(-) delete mode 100644 tools/terraform_sync_tool/bq_schema.json diff --git a/tools/terraform_sync_tool/bq_schema.json b/tools/terraform_sync_tool/bq_schema.json deleted file mode 100644 index 32d78ef24..000000000 --- a/tools/terraform_sync_tool/bq_schema.json +++ /dev/null @@ -1,32 +0,0 @@ -[ - { - "description": "Full visitor ID", - "mode": "NULLABLE", - "name": "fullVisitorId", - "type": "STRING" - }, - { - "description": "Visit number", - "mode": "NULLABLE", - "name": "visitNumber", - "type": "INTEGER" - }, - { - "description": "Visit ID", - "mode": "NULLABLE", - "name": "visitId", - "type": "INTEGER" - }, - { - "description": "Visit Start Time", - "mode": "NULLABLE", - "name": "visitStartTime", - "type": "INTEGER" - }, - { - "description": "Full Date of Visit", - "mode": "NULLABLE", - "name": "fullDate", - "type": "DATE" - } -] \ No newline at end of file From feb176f9d7c8648e57e18a53c3c7b106f5ad3c70 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 6 Jun 2022 15:43:24 -0500 Subject: [PATCH 06/52] Delete BatchControlLog.json --- .../terraform_sync_tool/BatchControlLog.json | 69 ------------------- 1 file changed, 69 deletions(-) delete mode 100644 tools/terraform_sync_tool/BatchControlLog.json diff --git a/tools/terraform_sync_tool/BatchControlLog.json b/tools/terraform_sync_tool/BatchControlLog.json deleted file mode 100644 index ed3ea2e17..000000000 --- a/tools/terraform_sync_tool/BatchControlLog.json +++ /dev/null @@ -1,69 +0,0 @@ - -[ - { - "description": "Batch_ID", - "mode": "NULLABLE", - "name": "Batch_ID", - "type": "STRING" - }, - { - "description": "Batch_Start_DateTime", - "mode": "NULLABLE", - "name": "Batch_Start_DateTime", - "type": "TIMESTAMP" - }, - { - "description": "Batch_Name", - "mode": "NULLABLE", - "name": "Batch_Name", - "type": "STRING" - }, - { - "description": "Job_ID", - "mode": "NULLABLE", - "name": "Job_ID", - "type": "STRING" - }, - { - "description": "Dataflow_ID", - "mode": "NULLABLE", - "name": "Dataflow_ID", - "type": "STRING" - }, - { - "description": "Batch_End_DateTime", - "mode": "NULLABLE", - "name": "Batch_End_DateTime", - "type": "TIMESTAMP" - }, - { - "description": "Batch_Status_Code", - "mode": "NULLABLE", - "name": "Batch_Status_Code", - "type": "STRING" - }, - { - "description": "Failure_Reason", - "mode": "NULLABLE", - "name": "Failure_Reason", - "type": "STRING" - }, - { - "description": "Source_File_Name", - "mode": "NULLABLE", - "name": "Source_File_Name", - "type": "STRING" - }, - { - "description": "Number_of_Records", - "mode": "NULLABLE", - "name": "Number_of_Records", - "type": "STRING" - }, - { - "description": "Number_of_Records_Failed", - "mode": "NULLABLE", - "name": "Number_of_Records_Failed", - "type": "STRING" - } -] \ No newline at end of file From d94c55b2f598cd670e6d0bebe22eee123533eccc Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Wed, 8 Jun 2022 13:25:40 -0400 Subject: [PATCH 07/52] terraform sync tool --- tools/terraform_sync_tool/cloudbuild.yaml | 13 +- tools/terraform_sync_tool/deploy.sh | 7 + tools/terraform_sync_tool/main.tf | 28 --- .../modules/terraform-sync-tool/main.tf | 96 ++++++++ .../modules/terraform-sync-tool/variables.tf | 113 +++++++++ .../G4eNQFLLAF6iZ90VzUweI-xTZiE/backend.tf | 7 + .../G4eNQFLLAF6iZ90VzUweI-xTZiE/main.tf | 214 ++++++++++++++++++ .../G4eNQFLLAF6iZ90VzUweI-xTZiE/provider.tf | 4 + .../terragrunt.hcl | 39 ++++ .../G4eNQFLLAF6iZ90VzUweI-xTZiE/variables.tf | 171 ++++++++++++++ .../json_schemas/TableForTest.json | 56 +++++ .../qa/terraform-sync-tool/terragrunt.hcl | 39 ++++ tools/terraform_sync_tool/qa/terragrunt.hcl | 42 ++++ tools/terraform_sync_tool/state.json | 6 + tools/terraform_sync_tool/variables.tf | 7 - 15 files changed, 805 insertions(+), 37 deletions(-) create mode 100644 tools/terraform_sync_tool/deploy.sh delete mode 100644 tools/terraform_sync_tool/main.tf create mode 100644 tools/terraform_sync_tool/modules/terraform-sync-tool/main.tf create mode 100644 tools/terraform_sync_tool/modules/terraform-sync-tool/variables.tf create mode 100644 tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/backend.tf create mode 100644 tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/main.tf create mode 100644 tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/provider.tf create mode 100644 tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/terragrunt.hcl create mode 100644 tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/variables.tf create mode 100644 tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest.json create mode 100644 tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl create mode 100644 tools/terraform_sync_tool/qa/terragrunt.hcl create mode 100644 tools/terraform_sync_tool/state.json delete mode 100644 tools/terraform_sync_tool/variables.tf diff --git a/tools/terraform_sync_tool/cloudbuild.yaml b/tools/terraform_sync_tool/cloudbuild.yaml index 123575ccd..1bfd8a99e 100644 --- a/tools/terraform_sync_tool/cloudbuild.yaml +++ b/tools/terraform_sync_tool/cloudbuild.yaml @@ -1,3 +1,12 @@ steps: -- name: 'bash' - args: ['echo', 'I am running a bash command'] \ No newline at end of file + # step 0: run terraform commands in deploy.sh to detects drifts + - name: 'alpine/terragrunt' + entrypoint: 'bash' + args: ['./deploy.sh', 'qa', 'terraform-sync-tool'] + + # step 1: run python scripts to investigate terraform output + # - name: python:3.7 + # entrypoint: 'bash' + # args: + # - -c + # - 'pip install -r ./requirements.txt && python terraform_sync.py' \ No newline at end of file diff --git a/tools/terraform_sync_tool/deploy.sh b/tools/terraform_sync_tool/deploy.sh new file mode 100644 index 000000000..30cafef30 --- /dev/null +++ b/tools/terraform_sync_tool/deploy.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +env=$1 +tool=$2 + +terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" > state.json +# terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir=/qa/terraform-sync-tool > state.json \ No newline at end of file diff --git a/tools/terraform_sync_tool/main.tf b/tools/terraform_sync_tool/main.tf deleted file mode 100644 index cb88ee477..000000000 --- a/tools/terraform_sync_tool/main.tf +++ /dev/null @@ -1,28 +0,0 @@ -provider "google" { - project = var.project_id - region = "us-central1" - zone = "us-central1-c" -} - -resource "google_bigquery_dataset" "example_dataset" { - dataset_id = var.dataset_id - friendly_name = "test" - description = "This is a description" - location = "US" - default_table_expiration_ms = 3600000 -} - -resource "google_bigquery_dataset_iam_binding" "reader" { - dataset_id = google_bigquery_dataset.example_dataset.dataset_id - role = "roles/bigquery.dataViewer" - - members = [ - "user:candicehou@google.com", - ] -} - -resource "google_bigquery_table" "foo" { - dataset_id = google_bigquery_dataset.example_dataset.dataset_id - table_id = "foo" - schema = file("bq_schema.json") -} \ No newline at end of file diff --git a/tools/terraform_sync_tool/modules/terraform-sync-tool/main.tf b/tools/terraform_sync_tool/modules/terraform-sync-tool/main.tf new file mode 100644 index 000000000..06e92d340 --- /dev/null +++ b/tools/terraform_sync_tool/modules/terraform-sync-tool/main.tf @@ -0,0 +1,96 @@ +locals { + datasets = { for dataset in var.datasets : dataset["dataset_id"] => dataset } + tables = { for table in var.tables : table["table_id"] => table } + views = { for view in var.views : view["view_id"] => view } + + iam_to_primitive = { + "roles/bigquery.dataOwner" : "OWNER" + "roles/bigquery.dataEditor" : "WRITER" + "roles/bigquery.dataViewer" : "READER" + } +} + +#this is the test for dataset list creation +resource "google_bigquery_dataset" "bq_dataset" { + for_each = local.datasets + friendly_name = each.value["friendly_name"] + dataset_id = each.key + location = each.value["location"] + project = var.project_id + + dynamic "default_encryption_configuration" { + for_each = var.encryption_key == null ? [] : [var.encryption_key] + content { + kms_key_name = var.encryption_key + } + } + + dynamic "access" { + for_each = var.access + + content { + # BigQuery API converts IAM to primitive roles in its backend. + # This causes Terraform to show a diff on every plan that uses IAM equivalent roles. + # Thus, do the conversion between IAM to primitive role here to prevent the diff. + role = lookup(local.iam_to_primitive, access.value.role, access.value.role) + + domain = lookup(access.value, "domain", null) + group_by_email = lookup(access.value, "group_by_email", null) + user_by_email = lookup(access.value, "user_by_email", null) + special_group = lookup(access.value, "special_group", null) + } + } + } + +resource "google_bigquery_table" "bq_table" { + for_each = local.tables + dataset_id = each.value["dataset_id"] + friendly_name = each.key + table_id = each.key + labels = each.value["labels"] + schema = file(each.value["schema"]) + clustering = each.value["clustering"] + expiration_time = each.value["expiration_time"] + project = var.project_id + deletion_protection = each.value["deletion_protection"] + depends_on = [google_bigquery_dataset.bq_dataset] + + dynamic "time_partitioning" { + for_each = each.value["time_partitioning"] != null ? [each.value["time_partitioning"]] : [] + content { + type = time_partitioning.value["type"] + expiration_ms = time_partitioning.value["expiration_ms"] + field = time_partitioning.value["field"] + require_partition_filter = time_partitioning.value["require_partition_filter"] + } + } + + dynamic "range_partitioning" { + for_each = each.value["range_partitioning"] != null ? [each.value["range_partitioning"]] : [] + content { + field = range_partitioning.value["field"] + range { + start = range_partitioning.value["range"].start + end = range_partitioning.value["range"].end + interval = range_partitioning.value["range"].interval + } + } + } + +} + +resource "google_bigquery_table" "bq_view" { + for_each = local.views + dataset_id = each.value["dataset_id"] + friendly_name = each.key + table_id = each.key + labels = each.value["labels"] + project = var.project_id + deletion_protection = each.value["deletion_protection"] + depends_on = [google_bigquery_table.bq_table] + + view { + query = each.value["query"] + use_legacy_sql = each.value["use_legacy_sql"] + } +} \ No newline at end of file diff --git a/tools/terraform_sync_tool/modules/terraform-sync-tool/variables.tf b/tools/terraform_sync_tool/modules/terraform-sync-tool/variables.tf new file mode 100644 index 000000000..28e92aadd --- /dev/null +++ b/tools/terraform_sync_tool/modules/terraform-sync-tool/variables.tf @@ -0,0 +1,113 @@ +variable "description" { + description = "Dataset description." + type = string + default = null +} + +variable "location" { + description = "The regional location for the dataset only US and EU are allowed in module" + type = string + default = "US" +} + +variable "delete_contents_on_destroy" { + description = "(Optional) If set to true, delete all the tables in the dataset when destroying the resource; otherwise, destroying the resource will fail if tables are present." + type = bool + default = null +} + +variable "deletion_protection" { + description = "Whether or not to allow Terraform to destroy the instance. Unless this field is set to false in Terraform state, a terraform destroy or terraform apply that would delete the instance will fail." + type = bool + default = true + } + +variable "default_table_expiration_ms" { + description = "TTL of tables using the dataset in MS" + type = number + default = null +} + +variable "project_id" { + description = "Project where the dataset and table are created" + type = string +} + +variable "encryption_key" { + description = "Default encryption key to apply to the dataset. Defaults to null (Google-managed)." + type = string + default = null +} + +variable "dataset_labels" { + description = "Key value pairs in a map for dataset labels" + type = map(string) + default = {} +} + +# Format: list(objects) +# domain: A domain to grant access to. +# group_by_email: An email address of a Google Group to grant access to. +# user_by_email: An email address of a user to grant access to. +# special_group: A special group to grant access to. + +variable "access" { + description = "An array of objects that define dataset access for one or more entities." + type = any + + # At least one owner access is required. + default = [{ + role = "roles/bigquery.dataOwner" + special_group = "projectOwners" + }] +} +variable "datasets" { + description = "this is a test DS" + default = [] + type = list(object({ + dataset_id = string + friendly_name = string + location = string + } + )) +} +variable "tables" { + description = "A list of objects which include table_id, schema, clustering, time_partitioning, expiration_time and labels." + default = [] + type = list(object({ + table_id = string, + dataset_id = string, #added to test creating multi dataset + schema = string, + clustering = list(string), + deletion_protection=bool, + time_partitioning = object({ + expiration_ms = string, + field = string, + type = string, + require_partition_filter = bool, + }), + range_partitioning = object({ + field = string, + range = object({ + start = string, + end = string, + interval = string, + }), + }), + expiration_time = string, + labels = map(string), + } + )) +} +variable "views" { + description = "A list of objects which include table_id, which is view id, and view query" + default = [] + type = list(object({ + view_id = string, + dataset_id = string, + query = string, + deletion_protection=bool, + use_legacy_sql = bool, + labels = map(string), + })) +} \ No newline at end of file diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/backend.tf b/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/backend.tf new file mode 100644 index 000000000..ff630c49f --- /dev/null +++ b/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/backend.tf @@ -0,0 +1,7 @@ +# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa +terraform { + backend "gcs" { + bucket = "synctooltest" + prefix = "qa/terraform-sync-tool/terraform.tfstate" + } +} diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/main.tf b/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/main.tf new file mode 100644 index 000000000..ada6df22f --- /dev/null +++ b/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/main.tf @@ -0,0 +1,214 @@ +# provider "google" { +# project = var.project_id +# region = "us-central1" +# zone = "us-central1-c" +# } + +# resource "google_bigquery_dataset" "example_dataset" { +# dataset_id = var.dataset_id +# friendly_name = "test" +# description = "This is a description" +# location = "US" +# default_table_expiration_ms = 3600000 +# } + +# resource "google_bigquery_dataset_iam_binding" "reader" { +# dataset_id = google_bigquery_dataset.example_dataset.dataset_id +# role = "roles/bigquery.dataViewer" + +# members = [ +# "user:candicehou@google.com", +# ] +# } + +# resource "google_bigquery_table" "foo" { +# dataset_id = google_bigquery_dataset.example_dataset.dataset_id +# table_id = "foo" +# schema = file("bq_schema.json") +# } + +locals { + datasets = { for dataset in var.datasets : dataset["dataset_id"] => dataset } + tables = { for table in var.tables : table["table_id"] => table } + views = { for view in var.views : view["view_id"] => view } + + iam_to_primitive = { + "roles/bigquery.dataOwner" : "OWNER" + "roles/bigquery.dataEditor" : "WRITER" + "roles/bigquery.dataViewer" : "READER" + } +} + +#this is the test for dataset list creation +resource "google_bigquery_dataset" "bq_dataset" { + for_each = local.datasets + friendly_name = each.value["friendly_name"] + dataset_id = each.key + location = each.value["location"] + project = var.project_id + + dynamic "default_encryption_configuration" { + for_each = var.encryption_key == null ? [] : [var.encryption_key] + content { + kms_key_name = var.encryption_key + } + } + + dynamic "access" { + for_each = var.access + + content { + # BigQuery API converts IAM to primitive roles in its backend. + # This causes Terraform to show a diff on every plan that uses IAM equivalent roles. + # Thus, do the conversion between IAM to primitive role here to prevent the diff. + role = lookup(local.iam_to_primitive, access.value.role, access.value.role) + + domain = lookup(access.value, "domain", null) + group_by_email = lookup(access.value, "group_by_email", null) + user_by_email = lookup(access.value, "user_by_email", null) + special_group = lookup(access.value, "special_group", null) + } + } + } + +resource "google_bigquery_table" "bq_table" { + for_each = local.tables + dataset_id = each.value["dataset_id"] + friendly_name = each.key + table_id = each.key + labels = each.value["labels"] + schema = file(each.value["schema"]) + clustering = each.value["clustering"] + expiration_time = each.value["expiration_time"] + project = var.project_id + deletion_protection = each.value["deletion_protection"] + depends_on = [google_bigquery_dataset.bq_dataset] + + dynamic "time_partitioning" { + for_each = each.value["time_partitioning"] != null ? [each.value["time_partitioning"]] : [] + content { + type = time_partitioning.value["type"] + expiration_ms = time_partitioning.value["expiration_ms"] + field = time_partitioning.value["field"] + require_partition_filter = time_partitioning.value["require_partition_filter"] + } + } + + dynamic "range_partitioning" { + for_each = each.value["range_partitioning"] != null ? [each.value["range_partitioning"]] : [] + content { + field = range_partitioning.value["field"] + range { + start = range_partitioning.value["range"].start + end = range_partitioning.value["range"].end + interval = range_partitioning.value["range"].interval + } + } + } + +} + +resource "google_bigquery_table" "bq_view" { + for_each = local.views + dataset_id = each.value["dataset_id"] + friendly_name = each.key + table_id = each.key + labels = each.value["labels"] + project = var.project_id + deletion_protection = each.value["deletion_protection"] + depends_on = [google_bigquery_table.bq_table] + + view { + query = each.value["query"] + use_legacy_sql = each.value["use_legacy_sql"] + } +} + +# resource "google_bigquery_table" "external_table" { +# for_each = local.external_tables +# dataset_id = each.value["dataset_id"] +# friendly_name = each.key +# table_id = each.key +# labels = each.value["labels"] +# expiration_time = each.value["expiration_time"] +# deletion_protection = each.value["deletion_protection"] +# project = var.project_id +# depends_on = [google_bigquery_dataset.bq_dataset] + +# external_data_configuration { +# autodetect = each.value["autodetect"] +# compression = each.value["compression"] +# ignore_unknown_values = each.value["ignore_unknown_values"] +# max_bad_records = each.value["max_bad_records"] +# schema = each.value["schema"] +# source_format = each.value["source_format"] +# source_uris = each.value["source_uris"] + +# dynamic "csv_options" { +# for_each = each.value["csv_options"] != null ? [each.value["csv_options"]] : [] +# content { +# quote = csv_options.value["quote"] +# allow_jagged_rows = csv_options.value["allow_jagged_rows"] +# allow_quoted_newlines = csv_options.value["allow_quoted_newlines"] +# encoding = csv_options.value["encoding"] +# field_delimiter = csv_options.value["field_delimiter"] +# skip_leading_rows = csv_options.value["skip_leading_rows"] +# } +# } + +# dynamic "google_sheets_options" { +# for_each = each.value["google_sheets_options"] != null ? [each.value["google_sheets_options"]] : [] +# content { +# range = google_sheets_options.value["range"] +# skip_leading_rows = google_sheets_options.value["skip_leading_rows"] +# } +# } + +# dynamic "hive_partitioning_options" { +# for_each = each.value["hive_partitioning_options"] != null ? [each.value["hive_partitioning_options"]] : [] +# content { +# mode = hive_partitioning_options.value["mode"] +# source_uri_prefix = hive_partitioning_options.value["source_uri_prefix"] +# } +# } +# } +# } + +#added - 1220223 +# resource "google_bigquery_dataset" "bq_dataset_ignore_access" { +# for_each = local.datasets_lyf +# friendly_name = each.value["friendly_name"] +# dataset_id = each.key +# location = each.value["location"] +# project = var.project_id + +# dynamic "default_encryption_configuration" { +# for_each = var.encryption_key == null ? [] : [var.encryption_key] +# content { +# kms_key_name = var.encryption_key +# } +# } + +# dynamic "access" { +# for_each = var.access_lyf + +# content { +# # BigQuery API converts IAM to primitive roles in its backend. +# # This causes Terraform to show a diff on every plan that uses IAM equivalent roles. +# # Thus, do the conversion between IAM to primitive role here to prevent the diff. +# role = lookup(local.iam_to_primitive, access.value.role, access.value.role) + +# domain = lookup(access.value, "domain", null) +# group_by_email = lookup(access.value, "group_by_email", null) +# user_by_email = lookup(access.value, "user_by_email", null) +# special_group = lookup(access.value, "special_group", null) + +# } +# } + +# lifecycle { +# ignore_changes = [ +# access #ignoring changes to access +# ] +# } +# } \ No newline at end of file diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/provider.tf b/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/provider.tf new file mode 100644 index 000000000..3ae2421da --- /dev/null +++ b/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/provider.tf @@ -0,0 +1,4 @@ +# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa +provider "google" { + project = "candicehou-terraform-sync-tool" +} diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/terragrunt.hcl b/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/terragrunt.hcl new file mode 100644 index 000000000..ba68000c0 --- /dev/null +++ b/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/terragrunt.hcl @@ -0,0 +1,39 @@ +terraform { + source = "../../modules/terraform-sync-tool" +} + +include "root" { + path = find_in_parent_folders() + expose = true +} + +locals { + dataset_id = "tf_test_sync_tool" +} + +inputs = { + project_id = include.root.inputs.project_id + # The ID of the project in which the resource belongs. If it is not provided, the provider project is used. + datasets = [ + { + dataset_id = "${local.dataset_id}" + friendly_name = "Dataset for Terraform Sync Tool" + location = "US" + labels = {} + } + ] + + tables = [ + { + table_id = "TableForTest" + dataset_id = "${local.dataset_id}" + schema = "json_schemas/TableForTest.json" + clustering = [] + expiration_time = null + deletion_protection = true + range_partitioning = null + time_partitioning = null + labels = {} + } + ] +} \ No newline at end of file diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/variables.tf b/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/variables.tf new file mode 100644 index 000000000..ff13fd029 --- /dev/null +++ b/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/variables.tf @@ -0,0 +1,171 @@ +variable "description" { + description = "Dataset description." + type = string + default = null +} + +variable "location" { + description = "The regional location for the dataset only US and EU are allowed in module" + type = string + default = "US" +} + +variable "delete_contents_on_destroy" { + description = "(Optional) If set to true, delete all the tables in the dataset when destroying the resource; otherwise, destroying the resource will fail if tables are present." + type = bool + default = null +} + +variable "deletion_protection" { + description = "Whether or not to allow Terraform to destroy the instance. Unless this field is set to false in Terraform state, a terraform destroy or terraform apply that would delete the instance will fail." + type = bool + default = true + } + +variable "default_table_expiration_ms" { + description = "TTL of tables using the dataset in MS" + type = number + default = null +} + +variable "project_id" { + description = "Project where the dataset and table are created" + type = string +} + +variable "encryption_key" { + description = "Default encryption key to apply to the dataset. Defaults to null (Google-managed)." + type = string + default = null +} + +variable "dataset_labels" { + description = "Key value pairs in a map for dataset labels" + type = map(string) + default = {} +} + +# Format: list(objects) +# domain: A domain to grant access to. +# group_by_email: An email address of a Google Group to grant access to. +# user_by_email: An email address of a user to grant access to. +# special_group: A special group to grant access to. + +variable "access" { + description = "An array of objects that define dataset access for one or more entities." + type = any + + # At least one owner access is required. + default = [{ + role = "roles/bigquery.dataOwner" + special_group = "projectOwners" + }] +} +variable "datasets" { + description = "this is a test DS" + default = [] + type = list(object({ + dataset_id = string + friendly_name = string + location = string + } + )) +} +variable "tables" { + description = "A list of objects which include table_id, schema, clustering, time_partitioning, expiration_time and labels." + default = [] + type = list(object({ + table_id = string, + dataset_id = string, #added to test creating multi dataset + schema = string, + clustering = list(string), + deletion_protection=bool, + time_partitioning = object({ + expiration_ms = string, + field = string, + type = string, + require_partition_filter = bool, + }), + range_partitioning = object({ + field = string, + range = object({ + start = string, + end = string, + interval = string, + }), + }), + expiration_time = string, + labels = map(string), + } + )) +} +variable "views" { + description = "A list of objects which include table_id, which is view id, and view query" + default = [] + type = list(object({ + view_id = string, + dataset_id = string, + query = string, + deletion_protection=bool, + use_legacy_sql = bool, + labels = map(string), + })) +} + +# variable "external_tables" { +# description = "A list of objects which include table_id, expiration_time, external_data_configuration, and labels." +# default = [] +# type = list(object({ +# table_id = string, +# dataset_id = string, +# autodetect = bool, +# compression = string, +# ignore_unknown_values = bool, +# max_bad_records = number, +# schema = string, +# source_format = string, +# deletion_protection = bool, +# source_uris = list(string), +# csv_options = object({ +# quote = string, +# allow_jagged_rows = bool, +# allow_quoted_newlines = bool, +# encoding = string, +# field_delimiter = string, +# skip_leading_rows = number, +# }), +# google_sheets_options = object({ +# range = string, +# skip_leading_rows = number, +# }), +# hive_partitioning_options = object({ +# mode = string, +# source_uri_prefix = string, +# }), +# expiration_time = string, +# labels = map(string), +# })) +# } + +# variable "datasets_lyf" { +# description = "this is a test DS" +# default = [] +# type = list(object({ +# dataset_id = string +# friendly_name = string +# location = string +# } +# )) +# } + +# variable "access_lyf" { +# description = "An array of objects that define dataset access for one or more entities." +# type = any + +# # At least one owner access is required. +# default = [ +# { +# role = "roles/bigquery.dataOwner" +# special_group = "projectOwners" +# }] +# } \ No newline at end of file diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest.json b/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest.json new file mode 100644 index 000000000..84b55ccbf --- /dev/null +++ b/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest.json @@ -0,0 +1,56 @@ + [ + { + "description": "Col1", + "mode": "NULLABLE", + "name": "Col1", + "type": "STRING" + }, + { + "description": "Col2", + "mode": "NULLABLE", + "name": "Col2", + "type": "STRING" + }, + { + "description": "Col3", + "mode": "NULLABLE", + "name": "Col3", + "type": "STRING" + }, + { + "description": "Col4", + "mode": "NULLABLE", + "name": "Col4", + "type": "STRING" + }, + { + "description": "Col5", + "mode": "NULLABLE", + "name": "Col5", + "type": "STRING" + }, + { + "description": "Col6", + "mode": "NULLABLE", + "name": "Col6", + "type": "STRING" + }, + { + "description": "Col7", + "mode": "NULLABLE", + "name": "Col7", + "type": "STRING" + }, + { + "description": "Col8", + "mode": "NULLABLE", + "name": "Col8", + "type": "STRING" + }, + { + "description": "Col9", + "mode": "NULLABLE", + "name": "Col9", + "type": "STRING" + } + ] \ No newline at end of file diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl b/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl new file mode 100644 index 000000000..ba68000c0 --- /dev/null +++ b/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl @@ -0,0 +1,39 @@ +terraform { + source = "../../modules/terraform-sync-tool" +} + +include "root" { + path = find_in_parent_folders() + expose = true +} + +locals { + dataset_id = "tf_test_sync_tool" +} + +inputs = { + project_id = include.root.inputs.project_id + # The ID of the project in which the resource belongs. If it is not provided, the provider project is used. + datasets = [ + { + dataset_id = "${local.dataset_id}" + friendly_name = "Dataset for Terraform Sync Tool" + location = "US" + labels = {} + } + ] + + tables = [ + { + table_id = "TableForTest" + dataset_id = "${local.dataset_id}" + schema = "json_schemas/TableForTest.json" + clustering = [] + expiration_time = null + deletion_protection = true + range_partitioning = null + time_partitioning = null + labels = {} + } + ] +} \ No newline at end of file diff --git a/tools/terraform_sync_tool/qa/terragrunt.hcl b/tools/terraform_sync_tool/qa/terragrunt.hcl new file mode 100644 index 000000000..7237b6020 --- /dev/null +++ b/tools/terraform_sync_tool/qa/terragrunt.hcl @@ -0,0 +1,42 @@ +# Indicate where to source the terraform module from. +# The URL used here is a shorthand for +# "tfr://registry.terraform.io/terraform-aws-modules/vpc/aws?version=3.5.0". +# Note the extra `/` after the protocol is required for the shorthand +# notation. + +locals { + gcp_project_id = "candicehou-terraform-sync-tool" +} + +inputs = { + project_id = local.gcp_project_id + gcp_region = "us-central1" +} + +generate "provider" { + path = "provider.tf" + if_exists = "overwrite" + contents = < Date: Wed, 8 Jun 2022 13:44:27 -0400 Subject: [PATCH 08/52] change dir --- tools/terraform_sync_tool/cloudbuild.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/terraform_sync_tool/cloudbuild.yaml b/tools/terraform_sync_tool/cloudbuild.yaml index 1bfd8a99e..6d4b5a262 100644 --- a/tools/terraform_sync_tool/cloudbuild.yaml +++ b/tools/terraform_sync_tool/cloudbuild.yaml @@ -2,7 +2,7 @@ steps: # step 0: run terraform commands in deploy.sh to detects drifts - name: 'alpine/terragrunt' entrypoint: 'bash' - args: ['./deploy.sh', 'qa', 'terraform-sync-tool'] + args: ['./tools/terraform_sync_tool/deploy.sh', 'qa', 'terraform-sync-tool'] # step 1: run python scripts to investigate terraform output # - name: python:3.7 From 3cfcbc260b04fa970fe329422098617e312a30cd Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Wed, 8 Jun 2022 13:49:12 -0400 Subject: [PATCH 09/52] specify dir from yaml file --- tools/terraform_sync_tool/cloudbuild.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/terraform_sync_tool/cloudbuild.yaml b/tools/terraform_sync_tool/cloudbuild.yaml index 6d4b5a262..5b8df9469 100644 --- a/tools/terraform_sync_tool/cloudbuild.yaml +++ b/tools/terraform_sync_tool/cloudbuild.yaml @@ -2,7 +2,8 @@ steps: # step 0: run terraform commands in deploy.sh to detects drifts - name: 'alpine/terragrunt' entrypoint: 'bash' - args: ['./tools/terraform_sync_tool/deploy.sh', 'qa', 'terraform-sync-tool'] + dir: './tools/terraform_sync_tool/' + args: ['deploy.sh', 'qa', 'terraform-sync-tool'] # step 1: run python scripts to investigate terraform output # - name: python:3.7 From 8ae5b484e4bf5facf53fb18eef6987e0061a5d9b Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Wed, 8 Jun 2022 13:52:03 -0400 Subject: [PATCH 10/52] add step1 to run python scripts --- tools/terraform_sync_tool/cloudbuild.yaml | 13 ++--- tools/terraform_sync_tool/requirements.txt | 1 + tools/terraform_sync_tool/terraform_sync.py | 60 +++++++++++++++++++++ 3 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 tools/terraform_sync_tool/requirements.txt create mode 100644 tools/terraform_sync_tool/terraform_sync.py diff --git a/tools/terraform_sync_tool/cloudbuild.yaml b/tools/terraform_sync_tool/cloudbuild.yaml index 5b8df9469..78c32b8cc 100644 --- a/tools/terraform_sync_tool/cloudbuild.yaml +++ b/tools/terraform_sync_tool/cloudbuild.yaml @@ -4,10 +4,11 @@ steps: entrypoint: 'bash' dir: './tools/terraform_sync_tool/' args: ['deploy.sh', 'qa', 'terraform-sync-tool'] - + # step 1: run python scripts to investigate terraform output - # - name: python:3.7 - # entrypoint: 'bash' - # args: - # - -c - # - 'pip install -r ./requirements.txt && python terraform_sync.py' \ No newline at end of file + - name: python:3.7 + entrypoint: 'bash' + dir: './tools/terraform_sync_tool/' + args: + - -c + - 'pip install -r ./requirements.txt && python terraform_sync.py' \ No newline at end of file diff --git a/tools/terraform_sync_tool/requirements.txt b/tools/terraform_sync_tool/requirements.txt new file mode 100644 index 000000000..3700b272e --- /dev/null +++ b/tools/terraform_sync_tool/requirements.txt @@ -0,0 +1 @@ +google-cloud-bigquery diff --git a/tools/terraform_sync_tool/terraform_sync.py b/tools/terraform_sync_tool/terraform_sync.py new file mode 100644 index 000000000..0e313ebe2 --- /dev/null +++ b/tools/terraform_sync_tool/terraform_sync.py @@ -0,0 +1,60 @@ +import json +import io +from google.cloud import bigquery + +# Fetch schemas for drifted tables form BQ +def get_schemas_from_BQ(drifted_tables): + table_schemas = [] + client = bigquery.Client() + for table_id in drifted_tables: + table = client.get_table(table_id) + file = io.StringIO("") + client.schema_to_json(table.schema, file) + # append schema as a dict where key=table_id, value=table_schema + schema_bq = dict({table_id: json.loads(file.getvalue())}) + table_schemas.append(schema_bq) + return table_schemas + +# Convert table resource from terraform log output to +# table_id format:[gcp_project_id].[dataset_id].[table_id] +def convert_to_table_id(input): + table_id = "" + for s in input.rsplit("/"): + if(s != "projects" and s != "datasets" and s != "tables"): + table_id += s+"." + return table_id[:len(table_id)-1] + +# Opening JSON file +f = open('./state.json', 'r') +data = f.read() +data = data.split("}\n") +data = [d.strip() + "}" for d in data] +data = list(filter(("}").__ne__, data)) +data = [json.loads(d) for d in data] +drifted_tables = [] +# find table with drifts and add to drifted_tables list +for line in data: + if line['type'] == 'resource_drift' and line['change']['resource']['resource_type'] == 'google_bigquery_table': + table_id = line['change']['resource']['resource_key'] + drifted_tables.append(table_id) + +# Convert drifted_tables format to table_ids +if len(drifted_tables) > 0: + # iterate through drifted_tables list + i = 0 + for line in data: + if line['type'] == 'refresh_complete': + resource_table = line['hook']['id_value'] + if(resource_table[len(resource_table) - len(drifted_tables[i]):] == drifted_tables[i]): + drifted_tables[i] = convert_to_table_id(resource_table) + i += 1 + if(i == len(drifted_tables)): + break + # Fetch schemas for drifted tables from BQ + drifted_table_schemas = get_schemas_from_BQ(drifted_tables) + # Drifts detected, throw exceptions + raise Exception("Drifts are detected in these tables, please update your terraform schema files with the following updated table schemas. ", drifted_table_schemas) + + +# Closing file +f.close() \ No newline at end of file From 4d5ac73489f2a8457698d634f97d9709ffba50bd Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Wed, 8 Jun 2022 13:04:20 -0500 Subject: [PATCH 11/52] Delete auto generated contents --- .../G4eNQFLLAF6iZ90VzUweI-xTZiE/backend.tf | 7 - .../G4eNQFLLAF6iZ90VzUweI-xTZiE/main.tf | 214 ------------------ .../G4eNQFLLAF6iZ90VzUweI-xTZiE/provider.tf | 4 - .../terragrunt.hcl | 39 ---- .../G4eNQFLLAF6iZ90VzUweI-xTZiE/variables.tf | 171 -------------- 5 files changed, 435 deletions(-) delete mode 100644 tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/backend.tf delete mode 100644 tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/main.tf delete mode 100644 tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/provider.tf delete mode 100644 tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/terragrunt.hcl delete mode 100644 tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/variables.tf diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/backend.tf b/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/backend.tf deleted file mode 100644 index ff630c49f..000000000 --- a/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/backend.tf +++ /dev/null @@ -1,7 +0,0 @@ -# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa -terraform { - backend "gcs" { - bucket = "synctooltest" - prefix = "qa/terraform-sync-tool/terraform.tfstate" - } -} diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/main.tf b/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/main.tf deleted file mode 100644 index ada6df22f..000000000 --- a/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/main.tf +++ /dev/null @@ -1,214 +0,0 @@ -# provider "google" { -# project = var.project_id -# region = "us-central1" -# zone = "us-central1-c" -# } - -# resource "google_bigquery_dataset" "example_dataset" { -# dataset_id = var.dataset_id -# friendly_name = "test" -# description = "This is a description" -# location = "US" -# default_table_expiration_ms = 3600000 -# } - -# resource "google_bigquery_dataset_iam_binding" "reader" { -# dataset_id = google_bigquery_dataset.example_dataset.dataset_id -# role = "roles/bigquery.dataViewer" - -# members = [ -# "user:candicehou@google.com", -# ] -# } - -# resource "google_bigquery_table" "foo" { -# dataset_id = google_bigquery_dataset.example_dataset.dataset_id -# table_id = "foo" -# schema = file("bq_schema.json") -# } - -locals { - datasets = { for dataset in var.datasets : dataset["dataset_id"] => dataset } - tables = { for table in var.tables : table["table_id"] => table } - views = { for view in var.views : view["view_id"] => view } - - iam_to_primitive = { - "roles/bigquery.dataOwner" : "OWNER" - "roles/bigquery.dataEditor" : "WRITER" - "roles/bigquery.dataViewer" : "READER" - } -} - -#this is the test for dataset list creation -resource "google_bigquery_dataset" "bq_dataset" { - for_each = local.datasets - friendly_name = each.value["friendly_name"] - dataset_id = each.key - location = each.value["location"] - project = var.project_id - - dynamic "default_encryption_configuration" { - for_each = var.encryption_key == null ? [] : [var.encryption_key] - content { - kms_key_name = var.encryption_key - } - } - - dynamic "access" { - for_each = var.access - - content { - # BigQuery API converts IAM to primitive roles in its backend. - # This causes Terraform to show a diff on every plan that uses IAM equivalent roles. - # Thus, do the conversion between IAM to primitive role here to prevent the diff. - role = lookup(local.iam_to_primitive, access.value.role, access.value.role) - - domain = lookup(access.value, "domain", null) - group_by_email = lookup(access.value, "group_by_email", null) - user_by_email = lookup(access.value, "user_by_email", null) - special_group = lookup(access.value, "special_group", null) - } - } - } - -resource "google_bigquery_table" "bq_table" { - for_each = local.tables - dataset_id = each.value["dataset_id"] - friendly_name = each.key - table_id = each.key - labels = each.value["labels"] - schema = file(each.value["schema"]) - clustering = each.value["clustering"] - expiration_time = each.value["expiration_time"] - project = var.project_id - deletion_protection = each.value["deletion_protection"] - depends_on = [google_bigquery_dataset.bq_dataset] - - dynamic "time_partitioning" { - for_each = each.value["time_partitioning"] != null ? [each.value["time_partitioning"]] : [] - content { - type = time_partitioning.value["type"] - expiration_ms = time_partitioning.value["expiration_ms"] - field = time_partitioning.value["field"] - require_partition_filter = time_partitioning.value["require_partition_filter"] - } - } - - dynamic "range_partitioning" { - for_each = each.value["range_partitioning"] != null ? [each.value["range_partitioning"]] : [] - content { - field = range_partitioning.value["field"] - range { - start = range_partitioning.value["range"].start - end = range_partitioning.value["range"].end - interval = range_partitioning.value["range"].interval - } - } - } - -} - -resource "google_bigquery_table" "bq_view" { - for_each = local.views - dataset_id = each.value["dataset_id"] - friendly_name = each.key - table_id = each.key - labels = each.value["labels"] - project = var.project_id - deletion_protection = each.value["deletion_protection"] - depends_on = [google_bigquery_table.bq_table] - - view { - query = each.value["query"] - use_legacy_sql = each.value["use_legacy_sql"] - } -} - -# resource "google_bigquery_table" "external_table" { -# for_each = local.external_tables -# dataset_id = each.value["dataset_id"] -# friendly_name = each.key -# table_id = each.key -# labels = each.value["labels"] -# expiration_time = each.value["expiration_time"] -# deletion_protection = each.value["deletion_protection"] -# project = var.project_id -# depends_on = [google_bigquery_dataset.bq_dataset] - -# external_data_configuration { -# autodetect = each.value["autodetect"] -# compression = each.value["compression"] -# ignore_unknown_values = each.value["ignore_unknown_values"] -# max_bad_records = each.value["max_bad_records"] -# schema = each.value["schema"] -# source_format = each.value["source_format"] -# source_uris = each.value["source_uris"] - -# dynamic "csv_options" { -# for_each = each.value["csv_options"] != null ? [each.value["csv_options"]] : [] -# content { -# quote = csv_options.value["quote"] -# allow_jagged_rows = csv_options.value["allow_jagged_rows"] -# allow_quoted_newlines = csv_options.value["allow_quoted_newlines"] -# encoding = csv_options.value["encoding"] -# field_delimiter = csv_options.value["field_delimiter"] -# skip_leading_rows = csv_options.value["skip_leading_rows"] -# } -# } - -# dynamic "google_sheets_options" { -# for_each = each.value["google_sheets_options"] != null ? [each.value["google_sheets_options"]] : [] -# content { -# range = google_sheets_options.value["range"] -# skip_leading_rows = google_sheets_options.value["skip_leading_rows"] -# } -# } - -# dynamic "hive_partitioning_options" { -# for_each = each.value["hive_partitioning_options"] != null ? [each.value["hive_partitioning_options"]] : [] -# content { -# mode = hive_partitioning_options.value["mode"] -# source_uri_prefix = hive_partitioning_options.value["source_uri_prefix"] -# } -# } -# } -# } - -#added - 1220223 -# resource "google_bigquery_dataset" "bq_dataset_ignore_access" { -# for_each = local.datasets_lyf -# friendly_name = each.value["friendly_name"] -# dataset_id = each.key -# location = each.value["location"] -# project = var.project_id - -# dynamic "default_encryption_configuration" { -# for_each = var.encryption_key == null ? [] : [var.encryption_key] -# content { -# kms_key_name = var.encryption_key -# } -# } - -# dynamic "access" { -# for_each = var.access_lyf - -# content { -# # BigQuery API converts IAM to primitive roles in its backend. -# # This causes Terraform to show a diff on every plan that uses IAM equivalent roles. -# # Thus, do the conversion between IAM to primitive role here to prevent the diff. -# role = lookup(local.iam_to_primitive, access.value.role, access.value.role) - -# domain = lookup(access.value, "domain", null) -# group_by_email = lookup(access.value, "group_by_email", null) -# user_by_email = lookup(access.value, "user_by_email", null) -# special_group = lookup(access.value, "special_group", null) - -# } -# } - -# lifecycle { -# ignore_changes = [ -# access #ignoring changes to access -# ] -# } -# } \ No newline at end of file diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/provider.tf b/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/provider.tf deleted file mode 100644 index 3ae2421da..000000000 --- a/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/provider.tf +++ /dev/null @@ -1,4 +0,0 @@ -# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa -provider "google" { - project = "candicehou-terraform-sync-tool" -} diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/terragrunt.hcl b/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/terragrunt.hcl deleted file mode 100644 index ba68000c0..000000000 --- a/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/terragrunt.hcl +++ /dev/null @@ -1,39 +0,0 @@ -terraform { - source = "../../modules/terraform-sync-tool" -} - -include "root" { - path = find_in_parent_folders() - expose = true -} - -locals { - dataset_id = "tf_test_sync_tool" -} - -inputs = { - project_id = include.root.inputs.project_id - # The ID of the project in which the resource belongs. If it is not provided, the provider project is used. - datasets = [ - { - dataset_id = "${local.dataset_id}" - friendly_name = "Dataset for Terraform Sync Tool" - location = "US" - labels = {} - } - ] - - tables = [ - { - table_id = "TableForTest" - dataset_id = "${local.dataset_id}" - schema = "json_schemas/TableForTest.json" - clustering = [] - expiration_time = null - deletion_protection = true - range_partitioning = null - time_partitioning = null - labels = {} - } - ] -} \ No newline at end of file diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/variables.tf b/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/variables.tf deleted file mode 100644 index ff13fd029..000000000 --- a/tools/terraform_sync_tool/qa/terraform-sync-tool/.terragrunt-cache/p_j4FB4mL9XxOE2ZEpQd6ow4-uQ/G4eNQFLLAF6iZ90VzUweI-xTZiE/variables.tf +++ /dev/null @@ -1,171 +0,0 @@ -variable "description" { - description = "Dataset description." - type = string - default = null -} - -variable "location" { - description = "The regional location for the dataset only US and EU are allowed in module" - type = string - default = "US" -} - -variable "delete_contents_on_destroy" { - description = "(Optional) If set to true, delete all the tables in the dataset when destroying the resource; otherwise, destroying the resource will fail if tables are present." - type = bool - default = null -} - -variable "deletion_protection" { - description = "Whether or not to allow Terraform to destroy the instance. Unless this field is set to false in Terraform state, a terraform destroy or terraform apply that would delete the instance will fail." - type = bool - default = true - } - -variable "default_table_expiration_ms" { - description = "TTL of tables using the dataset in MS" - type = number - default = null -} - -variable "project_id" { - description = "Project where the dataset and table are created" - type = string -} - -variable "encryption_key" { - description = "Default encryption key to apply to the dataset. Defaults to null (Google-managed)." - type = string - default = null -} - -variable "dataset_labels" { - description = "Key value pairs in a map for dataset labels" - type = map(string) - default = {} -} - -# Format: list(objects) -# domain: A domain to grant access to. -# group_by_email: An email address of a Google Group to grant access to. -# user_by_email: An email address of a user to grant access to. -# special_group: A special group to grant access to. - -variable "access" { - description = "An array of objects that define dataset access for one or more entities." - type = any - - # At least one owner access is required. - default = [{ - role = "roles/bigquery.dataOwner" - special_group = "projectOwners" - }] -} -variable "datasets" { - description = "this is a test DS" - default = [] - type = list(object({ - dataset_id = string - friendly_name = string - location = string - } - )) -} -variable "tables" { - description = "A list of objects which include table_id, schema, clustering, time_partitioning, expiration_time and labels." - default = [] - type = list(object({ - table_id = string, - dataset_id = string, #added to test creating multi dataset - schema = string, - clustering = list(string), - deletion_protection=bool, - time_partitioning = object({ - expiration_ms = string, - field = string, - type = string, - require_partition_filter = bool, - }), - range_partitioning = object({ - field = string, - range = object({ - start = string, - end = string, - interval = string, - }), - }), - expiration_time = string, - labels = map(string), - } - )) -} -variable "views" { - description = "A list of objects which include table_id, which is view id, and view query" - default = [] - type = list(object({ - view_id = string, - dataset_id = string, - query = string, - deletion_protection=bool, - use_legacy_sql = bool, - labels = map(string), - })) -} - -# variable "external_tables" { -# description = "A list of objects which include table_id, expiration_time, external_data_configuration, and labels." -# default = [] -# type = list(object({ -# table_id = string, -# dataset_id = string, -# autodetect = bool, -# compression = string, -# ignore_unknown_values = bool, -# max_bad_records = number, -# schema = string, -# source_format = string, -# deletion_protection = bool, -# source_uris = list(string), -# csv_options = object({ -# quote = string, -# allow_jagged_rows = bool, -# allow_quoted_newlines = bool, -# encoding = string, -# field_delimiter = string, -# skip_leading_rows = number, -# }), -# google_sheets_options = object({ -# range = string, -# skip_leading_rows = number, -# }), -# hive_partitioning_options = object({ -# mode = string, -# source_uri_prefix = string, -# }), -# expiration_time = string, -# labels = map(string), -# })) -# } - -# variable "datasets_lyf" { -# description = "this is a test DS" -# default = [] -# type = list(object({ -# dataset_id = string -# friendly_name = string -# location = string -# } -# )) -# } - -# variable "access_lyf" { -# description = "An array of objects that define dataset access for one or more entities." -# type = any - -# # At least one owner access is required. -# default = [ -# { -# role = "roles/bigquery.dataOwner" -# special_group = "projectOwners" -# }] -# } \ No newline at end of file From 7c8e1d13b7aca89fd86cfdd3b222a0b54cd39822 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Thu, 9 Jun 2022 12:14:54 -0500 Subject: [PATCH 12/52] Update tools/terraform_sync_tool/deploy.sh output json file Co-authored-by: Daniel De Leo --- tools/terraform_sync_tool/deploy.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/terraform_sync_tool/deploy.sh b/tools/terraform_sync_tool/deploy.sh index 30cafef30..5123e5684 100644 --- a/tools/terraform_sync_tool/deploy.sh +++ b/tools/terraform_sync_tool/deploy.sh @@ -3,5 +3,5 @@ env=$1 tool=$2 -terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" > state.json +terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" > plan_out.json # terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir=/qa/terraform-sync-tool > state.json \ No newline at end of file From 8b690c344271ea8d50f4e1966565e1028e75d3cd Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Thu, 9 Jun 2022 12:16:17 -0500 Subject: [PATCH 13/52] Update tools/terraform_sync_tool/qa/terragrunt.hcl Co-authored-by: Daniel De Leo --- tools/terraform_sync_tool/qa/terragrunt.hcl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/terraform_sync_tool/qa/terragrunt.hcl b/tools/terraform_sync_tool/qa/terragrunt.hcl index 7237b6020..ebc1c596b 100644 --- a/tools/terraform_sync_tool/qa/terragrunt.hcl +++ b/tools/terraform_sync_tool/qa/terragrunt.hcl @@ -29,7 +29,7 @@ remote_state { project = local.gcp_project_id location = "us" bucket = "synctooltest" - prefix = "qa/${path_relative_to_include()}/terraform.tfstate" + prefix = "qa/${path_relative_to_include()}" gcs_bucket_labels = { owner = "terragrunt_test" name = "terraform_state_storage" From c2fc146d446acd55cb8965d0d520357c4e5760c3 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Thu, 9 Jun 2022 12:23:03 -0500 Subject: [PATCH 14/52] Delete state.json --- tools/terraform_sync_tool/state.json | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 tools/terraform_sync_tool/state.json diff --git a/tools/terraform_sync_tool/state.json b/tools/terraform_sync_tool/state.json deleted file mode 100644 index a1181a37c..000000000 --- a/tools/terraform_sync_tool/state.json +++ /dev/null @@ -1,6 +0,0 @@ -{"@level":"info","@message":"Terraform 1.1.9","@module":"terraform.ui","@timestamp":"2022-06-08T13:22:12.564592-04:00","terraform":"1.1.9","type":"version","ui":"1.0"} -{"@level":"info","@message":"google_bigquery_dataset.bq_dataset[\"tf_test_sync_tool\"]: Refreshing state... [id=projects/candicehou-terraform-sync-tool/datasets/tf_test_sync_tool]","@module":"terraform.ui","@timestamp":"2022-06-08T13:22:14.589057-04:00","hook":{"resource":{"addr":"google_bigquery_dataset.bq_dataset[\"tf_test_sync_tool\"]","module":"","resource":"google_bigquery_dataset.bq_dataset[\"tf_test_sync_tool\"]","implied_provider":"google","resource_type":"google_bigquery_dataset","resource_name":"bq_dataset","resource_key":"tf_test_sync_tool"},"id_key":"id","id_value":"projects/candicehou-terraform-sync-tool/datasets/tf_test_sync_tool"},"type":"refresh_start"} -{"@level":"info","@message":"google_bigquery_dataset.bq_dataset[\"tf_test_sync_tool\"]: Refresh complete [id=projects/candicehou-terraform-sync-tool/datasets/tf_test_sync_tool]","@module":"terraform.ui","@timestamp":"2022-06-08T13:22:15.112837-04:00","hook":{"resource":{"addr":"google_bigquery_dataset.bq_dataset[\"tf_test_sync_tool\"]","module":"","resource":"google_bigquery_dataset.bq_dataset[\"tf_test_sync_tool\"]","implied_provider":"google","resource_type":"google_bigquery_dataset","resource_name":"bq_dataset","resource_key":"tf_test_sync_tool"},"id_key":"id","id_value":"projects/candicehou-terraform-sync-tool/datasets/tf_test_sync_tool"},"type":"refresh_complete"} -{"@level":"info","@message":"google_bigquery_table.bq_table[\"TableForTest\"]: Refreshing state... [id=projects/candicehou-terraform-sync-tool/datasets/tf_test_sync_tool/tables/TableForTest]","@module":"terraform.ui","@timestamp":"2022-06-08T13:22:15.119610-04:00","hook":{"resource":{"addr":"google_bigquery_table.bq_table[\"TableForTest\"]","module":"","resource":"google_bigquery_table.bq_table[\"TableForTest\"]","implied_provider":"google","resource_type":"google_bigquery_table","resource_name":"bq_table","resource_key":"TableForTest"},"id_key":"id","id_value":"projects/candicehou-terraform-sync-tool/datasets/tf_test_sync_tool/tables/TableForTest"},"type":"refresh_start"} -{"@level":"info","@message":"google_bigquery_table.bq_table[\"TableForTest\"]: Refresh complete [id=projects/candicehou-terraform-sync-tool/datasets/tf_test_sync_tool/tables/TableForTest]","@module":"terraform.ui","@timestamp":"2022-06-08T13:22:15.275101-04:00","hook":{"resource":{"addr":"google_bigquery_table.bq_table[\"TableForTest\"]","module":"","resource":"google_bigquery_table.bq_table[\"TableForTest\"]","implied_provider":"google","resource_type":"google_bigquery_table","resource_name":"bq_table","resource_key":"TableForTest"},"id_key":"id","id_value":"projects/candicehou-terraform-sync-tool/datasets/tf_test_sync_tool/tables/TableForTest"},"type":"refresh_complete"} -{"@level":"info","@message":"Plan: 0 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","@timestamp":"2022-06-08T13:22:15.289118-04:00","changes":{"add":0,"change":0,"remove":0,"operation":"plan"},"type":"change_summary"} From dd75e1f7e030779bb981ec1cb44fc08341bf5892 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Thu, 9 Jun 2022 13:44:06 -0400 Subject: [PATCH 15/52] Update schema JSON --- .../qa/terraform-sync-tool/json_schemas/TableForTest.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest.json b/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest.json index 84b55ccbf..6182c22c1 100644 --- a/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest.json +++ b/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest.json @@ -52,5 +52,11 @@ "mode": "NULLABLE", "name": "Col9", "type": "STRING" + }, + { + "description": "Col10", + "mode": "NULLABLE", + "name": "Col10", + "type": "STRING" } ] \ No newline at end of file From 2a354f0fbe0428a9daabcb476c79f48938ab9c07 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Thu, 9 Jun 2022 14:42:56 -0400 Subject: [PATCH 16/52] Rename module to bq-setup --- .../modules/{terraform-sync-tool => bq-setup}/main.tf | 0 .../modules/{terraform-sync-tool => bq-setup}/variables.tf | 0 tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) rename tools/terraform_sync_tool/modules/{terraform-sync-tool => bq-setup}/main.tf (100%) rename tools/terraform_sync_tool/modules/{terraform-sync-tool => bq-setup}/variables.tf (100%) diff --git a/tools/terraform_sync_tool/modules/terraform-sync-tool/main.tf b/tools/terraform_sync_tool/modules/bq-setup/main.tf similarity index 100% rename from tools/terraform_sync_tool/modules/terraform-sync-tool/main.tf rename to tools/terraform_sync_tool/modules/bq-setup/main.tf diff --git a/tools/terraform_sync_tool/modules/terraform-sync-tool/variables.tf b/tools/terraform_sync_tool/modules/bq-setup/variables.tf similarity index 100% rename from tools/terraform_sync_tool/modules/terraform-sync-tool/variables.tf rename to tools/terraform_sync_tool/modules/bq-setup/variables.tf diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl b/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl index ba68000c0..f6c6c83fe 100644 --- a/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl +++ b/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl @@ -1,5 +1,5 @@ terraform { - source = "../../modules/terraform-sync-tool" + source = "../../modules/bq-setup" } include "root" { From 376b7fdf9cf75dfdd54eaa77ea40df7c5ea07126 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Thu, 9 Jun 2022 14:45:20 -0400 Subject: [PATCH 17/52] Rename JSON output file --- tools/terraform_sync_tool/deploy.sh | 1 - tools/terraform_sync_tool/terraform_sync.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/terraform_sync_tool/deploy.sh b/tools/terraform_sync_tool/deploy.sh index 5123e5684..0db27d0cc 100644 --- a/tools/terraform_sync_tool/deploy.sh +++ b/tools/terraform_sync_tool/deploy.sh @@ -4,4 +4,3 @@ env=$1 tool=$2 terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" > plan_out.json -# terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir=/qa/terraform-sync-tool > state.json \ No newline at end of file diff --git a/tools/terraform_sync_tool/terraform_sync.py b/tools/terraform_sync_tool/terraform_sync.py index 0e313ebe2..5e12fe54c 100644 --- a/tools/terraform_sync_tool/terraform_sync.py +++ b/tools/terraform_sync_tool/terraform_sync.py @@ -25,7 +25,7 @@ def convert_to_table_id(input): return table_id[:len(table_id)-1] # Opening JSON file -f = open('./state.json', 'r') +f = open('./plan_out.json', 'r') data = f.read() data = data.split("}\n") data = [d.strip() + "}" for d in data] From 7a33b29b35ab84f5dd6189b0522a72a0e4e49858 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 13 Jun 2022 11:51:23 -0400 Subject: [PATCH 18/52] Refractor python scripts --- tools/terraform_sync_tool/terraform_sync.py | 61 ++++++++++----------- 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/tools/terraform_sync_tool/terraform_sync.py b/tools/terraform_sync_tool/terraform_sync.py index 5e12fe54c..a1823a795 100644 --- a/tools/terraform_sync_tool/terraform_sync.py +++ b/tools/terraform_sync_tool/terraform_sync.py @@ -1,5 +1,6 @@ import json import io +import resource from google.cloud import bigquery # Fetch schemas for drifted tables form BQ @@ -24,37 +25,31 @@ def convert_to_table_id(input): table_id += s+"." return table_id[:len(table_id)-1] -# Opening JSON file -f = open('./plan_out.json', 'r') -data = f.read() -data = data.split("}\n") -data = [d.strip() + "}" for d in data] -data = list(filter(("}").__ne__, data)) -data = [json.loads(d) for d in data] -drifted_tables = [] -# find table with drifts and add to drifted_tables list -for line in data: - if line['type'] == 'resource_drift' and line['change']['resource']['resource_type'] == 'google_bigquery_table': - table_id = line['change']['resource']['resource_key'] - drifted_tables.append(table_id) +def main(): + # Opening JSON file + with open('plan_out.json') as file: + lines = file.readlines() + drifted_tables = [] + drifted_table = '' + for line in reversed(lines): + json_line = json.loads(line) + type = json_line.get('type') + # Scan the json lines to detect drifts + if type: + if type == 'resource_drift' and json_line.get('change').get('resource').get('resource_type') == 'google_bigquery_table': + table_name = json_line.get('change').get('resource').get('resource_key') + drifted_table = table_name + if json_line.get('type') == 'refresh_complete': + resource_table = json_line.get('hook').get('id_value') + print(resource_table) + if(resource_table[len(resource_table) - len(drifted_table):] == drifted_table): + drifted_tables.append(convert_to_table_id(resource_table)) + + if drifted_tables: + # Fetch latest schemas for drifted tables from BQ + drifted_table_schemas = get_schemas_from_BQ(drifted_tables) + # Drifts detected, throw exceptions + raise Exception("Drifts are detected in these tables, please update your terraform schema files with the following updated table schemas. ", drifted_table_schemas) -# Convert drifted_tables format to table_ids -if len(drifted_tables) > 0: - # iterate through drifted_tables list - i = 0 - for line in data: - if line['type'] == 'refresh_complete': - resource_table = line['hook']['id_value'] - if(resource_table[len(resource_table) - len(drifted_tables[i]):] == drifted_tables[i]): - drifted_tables[i] = convert_to_table_id(resource_table) - i += 1 - if(i == len(drifted_tables)): - break - # Fetch schemas for drifted tables from BQ - drifted_table_schemas = get_schemas_from_BQ(drifted_tables) - # Drifts detected, throw exceptions - raise Exception("Drifts are detected in these tables, please update your terraform schema files with the following updated table schemas. ", drifted_table_schemas) - - -# Closing file -f.close() \ No newline at end of file +if __name__ == "__main__": + main() \ No newline at end of file From 9a5fe09b2b4162d3f0243b186efef9d1f6fc983a Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 13 Jun 2022 11:55:46 -0400 Subject: [PATCH 19/52] Move Converting table names -> table ids in Main() --- tools/terraform_sync_tool/terraform_sync.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/tools/terraform_sync_tool/terraform_sync.py b/tools/terraform_sync_tool/terraform_sync.py index a1823a795..d64bc2be6 100644 --- a/tools/terraform_sync_tool/terraform_sync.py +++ b/tools/terraform_sync_tool/terraform_sync.py @@ -16,15 +16,6 @@ def get_schemas_from_BQ(drifted_tables): table_schemas.append(schema_bq) return table_schemas -# Convert table resource from terraform log output to -# table_id format:[gcp_project_id].[dataset_id].[table_id] -def convert_to_table_id(input): - table_id = "" - for s in input.rsplit("/"): - if(s != "projects" and s != "datasets" and s != "tables"): - table_id += s+"." - return table_id[:len(table_id)-1] - def main(): # Opening JSON file with open('plan_out.json') as file: @@ -41,14 +32,19 @@ def main(): drifted_table = table_name if json_line.get('type') == 'refresh_complete': resource_table = json_line.get('hook').get('id_value') - print(resource_table) if(resource_table[len(resource_table) - len(drifted_table):] == drifted_table): - drifted_tables.append(convert_to_table_id(resource_table)) + # Convert table resource from terraform log output to + # table_id format:[gcp_project_id].[dataset_id].[table_id] + table_id = "" + for s in resource_table.rsplit("/"): + if(s != "projects" and s != "datasets" and s != "tables"): + table_id += s+"." + drifted_tables.append(table_id[:len(table_id)-1]) if drifted_tables: # Fetch latest schemas for drifted tables from BQ drifted_table_schemas = get_schemas_from_BQ(drifted_tables) - # Drifts detected, throw exceptions + # Drifts detected, throw exceptions raise Exception("Drifts are detected in these tables, please update your terraform schema files with the following updated table schemas. ", drifted_table_schemas) if __name__ == "__main__": From 5c36be7f3696d8f9fcd6b7b8e4a5f8e0ec32b7c2 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 13 Jun 2022 12:39:31 -0400 Subject: [PATCH 20/52] Accept user-provided arguments --- tools/terraform_sync_tool/cloudbuild.yaml | 3 ++- tools/terraform_sync_tool/terraform_sync.py | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/tools/terraform_sync_tool/cloudbuild.yaml b/tools/terraform_sync_tool/cloudbuild.yaml index 78c32b8cc..310de2e21 100644 --- a/tools/terraform_sync_tool/cloudbuild.yaml +++ b/tools/terraform_sync_tool/cloudbuild.yaml @@ -11,4 +11,5 @@ steps: dir: './tools/terraform_sync_tool/' args: - -c - - 'pip install -r ./requirements.txt && python terraform_sync.py' \ No newline at end of file + - 'pip install -r ./requirements.txt && python terraform_sync.py' + - 'plan_out.json' \ No newline at end of file diff --git a/tools/terraform_sync_tool/terraform_sync.py b/tools/terraform_sync_tool/terraform_sync.py index d64bc2be6..98851b4aa 100644 --- a/tools/terraform_sync_tool/terraform_sync.py +++ b/tools/terraform_sync_tool/terraform_sync.py @@ -1,8 +1,10 @@ import json import io -import resource +import argparse from google.cloud import bigquery + + # Fetch schemas for drifted tables form BQ def get_schemas_from_BQ(drifted_tables): table_schemas = [] @@ -17,8 +19,12 @@ def get_schemas_from_BQ(drifted_tables): return table_schemas def main(): + parser = argparse.ArgumentParser(description='user-provided arguments') + parser.add_argument('filename') + args = parser.parse_args() + # Opening JSON file - with open('plan_out.json') as file: + with open(args.filename) as file: lines = file.readlines() drifted_tables = [] drifted_table = '' From d330bb91a09da86f25d0886a1b283ccf8fd4c447 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 13 Jun 2022 12:41:33 -0400 Subject: [PATCH 21/52] Update yaml file --- tools/terraform_sync_tool/cloudbuild.yaml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tools/terraform_sync_tool/cloudbuild.yaml b/tools/terraform_sync_tool/cloudbuild.yaml index 310de2e21..6721e28e6 100644 --- a/tools/terraform_sync_tool/cloudbuild.yaml +++ b/tools/terraform_sync_tool/cloudbuild.yaml @@ -11,5 +11,14 @@ steps: dir: './tools/terraform_sync_tool/' args: - -c - - 'pip install -r ./requirements.txt && python terraform_sync.py' - - 'plan_out.json' \ No newline at end of file + - 'pip install -r ./requirements.txt' + - 'python terraform_sync.py plan_out.json' + + # # step 1: run python scripts to investigate terraform output + # - name: python:3.7 + # entrypoint: 'bash' + # dir: './tools/terraform_sync_tool/' + # args: + # - -c + # - 'pip install -r ./requirements.txt && python terraform_sync.py' + # - 'plan_out.json' \ No newline at end of file From 252dcab4f78fbe873052235a8861b702ff75e2f7 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 13 Jun 2022 12:43:42 -0400 Subject: [PATCH 22/52] Clean up yaml file --- tools/terraform_sync_tool/cloudbuild.yaml | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/tools/terraform_sync_tool/cloudbuild.yaml b/tools/terraform_sync_tool/cloudbuild.yaml index 6721e28e6..72a1320ab 100644 --- a/tools/terraform_sync_tool/cloudbuild.yaml +++ b/tools/terraform_sync_tool/cloudbuild.yaml @@ -12,13 +12,4 @@ steps: args: - -c - 'pip install -r ./requirements.txt' - - 'python terraform_sync.py plan_out.json' - - # # step 1: run python scripts to investigate terraform output - # - name: python:3.7 - # entrypoint: 'bash' - # dir: './tools/terraform_sync_tool/' - # args: - # - -c - # - 'pip install -r ./requirements.txt && python terraform_sync.py' - # - 'plan_out.json' \ No newline at end of file + - 'python terraform_sync.py plan_out.json' \ No newline at end of file From abcf98f86a82f11afc7d74964da4357eeb6ad929 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 13 Jun 2022 16:31:24 -0400 Subject: [PATCH 23/52] Update identifiers and refractor terraform_sync.py --- tools/terraform_sync_tool/terraform_sync.py | 52 ++++++++++++--------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/tools/terraform_sync_tool/terraform_sync.py b/tools/terraform_sync_tool/terraform_sync.py index 98851b4aa..3e33cc29d 100644 --- a/tools/terraform_sync_tool/terraform_sync.py +++ b/tools/terraform_sync_tool/terraform_sync.py @@ -3,8 +3,6 @@ import argparse from google.cloud import bigquery - - # Fetch schemas for drifted tables form BQ def get_schemas_from_BQ(drifted_tables): table_schemas = [] @@ -18,40 +16,52 @@ def get_schemas_from_BQ(drifted_tables): table_schemas.append(schema_bq) return table_schemas -def main(): - parser = argparse.ArgumentParser(description='user-provided arguments') - parser.add_argument('filename') - args = parser.parse_args() - +# Identify tables with drifts and add return drifted_tables list +def get_drifted_tables(json_file): # Opening JSON file - with open(args.filename) as file: + with open(json_file) as file: lines = file.readlines() drifted_tables = [] - drifted_table = '' + drifted_table = {} for line in reversed(lines): json_line = json.loads(line) type = json_line.get('type') # Scan the json lines to detect drifts if type: if type == 'resource_drift' and json_line.get('change').get('resource').get('resource_type') == 'google_bigquery_table': - table_name = json_line.get('change').get('resource').get('resource_key') - drifted_table = table_name - if json_line.get('type') == 'refresh_complete': - resource_table = json_line.get('hook').get('id_value') - if(resource_table[len(resource_table) - len(drifted_table):] == drifted_table): + drifted_table = { + 'resource_name':json_line.get('change').get('resource').get('resource_name'), + 'resource_key':json_line.get('change').get('resource').get('resource_key') + } + if json_line.get('type') == 'refresh_complete' and json_line.get('hook').get('resource').get('resource_type') == 'google_bigquery_table': + event_table = { + 'resource_name':json_line.get('hook').get('resource').get('resource_name'), + 'resource_key':json_line.get('hook').get('resource').get('resource_key') + } + if(drifted_table == event_table): + drifted_table_name = json_line.get('hook').get('id_value') # Convert table resource from terraform log output to # table_id format:[gcp_project_id].[dataset_id].[table_id] table_id = "" - for s in resource_table.rsplit("/"): + for s in drifted_table_name.rsplit("/"): if(s != "projects" and s != "datasets" and s != "tables"): table_id += s+"." - drifted_tables.append(table_id[:len(table_id)-1]) + drifted_tables.append(table_id[:len(table_id)-1]) + return drifted_tables + +def main(): + parser = argparse.ArgumentParser(description='user-provided arguments') + parser.add_argument('filename') + args = parser.parse_args() + + # Parse json file to identify drifted tables + drifted_tables = get_drifted_tables(args.filename) - if drifted_tables: - # Fetch latest schemas for drifted tables from BQ - drifted_table_schemas = get_schemas_from_BQ(drifted_tables) - # Drifts detected, throw exceptions - raise Exception("Drifts are detected in these tables, please update your terraform schema files with the following updated table schemas. ", drifted_table_schemas) + if drifted_tables: + # Fetch latest schemas for drifted tables from BQ + drifted_table_schemas = get_schemas_from_BQ(drifted_tables) + # Drifts detected, throw exceptions + raise Exception("Drifts are detected in these tables, please update your terraform schema files with the following updated table schemas. ", drifted_table_schemas) if __name__ == "__main__": main() \ No newline at end of file From 2422a8845c4a2c38d81f070e05c8f518017bcf73 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 13 Jun 2022 17:06:43 -0400 Subject: [PATCH 24/52] Add comments to get_drifted_tables() --- tools/terraform_sync_tool/terraform_sync.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tools/terraform_sync_tool/terraform_sync.py b/tools/terraform_sync_tool/terraform_sync.py index 3e33cc29d..422879964 100644 --- a/tools/terraform_sync_tool/terraform_sync.py +++ b/tools/terraform_sync_tool/terraform_sync.py @@ -33,15 +33,17 @@ def get_drifted_tables(json_file): 'resource_name':json_line.get('change').get('resource').get('resource_name'), 'resource_key':json_line.get('change').get('resource').get('resource_key') } + # Trace the origins for drifted_table and convert table_name format to fully-qualified table_id if json_line.get('type') == 'refresh_complete' and json_line.get('hook').get('resource').get('resource_type') == 'google_bigquery_table': event_table = { 'resource_name':json_line.get('hook').get('resource').get('resource_name'), 'resource_key':json_line.get('hook').get('resource').get('resource_key') - } + } + # Match drifted_table with event_table using resource_name and resource_key as identifiers if(drifted_table == event_table): - drifted_table_name = json_line.get('hook').get('id_value') - # Convert table resource from terraform log output to + # Retrive the drifted_table_name and convert it into # table_id format:[gcp_project_id].[dataset_id].[table_id] + drifted_table_name = json_line.get('hook').get('id_value') table_id = "" for s in drifted_table_name.rsplit("/"): if(s != "projects" and s != "datasets" and s != "tables"): From 3fca695765390819eeb2d0e12f4745c98be42eb9 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 13 Jun 2022 17:16:45 -0400 Subject: [PATCH 25/52] Add developer's TODOs: Update project ID and dataset ID --- .../terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl | 3 ++- tools/terraform_sync_tool/qa/terragrunt.hcl | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl b/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl index f6c6c83fe..2ba286dda 100644 --- a/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl +++ b/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl @@ -8,7 +8,8 @@ include "root" { } locals { - dataset_id = "tf_test_sync_tool" + # TODO: Update your dataset ID + dataset_id = "YOUR_DATASET_ID" } inputs = { diff --git a/tools/terraform_sync_tool/qa/terragrunt.hcl b/tools/terraform_sync_tool/qa/terragrunt.hcl index ebc1c596b..9f8b65190 100644 --- a/tools/terraform_sync_tool/qa/terragrunt.hcl +++ b/tools/terraform_sync_tool/qa/terragrunt.hcl @@ -5,7 +5,8 @@ # notation. locals { - gcp_project_id = "candicehou-terraform-sync-tool" + #TODO: Update your GCP Project ID + gcp_project_id = "YOUR_GCP_PROJECT_ID" } inputs = { From 485bde1f7c85cd7df5d5a2ee9ea2694749bab55a Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Tue, 14 Jun 2022 11:59:34 -0400 Subject: [PATCH 26/52] Add README file --- tools/terraform_sync_tool/README.md | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tools/terraform_sync_tool/README.md diff --git a/tools/terraform_sync_tool/README.md b/tools/terraform_sync_tool/README.md new file mode 100644 index 000000000..30b435ab8 --- /dev/null +++ b/tools/terraform_sync_tool/README.md @@ -0,0 +1,40 @@ +# Terraform Sync Tool + +This directory contains the setup for the Terraform Sync Tool. Terraform Sync Tool was designed to keep the +Terraform schemas up-to-date with the BigQuery table schemas in production environment. + +## Prerequisite +Before building the terraform sync tool, please ensure billing and Cloud Build are enabled for your Cloud project. + +## Understand the Directory + +## TODOs +Please make sure to update YOUR_GCP_PROJECT_ID in "./qa/terragrunt.hcl" and YOUR_DATASET_ID in "./qa/terraform-sync-tool/terragrunt.hcl" + +## Setup + +Use Cloud SDK to set the specified property in your active configuration only +``` +gcloud config set project +``` + +### Local Test + +To test using terragrunt commands +``` +env = qa +tool = terraform-sync-tool +echo $env +echo $tool +terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" > plan_out.json +``` + +To test using terragrunt commands without writing the output into a JSON file +``` +terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" +``` + +Provide argument to test terraform_sync.py +``` +terraform_sync.py plan_out.json +``` From 52467af7ae629b08238092d69192e1dfc98f3dc8 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Tue, 14 Jun 2022 14:19:17 -0400 Subject: [PATCH 27/52] Update README: add folder structure section --- tools/terraform_sync_tool/README.md | 43 ++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/tools/terraform_sync_tool/README.md b/tools/terraform_sync_tool/README.md index 30b435ab8..08995bf86 100644 --- a/tools/terraform_sync_tool/README.md +++ b/tools/terraform_sync_tool/README.md @@ -1,15 +1,44 @@ # Terraform Sync Tool -This directory contains the setup for the Terraform Sync Tool. Terraform Sync Tool was designed to keep the +This directory contains the setup for the Terraform Sync Tool. Terraform Sync Tool was designed to address the schema drifts in BigQuery tables and keep the Terraform schemas up-to-date with the BigQuery table schemas in production environment. +Terraform Sync Tool can be integrated into your CI/CD pipeline using Cloud Build. You'll neeed add two steps to `cloudbuild.yaml`. +- Step 0: Use Terragrunt command to detect resource drifts and write output into a JSON file +- Step 1: Use Python scripts to identify and investigate the drifts + +Cloud Build fails the build attemps if resource drifts are detected and notifies the latest resource information. Developers should be able to update +the Terraform resources accordingly. + ## Prerequisite -Before building the terraform sync tool, please ensure billing and Cloud Build are enabled for your Cloud project. +Before building the terraform sync tool, please ensure that billing and Cloud Build are enabled for your Cloud project. + +You'll also need to install Cloud SDK(https://cloud.google.com/sdk/docs/install) and Terragrunt(https://terragrunt.gruntwork.io/docs/getting-started/install) + +## Folder Structure +This directory serves as a starting point for your cloud project with terraform-sync-tool as one of qa tools integrated. + + . + ├── modules # Terraform modules directory + │ ├── bq-setup # Example Terraform BigQuery Setup + │ └── ... # Other modules setup you have + ├── qa # qa environment directory + │ ├── terragrunt.hcl + │ └── terraform-sync-tool # Tool terraform-sync-tool + │ ├── json_schemas # Terraform schema files + │ ├── terragrunt.hcl + │ └── ... + ├── cloudbuild.yaml # Cloud Build configuration file + ├── deploy.sh # Build Step 0 - contains terragrunt commands + ├── requirements.txt # Build Step 1 - Specifies python dependencies + ├── terraform_sync.py # Build Step 1 - python scripts + └── ... # etc. + -## Understand the Directory +## TODO +Please make sure to update **YOUR_GCP_PROJECT_ID** in `./qa/terragrunt.hcl` -## TODOs -Please make sure to update YOUR_GCP_PROJECT_ID in "./qa/terragrunt.hcl" and YOUR_DATASET_ID in "./qa/terraform-sync-tool/terragrunt.hcl" +and update **YOUR_DATASET_ID** in `./qa/terraform-sync-tool/terragrunt.hcl` ## Setup @@ -20,7 +49,7 @@ gcloud config set project ### Local Test -To test using terragrunt commands +To test using terragrunt commands. Feel free to replace `plan_out.json` with your JSON FILENAME. ``` env = qa tool = terraform-sync-tool @@ -34,7 +63,7 @@ To test using terragrunt commands without writing the output into a JSON file terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" ``` -Provide argument to test terraform_sync.py +Provide argument to test `terraform_sync.py`. Feel free to replace `plan_out.json` with your JSON FILENAME. ``` terraform_sync.py plan_out.json ``` From 9211697ce4e98e0c99c4c7abd51fbff88427c38b Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Tue, 14 Jun 2022 14:27:04 -0400 Subject: [PATCH 28/52] Update README: add Cloud Build setup instruction --- tools/terraform_sync_tool/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/terraform_sync_tool/README.md b/tools/terraform_sync_tool/README.md index 08995bf86..3670d45a6 100644 --- a/tools/terraform_sync_tool/README.md +++ b/tools/terraform_sync_tool/README.md @@ -36,9 +36,11 @@ This directory serves as a starting point for your cloud project with terraform- ## TODO -Please make sure to update **YOUR_GCP_PROJECT_ID** in `./qa/terragrunt.hcl` +Please make sure to update +- **YOUR_GCP_PROJECT_ID** in `./qa/terragrunt.hcl` +- **YOUR_DATASET_ID** in `./qa/terraform-sync-tool/terragrunt.hcl` -and update **YOUR_DATASET_ID** in `./qa/terraform-sync-tool/terragrunt.hcl` +Please **create and configure your trigger in Cloud Build** and make sure to use `cloudbuild.yaml` as **Cloud Build configuration file location** ## Setup From 5dabf46f3781bb8c3df7ab63eb4451ff4c5cdaca Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Wed, 15 Jun 2022 14:46:42 -0400 Subject: [PATCH 29/52] Add Comments to terraform_sync.py --- tools/terraform_sync_tool/README.md | 2 +- tools/terraform_sync_tool/terraform_sync.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/terraform_sync_tool/README.md b/tools/terraform_sync_tool/README.md index 3670d45a6..8fbf24c01 100644 --- a/tools/terraform_sync_tool/README.md +++ b/tools/terraform_sync_tool/README.md @@ -3,7 +3,7 @@ This directory contains the setup for the Terraform Sync Tool. Terraform Sync Tool was designed to address the schema drifts in BigQuery tables and keep the Terraform schemas up-to-date with the BigQuery table schemas in production environment. -Terraform Sync Tool can be integrated into your CI/CD pipeline using Cloud Build. You'll neeed add two steps to `cloudbuild.yaml`. +Terraform Sync Tool can be integrated into your CI/CD pipeline using Cloud Build. You'll need to add two steps to `cloudbuild.yaml`. - Step 0: Use Terragrunt command to detect resource drifts and write output into a JSON file - Step 1: Use Python scripts to identify and investigate the drifts diff --git a/tools/terraform_sync_tool/terraform_sync.py b/tools/terraform_sync_tool/terraform_sync.py index 422879964..491bb72c9 100644 --- a/tools/terraform_sync_tool/terraform_sync.py +++ b/tools/terraform_sync_tool/terraform_sync.py @@ -52,6 +52,7 @@ def get_drifted_tables(json_file): return drifted_tables def main(): + # Provide arguments for JSON filename parser = argparse.ArgumentParser(description='user-provided arguments') parser.add_argument('filename') args = parser.parse_args() @@ -59,6 +60,7 @@ def main(): # Parse json file to identify drifted tables drifted_tables = get_drifted_tables(args.filename) + # Fail the build and Fetch latest schemas if drifts are detected if drifted_tables: # Fetch latest schemas for drifted tables from BQ drifted_table_schemas = get_schemas_from_BQ(drifted_tables) From e85bd2110e5113d32d5d4e3cc3160be6e37527c9 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Sun, 26 Jun 2022 11:52:37 -0400 Subject: [PATCH 30/52] Rename module to bigquery & provide default datasetID --- tools/terraform_sync_tool/README.md | 2 +- .../modules/{bq-setup => bigquery}/main.tf | 0 .../modules/{bq-setup => bigquery}/variables.tf | 0 .../terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl | 4 ++-- 4 files changed, 3 insertions(+), 3 deletions(-) rename tools/terraform_sync_tool/modules/{bq-setup => bigquery}/main.tf (100%) rename tools/terraform_sync_tool/modules/{bq-setup => bigquery}/variables.tf (100%) diff --git a/tools/terraform_sync_tool/README.md b/tools/terraform_sync_tool/README.md index 8fbf24c01..092554b2d 100644 --- a/tools/terraform_sync_tool/README.md +++ b/tools/terraform_sync_tool/README.md @@ -20,7 +20,7 @@ This directory serves as a starting point for your cloud project with terraform- . ├── modules # Terraform modules directory - │ ├── bq-setup # Example Terraform BigQuery Setup + │ ├── bigquery # Example Terraform BigQuery Setup │ └── ... # Other modules setup you have ├── qa # qa environment directory │ ├── terragrunt.hcl diff --git a/tools/terraform_sync_tool/modules/bq-setup/main.tf b/tools/terraform_sync_tool/modules/bigquery/main.tf similarity index 100% rename from tools/terraform_sync_tool/modules/bq-setup/main.tf rename to tools/terraform_sync_tool/modules/bigquery/main.tf diff --git a/tools/terraform_sync_tool/modules/bq-setup/variables.tf b/tools/terraform_sync_tool/modules/bigquery/variables.tf similarity index 100% rename from tools/terraform_sync_tool/modules/bq-setup/variables.tf rename to tools/terraform_sync_tool/modules/bigquery/variables.tf diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl b/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl index 2ba286dda..108fbfed8 100644 --- a/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl +++ b/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl @@ -1,5 +1,5 @@ terraform { - source = "../../modules/bq-setup" + source = "../../modules/bigquery" } include "root" { @@ -9,7 +9,7 @@ include "root" { locals { # TODO: Update your dataset ID - dataset_id = "YOUR_DATASET_ID" + dataset_id = "DatasetForTest" #YOUR_DATASET_ID } inputs = { From 073e19276b102a58a0670c118e7c2cb519f8e864 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Sun, 26 Jun 2022 11:55:06 -0400 Subject: [PATCH 31/52] Rename prefix path --- tools/terraform_sync_tool/qa/terragrunt.hcl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/terraform_sync_tool/qa/terragrunt.hcl b/tools/terraform_sync_tool/qa/terragrunt.hcl index 9f8b65190..f1a4d4943 100644 --- a/tools/terraform_sync_tool/qa/terragrunt.hcl +++ b/tools/terraform_sync_tool/qa/terragrunt.hcl @@ -6,7 +6,7 @@ locals { #TODO: Update your GCP Project ID - gcp_project_id = "YOUR_GCP_PROJECT_ID" + gcp_project_id = "YOUR_GCP_PROJECT_ID" #YOUR_GCP_PROJECT_ID } inputs = { @@ -30,7 +30,7 @@ remote_state { project = local.gcp_project_id location = "us" bucket = "synctooltest" - prefix = "qa/${path_relative_to_include()}" + prefix = "${path_relative_to_include()}" gcs_bucket_labels = { owner = "terragrunt_test" name = "terraform_state_storage" From f699fdd13effb398e513436df2ef901f45f63569 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Sun, 26 Jun 2022 11:58:30 -0400 Subject: [PATCH 32/52] Use provided bucket name --- tools/terraform_sync_tool/README.md | 3 ++- tools/terraform_sync_tool/qa/terragrunt.hcl | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/terraform_sync_tool/README.md b/tools/terraform_sync_tool/README.md index 092554b2d..8eb5a46c8 100644 --- a/tools/terraform_sync_tool/README.md +++ b/tools/terraform_sync_tool/README.md @@ -38,7 +38,8 @@ This directory serves as a starting point for your cloud project with terraform- ## TODO Please make sure to update - **YOUR_GCP_PROJECT_ID** in `./qa/terragrunt.hcl` -- **YOUR_DATASET_ID** in `./qa/terraform-sync-tool/terragrunt.hcl` +- **YOUR_BUCKET_NAME** in `./qa/terragrunt.hcl` +- **YOUR_DATASET_ID** in `./qa/terraform-sync-tool/terragrunt.hcl` Please **create and configure your trigger in Cloud Build** and make sure to use `cloudbuild.yaml` as **Cloud Build configuration file location** diff --git a/tools/terraform_sync_tool/qa/terragrunt.hcl b/tools/terraform_sync_tool/qa/terragrunt.hcl index f1a4d4943..0337b3f59 100644 --- a/tools/terraform_sync_tool/qa/terragrunt.hcl +++ b/tools/terraform_sync_tool/qa/terragrunt.hcl @@ -7,6 +7,7 @@ locals { #TODO: Update your GCP Project ID gcp_project_id = "YOUR_GCP_PROJECT_ID" #YOUR_GCP_PROJECT_ID + bucket_name = "YOUR_GCP_BUCKET_NAME" #YOUR_GCP_BUCKET_NAME } inputs = { @@ -29,7 +30,7 @@ remote_state { config = { project = local.gcp_project_id location = "us" - bucket = "synctooltest" + bucket = local.bucket_name prefix = "${path_relative_to_include()}" gcs_bucket_labels = { owner = "terragrunt_test" From c89fee761a4689a001f48a3f9a5295d185a9ce37 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Sun, 26 Jun 2022 12:16:44 -0400 Subject: [PATCH 33/52] Clean out variables unused --- .../modules/bigquery/main.tf | 43 +------------- .../modules/bigquery/variables.tf | 59 +------------------ 2 files changed, 2 insertions(+), 100 deletions(-) diff --git a/tools/terraform_sync_tool/modules/bigquery/main.tf b/tools/terraform_sync_tool/modules/bigquery/main.tf index 06e92d340..143c928db 100644 --- a/tools/terraform_sync_tool/modules/bigquery/main.tf +++ b/tools/terraform_sync_tool/modules/bigquery/main.tf @@ -1,7 +1,6 @@ locals { datasets = { for dataset in var.datasets : dataset["dataset_id"] => dataset } tables = { for table in var.tables : table["table_id"] => table } - views = { for view in var.views : view["view_id"] => view } iam_to_primitive = { "roles/bigquery.dataOwner" : "OWNER" @@ -17,30 +16,7 @@ resource "google_bigquery_dataset" "bq_dataset" { dataset_id = each.key location = each.value["location"] project = var.project_id - - dynamic "default_encryption_configuration" { - for_each = var.encryption_key == null ? [] : [var.encryption_key] - content { - kms_key_name = var.encryption_key - } - } - - dynamic "access" { - for_each = var.access - - content { - # BigQuery API converts IAM to primitive roles in its backend. - # This causes Terraform to show a diff on every plan that uses IAM equivalent roles. - # Thus, do the conversion between IAM to primitive role here to prevent the diff. - role = lookup(local.iam_to_primitive, access.value.role, access.value.role) - - domain = lookup(access.value, "domain", null) - group_by_email = lookup(access.value, "group_by_email", null) - user_by_email = lookup(access.value, "user_by_email", null) - special_group = lookup(access.value, "special_group", null) - } - } - } +} resource "google_bigquery_table" "bq_table" { for_each = local.tables @@ -76,21 +52,4 @@ resource "google_bigquery_table" "bq_table" { } } } - -} - -resource "google_bigquery_table" "bq_view" { - for_each = local.views - dataset_id = each.value["dataset_id"] - friendly_name = each.key - table_id = each.key - labels = each.value["labels"] - project = var.project_id - deletion_protection = each.value["deletion_protection"] - depends_on = [google_bigquery_table.bq_table] - - view { - query = each.value["query"] - use_legacy_sql = each.value["use_legacy_sql"] - } } \ No newline at end of file diff --git a/tools/terraform_sync_tool/modules/bigquery/variables.tf b/tools/terraform_sync_tool/modules/bigquery/variables.tf index 28e92aadd..03148c9b5 100644 --- a/tools/terraform_sync_tool/modules/bigquery/variables.tf +++ b/tools/terraform_sync_tool/modules/bigquery/variables.tf @@ -1,66 +1,20 @@ -variable "description" { - description = "Dataset description." - type = string - default = null -} - variable "location" { description = "The regional location for the dataset only US and EU are allowed in module" type = string default = "US" } -variable "delete_contents_on_destroy" { - description = "(Optional) If set to true, delete all the tables in the dataset when destroying the resource; otherwise, destroying the resource will fail if tables are present." - type = bool - default = null -} - variable "deletion_protection" { description = "Whether or not to allow Terraform to destroy the instance. Unless this field is set to false in Terraform state, a terraform destroy or terraform apply that would delete the instance will fail." type = bool default = true } -variable "default_table_expiration_ms" { - description = "TTL of tables using the dataset in MS" - type = number - default = null -} - variable "project_id" { description = "Project where the dataset and table are created" type = string } -variable "encryption_key" { - description = "Default encryption key to apply to the dataset. Defaults to null (Google-managed)." - type = string - default = null -} - -variable "dataset_labels" { - description = "Key value pairs in a map for dataset labels" - type = map(string) - default = {} -} - -# Format: list(objects) -# domain: A domain to grant access to. -# group_by_email: An email address of a Google Group to grant access to. -# user_by_email: An email address of a user to grant access to. -# special_group: A special group to grant access to. - -variable "access" { - description = "An array of objects that define dataset access for one or more entities." - type = any - - # At least one owner access is required. - default = [{ - role = "roles/bigquery.dataOwner" - special_group = "projectOwners" - }] -} variable "datasets" { description = "this is a test DS" default = [] @@ -71,6 +25,7 @@ variable "datasets" { } )) } + variable "tables" { description = "A list of objects which include table_id, schema, clustering, time_partitioning, expiration_time and labels." default = [] @@ -98,16 +53,4 @@ variable "tables" { labels = map(string), } )) -} -variable "views" { - description = "A list of objects which include table_id, which is view id, and view query" - default = [] - type = list(object({ - view_id = string, - dataset_id = string, - query = string, - deletion_protection=bool, - use_legacy_sql = bool, - labels = map(string), - })) } \ No newline at end of file From 1e65ae78d078249af8fb2d47dd866aeb1fa94bf1 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Sun, 26 Jun 2022 13:48:17 -0400 Subject: [PATCH 34/52] Test with mutiple tables and update python scripts to handle muti tables --- .../json_schemas/TableForTest2.json | 26 +++++++ .../qa/terraform-sync-tool/terragrunt.hcl | 11 +++ tools/terraform_sync_tool/terraform_sync.py | 75 +++++++++++-------- 3 files changed, 81 insertions(+), 31 deletions(-) create mode 100644 tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest2.json diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest2.json b/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest2.json new file mode 100644 index 000000000..a8fe38d0e --- /dev/null +++ b/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest2.json @@ -0,0 +1,26 @@ +[ + { + "description": "Col1", + "mode": "NULLABLE", + "name": "Col1", + "type": "STRING" + }, + { + "description": "Col2", + "mode": "NULLABLE", + "name": "Col2", + "type": "STRING" + }, + { + "description": "Col3", + "mode": "NULLABLE", + "name": "Col3", + "type": "STRING" + }, + { + "description": "Col4", + "mode": "NULLABLE", + "name": "Col4", + "type": "STRING" + } + ] \ No newline at end of file diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl b/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl index 108fbfed8..3a26a7c7b 100644 --- a/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl +++ b/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl @@ -35,6 +35,17 @@ inputs = { range_partitioning = null time_partitioning = null labels = {} + }, + { + table_id = "TableForTest2" + dataset_id = "${local.dataset_id}" + schema = "json_schemas/TableForTest2.json" + clustering = [] + expiration_time = null + deletion_protection = true + range_partitioning = null + time_partitioning = null + labels = {} } ] } \ No newline at end of file diff --git a/tools/terraform_sync_tool/terraform_sync.py b/tools/terraform_sync_tool/terraform_sync.py index 491bb72c9..05fad1baa 100644 --- a/tools/terraform_sync_tool/terraform_sync.py +++ b/tools/terraform_sync_tool/terraform_sync.py @@ -4,10 +4,11 @@ from google.cloud import bigquery # Fetch schemas for drifted tables form BQ -def get_schemas_from_BQ(drifted_tables): +def get_schemas_from_BQ(tables_of_interest): table_schemas = [] client = bigquery.Client() - for table_id in drifted_tables: + for k in tables_of_interest: + table_id = tables_of_interest.get(k).get('table_id') table = client.get_table(table_id) file = io.StringIO("") client.schema_to_json(table.schema, file) @@ -16,40 +17,52 @@ def get_schemas_from_BQ(drifted_tables): table_schemas.append(schema_bq) return table_schemas -# Identify tables with drifts and add return drifted_tables list +# Identify tables with drifts and add return tables_of_interest def get_drifted_tables(json_file): + tables_of_interest = {} # store events(dict) of interest where key=resource_name+resource_key as identifier and value=resource_id # Opening JSON file with open(json_file) as file: lines = file.readlines() - drifted_tables = [] - drifted_table = {} + # Scan json lines to store events of interest for line in reversed(lines): json_line = json.loads(line) type = json_line.get('type') - # Scan the json lines to detect drifts - if type: - if type == 'resource_drift' and json_line.get('change').get('resource').get('resource_type') == 'google_bigquery_table': - drifted_table = { - 'resource_name':json_line.get('change').get('resource').get('resource_name'), - 'resource_key':json_line.get('change').get('resource').get('resource_key') + change = json_line.get('change') + hook = json_line.get('hook') + # When resource_drift event detected, append new dict to tables_of_interest + if type == 'resource_drift' and change and change.get('resource') and change.get('resource').get('resource_type') == 'google_bigquery_table': + change_resource = change.get('resource') + # Use condensed resource_name+resource_key as key of new dict + resource_condensed = change_resource.get('resource_name')+change_resource.get('resource_key') + # Use resource infomation from resource_drift event in value of new dict + resource_from_drift = {'identifier': + { + 'resource_name':change_resource.get('resource_name'), + 'resource_key':change_resource.get('resource_key') + }} + # Add to tables_of_interest + tables_of_interest[resource_condensed] = resource_from_drift + # When refresh_complete event detected, add id_value and convert it to fully-qualified table_id, and add table_id to dict + if type == 'refresh_complete' and hook and hook.get('resource') and hook.get('resource').get('resource_type') == 'google_bigquery_table': + hook_resource = hook.get('resource') + # Use condensed resource_name+resource_key as the key to check if it exists in tables_of_interest + resource_condensed = hook_resource.get('resource_name') + hook_resource.get('resource_key') + # Usee resource information from refresh_complete event as identifier + resource_from_hook = { + 'resource_name':hook_resource.get('resource_name'), + 'resource_key':hook_resource.get('resource_key') } - # Trace the origins for drifted_table and convert table_name format to fully-qualified table_id - if json_line.get('type') == 'refresh_complete' and json_line.get('hook').get('resource').get('resource_type') == 'google_bigquery_table': - event_table = { - 'resource_name':json_line.get('hook').get('resource').get('resource_name'), - 'resource_key':json_line.get('hook').get('resource').get('resource_key') - } - # Match drifted_table with event_table using resource_name and resource_key as identifiers - if(drifted_table == event_table): - # Retrive the drifted_table_name and convert it into - # table_id format:[gcp_project_id].[dataset_id].[table_id] - drifted_table_name = json_line.get('hook').get('id_value') - table_id = "" - for s in drifted_table_name.rsplit("/"): - if(s != "projects" and s != "datasets" and s != "tables"): - table_id += s+"." - drifted_tables.append(table_id[:len(table_id)-1]) - return drifted_tables + # When the resource_condensed exists and resource_from_hook matched with identifier in dict + # add id_value and convert it to fully-qualified table_id, and add table_id to dict + if resource_from_hook == tables_of_interest.get(resource_condensed).get('identifier'): + drifted_table_name = hook.get('id_value') + tables_of_interest[resource_condensed]['id_value'] = drifted_table_name + table_id = "" + for s in drifted_table_name.rsplit("/"): + if(s != "projects" and s != "datasets" and s != "tables"): + table_id += s+"." + tables_of_interest[resource_condensed]['table_id'] = table_id[:len(table_id)-1] + return tables_of_interest def main(): # Provide arguments for JSON filename @@ -58,12 +71,12 @@ def main(): args = parser.parse_args() # Parse json file to identify drifted tables - drifted_tables = get_drifted_tables(args.filename) + tables_of_interest = get_drifted_tables(args.filename) # Fail the build and Fetch latest schemas if drifts are detected - if drifted_tables: + if tables_of_interest: # Fetch latest schemas for drifted tables from BQ - drifted_table_schemas = get_schemas_from_BQ(drifted_tables) + drifted_table_schemas = get_schemas_from_BQ(tables_of_interest) # Drifts detected, throw exceptions raise Exception("Drifts are detected in these tables, please update your terraform schema files with the following updated table schemas. ", drifted_table_schemas) From ec2d1399d021ff756bf5313bb7758c1eac430d6b Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Sun, 26 Jun 2022 13:55:55 -0400 Subject: [PATCH 35/52] Update python scripts - Add check resource_condensed exists condition --- tools/terraform_sync_tool/terraform_sync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/terraform_sync_tool/terraform_sync.py b/tools/terraform_sync_tool/terraform_sync.py index 05fad1baa..4768ee81c 100644 --- a/tools/terraform_sync_tool/terraform_sync.py +++ b/tools/terraform_sync_tool/terraform_sync.py @@ -54,7 +54,7 @@ def get_drifted_tables(json_file): } # When the resource_condensed exists and resource_from_hook matched with identifier in dict # add id_value and convert it to fully-qualified table_id, and add table_id to dict - if resource_from_hook == tables_of_interest.get(resource_condensed).get('identifier'): + if resource_condensed in tables_of_interest and resource_from_hook == tables_of_interest.get(resource_condensed).get('identifier'): drifted_table_name = hook.get('id_value') tables_of_interest[resource_condensed]['id_value'] = drifted_table_name table_id = "" From 90cb7da6ef9032833ff722682f2dc9cf408bcd32 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Tue, 28 Jun 2022 14:17:43 -0400 Subject: [PATCH 36/52] Initiate BigQuery client in main() --- tools/terraform_sync_tool/terraform_sync.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/terraform_sync_tool/terraform_sync.py b/tools/terraform_sync_tool/terraform_sync.py index 4768ee81c..184f6a571 100644 --- a/tools/terraform_sync_tool/terraform_sync.py +++ b/tools/terraform_sync_tool/terraform_sync.py @@ -4,9 +4,8 @@ from google.cloud import bigquery # Fetch schemas for drifted tables form BQ -def get_schemas_from_BQ(tables_of_interest): +def get_schemas_from_BQ(tables_of_interest, client): table_schemas = [] - client = bigquery.Client() for k in tables_of_interest: table_id = tables_of_interest.get(k).get('table_id') table = client.get_table(table_id) @@ -76,7 +75,8 @@ def main(): # Fail the build and Fetch latest schemas if drifts are detected if tables_of_interest: # Fetch latest schemas for drifted tables from BQ - drifted_table_schemas = get_schemas_from_BQ(tables_of_interest) + client = bigquery.Client() + drifted_table_schemas = get_schemas_from_BQ(tables_of_interest, client) # Drifts detected, throw exceptions raise Exception("Drifts are detected in these tables, please update your terraform schema files with the following updated table schemas. ", drifted_table_schemas) From 23f1701a0e2fca0f4778049f1162b25d66fa4edb Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Fri, 1 Jul 2022 17:26:21 -0400 Subject: [PATCH 37/52] Use regex to convert id_value to fully-qualified table_id --- tools/terraform_sync_tool/terraform_sync.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/terraform_sync_tool/terraform_sync.py b/tools/terraform_sync_tool/terraform_sync.py index 184f6a571..024970515 100644 --- a/tools/terraform_sync_tool/terraform_sync.py +++ b/tools/terraform_sync_tool/terraform_sync.py @@ -1,6 +1,7 @@ import json import io import argparse +import re from google.cloud import bigquery # Fetch schemas for drifted tables form BQ @@ -56,11 +57,10 @@ def get_drifted_tables(json_file): if resource_condensed in tables_of_interest and resource_from_hook == tables_of_interest.get(resource_condensed).get('identifier'): drifted_table_name = hook.get('id_value') tables_of_interest[resource_condensed]['id_value'] = drifted_table_name - table_id = "" - for s in drifted_table_name.rsplit("/"): - if(s != "projects" and s != "datasets" and s != "tables"): - table_id += s+"." - tables_of_interest[resource_condensed]['table_id'] = table_id[:len(table_id)-1] + + x = re.findall(r'projects/(.*)/datasets/(.*)/tables/(.*)', drifted_table_name) + table_id = '.'.join(list(x[0])) + tables_of_interest[resource_condensed]['table_id'] = table_id return tables_of_interest def main(): From 993bffa5590081eb60189353606d5c767efae37c Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Sun, 10 Jul 2022 22:15:15 -0400 Subject: [PATCH 38/52] Update Prerequisite in README --- tools/terraform_sync_tool/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/terraform_sync_tool/README.md b/tools/terraform_sync_tool/README.md index 8eb5a46c8..8b081417e 100644 --- a/tools/terraform_sync_tool/README.md +++ b/tools/terraform_sync_tool/README.md @@ -13,7 +13,7 @@ the Terraform resources accordingly. ## Prerequisite Before building the terraform sync tool, please ensure that billing and Cloud Build are enabled for your Cloud project. -You'll also need to install Cloud SDK(https://cloud.google.com/sdk/docs/install) and Terragrunt(https://terragrunt.gruntwork.io/docs/getting-started/install) +You'll also need to install Terragrunt(https://terragrunt.gruntwork.io/docs/getting-started/install) ## Folder Structure This directory serves as a starting point for your cloud project with terraform-sync-tool as one of qa tools integrated. From d31e25fa36e9cc9d1673dd814f61aab656dad4d8 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Sun, 10 Jul 2022 23:08:13 -0400 Subject: [PATCH 39/52] Update README --- tools/terraform_sync_tool/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/terraform_sync_tool/README.md b/tools/terraform_sync_tool/README.md index 8b081417e..758612dc2 100644 --- a/tools/terraform_sync_tool/README.md +++ b/tools/terraform_sync_tool/README.md @@ -13,7 +13,7 @@ the Terraform resources accordingly. ## Prerequisite Before building the terraform sync tool, please ensure that billing and Cloud Build are enabled for your Cloud project. -You'll also need to install Terragrunt(https://terragrunt.gruntwork.io/docs/getting-started/install) +You'll need to install Terragrunt(https://terragrunt.gruntwork.io/docs/getting-started/install) ## Folder Structure This directory serves as a starting point for your cloud project with terraform-sync-tool as one of qa tools integrated. From 1e7a6fc54c100d6919d47820e3c20c9515894fb5 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 11 Jul 2022 12:16:41 -0400 Subject: [PATCH 40/52] Derive project ID from default credentials --- tools/terraform_sync_tool/terraform_sync.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/terraform_sync_tool/terraform_sync.py b/tools/terraform_sync_tool/terraform_sync.py index 024970515..c96c961f5 100644 --- a/tools/terraform_sync_tool/terraform_sync.py +++ b/tools/terraform_sync_tool/terraform_sync.py @@ -3,6 +3,8 @@ import argparse import re from google.cloud import bigquery +import google.auth + # Fetch schemas for drifted tables form BQ def get_schemas_from_BQ(tables_of_interest, client): @@ -74,8 +76,10 @@ def main(): # Fail the build and Fetch latest schemas if drifts are detected if tables_of_interest: + #obtain credentials + credentials, project_id = google.auth.default() # Fetch latest schemas for drifted tables from BQ - client = bigquery.Client() + client = bigquery.Client(project_id) drifted_table_schemas = get_schemas_from_BQ(tables_of_interest, client) # Drifts detected, throw exceptions raise Exception("Drifts are detected in these tables, please update your terraform schema files with the following updated table schemas. ", drifted_table_schemas) From 67505be33f044d12c9ddea622675567162e14014 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 11 Jul 2022 12:26:45 -0400 Subject: [PATCH 41/52] Update user-provided arguments description --- tools/terraform_sync_tool/terraform_sync.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/terraform_sync_tool/terraform_sync.py b/tools/terraform_sync_tool/terraform_sync.py index c96c961f5..2b25786a9 100644 --- a/tools/terraform_sync_tool/terraform_sync.py +++ b/tools/terraform_sync_tool/terraform_sync.py @@ -67,7 +67,7 @@ def get_drifted_tables(json_file): def main(): # Provide arguments for JSON filename - parser = argparse.ArgumentParser(description='user-provided arguments') + parser = argparse.ArgumentParser(description='user-provided arguments: filename of terragrunt ouput JSON file') parser.add_argument('filename') args = parser.parse_args() @@ -76,8 +76,8 @@ def main(): # Fail the build and Fetch latest schemas if drifts are detected if tables_of_interest: - #obtain credentials - credentials, project_id = google.auth.default() + # Obtain credentials + project_id = google.auth.default() # Fetch latest schemas for drifted tables from BQ client = bigquery.Client(project_id) drifted_table_schemas = get_schemas_from_BQ(tables_of_interest, client) From 69a2df35cf0d995b0ca94c95a2a5fbc79aeaeb58 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 11 Jul 2022 16:50:20 -0400 Subject: [PATCH 42/52] Update terraform_sync.py --- tools/terraform_sync_tool/terraform_sync.py | 52 ++++++++------------- 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/tools/terraform_sync_tool/terraform_sync.py b/tools/terraform_sync_tool/terraform_sync.py index 2b25786a9..8e18b787c 100644 --- a/tools/terraform_sync_tool/terraform_sync.py +++ b/tools/terraform_sync_tool/terraform_sync.py @@ -7,10 +7,9 @@ # Fetch schemas for drifted tables form BQ -def get_schemas_from_BQ(tables_of_interest, client): +def get_schemas_from_BQ(drifted_tables, client): table_schemas = [] - for k in tables_of_interest: - table_id = tables_of_interest.get(k).get('table_id') + for table_id in drifted_tables: table = client.get_table(table_id) file = io.StringIO("") client.schema_to_json(table.schema, file) @@ -21,7 +20,8 @@ def get_schemas_from_BQ(tables_of_interest, client): # Identify tables with drifts and add return tables_of_interest def get_drifted_tables(json_file): - tables_of_interest = {} # store events(dict) of interest where key=resource_name+resource_key as identifier and value=resource_id + tables_of_interest = set() # set of identifiers of interest in format resource_name+resource_key + drifted_tables = [] # list of table IDs of drifted tables # Opening JSON file with open(json_file) as file: lines = file.readlines() @@ -34,36 +34,24 @@ def get_drifted_tables(json_file): # When resource_drift event detected, append new dict to tables_of_interest if type == 'resource_drift' and change and change.get('resource') and change.get('resource').get('resource_type') == 'google_bigquery_table': change_resource = change.get('resource') - # Use condensed resource_name+resource_key as key of new dict - resource_condensed = change_resource.get('resource_name')+change_resource.get('resource_key') - # Use resource infomation from resource_drift event in value of new dict - resource_from_drift = {'identifier': - { - 'resource_name':change_resource.get('resource_name'), - 'resource_key':change_resource.get('resource_key') - }} + # Use condensed resource_name+resource_key as identifier + resource_from_drift = change_resource.get('resource_name') + change_resource.get('resource_key') # Add to tables_of_interest - tables_of_interest[resource_condensed] = resource_from_drift + tables_of_interest.add(resource_from_drift) # When refresh_complete event detected, add id_value and convert it to fully-qualified table_id, and add table_id to dict if type == 'refresh_complete' and hook and hook.get('resource') and hook.get('resource').get('resource_type') == 'google_bigquery_table': hook_resource = hook.get('resource') - # Use condensed resource_name+resource_key as the key to check if it exists in tables_of_interest - resource_condensed = hook_resource.get('resource_name') + hook_resource.get('resource_key') - # Usee resource information from refresh_complete event as identifier - resource_from_hook = { - 'resource_name':hook_resource.get('resource_name'), - 'resource_key':hook_resource.get('resource_key') - } - # When the resource_condensed exists and resource_from_hook matched with identifier in dict - # add id_value and convert it to fully-qualified table_id, and add table_id to dict - if resource_condensed in tables_of_interest and resource_from_hook == tables_of_interest.get(resource_condensed).get('identifier'): + # Use condensed resource_name+resource_key to check if it exists in tables_of_interest + resource_from_hook = hook_resource.get('resource_name') + hook_resource.get('resource_key') + # When the resource_identifier exists in tables of interst, + # add id_value and convert it to fully-qualified table_id, and add table_id to drifted_tables list + if resource_from_hook in tables_of_interest: drifted_table_name = hook.get('id_value') - tables_of_interest[resource_condensed]['id_value'] = drifted_table_name - + # Convert id_value to qualified table ID x = re.findall(r'projects/(.*)/datasets/(.*)/tables/(.*)', drifted_table_name) table_id = '.'.join(list(x[0])) - tables_of_interest[resource_condensed]['table_id'] = table_id - return tables_of_interest + drifted_tables.append(table_id) + return drifted_tables def main(): # Provide arguments for JSON filename @@ -72,15 +60,15 @@ def main(): args = parser.parse_args() # Parse json file to identify drifted tables - tables_of_interest = get_drifted_tables(args.filename) + drifted_tables = get_drifted_tables(args.filename) # Fail the build and Fetch latest schemas if drifts are detected - if tables_of_interest: + if drifted_tables: # Obtain credentials - project_id = google.auth.default() + credentials, project_id = google.auth.default() # Fetch latest schemas for drifted tables from BQ - client = bigquery.Client(project_id) - drifted_table_schemas = get_schemas_from_BQ(tables_of_interest, client) + client = bigquery.Client(project_id, credentials) + drifted_table_schemas = get_schemas_from_BQ(drifted_tables, client) # Drifts detected, throw exceptions raise Exception("Drifts are detected in these tables, please update your terraform schema files with the following updated table schemas. ", drifted_table_schemas) From 9cd0da7bd77a616f7a678e58d281f6081132dd6a Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Tue, 12 Jul 2022 16:14:51 -0400 Subject: [PATCH 43/52] Allow users to provide project_id to ArgumentParser --- tools/terraform_sync_tool/terraform_sync.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/terraform_sync_tool/terraform_sync.py b/tools/terraform_sync_tool/terraform_sync.py index 8e18b787c..4c10fbe6c 100644 --- a/tools/terraform_sync_tool/terraform_sync.py +++ b/tools/terraform_sync_tool/terraform_sync.py @@ -55,8 +55,10 @@ def get_drifted_tables(json_file): def main(): # Provide arguments for JSON filename - parser = argparse.ArgumentParser(description='user-provided arguments: filename of terragrunt ouput JSON file') + parser = argparse.ArgumentParser(description='user-provided arguments: filename of terragrunt ouput JSON file and project_id') parser.add_argument('filename') + # allow user to provide project_id + parser.add_argument('project_id') args = parser.parse_args() # Parse json file to identify drifted tables @@ -64,10 +66,8 @@ def main(): # Fail the build and Fetch latest schemas if drifts are detected if drifted_tables: - # Obtain credentials - credentials, project_id = google.auth.default() # Fetch latest schemas for drifted tables from BQ - client = bigquery.Client(project_id, credentials) + client = bigquery.Client(project=args.project_id) drifted_table_schemas = get_schemas_from_BQ(drifted_tables, client) # Drifts detected, throw exceptions raise Exception("Drifts are detected in these tables, please update your terraform schema files with the following updated table schemas. ", drifted_table_schemas) From 1c4fc9f67003961d4a35778c514fcfb8afd84eaa Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 18 Jul 2022 10:59:00 -0500 Subject: [PATCH 44/52] Formatting: add new line at end of files --- tools/terraform_sync_tool/modules/bigquery/main.tf | 2 +- tools/terraform_sync_tool/modules/bigquery/variables.tf | 2 +- .../qa/terraform-sync-tool/json_schemas/TableForTest.json | 3 ++- .../qa/terraform-sync-tool/json_schemas/TableForTest2.json | 3 ++- .../terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl | 2 +- tools/terraform_sync_tool/qa/terragrunt.hcl | 2 +- tools/terraform_sync_tool/terraform_sync.py | 2 +- 7 files changed, 9 insertions(+), 7 deletions(-) diff --git a/tools/terraform_sync_tool/modules/bigquery/main.tf b/tools/terraform_sync_tool/modules/bigquery/main.tf index 143c928db..bd9bdb1b4 100644 --- a/tools/terraform_sync_tool/modules/bigquery/main.tf +++ b/tools/terraform_sync_tool/modules/bigquery/main.tf @@ -52,4 +52,4 @@ resource "google_bigquery_table" "bq_table" { } } } -} \ No newline at end of file +} diff --git a/tools/terraform_sync_tool/modules/bigquery/variables.tf b/tools/terraform_sync_tool/modules/bigquery/variables.tf index 03148c9b5..355508492 100644 --- a/tools/terraform_sync_tool/modules/bigquery/variables.tf +++ b/tools/terraform_sync_tool/modules/bigquery/variables.tf @@ -53,4 +53,4 @@ variable "tables" { labels = map(string), } )) -} \ No newline at end of file +} diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest.json b/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest.json index 6182c22c1..b726f47ab 100644 --- a/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest.json +++ b/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest.json @@ -59,4 +59,5 @@ "name": "Col10", "type": "STRING" } - ] \ No newline at end of file + ] + \ No newline at end of file diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest2.json b/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest2.json index a8fe38d0e..a25bd127f 100644 --- a/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest2.json +++ b/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest2.json @@ -23,4 +23,5 @@ "name": "Col4", "type": "STRING" } - ] \ No newline at end of file + ] + \ No newline at end of file diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl b/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl index 3a26a7c7b..a63caf6dc 100644 --- a/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl +++ b/tools/terraform_sync_tool/qa/terraform-sync-tool/terragrunt.hcl @@ -48,4 +48,4 @@ inputs = { labels = {} } ] -} \ No newline at end of file +} diff --git a/tools/terraform_sync_tool/qa/terragrunt.hcl b/tools/terraform_sync_tool/qa/terragrunt.hcl index 0337b3f59..c547985b6 100644 --- a/tools/terraform_sync_tool/qa/terragrunt.hcl +++ b/tools/terraform_sync_tool/qa/terragrunt.hcl @@ -41,4 +41,4 @@ remote_state { path = "backend.tf" if_exists = "overwrite_terragrunt" } -} \ No newline at end of file +} diff --git a/tools/terraform_sync_tool/terraform_sync.py b/tools/terraform_sync_tool/terraform_sync.py index 4c10fbe6c..24a14dfdb 100644 --- a/tools/terraform_sync_tool/terraform_sync.py +++ b/tools/terraform_sync_tool/terraform_sync.py @@ -73,4 +73,4 @@ def main(): raise Exception("Drifts are detected in these tables, please update your terraform schema files with the following updated table schemas. ", drifted_table_schemas) if __name__ == "__main__": - main() \ No newline at end of file + main() From d2e5e0e7c20b790c018355db7dec856129e1af66 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 18 Jul 2022 11:01:35 -0500 Subject: [PATCH 45/52] Formatting schema json files --- .../json_schemas/TableForTest.json | 125 +++++++++--------- .../json_schemas/TableForTest2.json | 51 ++++--- 2 files changed, 87 insertions(+), 89 deletions(-) diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest.json b/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest.json index b726f47ab..682953cb2 100644 --- a/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest.json +++ b/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest.json @@ -1,63 +1,62 @@ - [ - { - "description": "Col1", - "mode": "NULLABLE", - "name": "Col1", - "type": "STRING" - }, - { - "description": "Col2", - "mode": "NULLABLE", - "name": "Col2", - "type": "STRING" - }, - { - "description": "Col3", - "mode": "NULLABLE", - "name": "Col3", - "type": "STRING" - }, - { - "description": "Col4", - "mode": "NULLABLE", - "name": "Col4", - "type": "STRING" - }, - { - "description": "Col5", - "mode": "NULLABLE", - "name": "Col5", - "type": "STRING" - }, - { - "description": "Col6", - "mode": "NULLABLE", - "name": "Col6", - "type": "STRING" - }, - { - "description": "Col7", - "mode": "NULLABLE", - "name": "Col7", - "type": "STRING" - }, - { - "description": "Col8", - "mode": "NULLABLE", - "name": "Col8", - "type": "STRING" - }, - { - "description": "Col9", - "mode": "NULLABLE", - "name": "Col9", - "type": "STRING" - }, - { - "description": "Col10", - "mode": "NULLABLE", - "name": "Col10", - "type": "STRING" - } - ] - \ No newline at end of file +[ + { + "description": "Col1", + "mode": "NULLABLE", + "name": "Col1", + "type": "STRING" + }, + { + "description": "Col2", + "mode": "NULLABLE", + "name": "Col2", + "type": "STRING" + }, + { + "description": "Col3", + "mode": "NULLABLE", + "name": "Col3", + "type": "STRING" + }, + { + "description": "Col4", + "mode": "NULLABLE", + "name": "Col4", + "type": "STRING" + }, + { + "description": "Col5", + "mode": "NULLABLE", + "name": "Col5", + "type": "STRING" + }, + { + "description": "Col6", + "mode": "NULLABLE", + "name": "Col6", + "type": "STRING" + }, + { + "description": "Col7", + "mode": "NULLABLE", + "name": "Col7", + "type": "STRING" + }, + { + "description": "Col8", + "mode": "NULLABLE", + "name": "Col8", + "type": "STRING" + }, + { + "description": "Col9", + "mode": "NULLABLE", + "name": "Col9", + "type": "STRING" + }, + { + "description": "Col10", + "mode": "NULLABLE", + "name": "Col10", + "type": "STRING" + } +] diff --git a/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest2.json b/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest2.json index a25bd127f..777cf5d12 100644 --- a/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest2.json +++ b/tools/terraform_sync_tool/qa/terraform-sync-tool/json_schemas/TableForTest2.json @@ -1,27 +1,26 @@ [ - { - "description": "Col1", - "mode": "NULLABLE", - "name": "Col1", - "type": "STRING" - }, - { - "description": "Col2", - "mode": "NULLABLE", - "name": "Col2", - "type": "STRING" - }, - { - "description": "Col3", - "mode": "NULLABLE", - "name": "Col3", - "type": "STRING" - }, - { - "description": "Col4", - "mode": "NULLABLE", - "name": "Col4", - "type": "STRING" - } - ] - \ No newline at end of file + { + "description": "Col1", + "mode": "NULLABLE", + "name": "Col1", + "type": "STRING" + }, + { + "description": "Col2", + "mode": "NULLABLE", + "name": "Col2", + "type": "STRING" + }, + { + "description": "Col3", + "mode": "NULLABLE", + "name": "Col3", + "type": "STRING" + }, + { + "description": "Col4", + "mode": "NULLABLE", + "name": "Col4", + "type": "STRING" + } +] From 815bee449f493223408f8d44ae91c0abe6e17e75 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 18 Jul 2022 20:47:53 -0500 Subject: [PATCH 46/52] Update README: provide more details on setup --- tools/terraform_sync_tool/README.md | 84 ++++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 12 deletions(-) diff --git a/tools/terraform_sync_tool/README.md b/tools/terraform_sync_tool/README.md index 758612dc2..6abae7434 100644 --- a/tools/terraform_sync_tool/README.md +++ b/tools/terraform_sync_tool/README.md @@ -15,6 +15,10 @@ Before building the terraform sync tool, please ensure that billing and Cloud Bu You'll need to install Terragrunt(https://terragrunt.gruntwork.io/docs/getting-started/install) +## What is Terragrunt? +Terragrunt is a framework on top of Terraform with some new tools out-of-the-box. +Using new files *.hcl and new keywords, you can share variables across terraform modules easily. + ## Folder Structure This directory serves as a starting point for your cloud project with terraform-sync-tool as one of qa tools integrated. @@ -34,25 +38,81 @@ This directory serves as a starting point for your cloud project with terraform- ├── terraform_sync.py # Build Step 1 - python scripts └── ... # etc. +## How to run Terraform Schema Sync Tool(TODO) + +#### Use Terraform/Terragrunt commands to test if any resources drifts existed + +Terragrunt/Terraform commands: +``` +terragrunt run-all plan -json --terragrunt-non-interactive + +# If you need to pass variables to specify working directory +env = VALUE_OF_ENV # Value of env, for example "qa" +tool = VALUE_OF_TOOL # Value of tool, for example "terraform-sync-tool" +terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" + +# If you need to write outputs into a json file. Feel free to replace `plan_out.json` with your JSON FILENAME. +terragrunt run-all plan -json --terragrunt-non-interactive > plan_out.json + +# If you need to write outputs into a json file with variables specified. Feel free to replace `plan_out.json` with your JSON FILENAME. +env = VALUE_OF_ENV # Value of env, for example "qa" +tool = VALUE_OF_TOOL # Value of tool, for example "terraform-sync-tool" +terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" > plan_out.json +``` + +After running the Terrform plan command, **the event type "resource_drift"("type": "resource_drift") indicates a drift has occurred**. +If drifts detected, please update your terraform configurations and address the resource drifts based on the event outputs. + + +#### Add Could Build Steps to your configuration file + +Please check cloud build steps in `cloudbuild.yaml` file, and add these steps to your Cloud Build Configuration File. + +Here are two steps in `cloudbuild.yaml` for Terraform Schema Sync Tool integration: + +- step 0: run terraform commands in `deploy.sh` to detects drifts + +Add `deploy.sh` to your project directory. `deploy.sh` contains terraform plan command that writes event outputs into `plan_out.json` file. We'll use `plan_out.json` file for further investigation in the future steps. Feel free to repace `plan_out.json` with your JSON filename. + + +- step 1: run python scripts to investigate terraform output + +Add `requirements.txt` and `terraform_sync.py` to your project directory. `requirements.txt` specifies python dependencies, and `terraform_sync.py` contains python scripts to +investigate terraform event outputs stored from step 0 to detect and address schema drifts + +#### (Optional if you haven't created Cloud Build Trigger) Create and configure a new Trigger in Cloud Build +Make sure to indicate your cloud configuration file location correctly. + +#### That's all you need! Let's commit and test in CLoud Build! + + +## How to run this sample repo? + +#### Fork and Clone this repo + +#### Go to the directory you just cloned, and update -## TODO -Please make sure to update - **YOUR_GCP_PROJECT_ID** in `./qa/terragrunt.hcl` - **YOUR_BUCKET_NAME** in `./qa/terragrunt.hcl` - **YOUR_DATASET_ID** in `./qa/terraform-sync-tool/terragrunt.hcl` -Please **create and configure your trigger in Cloud Build** and make sure to use `cloudbuild.yaml` as **Cloud Build configuration file location** +#### (First time only) Use terraform plan & apply to deploy your resource to you GCP Project -## Setup - -Use Cloud SDK to set the specified property in your active configuration only ``` -gcloud config set project +env = qa +tool = terraform-sync-tool +echo $env +echo $tool +terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" +terragrunt run-all apply -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" ``` +#### Create and configure a new Trigger in Cloud Build +Make sure to indicate your cloud configuration file location correctly. +In this sample repo, use `tools/terraform_sync_tool/cloudbuild.yaml` as your cloud configuration file location -### Local Test +### How to test each Build Step without triggering Cloud Build? -To test using terragrunt commands. Feel free to replace `plan_out.json` with your JSON FILENAME. +- To test using terragrunt commands. Feel free to replace `plan_out.json` with your JSON FILENAME and change values of variables. ``` env = qa tool = terraform-sync-tool @@ -61,12 +121,12 @@ echo $tool terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" > plan_out.json ``` -To test using terragrunt commands without writing the output into a JSON file +- To test using terragrunt commands without writing the output into a JSON file ``` terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" ``` -Provide argument to test `terraform_sync.py`. Feel free to replace `plan_out.json` with your JSON FILENAME. +- To test python scripts. `terraform_sync.py` requires two arguments: JSON filename and gcp_project_id. Provide arguments to test `terraform_sync.py`. Feel free to replace `plan_out.json` with your JSON FILENAME. ``` -terraform_sync.py plan_out.json +terraform_sync.py plan_out.json ``` From 2bf25ea4211adc886ab7f39d5e99d51eb4ffcdda Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 18 Jul 2022 20:52:22 -0500 Subject: [PATCH 47/52] Update README --- tools/terraform_sync_tool/README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tools/terraform_sync_tool/README.md b/tools/terraform_sync_tool/README.md index 6abae7434..f9c4a222f 100644 --- a/tools/terraform_sync_tool/README.md +++ b/tools/terraform_sync_tool/README.md @@ -13,10 +13,8 @@ the Terraform resources accordingly. ## Prerequisite Before building the terraform sync tool, please ensure that billing and Cloud Build are enabled for your Cloud project. -You'll need to install Terragrunt(https://terragrunt.gruntwork.io/docs/getting-started/install) - ## What is Terragrunt? -Terragrunt is a framework on top of Terraform with some new tools out-of-the-box. +Terragrunt(https://terragrunt.gruntwork.io/docs/getting-started/install) is a framework on top of Terraform with some new tools out-of-the-box. Using new files *.hcl and new keywords, you can share variables across terraform modules easily. ## Folder Structure @@ -38,7 +36,7 @@ This directory serves as a starting point for your cloud project with terraform- ├── terraform_sync.py # Build Step 1 - python scripts └── ... # etc. -## How to run Terraform Schema Sync Tool(TODO) +## How to run Terraform Schema Sync Tool #### Use Terraform/Terragrunt commands to test if any resources drifts existed @@ -54,7 +52,8 @@ terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working- # If you need to write outputs into a json file. Feel free to replace `plan_out.json` with your JSON FILENAME. terragrunt run-all plan -json --terragrunt-non-interactive > plan_out.json -# If you need to write outputs into a json file with variables specified. Feel free to replace `plan_out.json` with your JSON FILENAME. +# If you need to write outputs into a json file with variables specified. +# Feel free to replace `plan_out.json` with your JSON FILENAME. env = VALUE_OF_ENV # Value of env, for example "qa" tool = VALUE_OF_TOOL # Value of tool, for example "terraform-sync-tool" terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" > plan_out.json From 4e1bb73ddab8b731c8b5509f83906955214a99f5 Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 18 Jul 2022 21:36:39 -0500 Subject: [PATCH 48/52] Update cloudbuild.yaml: provide project_id --- tools/terraform_sync_tool/cloudbuild.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/terraform_sync_tool/cloudbuild.yaml b/tools/terraform_sync_tool/cloudbuild.yaml index 72a1320ab..48c6bae52 100644 --- a/tools/terraform_sync_tool/cloudbuild.yaml +++ b/tools/terraform_sync_tool/cloudbuild.yaml @@ -12,4 +12,4 @@ steps: args: - -c - 'pip install -r ./requirements.txt' - - 'python terraform_sync.py plan_out.json' \ No newline at end of file + - 'python terraform_sync.py plan_out.json ' From 2b20ed52d92e66aa0bedcb7471a80f3fac87ce4e Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Mon, 25 Jul 2022 14:36:23 -0500 Subject: [PATCH 49/52] Reorder and update README --- tools/terraform_sync_tool/README.md | 108 +++++++++++++-------- tools/terraform_sync_tool/architecture.png | Bin 0 -> 115466 bytes 2 files changed, 68 insertions(+), 40 deletions(-) create mode 100644 tools/terraform_sync_tool/architecture.png diff --git a/tools/terraform_sync_tool/README.md b/tools/terraform_sync_tool/README.md index f9c4a222f..e576dfbd7 100644 --- a/tools/terraform_sync_tool/README.md +++ b/tools/terraform_sync_tool/README.md @@ -1,23 +1,76 @@ # Terraform Sync Tool This directory contains the setup for the Terraform Sync Tool. Terraform Sync Tool was designed to address the schema drifts in BigQuery tables and keep the -Terraform schemas up-to-date with the BigQuery table schemas in production environment. +Terraform schemas up-to-date with the BigQuery table schemas in production environment. Schema drifts occurred when BigQuery Table schemas are updated by newly +ingested data while Terraform schema files contain the outdated schemas. Therefore, this tool will detect the schema drifts, trace the origins of the drifts, and alert +developers/data engineers. -Terraform Sync Tool can be integrated into your CI/CD pipeline using Cloud Build. You'll need to add two steps to `cloudbuild.yaml`. -- Step 0: Use Terragrunt command to detect resource drifts and write output into a JSON file +The Terraform Schema Sync Tool fails the build attemps if resource drifts are detected and notifies the latest resource information. Developers and data engineers should be able to update the Terraform resources accordingly. + +Terraform Sync Tool can be integrated into your CI/CD pipeline. You'll need to add two steps to CI/CD pipeline. +- Step 0: Use Terraform/erragrunt command to detect resource drifts and write output into a JSON file - Step 1: Use Python scripts to identify and investigate the drifts -Cloud Build fails the build attemps if resource drifts are detected and notifies the latest resource information. Developers should be able to update -the Terraform resources accordingly. +## How to run Terraform Schema Sync Tool -## Prerequisite -Before building the terraform sync tool, please ensure that billing and Cloud Build are enabled for your Cloud project. +#### Use Terraform/Terragrunt commands to test if any resources drifts existed -## What is Terragrunt? -Terragrunt(https://terragrunt.gruntwork.io/docs/getting-started/install) is a framework on top of Terraform with some new tools out-of-the-box. -Using new files *.hcl and new keywords, you can share variables across terraform modules easily. +Terragrunt/Terraform commands: +``` +terragrunt run-all plan -json --terragrunt-non-interactive + +# Terraform Command +terraform plan -json +``` + +After running the Terrform plan command, **the event type "resource_drift"("type": "resource_drift") indicates a drift has occurred**. +If drifts detected, please update your terraform configurations and address the resource drifts based on the event outputs. + + +#### Add Could Build Steps to your configuration file + +Please check cloud build steps in `cloudbuild.yaml` file, and add these steps to your Cloud Build Configuration File. + +- step 0: run terraform commands in `deploy.sh` to detects drifts + +Add `deploy.sh` to your project directory. + +- step 1: run python scripts to investigate terraform output + +Add `requirements.txt` and `terraform_sync.py` to your project directory. + +#### (Optional if you haven't created Cloud Build Trigger) Create and configure a new Trigger in Cloud Build +Make sure to indicate your cloud configuration file location correctly. + +#### That's all you need! Let's commit and test in CLoud Build! + +## How Terraform Schema Sync Tool Works + +![Architecture Diagram](architecture.png) + +**Executing the Sync Tool** + +The Terraform Sync Tool will be executed as part of the CI/CD pipeline build steps triggered anytime when developers make a change to the linked repository. A build step specifies an action that you want Cloud Build to perform. For each build step, Cloud Build executes a docker container as an instance of docker run. + +**Step 0: Terraform Detects Drifts** + +`deploy.sh` contains terraform plan command that writes event outputs into `plan_out.json` file. We'll use `plan_out.json` file for further investigation in the future steps. Feel free to repace `plan_out.json` with your JSON filename. we can pass through the variables ${env} and ${tool} if any. -## Folder Structure +**Step 1: Investigate Drifts** + + `requirements.txt` specifies python dependencies, and `terraform_sync.py` contains python scripts to +investigate terraform event outputs stored from step 0 to detect and address schema drifts + +In the python scripts(`terraform_sync.py`), we firstly scan through the output by line to identify all the drifted tables and store their table names. +After storing the drifted table names and converted them into the table_id format:[gcp_project_id].[dataset_id].[table_id], we make API calls, to fetch the latest table schemas from BigQuery. + +**Step 2: Fail Builds and Notify Expected Schemas** + +Once the schema drifts are detected and identified, we fail the build and notify the developer who makes changes to the repository. The notifications will include the details and the expected schemas in order keep the schema files up-to-date with the latest table schemas in BigQuery. + +To interpret the message, the expected table schema is in the format of [{table1_id:table1_schema}, {table2_id: table2_schema}, ...... ]. table_id falls in the format of [gcp_project_id].[dataset_id].[table_id] + +#### Folder Structure #### This directory serves as a starting point for your cloud project with terraform-sync-tool as one of qa tools integrated. . @@ -36,11 +89,12 @@ This directory serves as a starting point for your cloud project with terraform- ├── terraform_sync.py # Build Step 1 - python scripts └── ... # etc. -## How to run Terraform Schema Sync Tool +**What is Terragrunt?** -#### Use Terraform/Terragrunt commands to test if any resources drifts existed +Terragrunt(https://terragrunt.gruntwork.io/docs/getting-started/install) is a framework on top of Terraform with some new tools out-of-the-box. +Using new files *.hcl and new keywords, you can share variables across terraform modules easily. -Terragrunt/Terraform commands: +**Using terragrunt to detect resource drifts** ``` terragrunt run-all plan -json --terragrunt-non-interactive @@ -59,32 +113,6 @@ tool = VALUE_OF_TOOL # Value of tool, for example "terraform-sync-tool" terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" > plan_out.json ``` -After running the Terrform plan command, **the event type "resource_drift"("type": "resource_drift") indicates a drift has occurred**. -If drifts detected, please update your terraform configurations and address the resource drifts based on the event outputs. - - -#### Add Could Build Steps to your configuration file - -Please check cloud build steps in `cloudbuild.yaml` file, and add these steps to your Cloud Build Configuration File. - -Here are two steps in `cloudbuild.yaml` for Terraform Schema Sync Tool integration: - -- step 0: run terraform commands in `deploy.sh` to detects drifts - -Add `deploy.sh` to your project directory. `deploy.sh` contains terraform plan command that writes event outputs into `plan_out.json` file. We'll use `plan_out.json` file for further investigation in the future steps. Feel free to repace `plan_out.json` with your JSON filename. - - -- step 1: run python scripts to investigate terraform output - -Add `requirements.txt` and `terraform_sync.py` to your project directory. `requirements.txt` specifies python dependencies, and `terraform_sync.py` contains python scripts to -investigate terraform event outputs stored from step 0 to detect and address schema drifts - -#### (Optional if you haven't created Cloud Build Trigger) Create and configure a new Trigger in Cloud Build -Make sure to indicate your cloud configuration file location correctly. - -#### That's all you need! Let's commit and test in CLoud Build! - - ## How to run this sample repo? #### Fork and Clone this repo diff --git a/tools/terraform_sync_tool/architecture.png b/tools/terraform_sync_tool/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..a9929430966f700256f33bfd27b18a4b9cdecb1a GIT binary patch literal 115466 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 Date: Tue, 30 Aug 2022 13:59:30 -0400 Subject: [PATCH 50/52] Update README Introduction --- tools/terraform_sync_tool/README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tools/terraform_sync_tool/README.md b/tools/terraform_sync_tool/README.md index e576dfbd7..78f86efdc 100644 --- a/tools/terraform_sync_tool/README.md +++ b/tools/terraform_sync_tool/README.md @@ -1,19 +1,20 @@ # Terraform Sync Tool -This directory contains the setup for the Terraform Sync Tool. Terraform Sync Tool was designed to address the schema drifts in BigQuery tables and keep the -Terraform schemas up-to-date with the BigQuery table schemas in production environment. Schema drifts occurred when BigQuery Table schemas are updated by newly -ingested data while Terraform schema files contain the outdated schemas. Therefore, this tool will detect the schema drifts, trace the origins of the drifts, and alert -developers/data engineers. +This directory contains the Terraform Sync Tool. This tool intentionally fails your CI/CD pipeline when schema drifts occur between +what your BigQuery Terraform resources declare and what's actually present in your BigQuery environment. +Theses schema drifts happen when BigQuery tables are updated by processes outside of Terraform (ETL process may dynamically add new columns when loading data into BigQuery). +When drifts occur, you end up with outdated BigQuery Terraform resource files. This tool detects the schema drifts, +traces the origins of the drifts, and alerts developers/data engineers (by failing the CI/CD pipeline) +so they can patch the Terraform in their current commit. -The Terraform Schema Sync Tool fails the build attemps if resource drifts are detected and notifies the latest resource information. Developers and data engineers should be able to update the Terraform resources accordingly. Terraform Sync Tool can be integrated into your CI/CD pipeline. You'll need to add two steps to CI/CD pipeline. -- Step 0: Use Terraform/erragrunt command to detect resource drifts and write output into a JSON file +- Step 0: Run the Terraform plan command (using either Terraform/Terragrunt) with the `-json` option and write the output into a JSON file using the caret operator `> output.json` - Step 1: Use Python scripts to identify and investigate the drifts ## How to run Terraform Schema Sync Tool -#### Use Terraform/Terragrunt commands to test if any resources drifts existed +### Use Terraform/Terragrunt commands to test if any resources drifts existed Terragrunt/Terraform commands: ``` From 5496102bcbf327ed267d8b018d2e8d599d05f84f Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Tue, 30 Aug 2022 14:18:58 -0400 Subject: [PATCH 51/52] Update README: update structure --- tools/terraform_sync_tool/README.md | 123 ++++++++++------------------ 1 file changed, 41 insertions(+), 82 deletions(-) diff --git a/tools/terraform_sync_tool/README.md b/tools/terraform_sync_tool/README.md index 78f86efdc..0ddea02ba 100644 --- a/tools/terraform_sync_tool/README.md +++ b/tools/terraform_sync_tool/README.md @@ -14,36 +14,18 @@ Terraform Sync Tool can be integrated into your CI/CD pipeline. You'll need to a ## How to run Terraform Schema Sync Tool -### Use Terraform/Terragrunt commands to test if any resources drifts existed - -Terragrunt/Terraform commands: -``` -terragrunt run-all plan -json --terragrunt-non-interactive - -# Terraform Command -terraform plan -json -``` - -After running the Terrform plan command, **the event type "resource_drift"("type": "resource_drift") indicates a drift has occurred**. -If drifts detected, please update your terraform configurations and address the resource drifts based on the event outputs. - - -#### Add Could Build Steps to your configuration file - -Please check cloud build steps in `cloudbuild.yaml` file, and add these steps to your Cloud Build Configuration File. - -- step 0: run terraform commands in `deploy.sh` to detects drifts +``bash +############### +# Using Terragrunt +############### +terragrunt run-all plan -json --terragrunt-non-interactive > plan_output.json +python3 terraform_sync.py plan_output.json +############## +# Using Terraform +############## +terraform plan -json > plan_output.json +python3 terraform_sync.py plan_output.json -Add `deploy.sh` to your project directory. - -- step 1: run python scripts to investigate terraform output - -Add `requirements.txt` and `terraform_sync.py` to your project directory. - -#### (Optional if you haven't created Cloud Build Trigger) Create and configure a new Trigger in Cloud Build -Make sure to indicate your cloud configuration file location correctly. - -#### That's all you need! Let's commit and test in CLoud Build! ## How Terraform Schema Sync Tool Works @@ -71,7 +53,16 @@ Once the schema drifts are detected and identified, we fail the build and notify To interpret the message, the expected table schema is in the format of [{table1_id:table1_schema}, {table2_id: table2_schema}, ...... ]. table_id falls in the format of [gcp_project_id].[dataset_id].[table_id] -#### Folder Structure #### +**What is Terragrunt?** + +Terragrunt(https://terragrunt.gruntwork.io/docs/getting-started/install) is a framework on top of Terraform with some new tools out-of-the-box. +Using new files *.hcl and new keywords, you can share variables across terraform modules easily. + +## How to run this sample repo? + +#### Fork and Clone this repo + +#### Folder Structure This directory serves as a starting point for your cloud project with terraform-sync-tool as one of qa tools integrated. . @@ -90,71 +81,39 @@ This directory serves as a starting point for your cloud project with terraform- ├── terraform_sync.py # Build Step 1 - python scripts └── ... # etc. -**What is Terragrunt?** +#### Go to the directory you just cloned, and update -Terragrunt(https://terragrunt.gruntwork.io/docs/getting-started/install) is a framework on top of Terraform with some new tools out-of-the-box. -Using new files *.hcl and new keywords, you can share variables across terraform modules easily. +- **YOUR_GCP_PROJECT_ID** in `./qa/terragrunt.hcl` +- **YOUR_BUCKET_NAME** in `./qa/terragrunt.hcl` +- **YOUR_DATASET_ID** in `./qa/terraform-sync-tool/terragrunt.hcl` + +### Use Terraform/Terragrunt commands to test if any resources drifts existed -**Using terragrunt to detect resource drifts** +Terragrunt/Terraform commands: ``` terragrunt run-all plan -json --terragrunt-non-interactive -# If you need to pass variables to specify working directory -env = VALUE_OF_ENV # Value of env, for example "qa" -tool = VALUE_OF_TOOL # Value of tool, for example "terraform-sync-tool" -terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" - -# If you need to write outputs into a json file. Feel free to replace `plan_out.json` with your JSON FILENAME. -terragrunt run-all plan -json --terragrunt-non-interactive > plan_out.json - -# If you need to write outputs into a json file with variables specified. -# Feel free to replace `plan_out.json` with your JSON FILENAME. -env = VALUE_OF_ENV # Value of env, for example "qa" -tool = VALUE_OF_TOOL # Value of tool, for example "terraform-sync-tool" -terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" > plan_out.json +# Terraform Command +terraform plan -json ``` -## How to run this sample repo? +After running the Terrform plan command, **the event type "resource_drift"("type": "resource_drift") indicates a drift has occurred**. +If drifts detected, please update your terraform configurations and address the resource drifts based on the event outputs. -#### Fork and Clone this repo -#### Go to the directory you just cloned, and update +#### Add Could Build Steps to your configuration file -- **YOUR_GCP_PROJECT_ID** in `./qa/terragrunt.hcl` -- **YOUR_BUCKET_NAME** in `./qa/terragrunt.hcl` -- **YOUR_DATASET_ID** in `./qa/terraform-sync-tool/terragrunt.hcl` +Please check cloud build steps in `cloudbuild.yaml` file, and add these steps to your Cloud Build Configuration File. -#### (First time only) Use terraform plan & apply to deploy your resource to you GCP Project +- step 0: run terraform commands in `deploy.sh` to detects drifts -``` -env = qa -tool = terraform-sync-tool -echo $env -echo $tool -terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" -terragrunt run-all apply -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" -``` -#### Create and configure a new Trigger in Cloud Build -Make sure to indicate your cloud configuration file location correctly. -In this sample repo, use `tools/terraform_sync_tool/cloudbuild.yaml` as your cloud configuration file location +Add `deploy.sh` to your project directory. -### How to test each Build Step without triggering Cloud Build? +- step 1: run python scripts to investigate terraform output -- To test using terragrunt commands. Feel free to replace `plan_out.json` with your JSON FILENAME and change values of variables. -``` -env = qa -tool = terraform-sync-tool -echo $env -echo $tool -terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" > plan_out.json -``` +Add `requirements.txt` and `terraform_sync.py` to your project directory. -- To test using terragrunt commands without writing the output into a JSON file -``` -terragrunt run-all plan -json --terragrunt-non-interactive --terragrunt-working-dir="${env}"/"${tool}" -``` +#### (Optional if you haven't created a Cloud Build Trigger) Create and configure a new Trigger in Cloud Build +Make sure to indicate your cloud configuration file location correctly. In this sample repo, use `tools/terraform_sync_tool/cloudbuild.yaml` as your cloud configuration file location -- To test python scripts. `terraform_sync.py` requires two arguments: JSON filename and gcp_project_id. Provide arguments to test `terraform_sync.py`. Feel free to replace `plan_out.json` with your JSON FILENAME. -``` -terraform_sync.py plan_out.json -``` +#### That's all you need! Let's commit and test in Cloud Build! From 34f14611219b4bb73f3ff9bb7a892af9e97a2d5f Mon Sep 17 00:00:00 2001 From: Candice Hou <89653023+candicehou07@users.noreply.github.com> Date: Tue, 30 Aug 2022 14:20:58 -0400 Subject: [PATCH 52/52] Update README: fix --- tools/terraform_sync_tool/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/terraform_sync_tool/README.md b/tools/terraform_sync_tool/README.md index 0ddea02ba..7897fec2a 100644 --- a/tools/terraform_sync_tool/README.md +++ b/tools/terraform_sync_tool/README.md @@ -14,7 +14,7 @@ Terraform Sync Tool can be integrated into your CI/CD pipeline. You'll need to a ## How to run Terraform Schema Sync Tool -``bash +```bash ############### # Using Terragrunt ############### @@ -25,7 +25,7 @@ python3 terraform_sync.py plan_output.json ############## terraform plan -json > plan_output.json python3 terraform_sync.py plan_output.json - +``` ## How Terraform Schema Sync Tool Works