diff --git a/0-bootstrap/README-GitHub.md b/0-bootstrap/README-GitHub.md index 8b03f0040..1b7736272 100644 --- a/0-bootstrap/README-GitHub.md +++ b/0-bootstrap/README-GitHub.md @@ -148,20 +148,9 @@ You must be [authenticated to GitHub](https://docs.github.com/en/authentication/ cd ./envs/shared ``` -1. In the versions file `./versions.tf` un-comment the `github` required provider -1. In the variables file `./variables.tf` un-comment variables in the section `Specific to github_bootstrap` -1. In the outputs file `./outputs.tf` Comment-out outputs in the section `Specific to cloudbuild_module` -1. In the outputs file `./outputs.tf` un-comment outputs in the section `Specific to github_bootstrap` -1. Rename file `./cb.tf` to `./cb.tf.example` - - ```bash - mv ./cb.tf ./cb.tf.example - ``` - -1. Rename file `./github.tf.example` to `./github.tf` - +1. Run the helper script `choose_build_type.sh` to enable Bootstrap GitHub version ```bash - mv ./github.tf.example ./github.tf + ./scripts/choose_build_type.sh github ``` 1. Rename file `terraform.example.tfvars` to `terraform.tfvars` diff --git a/0-bootstrap/README-GitLab.md b/0-bootstrap/README-GitLab.md index 56adbb6c0..cd993df42 100644 --- a/0-bootstrap/README-GitLab.md +++ b/0-bootstrap/README-GitLab.md @@ -8,6 +8,8 @@ It is a best practice to have two separate projects here (`prj-b-seed` and `prj- On one hand, `prj-b-seed` stores terraform state and has the Service Accounts able to create / modify infrastructure. On the other hand, the authentication infrastructure using [Workload identity federation](https://cloud.google.com/iam/docs/workload-identity-federation) is implemented in `prj-b-cicd-wif-gl`. +## Requirements + To run the instructions described in this document, install the following: - [Google Cloud SDK](https://cloud.google.com/sdk/install) version 393.0.0 or later @@ -216,20 +218,9 @@ Run the `0-bootstrap/scripts/git_create_branches_helper.sh` script to create the cd ./envs/shared ``` -1. In the versions file `./versions.tf` un-comment the `gitlab` required provider -1. In the variables file `./variables.tf` un-comment variables in the section `Specific to gitlab_bootstrap` -1. In the outputs file `./outputs.tf` Comment-out outputs in the section `Specific to cloudbuild_module` -1. In the outputs file `./outputs.tf` un-comment outputs in the section `Specific to gitlab_bootstrap` -1. Rename file `./cb.tf` to `./cb.tf.example` - - ```bash - mv ./cb.tf ./cb.tf.example - ``` - -1. Rename file `./gitlab.tf.example` to `./gitlab.tf` - +1. Run the helper script `choose_build_type.sh` to enable Bootstrap GitLab version ```bash - mv ./gitlab.tf.example ./gitlab.tf + ./scripts/choose_build_type.sh gitlab ``` 1. Rename file `terraform.example.tfvars` to `terraform.tfvars` diff --git a/0-bootstrap/README-Jenkins.md b/0-bootstrap/README-Jenkins.md index 7d880bb68..cfe8e7236 100644 --- a/0-bootstrap/README-Jenkins.md +++ b/0-bootstrap/README-Jenkins.md @@ -139,22 +139,11 @@ You arrived to these instructions because you are using the `jenkins_bootstrap` cd ./envs/shared ``` -1. Activate the Jenkins module and disable the Cloud Build module. This implies manually editing the following files: - 1. Rename file `./cb.tf` to `./cb.tf.example` - - ```bash - mv ./cb.tf ./cb.tf.example - ``` - - 1. Rename file `./jenkins.tf.example` to `./jenkins.tf` - +1. Run the helper script `choose_build_type.sh` to enable Bootstrap Jenkins version ```bash - mv ./jenkins.tf.example ./jenkins.tf + ./scripts/choose_build_type.sh jenkins ``` - 1. Un-comment the `jenkins_bootstrap` variables in `./variables.tf` - 1. Un-comment the `jenkins_bootstrap` outputs in `./outputs.tf` - 1. Comment-out the `cloudbuild_bootstrap` outputs in `./outputs.tf` 1. Rename `terraform.example.tfvars` to `terraform.tfvars` and update the file with values from your environment. ```bash diff --git a/0-bootstrap/README-Terraform-Cloud.md b/0-bootstrap/README-Terraform-Cloud.md index 81bc4bf05..b5aa6a19d 100644 --- a/0-bootstrap/README-Terraform-Cloud.md +++ b/0-bootstrap/README-Terraform-Cloud.md @@ -136,21 +136,9 @@ You must be authenticated to the VCS provider. See [GitHub authentication](https cd ./envs/shared ``` -1. In the versions file `./versions.tf` un-comment the `tfe` required provider -1. In the variables file `./variables.tf` un-comment variables in the section `Specific to tfc_bootstrap` -1. In the outputs file `./outputs.tf` Comment-out outputs in the section `Specific to cloudbuild_module` -1. In the outputs file `./outputs.tf` un-comment outputs in the section `Specific to tfc_bootstrap` - 1. If you want to use [Terraform Cloud with Agents](https://developer.hashicorp.com/terraform/cloud-docs/agents), in addition to `Specific to tfc_bootstrap`, un-comment outputs in the section `Specific to tfc_bootstrap with Terraform Cloud Agents` and update `enable_tfc_cloud_agents` to `true` variable at `terraform.tfvars` -1. Rename file `./cb.tf` to `./cb.tf.example` - - ```bash - mv ./cb.tf ./cb.tf.example - ``` - -1. Rename file `.terraform_cloud.tf.example` to `./terraform_cloud.tf` - +1. Run the helper script `choose_build_type.sh` to enable Bootstrap Terraform Cloud version ```bash - mv ./terraform_cloud.tf.example ./terraform_cloud.tf + ./scripts/choose_build_type.sh terraform_cloud ``` 1. Rename file `terraform.example.tfvars` to `terraform.tfvars` diff --git a/0-bootstrap/cb.tf b/0-bootstrap/build_cb.tf similarity index 100% rename from 0-bootstrap/cb.tf rename to 0-bootstrap/build_cb.tf diff --git a/0-bootstrap/github.tf.example b/0-bootstrap/build_github.tf.example similarity index 100% rename from 0-bootstrap/github.tf.example rename to 0-bootstrap/build_github.tf.example diff --git a/0-bootstrap/gitlab.tf.example b/0-bootstrap/build_gitlab.tf.example similarity index 100% rename from 0-bootstrap/gitlab.tf.example rename to 0-bootstrap/build_gitlab.tf.example diff --git a/0-bootstrap/jenkins.tf.example b/0-bootstrap/build_jenkins.tf.example similarity index 100% rename from 0-bootstrap/jenkins.tf.example rename to 0-bootstrap/build_jenkins.tf.example diff --git a/0-bootstrap/terraform_cloud.tf.example b/0-bootstrap/build_terraform_cloud.tf.example similarity index 99% rename from 0-bootstrap/terraform_cloud.tf.example rename to 0-bootstrap/build_terraform_cloud.tf.example index 43aa54a2a..61a95ee89 100644 --- a/0-bootstrap/terraform_cloud.tf.example +++ b/0-bootstrap/build_terraform_cloud.tf.example @@ -304,7 +304,7 @@ module "tfc_agent_gke" { service_account_email = google_service_account.terraform-env-sa["bootstrap"].email service_account_id = google_service_account.terraform-env-sa["bootstrap"].id - //If you are using Terraform Cloud Agents, un-comment this block after the first apply according README instructions + //If you are using Terraform Cloud Agents, un-comment this block after the first apply according to README instructions # providers = { # kubernetes = kubernetes # } diff --git a/0-bootstrap/outputs.tf b/0-bootstrap/outputs.tf index 01e6fe09b..297811c79 100644 --- a/0-bootstrap/outputs.tf +++ b/0-bootstrap/outputs.tf @@ -80,152 +80,3 @@ output "optional_groups" { description = "List of Google Groups created that are optional to the Example Foundation steps." value = var.groups.create_optional_groups == false ? tomap(var.groups.optional_groups) : tomap({ for key, value in module.optional_group : key => value.id }) } - -/* ---------------------------------------- - Specific to cloudbuild_module - ---------------------------------------- */ -# Comment-out the cloudbuild_bootstrap module and its outputs if you want to use -# GitHub Actions, GitLab CI/CD, Terraform Cloud, or Jenkins instead of Cloud Build -output "cloudbuild_project_id" { - description = "Project where Cloud Build configuration and terraform container image will reside." - value = module.tf_source.cloudbuild_project_id -} - -output "gcs_bucket_cloudbuild_artifacts" { - description = "Bucket used to store Cloud Build artifacts in cicd project." - value = { for key, value in module.tf_workspace : key => replace(value.artifacts_bucket, local.bucket_self_link_prefix, "") } -} - -output "gcs_bucket_cloudbuild_logs" { - description = "Bucket used to store Cloud Build logs in cicd project." - value = { for key, value in module.tf_workspace : key => replace(value.logs_bucket, local.bucket_self_link_prefix, "") } -} - -output "cloud_builder_artifact_repo" { - description = "Artifact Registry (AR) Repository created to store TF Cloud Builder images." - value = "projects/${module.tf_source.cloudbuild_project_id}/locations/${var.default_region}/repositories/${module.tf_cloud_builder.artifact_repo}" -} - -output "csr_repos" { - description = "List of Cloud Source Repos created by the module, linked to Cloud Build triggers." - value = { for k, v in module.tf_source.csr_repos : k => { - "id" = v.id, - "name" = v.name, - "project" = v.project, - "url" = v.url, - } - } -} - -output "cloud_build_private_worker_pool_id" { - description = "ID of the Cloud Build private worker pool." - value = module.tf_private_pool.private_worker_pool_id -} - -output "cloud_build_worker_range_id" { - description = "The Cloud Build private worker IP range ID." - value = module.tf_private_pool.worker_range_id -} - -output "cloud_build_worker_peered_ip_range" { - description = "The IP range of the peered service network." - value = module.tf_private_pool.worker_peered_ip_range -} - -output "cloud_build_peered_network_id" { - description = "The ID of the Cloud Build peered network." - value = module.tf_private_pool.peered_network_id -} - -/* ---------------------------------------- - Specific to github_bootstrap - ---------------------------------------- */ -# Un-comment github_bootstrap and its outputs if you want to use GitHub Actions instead of Cloud Build -# output "cicd_project_id" { -# description = "Project where the CI/CD infrastructure for GitHub Action resides." -# value = module.gh_cicd.project_id -# } - -/* ---------------------------------------- - Specific to jenkins_bootstrap module - ---------------------------------------- */ -# # Un-comment the jenkins_bootstrap module and its outputs if you want to use Jenkins instead of Cloud Build -# output "cicd_project_id" { -# description = "Project where the [CI/CD Pipeline](/docs/GLOSSARY.md#foundation-cicd-pipeline) (Jenkins Agents and terraform builder container image) reside." -# value = module.jenkins_bootstrap.cicd_project_id -# } - -# output "jenkins_agent_gce_instance_id" { -# description = "Jenkins Agent GCE Instance id." -# value = module.jenkins_bootstrap.jenkins_agent_gce_instance_id -# } - -# output "jenkins_agent_vpc_id" { -# description = "Jenkins Agent VPC name." -# value = module.jenkins_bootstrap.jenkins_agent_vpc_id -# } - -# output "jenkins_agent_sa_email" { -# description = "Email for privileged custom service account for Jenkins Agent GCE instance." -# value = module.jenkins_bootstrap.jenkins_agent_sa_email -# } - -# output "jenkins_agent_sa_name" { -# description = "Fully qualified name for privileged custom service account for Jenkins Agent GCE instance." -# value = module.jenkins_bootstrap.jenkins_agent_sa_name -# } - -# output "gcs_bucket_jenkins_artifacts" { -# description = "Bucket used to store Jenkins artifacts in Jenkins project." -# value = module.jenkins_bootstrap.gcs_bucket_jenkins_artifacts -# } - -/* ---------------------------------------- - Specific to gitlab_bootstrap - ---------------------------------------- */ -# Un-comment gitlab_bootstrap and its outputs if you want to use GitLab CI/CD instead of Cloud Build -# output "cicd_project_id" { -# description = "Project where the CI/CD infrastructure for GitLab CI/CD resides." -# value = module.gitlab_cicd.project_id -# } - -/* ---------------------------------------- - Specific to tfc_bootstrap - ---------------------------------------- */ -# Un-comment tfc_bootstrap and its outputs if you want to use Terraform Cloud instead of Cloud Build -# output "cicd_project_id" { -# description = "Project where the CI/CD infrastructure for Terraform Cloud resides." -# value = module.tfc_cicd.project_id -# } -# -# output "tfc_org_name" { -# description = "Name of the TFC organization." -# value = var.tfc_org_name -# } - -/* ---------------------------------------- - Specific to tfc_bootstrap with Terraform Cloud Agents - ---------------------------------------- */ -# Un-comment if you want to use Terraform Cloud Agents -# (In other words, un-comment if you set enable_tfc_cloud_agents to true on .tfvars) - -# output "kubernetes_endpoint" { -# description = "The GKE cluster endpoint" -# sensitive = true -# value = module.tfc_agent_gke[0].kubernetes_endpoint -# } - -# output "service_account" { -# description = "The default service account used for TFC agent nodes" -# value = module.tfc_agent_gke[0].service_account -# } - -# output "cluster_name" { -# description = "GKE cluster name" -# value = module.tfc_agent_gke[0].cluster_name -# } - -# output "hub_cluster_membership_id" { -# value = module.tfc_agent_gke[0].hub_cluster_membership_id -# description = "The ID of the cluster membership" -# } diff --git a/0-bootstrap/outputs_cb.tf b/0-bootstrap/outputs_cb.tf new file mode 100644 index 000000000..2fa0be3ea --- /dev/null +++ b/0-bootstrap/outputs_cb.tf @@ -0,0 +1,69 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* ---------------------------------------- + Specific to cloudbuild_module + ---------------------------------------- */ +output "cloudbuild_project_id" { + description = "Project where Cloud Build configuration and terraform container image will reside." + value = module.tf_source.cloudbuild_project_id +} + +output "gcs_bucket_cloudbuild_artifacts" { + description = "Bucket used to store Cloud Build artifacts in cicd project." + value = { for key, value in module.tf_workspace : key => replace(value.artifacts_bucket, local.bucket_self_link_prefix, "") } +} + +output "gcs_bucket_cloudbuild_logs" { + description = "Bucket used to store Cloud Build logs in cicd project." + value = { for key, value in module.tf_workspace : key => replace(value.logs_bucket, local.bucket_self_link_prefix, "") } +} + +output "cloud_builder_artifact_repo" { + description = "Artifact Registry (AR) Repository created to store TF Cloud Builder images." + value = "projects/${module.tf_source.cloudbuild_project_id}/locations/${var.default_region}/repositories/${module.tf_cloud_builder.artifact_repo}" +} + +output "csr_repos" { + description = "List of Cloud Source Repos created by the module, linked to Cloud Build triggers." + value = { for k, v in module.tf_source.csr_repos : k => { + "id" = v.id, + "name" = v.name, + "project" = v.project, + "url" = v.url, + } + } +} + +output "cloud_build_private_worker_pool_id" { + description = "ID of the Cloud Build private worker pool." + value = module.tf_private_pool.private_worker_pool_id +} + +output "cloud_build_worker_range_id" { + description = "The Cloud Build private worker IP range ID." + value = module.tf_private_pool.worker_range_id +} + +output "cloud_build_worker_peered_ip_range" { + description = "The IP range of the peered service network." + value = module.tf_private_pool.worker_peered_ip_range +} + +output "cloud_build_peered_network_id" { + description = "The ID of the Cloud Build peered network." + value = module.tf_private_pool.peered_network_id +} diff --git a/0-bootstrap/outputs_github.tf.example b/0-bootstrap/outputs_github.tf.example new file mode 100644 index 000000000..817fb482a --- /dev/null +++ b/0-bootstrap/outputs_github.tf.example @@ -0,0 +1,23 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* ---------------------------------------- + Specific to github_bootstrap + ---------------------------------------- */ +output "cicd_project_id" { + description = "Project where the CI/CD infrastructure for GitHub Action resides." + value = module.gh_cicd.project_id +} diff --git a/0-bootstrap/outputs_gitlab.tf.example b/0-bootstrap/outputs_gitlab.tf.example new file mode 100644 index 000000000..0768500bc --- /dev/null +++ b/0-bootstrap/outputs_gitlab.tf.example @@ -0,0 +1,23 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* ---------------------------------------- + Specific to gitlab_bootstrap + ---------------------------------------- */ +output "cicd_project_id" { + description = "Project where the CI/CD infrastructure for GitLab CI/CD resides." + value = module.gitlab_cicd.project_id +} diff --git a/0-bootstrap/outputs_jenkins.tf.example b/0-bootstrap/outputs_jenkins.tf.example new file mode 100644 index 000000000..66d00d9f5 --- /dev/null +++ b/0-bootstrap/outputs_jenkins.tf.example @@ -0,0 +1,49 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +/* ---------------------------------------- + Specific to jenkins_bootstrap module + ---------------------------------------- */ +output "cicd_project_id" { + description = "Project where the [CI/CD Pipeline](/docs/GLOSSARY.md#foundation-cicd-pipeline) (Jenkins Agents and terraform builder container image) reside." + value = module.jenkins_bootstrap.cicd_project_id +} + +output "jenkins_agent_gce_instance_id" { + description = "Jenkins Agent GCE Instance id." + value = module.jenkins_bootstrap.jenkins_agent_gce_instance_id +} + +output "jenkins_agent_vpc_id" { + description = "Jenkins Agent VPC name." + value = module.jenkins_bootstrap.jenkins_agent_vpc_id +} + +output "jenkins_agent_sa_email" { + description = "Email for privileged custom service account for Jenkins Agent GCE instance." + value = module.jenkins_bootstrap.jenkins_agent_sa_email +} + +output "jenkins_agent_sa_name" { + description = "Fully qualified name for privileged custom service account for Jenkins Agent GCE instance." + value = module.jenkins_bootstrap.jenkins_agent_sa_name +} + +output "gcs_bucket_jenkins_artifacts" { + description = "Bucket used to store Jenkins artifacts in Jenkins project." + value = module.jenkins_bootstrap.gcs_bucket_jenkins_artifacts +} diff --git a/0-bootstrap/outputs_terraform_cloud.tf.example b/0-bootstrap/outputs_terraform_cloud.tf.example new file mode 100644 index 000000000..a37ebcc5a --- /dev/null +++ b/0-bootstrap/outputs_terraform_cloud.tf.example @@ -0,0 +1,56 @@ +/** + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* ---------------------------------------- + Specific to tfc_bootstrap + ---------------------------------------- */ +# Un-comment tfc_bootstrap and its outputs if you want to use Terraform Cloud instead of Cloud Build +output "cicd_project_id" { + description = "Project where the CI/CD infrastructure for Terraform Cloud resides." + value = module.tfc_cicd.project_id +} + +output "tfc_org_name" { + description = "Name of the TFC organization." + value = var.tfc_org_name +} + +/* ---------------------------------------- + Specific to tfc_bootstrap with Terraform Cloud Agents + ---------------------------------------- */ +# Un-comment if you want to use Terraform Cloud Agents +# (In other words, un-comment if you set enable_tfc_cloud_agents to true on .tfvars) + +# output "kubernetes_endpoint" { +# description = "The GKE cluster endpoint" +# sensitive = true +# value = module.tfc_agent_gke[0].kubernetes_endpoint +# } + +# output "service_account" { +# description = "The default service account used for TFC agent nodes" +# value = module.tfc_agent_gke[0].service_account +# } + +# output "cluster_name" { +# description = "GKE cluster name" +# value = module.tfc_agent_gke[0].cluster_name +# } + +# output "hub_cluster_membership_id" { +# value = module.tfc_agent_gke[0].hub_cluster_membership_id +# description = "The ID of the cluster membership" +# } diff --git a/0-bootstrap/scripts/choose_build_type.sh b/0-bootstrap/scripts/choose_build_type.sh new file mode 100755 index 000000000..9460c2a46 --- /dev/null +++ b/0-bootstrap/scripts/choose_build_type.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Define the base path where the build type files are +SCRIPTS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +BASE_PATH="$SCRIPTS_DIR/.." +TARGET_BUILD="$1" + +# Define the allowed build types for validation +build_types=("cb" "github" "gitlab" "jenkins" "terraform_cloud") + +# Validate the build_type input +if [[ ! " ${build_types[*]} " == *" ${TARGET_BUILD} "* ]]; then + echo "Error: Invalid build type '$TARGET_BUILD'. Must be one of: ${build_types[*]}" + exit 1 +fi + +# Rename *_cb.tf files to *_cb.tf.example if BUILD_TYPE is not "cb" +if [ "$TARGET_BUILD" != "cb" ]; then + find "$BASE_PATH" -name "*_cb.tf" -print0 | while IFS= read -r -d $'\0' file; do + new_name="${file}.example" + echo "Renaming \"$file\" to \"$new_name\"" + mv "$file" "$new_name" + done +fi + +# Rename *_BUILD_TYPE.tf.example to *_BUILD_TYPE.tf if they exist +find "$BASE_PATH" -name "*_$TARGET_BUILD.tf.example" -print0 | while IFS= read -r -d $'\0' file; do + # Extract the base name without the .example extension + base_name="${file%.tf.example}" + new_name="$base_name.tf" + + echo "Renaming \"$file\" to \"$new_name\"" + mv "$file" "$new_name" +done + +echo "File renaming complete." diff --git a/0-bootstrap/terraform.example.tfvars b/0-bootstrap/terraform.example.tfvars index 9b85c95fa..c2b6b3871 100644 --- a/0-bootstrap/terraform.example.tfvars +++ b/0-bootstrap/terraform.example.tfvars @@ -68,7 +68,7 @@ default_region_kms = "us" # to prevent saving the `gh_token` in plain text in this file, # export the GitHub fine grained access token in the command line # as an environment variable before running terraform. -# Run the following commnad in your shell: +# Run the following command in your shell: # export TF_VAR_gh_token="YOUR-FINE-GRAINED-ACCESS-TOKEN" diff --git a/0-bootstrap/variables.tf b/0-bootstrap/variables.tf index a2ceeded4..a860fece2 100644 --- a/0-bootstrap/variables.tf +++ b/0-bootstrap/variables.tf @@ -170,218 +170,4 @@ variable "initial_group_config" { default = "WITH_INITIAL_OWNER" } -/* ---------------------------------------- - Specific to github_bootstrap - ---------------------------------------- */ - -# Un-comment github_bootstrap and its outputs if you want to use GitHub Actions instead of Cloud Build -# variable "gh_repos" { -# description = < "${SAVE_PATH}"/.ci_job_token_file -# TODO +# Exchange the OIDC token for a Google Cloud credential. gcloud iam workload-identity-pools \ create-cred-config "${WIF_PROVIDER}" \ --service-account="${SA}" \ --output-file="${SAVE_PATH}"/.gcp_generated_credentials.json \ --credential-source-file="${SAVE_PATH}"/.ci_job_token_file \ -# TODO +# Authenticate using the generated credential. gcloud auth login --cred-file="${SAVE_PATH}"/.gcp_generated_credentials.json --update-adc diff --git a/helpers/foundation-deployer/README.md b/helpers/foundation-deployer/README.md index c5a9b042c..d0f3a35f6 100644 --- a/helpers/foundation-deployer/README.md +++ b/helpers/foundation-deployer/README.md @@ -16,7 +16,7 @@ Helper tool to deploy the Terraform example foundation using Cloud Build and Clo Your environment need to use the same [Terraform](https://www.terraform.io/downloads.html) version used on the build pipeline. Otherwise, you might experience Terraform state snapshot lock errors. -Version 1.5.7 is the last version before the license model change. To use a later version of Terraform, ensure that the Terraform version used in the Operational System to manually execute part of the steps in `3-networks` and `4-projects` is the same version configured in the following code +Version 1.5.7 is the last version before the license model change. To use a later version of Terraform, ensure that the Terraform version used in the Operating System to manually execute part of the steps in `3-networks` and `4-projects` is the same version configured in the following code - 0-bootstrap/modules/jenkins-agent/variables.tf ``` @@ -73,7 +73,7 @@ Version 1.5.7 is the last version before the license model change. To use a late ### Prepare the deploy environment -- Create a directory in the file system to host the Cloud Source repositories the will be created and a copy of the terraform example foundation. +- Create a directory in the file system to host the Git Repositories that will be created (Cloud Source repositories, Github, or GitLab) and a copy of the terraform example foundation repository. - Clone the `terraform-example-foundation` repository on this directory. ```text @@ -106,10 +106,6 @@ Version 1.5.7 is the last version before the license model change. To use a late By default the foundation regional resources are deployed in `us-west1` and `us-central1` regions and multi-regional resources are deployed in the `US` multi-region. -In addition to the variables declared in the file `global.tfvars` for configuring location, there are two locals, `default_region1` and `default_region2`, in each one of the environments (`production`, `nonproduction`, and `development`) in the network steps (`3-networks-svpc` and `3-networks-hub-and-spoke`). -They are located in the [main.tf](../../3-networks-svpc/envs/production/main.tf#L20-L21) files for each environments. -Change the two locals **before** starting the deployment to deploy in other regions. - **Note:** the region used for the variable `default_region` in the file `global.tfvars` **MUST** be one of the regions used for the `default_region1` and `default_region2` locals. ### Application default credentials diff --git a/helpers/foundation-deployer/gcp/gcp.go b/helpers/foundation-deployer/gcp/gcp.go index 0ba440be6..ba86202cb 100644 --- a/helpers/foundation-deployer/gcp/gcp.go +++ b/helpers/foundation-deployer/gcp/gcp.go @@ -18,7 +18,6 @@ import ( "context" "encoding/json" "fmt" - "regexp" "strings" "time" @@ -27,6 +26,8 @@ import ( "github.com/mitchellh/go-testing-interface" "github.com/tidwall/gjson" + localutil "github.com/terraform-google-modules/terraform-example-foundation/helpers/foundation-deployer/utils" + "github.com/terraform-google-modules/terraform-example-foundation/test/integration/testutils" "google.golang.org/api/cloudbuild/v1" @@ -51,21 +52,6 @@ type Build struct { CreateTime string `json:"createTime"` } -var ( - retryRegexp = map[*regexp.Regexp]string{} - // ctx = context.Background() -) - -func init() { - for e, m := range testutils.RetryableTransientErrors { - r, err := regexp.Compile(fmt.Sprintf("(?s)%s", e)) //(?s) enables dot (.) to match newline. - if err != nil { - panic(fmt.Sprintf("failed to compile regex %s: %s", e, err.Error())) - } - retryRegexp[r] = m - } -} - type GCP struct { Runf func(t testing.TB, cmd string, args ...interface{}) gjson.Result RunCmd func(t testing.TB, cmd string, args ...interface{}) string @@ -210,7 +196,8 @@ func (g GCP) WaitBuildSuccess(t testing.TB, project, region, repo, commitSha, fa } if status != StatusSuccess { - if !g.IsRetryableError(t, project, region, build) { + logs := g.GetBuildLogs(t, project, region, build) + if !localutil.IsRetryableError(t, logs) { return fmt.Errorf("%s\nSee:\nhttps://console.cloud.google.com/cloud-build/builds;region=%s/%s?project=%s\nfor details", failureMsg, region, build, project) } fmt.Println("build failed with retryable error. a new build will be triggered.") @@ -231,21 +218,6 @@ func (g GCP) WaitBuildSuccess(t testing.TB, project, region, repo, commitSha, fa return fmt.Errorf("%s\nbuild failed after %d retries.\nSee Cloud Build logs for details", failureMsg, maxErrorRetries) } -// IsRetryableError checks the logs of a failed Cloud Build build -// and verify if the error is a transient one and can be retried -func (g GCP) IsRetryableError(t testing.TB, projectID, region, build string) bool { - logs := g.GetBuildLogs(t, projectID, region, build) - found := false - for pattern, msg := range retryRegexp { - if pattern.MatchString(logs) { - found = true - fmt.Printf("error '%s' is worth of a retry\n", msg) - break - } - } - return found -} - // HasSccNotification checks if a Security Command Center notification exists func (g GCP) HasSccNotification(t testing.TB, orgID, sccName string) bool { filter := fmt.Sprintf("name=organizations/%s/locations/global/notificationConfigs/%s", orgID, sccName) diff --git a/helpers/foundation-deployer/github/github.go b/helpers/foundation-deployer/github/github.go new file mode 100644 index 000000000..fdd97bef7 --- /dev/null +++ b/helpers/foundation-deployer/github/github.go @@ -0,0 +1,314 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package github + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/google/go-github/v58/github" + "github.com/mitchellh/go-testing-interface" + + "github.com/terraform-google-modules/terraform-example-foundation/helpers/foundation-deployer/utils" + + "golang.org/x/oauth2" +) + +const ( + StatusQueued = "queued" + StatusPending = "pending" + StatusWaiting = "waiting" + StatusWorking = "in_progress" + statusCompleted = "completed" + StatusSuccess = "success" + StatusFailure = "failure" + StatusCancelled = "cancelled" + StatusTimeout = "timed_out" + StatusNeutral = "neutral" + StatusSkipped = "skipped" + StatusActionRequired = "action_required" +) + +type GH struct { + TriggerNewBuild func(t testing.TB, ctx context.Context, owner, repo, token, commitSha string, runID int64) (int64, string, string, error) + sleepTime time.Duration +} + +// NewGH creates a new wrapper for The GitHub API +func NewGH() GH { + return GH{ + TriggerNewBuild: triggerNewBuild, + sleepTime: 20, + } +} + +// triggerNewBuild triggers a new action execution +func triggerNewBuild(t testing.TB, ctx context.Context, owner, repo, token, commitSha string, runID int64) (int64, string, string, error) { + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + tc := oauth2.NewClient(ctx, ts) + client := github.NewClient(tc) + + resp, err := client.Actions.RerunWorkflowByID(ctx, owner, repo, runID) + if err != nil { + return 0, "", "", fmt.Errorf("error re-running workflow: %v", err) + } + if resp.StatusCode != http.StatusCreated { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return 0, "", "", fmt.Errorf("error re-running workflow status: %d body parsing error: %v", resp.StatusCode, err) + } + return 0, "", "", fmt.Errorf("error re-running workflow status: %d body: %s", resp.StatusCode, string(bodyBytes)) + } + + opts := &github.ListWorkflowRunsOptions{ + HeadSHA: commitSha, + ListOptions: github.ListOptions{ + PerPage: 1, + Page: 1, + }, + } + + // wait for the new workflow run to be created and appear + // in the API results after re-running it + // needed because RerunWorkflowByID dos no return + // the ID of the new workflow + time.Sleep(30 * time.Second) + + runs, _, err := client.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, opts) + + if err != nil { + return 0, "", "", fmt.Errorf("error listing workflow runs: %v", err) + } + + if len(runs.WorkflowRuns) == 0 { + return 0, "", "", fmt.Errorf("no workflow runs found for repo %s/%s", owner, repo) + } + + var newRunID int64 + if runs.WorkflowRuns[0].ID != nil { + newRunID = *runs.WorkflowRuns[0].ID + } + + var status string + if runs.WorkflowRuns[0].Status != nil { + status = *runs.WorkflowRuns[0].Status + } + + var conclusion string + if runs.WorkflowRuns[0].Conclusion != nil { + conclusion = *runs.WorkflowRuns[0].Conclusion + } + + return newRunID, status, conclusion, nil +} + +// GetLastActionState returns the state of the latest action +func (g GH) GetLastActionState(t testing.TB, ctx context.Context, owner, repo, token, commitSha string) (int64, string, string, error) { + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + tc := oauth2.NewClient(ctx, ts) + client := github.NewClient(tc) + + opts := &github.ListWorkflowRunsOptions{ + HeadSHA: commitSha, + ListOptions: github.ListOptions{ + PerPage: 1, + }, + } + + runs, _, err := client.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, opts) + + if err != nil { + return 0, "", "", fmt.Errorf("error listing workflow runs: %v", err) + } + + if len(runs.WorkflowRuns) == 0 { + return 0, "", "", fmt.Errorf("no action workflow found for repo: %s/%s", owner, repo) + } + + var runID int64 + var status string + var conclusion string + + if runs.WorkflowRuns[0].ID != nil { + runID = *runs.WorkflowRuns[0].ID + } + if runs.WorkflowRuns[0].Status != nil { + status = *runs.WorkflowRuns[0].Status + } + if runs.WorkflowRuns[0].Conclusion != nil { + conclusion = *runs.WorkflowRuns[0].Conclusion + } + + return runID, status, conclusion, nil +} + +// GetActionState returns the state of a given action +func (g GH) GetActionState(t testing.TB, ctx context.Context, owner, repo, token string, runID int64) (string, string, error) { + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + tc := oauth2.NewClient(ctx, ts) + client := github.NewClient(tc) + + run, _, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, runID) + if err != nil { + return "", "", fmt.Errorf("error getting workflow run: %v", err) + } + + var status string + if run.Status != nil { + status = *run.Status + } + + var conclusion string + if run.Conclusion != nil { + conclusion = *run.Conclusion + } + return status, conclusion, nil +} + +// GetBuildLogs returns the execution logs of an action +func (g GH) GetBuildLogs(t testing.TB, ctx context.Context, owner, repo, token string, runID int64) (string, error) { + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + tc := oauth2.NewClient(ctx, ts) + client := github.NewClient(tc) + + listJobsOpts := &github.ListWorkflowJobsOptions{ + ListOptions: github.ListOptions{PerPage: 10}, + } + jobs, _, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, listJobsOpts) + if err != nil { + return "", fmt.Errorf("error listing jobs for run %d: %v", runID, err) + } + + for _, job := range jobs.Jobs { + if job.Status == nil || job.Conclusion == nil { + continue + } + + if *job.Status == statusCompleted && *job.Conclusion == StatusFailure { + jobID := *job.ID + + logURL, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 3) + if err != nil { + return "", fmt.Errorf("error: Could not get log URL for job %d: %v", jobID, err) + } + if resp.StatusCode != http.StatusFound { + return "", fmt.Errorf("error: Expected a 302 redirect for job logs, but got %s", resp.Status) + } + + logContentResp, err := http.Get(logURL.String()) + if err != nil { + return "", fmt.Errorf("error: Could not download logs from %s: %v", logURL, err) + } + + defer func() { + err := logContentResp.Body.Close() + if err != nil { + fmt.Fprintf(os.Stderr, "error closing execution log file: %s", err) + } + }() + + if logContentResp.StatusCode != http.StatusOK { + return "", fmt.Errorf("error: Expected status 200 OK from log URL, but got %s", logContentResp.Status) + } + + bodyBytes, err := io.ReadAll(logContentResp.Body) + if err != nil { + return "", fmt.Errorf("error reading response body: %v", err) + } + return string(bodyBytes), nil + } + } + return "", nil +} + +// GetFinalActionState returns the final state of an action +func (g GH) GetFinalActionState(t testing.TB, ctx context.Context, owner, repo, token string, runID int64, maxBuildRetry int) (string, string, error) { + var status, conclusion string + var err error + count := 0 + fmt.Printf("waiting for action %d execution.\n", runID) + status, conclusion, err = g.GetActionState(t, ctx, owner, repo, token, runID) + if err != nil { + return "", "", err + } + fmt.Printf("action status is %s\n", status) + for status != statusCompleted { + fmt.Printf("action status is %s\n", status) + if count >= maxBuildRetry { + return "", "", fmt.Errorf("timeout waiting for action '%d' execution", runID) + } + count = count + 1 + time.Sleep(g.sleepTime * time.Second) + status, conclusion, err = g.GetActionState(t, ctx, owner, repo, token, runID) + if err != nil { + return "", "", err + } + } + fmt.Printf("final action state is %s\n", conclusion) + return status, conclusion, nil +} + +// WaitBuildSuccess waits for the current build in a repo to finish. +func (g GH) WaitBuildSuccess(t testing.TB, owner, repo, token, commitSha, failureMsg string, maxBuildRetry, maxErrorRetries int, timeBetweenErrorRetries time.Duration) error { + var status, conclusion string + var runID int64 + var err error + + // wait for the new workflow and action to be created and appear in the API results + // after the code being pushed to the repository + time.Sleep(30 * time.Second) + + ctx := context.Background() + runID, status, conclusion, err = g.GetLastActionState(t, ctx, owner, repo, token, commitSha) + if err != nil { + return err + } + for i := 0; i < maxErrorRetries; i++ { + if status != statusCompleted { + _, conclusion, err = g.GetFinalActionState(t, ctx, owner, repo, token, runID, maxBuildRetry) + if err != nil { + return err + } + } + + if conclusion != StatusSuccess { + logs, err := g.GetBuildLogs(t, ctx, owner, repo, token, runID) + if err != nil { + return err + } + if !utils.IsRetryableError(t, logs) { + return fmt.Errorf("%s\nSee:\nhttps://github.com/%s/%s/actions/runs/%d\nfor details", failureMsg, owner, repo, runID) + } + fmt.Println("build failed with retryable error. a new build will be triggered.") + } else { + return nil // Build succeeded + } + + // Trigger a new build + runID, status, conclusion, err = g.TriggerNewBuild(t, ctx, owner, repo, token, commitSha, runID) + if err != nil { + return fmt.Errorf("failed to trigger new action (attempt %d/%d): %w", i+1, maxErrorRetries, err) + } + fmt.Printf("triggered new action with ID: %d (attempt %d/%d)\n", runID, i+1, maxErrorRetries) + if i < maxErrorRetries-1 { + time.Sleep(timeBetweenErrorRetries) // Wait before retrying + } + } + return fmt.Errorf("%s action failed after %d retries", failureMsg, maxErrorRetries) +} diff --git a/helpers/foundation-deployer/gitlab/gitlab.go b/helpers/foundation-deployer/gitlab/gitlab.go new file mode 100644 index 000000000..8027656cc --- /dev/null +++ b/helpers/foundation-deployer/gitlab/gitlab.go @@ -0,0 +1,301 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gitlab + +import ( + "context" + "fmt" + "io" + "net/http" + "time" + + "github.com/mitchellh/go-testing-interface" + "github.com/terraform-google-modules/terraform-example-foundation/helpers/foundation-deployer/utils" + + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +const ( + StatusCreated = "created" + StatusManual = "manual" + StatusPending = "pending" + StatusPreparing = "preparing" + StatusSkipped = "skipped" + StatusWaitingForResource = "waiting_for_resource" + StatusScheduled = "scheduled" + StatusRunning = "running" + StatusSuccess = "success" + StatusFailed = "failed" + StatusCancelled = "canceled" + StatusCanceling = "canceling" +) + +type GL struct { + TriggerNewBuild func(t testing.TB, ctx context.Context, owner, project, token string, jobID int) (int, error) + sleepTime time.Duration +} + +// NewGL creates a new GitLab wrapper for The GitLab API +func NewGL() GL { + return GL{ + TriggerNewBuild: triggerNewBuild, + sleepTime: 20, + } +} + +// triggerNewBuild triggers a new job execution +func triggerNewBuild(t testing.TB, ctx context.Context, owner, project, token string, jobID int) (int, error) { + + git, err := gitlab.NewClient(token) + if err != nil { + return 0, fmt.Errorf("failed to create client: %v", err) + } + + job, resp, err := git.Jobs.RetryJob(fmt.Sprintf("%s/%s", owner, project), jobID, gitlab.WithContext(ctx)) + if err != nil { + return 0, fmt.Errorf("error retrying job: %v", err) + } + if resp.StatusCode >= http.StatusBadRequest && resp.StatusCode <= http.StatusNetworkAuthenticationRequired { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return 0, fmt.Errorf("error Status: %d, failed to read response body: %v", resp.StatusCode, err) + } + return 0, fmt.Errorf("error Status: %d\n body: %s", resp.StatusCode, string(bodyBytes)) + } + return job.ID, nil +} + +// GetLastJobStatusForSHA finds the latest job associated with a specific commit SHA +func (g GL) GetLastJobStatusForSHA(t testing.TB, ctx context.Context, owner, project, token, sha string) (string, int, error) { + git, err := gitlab.NewClient(token) + if err != nil { + return "", 0, fmt.Errorf("failed to create client: %v", err) + } + + projectPath := fmt.Sprintf("%s/%s", owner, project) + + pipelineOpts := &gitlab.ListProjectPipelinesOptions{ + ListOptions: gitlab.ListOptions{ + PerPage: 1, + Page: 1, + OrderBy: "id", + Sort: "desc", + }, + SHA: &sha, + } + + pipelines, _, err := git.Pipelines.ListProjectPipelines(projectPath, pipelineOpts, gitlab.WithContext(ctx)) + if err != nil { + return "", 0, fmt.Errorf("error listing pipelines for SHA %s: %w", sha, err) + } + + if len(pipelines) == 0 { + return "", 0, fmt.Errorf("no pipelines found for project '%s' at SHA '%s'", projectPath, sha) + } + + latestPipelineID := pipelines[0].ID + includeRetried := true + jobOpts := &gitlab.ListJobsOptions{ + ListOptions: gitlab.ListOptions{ + PerPage: 1, + Page: 1, + OrderBy: "id", + Sort: "desc", + }, + IncludeRetried: &includeRetried, + } + + jobs, _, err := git.Jobs.ListPipelineJobs(projectPath, latestPipelineID, jobOpts, gitlab.WithContext(ctx)) + if err != nil { + return "", 0, fmt.Errorf("error listing jobs for pipeline %d: %w", latestPipelineID, err) + } + + if len(jobs) == 0 { + return "", 0, fmt.Errorf("no jobs found for pipeline %d", latestPipelineID) + } + + return jobs[0].Status, jobs[0].ID, nil +} + +// GetJobLogs returns the execution logs of a given job +func (g GL) GetJobLogs(t testing.TB, ctx context.Context, owner, project, token string, jobID int) (string, error) { + git, err := gitlab.NewClient(token) + if err != nil { + return "", fmt.Errorf("failed to create client: %v", err) + } + + reader, resp, err := git.Jobs.GetTraceFile(fmt.Sprintf("%s/%s", owner, project), jobID) + if err != nil { + return "", fmt.Errorf("error getting job trace file: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("expected status 200 OK, but got %s for job %d trace", resp.Status, jobID) + } + + logBytes, err := io.ReadAll(reader) + if err != nil { + return "", fmt.Errorf("failed to read job %d trace from bytes.Reader: %v", jobID, err) + } + + return string(logBytes), nil +} + +// GetJobStatus returns the status of a given gob +func (g GL) GetJobStatus(t testing.TB, ctx context.Context, owner, project, token string, jobID int) (string, error) { + git, err := gitlab.NewClient(token) + if err != nil { + return "", fmt.Errorf("failed to create client: %v", err) + } + + job, _, err := git.Jobs.GetJob(fmt.Sprintf("%s/%s", owner, project), jobID, gitlab.WithContext(ctx)) + if err != nil { + return "", fmt.Errorf("error retrying job: %v", err) + } + + return job.Status, nil +} + +// GetFinalJobStatus return the final state of a running job +func (g GL) GetFinalJobStatus(t testing.TB, ctx context.Context, owner, project, token string, jobID int, maxBuildRetry int) (string, error) { + var status string + var err error + count := 0 + fmt.Printf("waiting for job %d execution.\n", jobID) + + status, err = g.GetJobStatus(t, ctx, owner, project, token, jobID) + if err != nil { + return "", err + } + fmt.Printf("job status is %s\n", status) + for status != StatusSuccess && status != StatusFailed && status != StatusCancelled { + fmt.Printf("job status is %s\n", status) + if count >= maxBuildRetry { + return "", fmt.Errorf("timeout waiting for job '%d' execution", jobID) + } + count = count + 1 + time.Sleep(g.sleepTime * time.Second) + status, err = g.GetJobStatus(t, ctx, owner, project, token, jobID) + if err != nil { + return "", err + } + } + fmt.Printf("final job state is %s\n", status) + return status, nil +} + +// WaitBuildSuccess waits for the current job in a project to finish. +func (g GL) WaitBuildSuccess(t testing.TB, owner, project, token, commitSha, failureMsg string, maxBuildRetry, maxErrorRetries int, timeBetweenErrorRetries time.Duration) error { + var status string + var jobID int + var err error + + // wait for the new job to be created and appear in the API results + // after the code being pushed to the project + time.Sleep(30 * time.Second) + + ctx := context.Background() + status, jobID, err = g.GetLastJobStatusForSHA(t, ctx, owner, project, token, commitSha) + if err != nil { + return err + } + for i := 0; i < maxErrorRetries; i++ { + if status != StatusSuccess && status != StatusFailed && status != StatusCancelled { + status, err = g.GetFinalJobStatus(t, ctx, owner, project, token, jobID, maxBuildRetry) + if err != nil { + return err + } + } + + if status != StatusSuccess { + logs, err := g.GetJobLogs(t, ctx, owner, project, token, jobID) + if err != nil { + return err + } + if !utils.IsRetryableError(t, logs) { + return fmt.Errorf("%s\nSee:\nhttps://gitlab.com/%s/%s/-/jobs/%d\nfor details", failureMsg, owner, project, jobID) + } + fmt.Println("job failed with retryable error. a new job will be triggered.") + } else { + return nil // job succeeded + } + + // Trigger a new build + jobID, err = g.TriggerNewBuild(t, ctx, owner, project, token, jobID) + if err != nil { + return fmt.Errorf("failed to trigger new job (attempt %d/%d): %w", i+1, maxErrorRetries, err) + } + fmt.Printf("triggered new job with ID: %d (attempt %d/%d)\n", jobID, i+1, maxErrorRetries) + if i < maxErrorRetries-1 { + time.Sleep(timeBetweenErrorRetries) // Wait before retrying + } + } + return fmt.Errorf("%s job failed after %d retries", failureMsg, maxErrorRetries) +} + +// AddProjectsToJobTokenScope adds a list of projects to the token scope of a project that host the runner image +func (g GL) AddProjectsToJobTokenScope(t testing.TB, owner, cicdProject, token string, repos []string) error { + ctx := context.Background() + git, err := gitlab.NewClient(token) + if err != nil { + return fmt.Errorf("failed to create client: %v", err) + } + runnerProjectPath := fmt.Sprintf("%s/%s", owner, cicdProject) + + projectsToAdd := make([]string, len(repos)) + for i, repo := range repos { + projectsToAdd[i] = fmt.Sprintf("%s/%s", owner, repo) + } + + runnerProjectID, err := getProjectIDByPath(git, ctx, runnerProjectPath) + if err != nil { + return fmt.Errorf("could not get the project ID for the runner repository. Aborting. Error: %v", err) + } + for _, targetProjectPath := range projectsToAdd { + + targetProjectID, err := getProjectIDByPath(git, ctx, targetProjectPath) + if err != nil { + return fmt.Errorf("could not find project ID. Error: %v", err) + } + + opts := &gitlab.JobTokenInboundAllowOptions{ + TargetProjectID: &targetProjectID, + } + + _, resp, err := git.JobTokenScope.AddProjectToJobScopeAllowList(runnerProjectID, opts, gitlab.WithContext(ctx)) + if err != nil { + // GitLab often returns a 409 Conflict if the project is already in the list. + if resp != nil && resp.StatusCode != http.StatusConflict { + return fmt.Errorf("failed to add project. Status: %s. Error: %v", resp.Status, err) + } + continue + } + + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("done (Status: %s)", resp.Status) + } + } + + return nil +} + +// getProjectIDByPath returns the ID or a project given its path +func getProjectIDByPath(git *gitlab.Client, ctx context.Context, projectPath string) (int, error) { + project, _, err := git.Projects.GetProject(projectPath, nil, gitlab.WithContext(ctx)) + if err != nil { + return 0, fmt.Errorf("failed to get project '%s': %w", projectPath, err) + } + return project.ID, nil +} diff --git a/helpers/foundation-deployer/global.tfvars.example b/helpers/foundation-deployer/global.tfvars.example index 4ed9169f0..da9d2e720 100644 --- a/helpers/foundation-deployer/global.tfvars.example +++ b/helpers/foundation-deployer/global.tfvars.example @@ -30,6 +30,9 @@ validator_project_id = "EXISTING_PROJECT_ID" project_deletion_policy = "PREVENT" # Use "DELETE" to allow deletion of the projects folder_deletion_protection = true +// build type to use. One of "cb", "github", "gitlab" +build_type = "cb" + // 0-bootstrap inputs // https://github.com/terraform-google-modules/terraform-example-foundation/blob/master/0-bootstrap/README.md#inputs @@ -82,6 +85,31 @@ groups = { } } +// Uncomment for GitHub or GitLab deploy + +// git_repos = { +// owner = "REPO_OWNER" +// bootstrap = "BOOTSTRAP_REPO" +// organization = "ORGANIZATION_REPO" +// environments = "ENVIRONMENTS_REPO" +// networks = "NETWORKS_REPO" +// projects = "PROJECTS_REPO" +// cicd_runner = "CICD_RUNNER_REPO" // Required for GitLab +// } + +// to prevent saving the git token in plain text in this file, +// export the token in the command line +// as an environment variable before running this helper. +// Run the following command in your shell: + +// export GIT_TOKEN="YOUR-ACCESS-TOKEN" + +// See: +// GitHub: https://github.com/terraform-google-modules/terraform-example-foundation/blob/main/0-bootstrap/README-GitHub.md#requirements +// GitLab: https://github.com/terraform-google-modules/terraform-example-foundation/blob/main/0-bootstrap/README-GitLab.md#requirements + + + // 1-org inputs // https://github.com/terraform-google-modules/terraform-example-foundation/blob/master/1-org/envs/shared/README.md#inputs diff --git a/helpers/foundation-deployer/go.mod b/helpers/foundation-deployer/go.mod index 1eacdb529..00e389fe1 100644 --- a/helpers/foundation-deployer/go.mod +++ b/helpers/foundation-deployer/go.mod @@ -9,12 +9,20 @@ require ( github.com/gruntwork-io/terratest v0.48.1 github.com/hashicorp/hcl/v2 v2.23.0 github.com/mitchellh/go-testing-interface v1.14.2-0.20210821155943-2d9075ca8770 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/terraform-google-modules/terraform-example-foundation/test/integration v0.0.0-20240808135927-5f1fd0f4104a github.com/tidwall/gjson v1.18.0 google.golang.org/api v0.206.0 ) +require ( + github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + gitlab.com/gitlab-org/api/client-go v0.158.0 // indirect + golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect + golang.org/x/time v0.12.0 // indirect +) + require ( cloud.google.com/go/auth v0.10.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.5 // indirect @@ -27,7 +35,8 @@ require ( github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-github/v58 v58.0.0 github.com/google/s2a-go v0.1.8 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect @@ -58,17 +67,17 @@ require ( go.opentelemetry.io/otel v1.29.0 // indirect go.opentelemetry.io/otel/metric v1.29.0 // indirect go.opentelemetry.io/otel/trace v1.29.0 // indirect - golang.org/x/crypto v0.36.0 // indirect - golang.org/x/mod v0.22.0 // indirect - golang.org/x/net v0.38.0 // indirect - golang.org/x/oauth2 v0.24.0 // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect - golang.org/x/tools v0.22.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/mod v0.26.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.35.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect google.golang.org/grpc v1.67.1 // indirect - google.golang.org/protobuf v1.35.1 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/helpers/foundation-deployer/go.sum b/helpers/foundation-deployer/go.sum index 29fa8a09f..92fe3406a 100644 --- a/helpers/foundation-deployer/go.sum +++ b/helpers/foundation-deployer/go.sum @@ -53,9 +53,15 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v58 v58.0.0 h1:Una7GGERlF/37XfkPwpzYJe0Vp4dt2k1kCjlxwjIvzw= +github.com/google/go-github/v58 v58.0.0/go.mod h1:k4hxDKEfoWpSqFlc8LTpGd9fu2KrV1YAa6Hi6FmDNY4= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -76,6 +82,10 @@ github.com/hashicorp/go-getter/v2 v2.2.3 h1:6CVzhT0KJQHqd9b0pK3xSP0CM/Cv+bVhk+jc github.com/hashicorp/go-getter/v2 v2.2.3/go.mod h1:hp5Yy0GMQvwWVUmwLs3ygivz1JSLI323hdIE9J9m7TY= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= @@ -115,6 +125,7 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/terraform-google-modules/terraform-example-foundation/test/integration v0.0.0-20240808135927-5f1fd0f4104a h1:4Ih0BauwdUTF+YuA55/qY8Q+d5brYKPpae0YWkB9D2A= github.com/terraform-google-modules/terraform-example-foundation/test/integration v0.0.0-20240808135927-5f1fd0f4104a/go.mod h1:p8CvVuYRey5Nb8dipH5KM+eY+TnqfLgDnQ5M1a7oHiw= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -135,6 +146,8 @@ github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ github.com/zclconf/go-cty v1.15.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +gitlab.com/gitlab-org/api/client-go v0.158.0 h1:CfWA94ZaU4STlIfsYBGcpks3eUVojXvNFaytmNptbX8= +gitlab.com/gitlab-org/api/client-go v0.158.0/go.mod h1:D0DHF7ILUfFo/JcoGMAEndiKMm8SiP/WjyJ4OfXxCKw= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= @@ -149,12 +162,17 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -163,26 +181,39 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -190,6 +221,8 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.206.0 h1:A27GClesCSheW5P2BymVHjpEeQ2XHH8DI8Srs2HI2L8= google.golang.org/api v0.206.0/go.mod h1:BtB8bfjTYIrai3d8UyvPmV9REGgox7coh+ZRwm0b+W8= @@ -221,6 +254,8 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/helpers/foundation-deployer/main.go b/helpers/foundation-deployer/main.go index ba84985f9..0e0089fe7 100644 --- a/helpers/foundation-deployer/main.go +++ b/helpers/foundation-deployer/main.go @@ -105,11 +105,25 @@ func main() { FoundationPath: globalTFVars.FoundationCodePath, CheckoutPath: globalTFVars.CodeCheckoutPath, PolicyPath: filepath.Join(globalTFVars.FoundationCodePath, "policy-library"), + BuildType: globalTFVars.BuildType, EnableHubAndSpoke: globalTFVars.EnableHubAndSpoke, DisablePrompt: cfg.disablePrompt, Logger: utils.GetLogger(cfg.quiet), } + // validate git configuration for GitHub and GitLab + if globalTFVars.BuildType == stages.BuildTypeGiHub || globalTFVars.BuildType == stages.BuildTypeGitLab { + token := os.Getenv("GIT_TOKEN") + if token == "" { + fmt.Println("# GIT_TOKEN environment variable not set. It is required for GitHub and GitLab.") + os.Exit(1) + } + if globalTFVars.GitRepos == nil { + fmt.Printf("# for build type %s variable 'git_repos' is required\n", globalTFVars.BuildType) + os.Exit(1) + } + conf.GitToken = token + } // only enable services if they are not already enabled if globalTFVars.HasValidatorProj() { conf.ValidatorProject = *globalTFVars.ValidatorProjectID @@ -168,24 +182,37 @@ func main() { return } + var envVars map[string]string + + switch conf.BuildType { + case stages.BuildTypeGiHub: + envVars = map[string]string{ + "TF_VAR_gh_token": conf.GitToken, + } + case stages.BuildTypeGitLab: + envVars = map[string]string{ + "TF_VAR_gitlab_token": conf.GitToken, + } + } // destroy stages if cfg.destroy { // Note: destroy is only terraform destroy, local directories are not deleted. - // 5-app-infra - msg.PrintStageMsg("Destroying 5-app-infra stage") - err = s.RunDestroyStep("bu1-example-app", func() error { - io := stages.GetInfraPipelineOutputs(t, conf.CheckoutPath, "bu1-example-app") - return stages.DestroyExampleAppStage(t, s, io, conf) - }) - if err != nil { - fmt.Printf("# Example app step destroy failed. Error: %s\n", err.Error()) - os.Exit(3) + if conf.BuildType == stages.BuildTypeCBCSR { + // 5-app-infra + msg.PrintStageMsg("Destroying 5-app-infra stage") + err = s.RunDestroyStep("bu1-example-app", func() error { + io := stages.GetInfraPipelineOutputs(t, conf.CheckoutPath, "bu1-example-app") + return stages.DestroyExampleAppStage(t, s, io, conf) + }) + if err != nil { + fmt.Printf("# Example app step destroy failed. Error: %s\n", err.Error()) + os.Exit(3) + } } - // 4-projects msg.PrintStageMsg("Destroying 4-projects stage") err = s.RunDestroyStep("gcp-projects", func() error { - bo := stages.GetBootstrapStepOutputs(t, conf.FoundationPath) + bo := stages.GetBootstrapStepOutputs(t, conf.FoundationPath, conf.BuildType) return stages.DestroyProjectsStage(t, s, bo, conf) }) if err != nil { @@ -196,7 +223,7 @@ func main() { // 3-networks msg.PrintStageMsg("Destroying 3-networks stage") err = s.RunDestroyStep("gcp-networks", func() error { - bo := stages.GetBootstrapStepOutputs(t, conf.FoundationPath) + bo := stages.GetBootstrapStepOutputs(t, conf.FoundationPath, conf.BuildType) return stages.DestroyNetworksStage(t, s, bo, conf) }) if err != nil { @@ -207,7 +234,7 @@ func main() { // 2-environments msg.PrintStageMsg("Destroying 2-environments stage") err = s.RunDestroyStep("gcp-environments", func() error { - bo := stages.GetBootstrapStepOutputs(t, conf.FoundationPath) + bo := stages.GetBootstrapStepOutputs(t, conf.FoundationPath, conf.BuildType) return stages.DestroyEnvStage(t, s, bo, conf) }) if err != nil { @@ -218,7 +245,7 @@ func main() { // 1-org msg.PrintStageMsg("Destroying 1-org stage") err = s.RunDestroyStep("gcp-org", func() error { - bo := stages.GetBootstrapStepOutputs(t, conf.FoundationPath) + bo := stages.GetBootstrapStepOutputs(t, conf.FoundationPath, conf.BuildType) return stages.DestroyOrgStage(t, s, bo, conf) }) if err != nil { @@ -229,7 +256,7 @@ func main() { // 0-bootstrap msg.PrintStageMsg("Destroying 0-bootstrap stage") err = s.RunDestroyStep("gcp-bootstrap", func() error { - return stages.DestroyBootstrapStage(t, s, conf) + return stages.DestroyBootstrapStage(t, s, conf, envVars) }) if err != nil { fmt.Printf("# Bootstrap step destroy failed. Error: %s\n", err.Error()) @@ -252,13 +279,14 @@ func main() { skipInnerBuildMsg := s.IsStepComplete("gcp-bootstrap") err = s.RunStep("gcp-bootstrap", func() error { return stages.DeployBootstrapStage(t, s, globalTFVars, conf) + }) if err != nil { fmt.Printf("# Bootstrap step failed. Error: %s\n", err.Error()) os.Exit(3) } - bo := stages.GetBootstrapStepOutputs(t, conf.FoundationPath) + bo := stages.GetBootstrapStepOutputs(t, conf.FoundationPath, conf.BuildType) if skipInnerBuildMsg { msg.PrintBuildMsg(bo.CICDProject, bo.DefaultRegion, conf.DisablePrompt) @@ -310,18 +338,21 @@ func main() { os.Exit(3) } - // 5-app-infra - msg.PrintStageMsg("Deploying 5-app-infra stage") - io := stages.GetInfraPipelineOutputs(t, conf.CheckoutPath, "bu1-example-app") - io.RemoteStateBucket = bo.RemoteStateBucketProjects + if conf.BuildType == stages.BuildTypeCBCSR { + // 5-app-infra + msg.PrintStageMsg("Deploying 5-app-infra stage") + io := stages.GetInfraPipelineOutputs(t, conf.CheckoutPath, "bu1-example-app") + io.RemoteStateBucket = bo.RemoteStateBucketProjects - msg.PrintBuildMsg(io.InfraPipeProj, io.DefaultRegion, conf.DisablePrompt) + msg.PrintBuildMsg(io.InfraPipeProj, io.DefaultRegion, conf.DisablePrompt) - err = s.RunStep("bu1-example-app", func() error { - return stages.DeployExampleAppStage(t, s, globalTFVars, io, conf) - }) - if err != nil { - fmt.Printf("# Example app step failed. Error: %s\n", err.Error()) - os.Exit(3) + err = s.RunStep("bu1-example-app", func() error { + return stages.DeployExampleAppStage(t, s, globalTFVars, io, conf) + }) + if err != nil { + fmt.Printf("# Example app step failed. Error: %s\n", err.Error()) + os.Exit(3) + } } + } diff --git a/helpers/foundation-deployer/msg/msg.go b/helpers/foundation-deployer/msg/msg.go index c2972be2a..cc794a16a 100644 --- a/helpers/foundation-deployer/msg/msg.go +++ b/helpers/foundation-deployer/msg/msg.go @@ -25,6 +25,7 @@ const ( size = 70 readmeURL = "https://github.com/terraform-google-modules/terraform-example-foundation/blob/master/%s/README.md" cloudBuildURL = "https://console.cloud.google.com/cloud-build/builds;region=%s?project=%s" + githubActionURL = "https://github.com/%s/%s/actions" buildErrorURL = "https://console.cloud.google.com/cloud-build/builds;region=%s/%s?project=%s" quotaURL = "https://support.google.com/code/contact/billing_quota_increase" troubleQuotaURL = "https://github.com/terraform-google-modules/terraform-example-foundation/blob/master/docs/TROUBLESHOOTING.md#billing-quota-exceeded" @@ -56,6 +57,9 @@ func pad(msg string, size int) string { func CloudBuildURL(project, region string) string { return fmt.Sprintf(cloudBuildURL, region, project) } +func GitHubActionURL(owner, repo string) string { + return fmt.Sprintf(githubActionURL, owner, repo) +} func BuildErrorURL(project, region, build string) string { return fmt.Sprintf(buildErrorURL, region, build, project) @@ -79,6 +83,16 @@ func PrintBuildMsg(project, region string, disablePrompt bool) { } } +func PrintGHActionMsg(owner, repo string, disablePrompt bool) { + fmt.Println("") + fmt.Println("# Follow workflow execution and check results in the GitHub:") + fmt.Printf("# %s\n", GitHubActionURL(owner, repo)) + if !disablePrompt { + PressEnter("# Press Enter to continue at any time") + fmt.Println("") + } +} + func PrintQuotaMsg(sa string, disablePrompt bool) { fmt.Println("") fmt.Println("# Request a billing quota increase for the service account of stage 4-projects") diff --git a/helpers/foundation-deployer/stages/apply.go b/helpers/foundation-deployer/stages/apply.go index 0667d2c9a..37b4e1239 100644 --- a/helpers/foundation-deployer/stages/apply.go +++ b/helpers/foundation-deployer/stages/apply.go @@ -23,6 +23,7 @@ import ( "github.com/mitchellh/go-testing-interface" "github.com/terraform-google-modules/terraform-example-foundation/helpers/foundation-deployer/gcp" + "github.com/terraform-google-modules/terraform-example-foundation/helpers/foundation-deployer/gitlab" "github.com/terraform-google-modules/terraform-example-foundation/helpers/foundation-deployer/msg" "github.com/terraform-google-modules/terraform-example-foundation/helpers/foundation-deployer/steps" "github.com/terraform-google-modules/terraform-example-foundation/helpers/foundation-deployer/utils" @@ -30,7 +31,66 @@ import ( "github.com/terraform-google-modules/terraform-example-foundation/test/integration/testutils" ) +func buildGitLabCICDImage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, c CommonConf) error { + gl := gitlab.NewGL() + cicdPath := filepath.Join(c.CheckoutPath, "gcp-cicd-runner") + repoURL := utils.BuildGitLabURL(tfvars.GitRepos.Owner, *tfvars.GitRepos.CICDRunner) + + conf := utils.GitClone(t, tfvars.BuildType, "", repoURL, cicdPath, "", c.Logger) + err := conf.CheckoutBranch("image") + if err != nil { + return err + } + + err = utils.CopyFile(filepath.Join(c.FoundationPath, "build/gitlab-ci.yml"), filepath.Join(cicdPath, ".gitlab-ci.yml")) + if err != nil { + return err + } + + err = utils.CopyFile(filepath.Join(c.FoundationPath, "0-bootstrap/Dockerfile"), filepath.Join(cicdPath, "Dockerfile")) + if err != nil { + return err + } + + err = conf.CommitFiles("Initialize CI/CD runner project") + if err != nil { + return err + } + err = conf.PushBranch("image", "origin") + if err != nil { + return err + } + + commitSha, err := conf.GetCommitSha() + if err != nil { + return err + } + + fmt.Println("") + fmt.Println("# Follow job execution in GitLab:") + fmt.Printf("# %s\n", fmt.Sprintf("https://gitlab.com/%s/%s/-/jobs", tfvars.GitRepos.Owner, *tfvars.GitRepos.CICDRunner)) + + failureMsg := fmt.Sprintf("CI/CD runner image job failed %s/%s repository.", tfvars.GitRepos.Owner, *tfvars.GitRepos.CICDRunner) + err = gl.WaitBuildSuccess(t, tfvars.GitRepos.Owner, *tfvars.GitRepos.CICDRunner, c.GitToken, commitSha, failureMsg, MaxBuildRetries, MaxErrorRetries, TimeBetweenErrorRetries) + if err != nil { + return err + } + + projectsToAdd := make([]string, 5) + projectsToAdd[0] = tfvars.GitRepos.Bootstrap + projectsToAdd[1] = tfvars.GitRepos.Organization + projectsToAdd[2] = tfvars.GitRepos.Environments + projectsToAdd[3] = tfvars.GitRepos.Networks + projectsToAdd[4] = tfvars.GitRepos.Projects + + return gl.AddProjectsToJobTokenScope(t, tfvars.GitRepos.Owner, *tfvars.GitRepos.CICDRunner, c.GitToken, projectsToAdd) +} + func DeployBootstrapStage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, c CommonConf) error { + + var err error + var envVars map[string]string + bootstrapTfvars := BootstrapTfvars{ OrgID: tfvars.OrgID, DefaultRegion: tfvars.DefaultRegion, @@ -50,23 +110,44 @@ func DeployBootstrapStage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, c Co ProjectDeletionPolicy: tfvars.ProjectDeletionPolicy, } - err := utils.WriteTfvars(filepath.Join(c.FoundationPath, BootstrapStep, "terraform.tfvars"), bootstrapTfvars) + if tfvars.BuildType == BuildTypeGiHub { + bootstrapTfvars.GitHubRepos = &GitHubRepos{ + Owner: tfvars.GitRepos.Owner, + Bootstrap: tfvars.GitRepos.Bootstrap, + Organization: tfvars.GitRepos.Organization, + Environments: tfvars.GitRepos.Environments, + Networks: tfvars.GitRepos.Networks, + Projects: tfvars.GitRepos.Projects, + } + envVars = map[string]string{ + "TF_VAR_gh_token": c.GitToken, + } + } + + if tfvars.BuildType == BuildTypeGitLab { + bootstrapTfvars.GitLabRepos = &GitLabRepos{ + Owner: tfvars.GitRepos.Owner, + Bootstrap: tfvars.GitRepos.Bootstrap, + Organization: tfvars.GitRepos.Organization, + Environments: tfvars.GitRepos.Environments, + Networks: tfvars.GitRepos.Networks, + Projects: tfvars.GitRepos.Projects, + CICDRunner: *tfvars.GitRepos.CICDRunner, + } + envVars = map[string]string{ + "TF_VAR_gitlab_token": c.GitToken, + } + } + + err = utils.RenameBuildFiles(filepath.Join(c.FoundationPath, BootstrapStep), tfvars.BuildType) if err != nil { return err } - // delete README-Jenkins.md due to private key checker false positive - jenkinsReadme := filepath.Join(c.FoundationPath, BootstrapStep, "README-Jenkins.md") - exist, err := utils.FileExists(jenkinsReadme) + err = utils.WriteTfvars(filepath.Join(c.FoundationPath, BootstrapStep, "terraform.tfvars"), bootstrapTfvars) if err != nil { return err } - if exist { - err = os.Remove(jenkinsReadme) - if err != nil { - return err - } - } terraformDir := filepath.Join(c.FoundationPath, BootstrapStep) options := &terraform.Options{ @@ -76,7 +157,9 @@ func DeployBootstrapStage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, c Co RetryableTerraformErrors: testutils.RetryableTransientErrors, MaxRetries: MaxErrorRetries, TimeBetweenRetries: TimeBetweenErrorRetries, + EnvVars: envVars, } + // terraform deploy err = applyLocal(t, options, "", c.PolicyPath, c.ValidatorProject) if err != nil { @@ -85,7 +168,6 @@ func DeployBootstrapStage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, c Co // read bootstrap outputs defaultRegion := terraform.OutputMap(t, options, "common_config")["default_region"] - cbProjectID := terraform.Output(t, options, "cloudbuild_project_id") backendBucket := terraform.Output(t, options, "gcs_bucket_tfstate") backendBucketProjects := terraform.Output(t, options, "projects_gcs_bucket_tfstate") @@ -129,30 +211,63 @@ func DeployBootstrapStage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, c Co return err } - msg.PrintBuildMsg(cbProjectID, defaultRegion, c.DisablePrompt) + var stageConf StageConf + var cbProjectID string + var executor Executor + var bootstrapConf utils.GitRepo - // Check if image build was successful. - err = gcp.NewGCP().WaitBuildSuccess(t, cbProjectID, defaultRegion, "tf-cloudbuilder", "", "Terraform Image builder Build Failed for tf-cloudbuilder repository.", MaxBuildRetries, MaxErrorRetries, TimeBetweenErrorRetries) - if err != nil { - return err + gcpBootstrapPath := filepath.Join(c.CheckoutPath, BootstrapRepo) + + if tfvars.BuildType == BuildTypeCBCSR { + cbProjectID = terraform.Output(t, options, "cloudbuild_project_id") + + msg.PrintBuildMsg(cbProjectID, defaultRegion, c.DisablePrompt) + + // Check if image build was successful. + buildTFBuilderExecutor := NewGCPExecutor(cbProjectID, defaultRegion, "tf-cloudbuilder") + err = buildTFBuilderExecutor.WaitBuildSuccess(t, "", "Terraform Image builder Build Failed for tf-cloudbuilder repository.") + if err != nil { + return err + } + + //prepare policies repo + gcpPoliciesPath := filepath.Join(c.CheckoutPath, PoliciesRepo) + policiesConf := utils.GitClone(t, "CSR", PoliciesRepo, "", gcpPoliciesPath, cbProjectID, c.Logger) + policiesBranch := "main" + + err = s.RunStep("gcp-bootstrap.gcp-policies", func() error { + return preparePoliciesRepo(policiesConf, policiesBranch, c.FoundationPath, gcpPoliciesPath) + }) + if err != nil { + return err + } + + bootstrapConf = utils.GitClone(t, "CSR", BootstrapRepo, "", gcpBootstrapPath, cbProjectID, c.Logger) + executor = NewGCPExecutor(cbProjectID, defaultRegion, BootstrapRepo) } - //prepare policies repo - gcpPoliciesPath := filepath.Join(c.CheckoutPath, PoliciesRepo) - policiesConf := utils.CloneCSR(t, PoliciesRepo, gcpPoliciesPath, cbProjectID, c.Logger) - policiesBranch := "main" + if tfvars.BuildType == BuildTypeGiHub { + executor = NewGitHubExecutor(tfvars.GitRepos.Owner, tfvars.GitRepos.Bootstrap, c.GitToken) + repoURL := utils.BuildGitHubURL(tfvars.GitRepos.Owner, tfvars.GitRepos.Bootstrap) + bootstrapConf = utils.GitClone(t, tfvars.BuildType, "", repoURL, gcpBootstrapPath, cbProjectID, c.Logger) - err = s.RunStep("gcp-bootstrap.gcp-policies", func() error { - return preparePoliciesRepo(policiesConf, policiesBranch, c.FoundationPath, gcpPoliciesPath) - }) - if err != nil { - return err } - //prepare bootstrap repo - gcpBootstrapPath := filepath.Join(c.CheckoutPath, BootstrapRepo) - bootstrapConf := utils.CloneCSR(t, BootstrapRepo, gcpBootstrapPath, cbProjectID, c.Logger) - stageConf := StageConf{ + if tfvars.BuildType == BuildTypeGitLab { + + err = s.RunStep("gcp-bootstrap.build-cicd-runner", func() error { + return buildGitLabCICDImage(t, s, tfvars, c) + }) + if err != nil { + return err + } + + executor = NewGitLabExecutor(tfvars.GitRepos.Owner, tfvars.GitRepos.Bootstrap, c.GitToken) + repoURL := utils.BuildGitLabURL(tfvars.GitRepos.Owner, tfvars.GitRepos.Bootstrap) + bootstrapConf = utils.GitClone(t, tfvars.BuildType, "", repoURL, gcpBootstrapPath, cbProjectID, c.Logger) + } + + stageConf = StageConf{ Stage: BootstrapRepo, CICDProject: cbProjectID, DefaultRegion: defaultRegion, @@ -161,6 +276,8 @@ func DeployBootstrapStage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, c Co CustomTargetDirPath: "envs/shared", GitConf: bootstrapConf, Envs: []string{"shared"}, + BuildType: tfvars.BuildType, + Executor: executor, } // if groups creation is enable the helper will just push the code @@ -177,6 +294,7 @@ func DeployBootstrapStage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, c Co if err != nil { return err } + // Init gcp-bootstrap terraform err = s.RunStep("gcp-bootstrap.init-tf", func() error { options := &terraform.Options{ @@ -186,6 +304,7 @@ func DeployBootstrapStage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, c Co RetryableTerraformErrors: testutils.RetryableTransientErrors, MaxRetries: MaxErrorRetries, TimeBetweenRetries: TimeBetweenErrorRetries, + EnvVars: envVars, } _, err := terraform.InitE(t, options) return err @@ -242,7 +361,23 @@ func DeployOrgStage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, outputs Bo return err } - conf := utils.CloneCSR(t, OrgRepo, filepath.Join(c.CheckoutPath, OrgRepo), outputs.CICDProject, c.Logger) + var conf utils.GitRepo + var executor Executor + + switch c.BuildType { + case BuildTypeGiHub: + executor = NewGitHubExecutor(tfvars.GitRepos.Owner, tfvars.GitRepos.Organization, c.GitToken) + repoURL := utils.BuildGitHubURL(tfvars.GitRepos.Owner, tfvars.GitRepos.Organization) + conf = utils.GitClone(t, tfvars.BuildType, "", repoURL, filepath.Join(c.CheckoutPath, OrgRepo), "", c.Logger) + case BuildTypeGitLab: + executor = NewGitLabExecutor(tfvars.GitRepos.Owner, tfvars.GitRepos.Organization, c.GitToken) + repoURL := utils.BuildGitLabURL(tfvars.GitRepos.Owner, tfvars.GitRepos.Organization) + conf = utils.GitClone(t, tfvars.BuildType, "", repoURL, filepath.Join(c.CheckoutPath, OrgRepo), "", c.Logger) + default: + executor = NewGCPExecutor(outputs.CICDProject, outputs.DefaultRegion, OrgRepo) + conf = utils.GitClone(t, "CSR", OrgRepo, "", filepath.Join(c.CheckoutPath, OrgRepo), outputs.CICDProject, c.Logger) + } + stageConf := StageConf{ Stage: OrgRepo, CICDProject: outputs.CICDProject, @@ -251,6 +386,8 @@ func DeployOrgStage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, outputs Bo Repo: OrgRepo, GitConf: conf, Envs: []string{"shared"}, + BuildType: c.BuildType, + Executor: executor, } return deployStage(t, stageConf, s, c) @@ -268,7 +405,23 @@ func DeployEnvStage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, outputs Bo return err } - conf := utils.CloneCSR(t, EnvironmentsRepo, filepath.Join(c.CheckoutPath, EnvironmentsRepo), outputs.CICDProject, c.Logger) + var conf utils.GitRepo + var executor Executor + + switch c.BuildType { + case BuildTypeGiHub: + executor = NewGitHubExecutor(tfvars.GitRepos.Owner, tfvars.GitRepos.Environments, c.GitToken) + repoURL := utils.BuildGitHubURL(tfvars.GitRepos.Owner, tfvars.GitRepos.Environments) + conf = utils.GitClone(t, tfvars.BuildType, "", repoURL, filepath.Join(c.CheckoutPath, EnvironmentsRepo), "", c.Logger) + case BuildTypeGitLab: + executor = NewGitLabExecutor(tfvars.GitRepos.Owner, tfvars.GitRepos.Environments, c.GitToken) + repoURL := utils.BuildGitLabURL(tfvars.GitRepos.Owner, tfvars.GitRepos.Environments) + conf = utils.GitClone(t, tfvars.BuildType, "", repoURL, filepath.Join(c.CheckoutPath, EnvironmentsRepo), "", c.Logger) + default: + executor = NewGCPExecutor(outputs.CICDProject, outputs.DefaultRegion, EnvironmentsRepo) + conf = utils.GitClone(t, "CSR", EnvironmentsRepo, "", filepath.Join(c.CheckoutPath, EnvironmentsRepo), outputs.CICDProject, c.Logger) + } + stageConf := StageConf{ Stage: EnvironmentsRepo, CICDProject: outputs.CICDProject, @@ -277,6 +430,8 @@ func DeployEnvStage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, outputs Bo Repo: EnvironmentsRepo, GitConf: conf, Envs: []string{"production", "nonproduction", "development"}, + BuildType: c.BuildType, + Executor: executor, } return deployStage(t, stageConf, s, c) @@ -332,7 +487,23 @@ func DeployNetworksStage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, outpu return err } - conf := utils.CloneCSR(t, NetworksRepo, filepath.Join(c.CheckoutPath, NetworksRepo), outputs.CICDProject, c.Logger) + var conf utils.GitRepo + var executor Executor + + switch c.BuildType { + case BuildTypeGiHub: + executor = NewGitHubExecutor(tfvars.GitRepos.Owner, tfvars.GitRepos.Networks, c.GitToken) + repoURL := utils.BuildGitHubURL(tfvars.GitRepos.Owner, tfvars.GitRepos.Networks) + conf = utils.GitClone(t, tfvars.BuildType, "", repoURL, filepath.Join(c.CheckoutPath, NetworksRepo), "", c.Logger) + case BuildTypeGitLab: + executor = NewGitLabExecutor(tfvars.GitRepos.Owner, tfvars.GitRepos.Networks, c.GitToken) + repoURL := utils.BuildGitLabURL(tfvars.GitRepos.Owner, tfvars.GitRepos.Networks) + conf = utils.GitClone(t, tfvars.BuildType, "", repoURL, filepath.Join(c.CheckoutPath, NetworksRepo), "", c.Logger) + default: + executor = NewGCPExecutor(outputs.CICDProject, outputs.DefaultRegion, NetworksRepo) + conf = utils.GitClone(t, "CSR", NetworksRepo, "", filepath.Join(c.CheckoutPath, NetworksRepo), outputs.CICDProject, c.Logger) + } + stageConf := StageConf{ Stage: NetworksRepo, StageSA: outputs.NetworkSA, @@ -345,6 +516,8 @@ func DeployNetworksStage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, outpu LocalSteps: localStep, GroupingUnits: []string{"envs"}, Envs: []string{"production", "nonproduction", "development"}, + BuildType: c.BuildType, + Executor: executor, } return deployStage(t, stageConf, s, c) } @@ -384,7 +557,23 @@ func DeployProjectsStage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, outpu } } - conf := utils.CloneCSR(t, ProjectsRepo, filepath.Join(c.CheckoutPath, ProjectsRepo), outputs.CICDProject, c.Logger) + var conf utils.GitRepo + var executor Executor + + switch c.BuildType { + case BuildTypeGiHub: + executor = NewGitHubExecutor(tfvars.GitRepos.Owner, tfvars.GitRepos.Projects, c.GitToken) + repoURL := utils.BuildGitHubURL(tfvars.GitRepos.Owner, tfvars.GitRepos.Projects) + conf = utils.GitClone(t, tfvars.BuildType, "", repoURL, filepath.Join(c.CheckoutPath, ProjectsRepo), "", c.Logger) + case BuildTypeGitLab: + executor = NewGitLabExecutor(tfvars.GitRepos.Owner, tfvars.GitRepos.Projects, c.GitToken) + repoURL := utils.BuildGitLabURL(tfvars.GitRepos.Owner, tfvars.GitRepos.Projects) + conf = utils.GitClone(t, tfvars.BuildType, "", repoURL, filepath.Join(c.CheckoutPath, ProjectsRepo), "", c.Logger) + default: + executor = NewGCPExecutor(outputs.CICDProject, outputs.DefaultRegion, ProjectsRepo) + conf = utils.GitClone(t, "CSR", ProjectsRepo, "", filepath.Join(c.CheckoutPath, ProjectsRepo), outputs.CICDProject, c.Logger) + } + stageConf := StageConf{ Stage: ProjectsRepo, StageSA: outputs.ProjectsSA, @@ -397,6 +586,8 @@ func DeployProjectsStage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, outpu LocalSteps: []string{"shared"}, GroupingUnits: []string{"business_unit_1"}, Envs: []string{"production", "nonproduction", "development"}, + BuildType: c.BuildType, + Executor: executor, } return deployStage(t, stageConf, s, c) @@ -425,9 +616,8 @@ func DeployExampleAppStage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, out return err } } - //prepare policies repo gcpPoliciesPath := filepath.Join(c.CheckoutPath, "gcp-policies-app-infra") - policiesConf := utils.CloneCSR(t, PoliciesRepo, gcpPoliciesPath, outputs.InfraPipeProj, c.Logger) + policiesConf := utils.GitClone(t, "CSR", PoliciesRepo, "", gcpPoliciesPath, outputs.InfraPipeProj, c.Logger) policiesBranch := "main" err = s.RunStep("bu1-example-app.gcp-policies-app-infra", func() error { return preparePoliciesRepo(policiesConf, policiesBranch, c.FoundationPath, gcpPoliciesPath) @@ -436,7 +626,7 @@ func DeployExampleAppStage(t testing.TB, s steps.Steps, tfvars GlobalTFVars, out return err } - conf := utils.CloneCSR(t, AppInfraRepo, filepath.Join(c.CheckoutPath, AppInfraRepo), outputs.InfraPipeProj, c.Logger) + conf := utils.GitClone(t, "CSR", AppInfraRepo, "", filepath.Join(c.CheckoutPath, AppInfraRepo), outputs.InfraPipeProj, c.Logger) stageConf := StageConf{ Stage: AppInfraRepo, CICDProject: outputs.InfraPipeProj, @@ -458,7 +648,7 @@ func deployStage(t testing.TB, sc StageConf, s steps.Steps, c CommonConf) error } err = s.RunStep(fmt.Sprintf("%s.copy-code", sc.Stage), func() error { - return copyStepCode(t, sc.GitConf, c.FoundationPath, c.CheckoutPath, sc.Repo, sc.Step, sc.CustomTargetDirPath) + return copyStepCode(t, sc.GitConf, c.FoundationPath, c.CheckoutPath, sc.Repo, sc.Step, sc.CustomTargetDirPath, sc.BuildType) }) if err != nil { return err @@ -490,7 +680,7 @@ func deployStage(t testing.TB, sc StageConf, s steps.Steps, c CommonConf) error } err = s.RunStep(fmt.Sprintf("%s.plan", sc.Stage), func() error { - return planStage(t, sc.GitConf, sc.CICDProject, sc.DefaultRegion, sc.Repo) + return planStage(t, sc.GitConf, sc.CICDProject, sc.DefaultRegion, sc.Repo, sc.Executor) }) if err != nil { return err @@ -502,7 +692,7 @@ func deployStage(t testing.TB, sc StageConf, s steps.Steps, c CommonConf) error if env == "shared" { aEnv = "production" } - return applyEnv(t, sc.GitConf, sc.CICDProject, sc.DefaultRegion, sc.Repo, aEnv) + return applyEnv(t, sc.GitConf, sc.CICDProject, sc.DefaultRegion, sc.Repo, aEnv, sc.Executor) }) if err != nil { return err @@ -529,28 +719,67 @@ func preparePoliciesRepo(policiesConf utils.GitRepo, policiesBranch, foundationP return policiesConf.PushBranch(policiesBranch, "origin") } -func copyStepCode(t testing.TB, conf utils.GitRepo, foundationPath, checkoutPath, repo, step, customPath string) error { +func copyStepCode(t testing.TB, conf utils.GitRepo, foundationPath, checkoutPath, repo, step, customPath, buildType string) error { gcpPath := filepath.Join(checkoutPath, repo) targetDir := gcpPath if customPath != "" { targetDir = filepath.Join(gcpPath, customPath) } + err := utils.CopyDirectory(filepath.Join(foundationPath, step), targetDir) if err != nil { return err } - err = utils.CopyFile(filepath.Join(foundationPath, "build/cloudbuild-tf-apply.yaml"), filepath.Join(gcpPath, "cloudbuild-tf-apply.yaml")) - if err != nil { - return err + + if buildType != BuildTypeCBCSR { + err := utils.CopyDirectory(filepath.Join(foundationPath, "policy-library"), filepath.Join(gcpPath, "policy-library")) + if err != nil { + return err + } } - err = utils.CopyFile(filepath.Join(foundationPath, "build/cloudbuild-tf-plan.yaml"), filepath.Join(gcpPath, "cloudbuild-tf-plan.yaml")) - if err != nil { - return err + + switch buildType { + case BuildTypeGiHub: + err = os.MkdirAll(filepath.Join(gcpPath, ".github/workflows/"), 0755) + if err != nil { + return err + } + err = utils.CopyFile(filepath.Join(foundationPath, "build/github-tf-apply.yaml"), filepath.Join(gcpPath, ".github/workflows/github-tf-apply.yaml")) + if err != nil { + return err + } + err = utils.CopyFile(filepath.Join(foundationPath, "build/github-tf-plan-all.yaml"), filepath.Join(gcpPath, ".github/workflows/github-tf-plan-all.yaml")) + if err != nil { + return err + } + err = utils.CopyFile(filepath.Join(foundationPath, "build/github-tf-pull-request.yaml"), filepath.Join(gcpPath, ".github/workflows/github-tf-pull-request.yaml")) + if err != nil { + return err + } + case BuildTypeGitLab: + err = utils.CopyFile(filepath.Join(foundationPath, "build/gitlab-ci.yml"), filepath.Join(gcpPath, ".gitlab-ci.yml")) + if err != nil { + return err + } + err = utils.CopyFile(filepath.Join(foundationPath, "build/run_gcp_auth.sh"), filepath.Join(gcpPath, "run_gcp_auth.sh")) + if err != nil { + return err + } + default: //BuildTypeCBCSR + err = utils.CopyFile(filepath.Join(foundationPath, "build/cloudbuild-tf-apply.yaml"), filepath.Join(gcpPath, "cloudbuild-tf-apply.yaml")) + if err != nil { + return err + } + err = utils.CopyFile(filepath.Join(foundationPath, "build/cloudbuild-tf-plan.yaml"), filepath.Join(gcpPath, "cloudbuild-tf-plan.yaml")) + if err != nil { + return err + } } + return utils.CopyFile(filepath.Join(foundationPath, "build/tf-wrapper.sh"), filepath.Join(gcpPath, "tf-wrapper.sh")) } -func planStage(t testing.TB, conf utils.GitRepo, project, region, repo string) error { +func planStage(t testing.TB, conf utils.GitRepo, project, region, repo string, buildExecutor Executor) error { err := conf.CommitFiles(fmt.Sprintf("Initialize %s repo", repo)) if err != nil { @@ -566,7 +795,7 @@ func planStage(t testing.TB, conf utils.GitRepo, project, region, repo string) e return err } - return gcp.NewGCP().WaitBuildSuccess(t, project, region, repo, commitSha, fmt.Sprintf("Terraform %s plan build Failed.", repo), MaxBuildRetries, MaxErrorRetries, TimeBetweenErrorRetries) + return buildExecutor.WaitBuildSuccess(t, commitSha, fmt.Sprintf("Terraform %s plan build Failed.", repo)) } func saveBootstrapCodeOnly(t testing.TB, sc StageConf, s steps.Steps, c CommonConf) error { @@ -577,7 +806,7 @@ func saveBootstrapCodeOnly(t testing.TB, sc StageConf, s steps.Steps, c CommonCo } err = s.RunStep(fmt.Sprintf("%s.copy-code", sc.Stage), func() error { - return copyStepCode(t, sc.GitConf, c.FoundationPath, c.CheckoutPath, sc.Repo, sc.Step, sc.CustomTargetDirPath) + return copyStepCode(t, sc.GitConf, c.FoundationPath, c.CheckoutPath, sc.Repo, sc.Step, sc.CustomTargetDirPath, sc.BuildType) }) if err != nil { return err @@ -616,7 +845,7 @@ func saveBootstrapCodeOnly(t testing.TB, sc StageConf, s steps.Steps, c CommonCo return nil } -func applyEnv(t testing.TB, conf utils.GitRepo, project, region, repo, environment string) error { +func applyEnv(t testing.TB, conf utils.GitRepo, project, region, repo, environment string, buildExecutor Executor) error { err := conf.CheckoutBranch(environment) if err != nil { return err @@ -630,7 +859,7 @@ func applyEnv(t testing.TB, conf utils.GitRepo, project, region, repo, environme return err } - return gcp.NewGCP().WaitBuildSuccess(t, project, region, repo, commitSha, fmt.Sprintf("Terraform %s apply %s build Failed.", repo, environment), MaxBuildRetries, MaxErrorRetries, TimeBetweenErrorRetries) + return buildExecutor.WaitBuildSuccess(t, commitSha, fmt.Sprintf("Terraform %s apply %s build Failed.", repo, environment)) } func applyLocal(t testing.TB, options *terraform.Options, serviceAccount, policyPath, validatorProjectID string) error { @@ -654,7 +883,7 @@ func applyLocal(t testing.TB, options *terraform.Options, serviceAccount, policy // Runs gcloud terraform vet if validatorProjectID != "" { - err = TerraformVet(t, options.TerraformDir, policyPath, validatorProjectID) + err = TerraformVet(t, options.TerraformDir, policyPath, validatorProjectID, options.EnvVars) if err != nil { return err } diff --git a/helpers/foundation-deployer/stages/data.go b/helpers/foundation-deployer/stages/data.go index 046d0cf0f..ff1c4862f 100644 --- a/helpers/foundation-deployer/stages/data.go +++ b/helpers/foundation-deployer/stages/data.go @@ -29,23 +29,28 @@ import ( ) const ( - PoliciesRepo = "gcp-policies" - BootstrapRepo = "gcp-bootstrap" - OrgRepo = "gcp-org" - EnvironmentsRepo = "gcp-environments" - NetworksRepo = "gcp-networks" - ProjectsRepo = "gcp-projects" - AppInfraRepo = "bu1-example-app" - BootstrapStep = "0-bootstrap" - OrgStep = "1-org" - EnvironmentsStep = "2-environments" - HubAndSpokeStep = "3-networks-hub-and-spoke" - SvpcStep = "3-networks-svpc" - ProjectsStep = "4-projects" - AppInfraStep = "5-app-infra" - MaxErrorRetries = 2 - TimeBetweenErrorRetries = 2 * time.Minute - MaxBuildRetries = 40 + PoliciesRepo = "gcp-policies" + BootstrapRepo = "gcp-bootstrap" + OrgRepo = "gcp-org" + EnvironmentsRepo = "gcp-environments" + NetworksRepo = "gcp-networks" + ProjectsRepo = "gcp-projects" + AppInfraRepo = "bu1-example-app" + BootstrapStep = "0-bootstrap" + OrgStep = "1-org" + EnvironmentsStep = "2-environments" + HubAndSpokeStep = "3-networks-hub-and-spoke" + SvpcStep = "3-networks-svpc" + ProjectsStep = "4-projects" + AppInfraStep = "5-app-infra" + MaxErrorRetries = 2 + TimeBetweenErrorRetries = 2 * time.Minute + MaxBuildRetries = 40 + BuildTypeCBCSR = "cb" + BuildTypeGiHub = "github" + BuildTypeGitLab = "gitlab" + CloudBuildProjectIdOutput = "cloudbuild_project_id" + CICDProjectIdOutput = "cicd_project_id" ) type CommonConf struct { @@ -53,9 +58,11 @@ type CommonConf struct { CheckoutPath string PolicyPath string ValidatorProject string + BuildType string EnableHubAndSpoke bool DisablePrompt bool Logger *logger.Logger + GitToken string } type StageConf struct { @@ -71,6 +78,8 @@ type StageConf struct { GroupingUnits []string Envs []string LocalSteps []string + BuildType string + Executor Executor } type BootstrapOutputs struct { @@ -133,6 +142,35 @@ type GcpGroups struct { KmsAdmin *string `cty:"kms_admin"` } +type GitRepos struct { + Owner string `cty:"owner"` + Bootstrap string `cty:"bootstrap"` + Organization string `cty:"organization"` + Environments string `cty:"environments"` + Networks string `cty:"networks"` + Projects string `cty:"projects"` + CICDRunner *string `cty:"cicd_runner"` +} + +type GitHubRepos struct { + Owner string `cty:"owner"` + Bootstrap string `cty:"bootstrap"` + Organization string `cty:"organization"` + Environments string `cty:"environments"` + Networks string `cty:"networks"` + Projects string `cty:"projects"` +} + +type GitLabRepos struct { + Owner string `cty:"owner"` + Bootstrap string `cty:"bootstrap"` + Organization string `cty:"organization"` + Environments string `cty:"environments"` + Networks string `cty:"networks"` + Projects string `cty:"projects"` + CICDRunner string `cty:"cicd_runner"` +} + // GlobalTFVars contains all the configuration for the deploy type GlobalTFVars struct { OrgID string `hcl:"org_id"` @@ -170,6 +208,8 @@ type GlobalTFVars struct { InitialGroupConfig *string `hcl:"initial_group_config"` FolderDeletionProtection *bool `hcl:"folder_deletion_protection"` ProjectDeletionPolicy string `hcl:"project_deletion_policy"` + BuildType string `hcl:"build_type"` + GitRepos *GitRepos `hcl:"git_repos"` } // HasValidatorProj checks if a Validator Project was provided @@ -203,22 +243,24 @@ func (g GlobalTFVars) CheckString(s string) { } type BootstrapTfvars struct { - OrgID string `hcl:"org_id"` - BillingAccount string `hcl:"billing_account"` - DefaultRegion string `hcl:"default_region"` - DefaultRegion2 string `hcl:"default_region_2"` - DefaultRegionGCS string `hcl:"default_region_gcs"` - DefaultRegionKMS string `hcl:"default_region_kms"` - ParentFolder *string `hcl:"parent_folder"` - ProjectPrefix *string `hcl:"project_prefix"` - FolderPrefix *string `hcl:"folder_prefix"` - BucketForceDestroy *bool `hcl:"bucket_force_destroy"` - BucketTfstateKmsForceDestroy *bool `hcl:"bucket_tfstate_kms_force_destroy"` - WorkflowDeletionProtection *bool `hcl:"workflow_deletion_protection"` - Groups Groups `hcl:"groups"` - InitialGroupConfig *string `hcl:"initial_group_config"` - FolderDeletionProtection *bool `hcl:"folder_deletion_protection"` - ProjectDeletionPolicy string `hcl:"project_deletion_policy"` + OrgID string `hcl:"org_id"` + BillingAccount string `hcl:"billing_account"` + DefaultRegion string `hcl:"default_region"` + DefaultRegion2 string `hcl:"default_region_2"` + DefaultRegionGCS string `hcl:"default_region_gcs"` + DefaultRegionKMS string `hcl:"default_region_kms"` + ParentFolder *string `hcl:"parent_folder"` + ProjectPrefix *string `hcl:"project_prefix"` + FolderPrefix *string `hcl:"folder_prefix"` + BucketForceDestroy *bool `hcl:"bucket_force_destroy"` + BucketTfstateKmsForceDestroy *bool `hcl:"bucket_tfstate_kms_force_destroy"` + WorkflowDeletionProtection *bool `hcl:"workflow_deletion_protection"` + Groups Groups `hcl:"groups"` + InitialGroupConfig *string `hcl:"initial_group_config"` + FolderDeletionProtection *bool `hcl:"folder_deletion_protection"` + ProjectDeletionPolicy string `hcl:"project_deletion_policy"` + GitHubRepos *GitHubRepos `hcl:"gh_repos"` + GitLabRepos *GitLabRepos `hcl:"gl_repos"` } type OrgTfvars struct { @@ -285,14 +327,19 @@ type AppInfraCommonTfvars struct { ImageDigest string `hcl:"confidential_image_digest"` } -func GetBootstrapStepOutputs(t testing.TB, foundationPath string) BootstrapOutputs { +func GetBootstrapStepOutputs(t testing.TB, foundationPath string, buildType string) BootstrapOutputs { options := &terraform.Options{ TerraformDir: filepath.Join(foundationPath, "0-bootstrap"), Logger: logger.Discard, NoColor: true, } + cicdProjectIDOutput := CloudBuildProjectIdOutput + if buildType != BuildTypeCBCSR { + cicdProjectIDOutput = CICDProjectIdOutput + } + return BootstrapOutputs{ - CICDProject: terraform.Output(t, options, "cloudbuild_project_id"), + CICDProject: terraform.Output(t, options, cicdProjectIDOutput), RemoteStateBucket: terraform.Output(t, options, "gcs_bucket_tfstate"), RemoteStateBucketProjects: terraform.Output(t, options, "projects_gcs_bucket_tfstate"), DefaultRegion: terraform.OutputMap(t, options, "common_config")["default_region"], diff --git a/helpers/foundation-deployer/stages/destroy.go b/helpers/foundation-deployer/stages/destroy.go index 6d70e74b3..5d60e116f 100644 --- a/helpers/foundation-deployer/stages/destroy.go +++ b/helpers/foundation-deployer/stages/destroy.go @@ -27,9 +27,13 @@ import ( "github.com/terraform-google-modules/terraform-example-foundation/test/integration/testutils" ) -func DestroyBootstrapStage(t testing.TB, s steps.Steps, c CommonConf) error { +var ( + emptyEnvVars = map[string]string{} +) + +func DestroyBootstrapStage(t testing.TB, s steps.Steps, c CommonConf, envVars map[string]string) error { - if err := forceBackendMigration(t, BootstrapRepo, "envs", "shared", c); err != nil { + if err := forceBackendMigration(t, BootstrapRepo, "envs", "shared", c, envVars); err != nil { return err } @@ -40,13 +44,13 @@ func DestroyBootstrapStage(t testing.TB, s steps.Steps, c CommonConf) error { GroupingUnits: []string{"envs"}, Envs: []string{"shared"}, } - return destroyStage(t, stageConf, s, c) + return destroyStage(t, stageConf, s, c, envVars) } // forceBackendMigration removes backend.tf file to force migration of the // terraform state from GCS to the local directory. // Before changing the backend we ensure it is has been initialized. -func forceBackendMigration(t testing.TB, repo, groupUnit, env string, c CommonConf) error { +func forceBackendMigration(t testing.TB, repo, groupUnit, env string, c CommonConf, envVars map[string]string) error { tfDir := filepath.Join(c.CheckoutPath, repo, groupUnit, env) backendF := filepath.Join(tfDir, "backend.tf") @@ -62,6 +66,7 @@ func forceBackendMigration(t testing.TB, repo, groupUnit, env string, c CommonCo RetryableTerraformErrors: testutils.RetryableTransientErrors, MaxRetries: MaxErrorRetries, TimeBetweenRetries: TimeBetweenErrorRetries, + EnvVars: envVars, } _, err := terraform.InitE(t, options) if err != nil { @@ -94,7 +99,7 @@ func DestroyOrgStage(t testing.TB, s steps.Steps, outputs BootstrapOutputs, c Co GroupingUnits: []string{"envs"}, Envs: []string{"shared"}, } - return destroyStage(t, stageConf, s, c) + return destroyStage(t, stageConf, s, c, emptyEnvVars) } func DestroyEnvStage(t testing.TB, s steps.Steps, outputs BootstrapOutputs, c CommonConf) error { @@ -107,7 +112,7 @@ func DestroyEnvStage(t testing.TB, s steps.Steps, outputs BootstrapOutputs, c Co GroupingUnits: []string{"envs"}, Envs: []string{"development", "nonproduction", "production"}, } - return destroyStage(t, stageConf, s, c) + return destroyStage(t, stageConf, s, c, emptyEnvVars) } func DestroyNetworksStage(t testing.TB, s steps.Steps, outputs BootstrapOutputs, c CommonConf) error { @@ -122,7 +127,7 @@ func DestroyNetworksStage(t testing.TB, s steps.Steps, outputs BootstrapOutputs, GroupingUnits: []string{"envs"}, Envs: []string{"development", "nonproduction", "production"}, } - return destroyStage(t, stageConf, s, c) + return destroyStage(t, stageConf, s, c, emptyEnvVars) } func DestroyProjectsStage(t testing.TB, s steps.Steps, outputs BootstrapOutputs, c CommonConf) error { @@ -136,7 +141,7 @@ func DestroyProjectsStage(t testing.TB, s steps.Steps, outputs BootstrapOutputs, GroupingUnits: []string{"business_unit_1"}, Envs: []string{"development", "nonproduction", "production"}, } - return destroyStage(t, stageConf, s, c) + return destroyStage(t, stageConf, s, c, emptyEnvVars) } func DestroyExampleAppStage(t testing.TB, s steps.Steps, outputs InfraPipelineOutputs, c CommonConf) error { @@ -149,10 +154,10 @@ func DestroyExampleAppStage(t testing.TB, s steps.Steps, outputs InfraPipelineOu GroupingUnits: []string{"business_unit_1"}, Envs: []string{"development", "nonproduction", "production"}, } - return destroyStage(t, stageConf, s, c) + return destroyStage(t, stageConf, s, c, emptyEnvVars) } -func destroyStage(t testing.TB, sc StageConf, s steps.Steps, c CommonConf) error { +func destroyStage(t testing.TB, sc StageConf, s steps.Steps, c CommonConf, envVars map[string]string) error { gcpPath := filepath.Join(c.CheckoutPath, sc.Repo) for _, e := range sc.Envs { err := s.RunDestroyStep(fmt.Sprintf("%s.%s", sc.Repo, e), func() error { @@ -164,8 +169,9 @@ func destroyStage(t testing.TB, sc StageConf, s steps.Steps, c CommonConf) error RetryableTerraformErrors: testutils.RetryableTransientErrors, MaxRetries: MaxErrorRetries, TimeBetweenRetries: TimeBetweenErrorRetries, + EnvVars: envVars, } - conf := utils.CloneCSR(t, sc.Repo, gcpPath, sc.CICDProject, c.Logger) + conf := utils.GetRepoOnly(t, gcpPath, c.Logger) branch := e if branch == "shared" { branch = "production" @@ -198,8 +204,9 @@ func destroyStage(t testing.TB, sc StageConf, s steps.Steps, c CommonConf) error RetryableTerraformErrors: testutils.RetryableTransientErrors, MaxRetries: MaxErrorRetries, TimeBetweenRetries: TimeBetweenErrorRetries, + EnvVars: envVars, } - conf := utils.CloneCSR(t, ProjectsRepo, gcpPath, sc.CICDProject, c.Logger) + conf := utils.GetRepoOnly(t, gcpPath, c.Logger) err := conf.CheckoutBranch("production") if err != nil { return err diff --git a/helpers/foundation-deployer/stages/executor.go b/helpers/foundation-deployer/stages/executor.go new file mode 100644 index 000000000..67d7946c2 --- /dev/null +++ b/helpers/foundation-deployer/stages/executor.go @@ -0,0 +1,85 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package stages + +import ( + "github.com/mitchellh/go-testing-interface" + "github.com/terraform-google-modules/terraform-example-foundation/helpers/foundation-deployer/gcp" + "github.com/terraform-google-modules/terraform-example-foundation/helpers/foundation-deployer/github" + "github.com/terraform-google-modules/terraform-example-foundation/helpers/foundation-deployer/gitlab" +) + +type Executor interface { + WaitBuildSuccess(t testing.TB, commitSha, failureMsg string) error +} + +type GCPExecutor struct { + executor gcp.GCP + project string + region string + repo string +} + +func (e *GCPExecutor) WaitBuildSuccess(t testing.TB, commitSha, failureMsg string) error { + return e.executor.WaitBuildSuccess(t, e.project, e.region, e.repo, commitSha, failureMsg, MaxBuildRetries, MaxErrorRetries, TimeBetweenErrorRetries) +} + +func NewGCPExecutor(project, region, repo string) *GCPExecutor { + return &GCPExecutor{ + project: project, + region: region, + repo: repo, + } +} + +type GitHubExecutor struct { + executor github.GH + owner string + repo string + token string +} + +func NewGitHubExecutor(owner, repo, token string) *GitHubExecutor { + return &GitHubExecutor{ + executor: github.NewGH(), + owner: owner, + repo: repo, + token: token, + } +} + +func (e *GitHubExecutor) WaitBuildSuccess(t testing.TB, commitSha, failureMsg string) error { + return e.executor.WaitBuildSuccess(t, e.owner, e.repo, e.token, commitSha, failureMsg, MaxBuildRetries, MaxErrorRetries, TimeBetweenErrorRetries) +} + +type GitLabExecutor struct { + executor gitlab.GL + owner string + project string + token string +} + +func NewGitLabExecutor(owner, project, token string) *GitLabExecutor { + return &GitLabExecutor{ + executor: gitlab.NewGL(), + owner: owner, + project: project, + token: token, + } +} + +func (e *GitLabExecutor) WaitBuildSuccess(t testing.TB, commitSha, failureMsg string) error { + return e.executor.WaitBuildSuccess(t, e.owner, e.project, e.token, commitSha, failureMsg, MaxBuildRetries, MaxErrorRetries, TimeBetweenErrorRetries) +} diff --git a/helpers/foundation-deployer/stages/vet.go b/helpers/foundation-deployer/stages/vet.go index c435fd4d9..3e3aea6b0 100644 --- a/helpers/foundation-deployer/stages/vet.go +++ b/helpers/foundation-deployer/stages/vet.go @@ -32,7 +32,7 @@ import ( ) // TerraformVet runs gcloud terraform vet on the plan of the provided terraform directory -func TerraformVet(t testing.TB, terraformDir, policyPath, project string) error { +func TerraformVet(t testing.TB, terraformDir, policyPath, project string, envVars map[string]string) error { fmt.Println("") fmt.Println("# Running gcloud terraform vet") @@ -46,6 +46,7 @@ func TerraformVet(t testing.TB, terraformDir, policyPath, project string) error RetryableTerraformErrors: testutils.RetryableTransientErrors, MaxRetries: MaxErrorRetries, TimeBetweenRetries: TimeBetweenErrorRetries, + EnvVars: envVars, } _, err := terraform.PlanE(t, options) if err != nil { diff --git a/helpers/foundation-deployer/utils/files.go b/helpers/foundation-deployer/utils/files.go index b7eab50b9..34ffcf729 100644 --- a/helpers/foundation-deployer/utils/files.go +++ b/helpers/foundation-deployer/utils/files.go @@ -16,16 +16,25 @@ package utils import ( "bytes" + "fmt" "io/fs" "os" "path/filepath" + "strings" ) const ( TerraformTempDir = ".terraform" TerraformLockFile = ".terraform.lock.hcl" + DisableFileSuffix = ".example" + DefaultBuild = "cb" + GitHubBuild = "github" + GitLabBuild = "gitlab" ) +// Allowed build types. +var AllowedBuildTypes = []string{"cb", "github", "gitlab", "jenkins", "terraform_cloud"} + // CopyFile copies a single file from the src path to the dest path func CopyFile(src string, dest string) error { s, err := os.Stat(src) @@ -103,3 +112,58 @@ func FileExists(filename string) (bool, error) { } return false, err } + +// RenameBuildFiles renames files based on targetBuild string. +func RenameBuildFiles(basePath, targetBuild string) error { + // Validate build type. + validBuildType := false + for _, validType := range AllowedBuildTypes { + if targetBuild == validType { + validBuildType = true + break + } + } + + if !validBuildType { + return fmt.Errorf("invalid build type '%s'. Must be one of: %s", targetBuild, strings.Join(AllowedBuildTypes, ", ")) + } + + // Rename default build files to "*.example" + if targetBuild != DefaultBuild { + patternDefault := filepath.Join(basePath, fmt.Sprintf("*_%s.tf", DefaultBuild)) + filesDefault, err := filepath.Glob(patternDefault) + if err != nil { + return fmt.Errorf("error finding files (%s rename): %w", DefaultBuild, err) + } + + for _, file := range filesDefault { + newName := file + DisableFileSuffix + fmt.Printf("Renaming \"%s\" to \"%s\"\n", file, newName) + + err := os.Rename(file, newName) + if err != nil { + return fmt.Errorf("error renaming file \"%s\": %w", file, err) + } + } + } + + // Rename *_BUILD_TYPE.tf.example to *_BUILD_TYPE.tf if they exist. + pattern := filepath.Join(basePath, fmt.Sprintf("*_%s.tf%s", targetBuild, DisableFileSuffix)) + files, err := filepath.Glob(pattern) + if err != nil { + return fmt.Errorf("error finding files (target rename): %w", err) + } + + for _, file := range files { + baseName := strings.TrimSuffix(file, fmt.Sprintf(".tf%s", DisableFileSuffix)) + newName := baseName + ".tf" + + fmt.Printf("Renaming \"%s\" to \"%s\"\n", file, newName) + + err := os.Rename(file, newName) + if err != nil { + return fmt.Errorf("error renaming file \"%s\": %w", file, err) + } + } + return nil +} diff --git a/helpers/foundation-deployer/utils/files_test.go b/helpers/foundation-deployer/utils/files_test.go index 5245bdaa3..9dcd44165 100644 --- a/helpers/foundation-deployer/utils/files_test.go +++ b/helpers/foundation-deployer/utils/files_test.go @@ -15,6 +15,7 @@ package utils import ( + "fmt" "os" "path/filepath" "testing" @@ -95,3 +96,64 @@ func TestFindFiles(t *testing.T) { assert.Len(t, files, 1, "must have found only one file") assert.Equal(t, filepath.Join(base, "one", "two", "three", "four", filename), files[0]) } + +func createRenameTestFiles(t *testing.T, tempDir string, targetBuild string, defaultBuild string) { + t.Helper() // Mark this function as a helper function. + // Create some test files to rename. + var filesToCreate []string + + // Files for the defaultBuild rename logic + filesToCreate = append(filesToCreate, filepath.Join(tempDir, fmt.Sprintf("test_%s.tf", defaultBuild))) + + // Files for the target build rename logic + filesToCreate = append(filesToCreate, filepath.Join(tempDir, fmt.Sprintf("test_%s.tf.example", targetBuild))) + filesToCreate = append(filesToCreate, filepath.Join(tempDir, "test_other.tf")) // Create a non-matching file + + for _, filename := range filesToCreate { + err := os.WriteFile(filename, []byte("test content"), 0644) // Write simple content + if err != nil { + t.Fatalf("Failed to create test file %s: %v", filename, err) + } + } +} + +func checkRenamedFiles(t *testing.T, tempDir string, targetBuild string, defaultBuild string) { + t.Helper() + // Check the files renamed successfully. + if targetBuild != DefaultBuild { + expectedNewName := filepath.Join(tempDir, fmt.Sprintf("test_%s.tf", targetBuild)) + assert.FileExists(t, expectedNewName, "File %s should exist after rename", expectedNewName) + + originalName := filepath.Join(tempDir, fmt.Sprintf("test_%s.tf.example", targetBuild)) + assert.NoFileExists(t, originalName, "File %s should not exist after rename", originalName) + + expectedDefaultBuildName := filepath.Join(tempDir, fmt.Sprintf("test_%s.tf.example", defaultBuild)) + assert.FileExists(t, expectedDefaultBuildName, "File %s should exist after default build rename", expectedDefaultBuildName) + + originalDefaultBuildName := filepath.Join(tempDir, fmt.Sprintf("test_%s.tf", defaultBuild)) + assert.NoFileExists(t, originalDefaultBuildName, "File %s should not exist after rename", originalDefaultBuildName) + + otherName := filepath.Join(tempDir, "test_other.tf") + assert.FileExists(t, otherName, "File %s should exist after default build rename", otherName) + } +} + +func TestRenameFiles(t *testing.T) { + for _, targetBuild := range AllowedBuildTypes { + t.Run(targetBuild, func(t *testing.T) { + tempDir := t.TempDir() + createRenameTestFiles(t, tempDir, targetBuild, DefaultBuild) + + err := RenameBuildFiles(tempDir, targetBuild) + assert.NoError(t, err, "RenameBuildFiles failed for targetBuild %s: %v", targetBuild, err) + checkRenamedFiles(t, tempDir, targetBuild, DefaultBuild) + }) + } + // Add a test case for an invalid build type + t.Run("invalid", func(t *testing.T) { + tempDir := t.TempDir() + err := RenameBuildFiles(tempDir, "invalid_build_type") + assert.Error(t, err, "RenameBuildFiles should have failed for invalid build type") + assert.Contains(t, err.Error(), "invalid build type", "RenameFiles should have returned an error about the build type") + }) +} diff --git a/helpers/foundation-deployer/utils/git.go b/helpers/foundation-deployer/utils/git.go index 7c2b55b3d..8e68870a0 100644 --- a/helpers/foundation-deployer/utils/git.go +++ b/helpers/foundation-deployer/utils/git.go @@ -16,7 +16,10 @@ package utils import ( "fmt" + "net/url" "os" + "os/exec" + "path" "strings" "github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test/pkg/gcloud" @@ -29,12 +32,46 @@ type GitRepo struct { conf *git.CmdCfg } +// GitClone clones git repositories, supporting CSR, Github and Gitlab type of source control +func GitClone(t testing.TB, repositoryType, repositoryName, repositoryURL, path, project string, logger *logger.Logger) GitRepo { + if repositoryType == "CSR" { + return cloneCSR(t, repositoryName, path, project, logger) + } + return cloneGit(t, repositoryURL, path, logger) +} + // CloneCSR clones a Google Cloud Source repository and returns a CmdConfig pointing to the repository. -func CloneCSR(t testing.TB, name, path, project string, logger *logger.Logger) GitRepo { +func cloneCSR(t testing.TB, repositoryName, path, project string, logger *logger.Logger) GitRepo { + _, err := os.Stat(path) + if os.IsNotExist(err) { + gcloud.Runf(t, "source repos clone %s %s --project %s", repositoryName, path, project) + } + return GitRepo{ + conf: git.NewCmdConfig(t, git.WithDir(path), git.WithLogger(logger)), + } +} + +func GetRepoOnly(t testing.TB, path string, logger *logger.Logger) GitRepo { + return GitRepo{ + conf: git.NewCmdConfig(t, git.WithDir(path), git.WithLogger(logger)), + } +} + +// cloneGit clones a Github or Gitlab repository and returns a CmdConfig pointing to the repository. +func cloneGit(t testing.TB, repositoryUrl, path string, logger *logger.Logger) GitRepo { _, err := os.Stat(path) + if os.IsNotExist(err) { - gcloud.Runf(t, "source repos clone %s %s --project %s", name, path, project) + cmd := exec.Command("git", "clone", repositoryUrl, path) + fmt.Printf("Executing command %s\n", cmd.String()) + // Run the command and capture its combined output + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("Error executing git command: %v", err) + } + fmt.Printf("git clone output:\n%s\n", string(output)) } + return GitRepo{ conf: git.NewCmdConfig(t, git.WithDir(path), git.WithLogger(logger)), } @@ -107,3 +144,30 @@ func (g GitRepo) AddRemote(name, url string) error { func (g GitRepo) GetCommitSha() (string, error) { return g.conf.RunCmdE("rev-parse", "HEAD") } + +func BuildGitHubURL(owner, repoName string) string { + return fmt.Sprintf("https://github.com/%s/%s.git", owner, repoName) +} + +func BuildGitLabURL(owner, repoName string) string { + return fmt.Sprintf("https://gitlab.com/%s/%s.git", owner, repoName) +} + +// ExtractRepoNameFromGitHubURL parses a GitHub URL and returns the repository name. +func ExtractRepoNameFromGitHubURL(githubURL string) (string, error) { + parsedURL, err := url.Parse(githubURL) + if err != nil { + return "", fmt.Errorf("failed to parse URL: %w", err) + } + + repoNameWithSuffix := path.Base(parsedURL.Path) + if repoNameWithSuffix == "" || repoNameWithSuffix == "." || repoNameWithSuffix == "/" { + return "", fmt.Errorf("could not find a repository name in the URL path: %s", githubURL) + } + + repoName := strings.TrimSuffix(repoNameWithSuffix, ".git") + if repoName == "" { + return "", fmt.Errorf("extracted repository name is empty after processing: %s", githubURL) + } + return repoName, nil +} diff --git a/helpers/foundation-deployer/utils/git_test.go b/helpers/foundation-deployer/utils/git_test.go index c9441ab86..c4f04d280 100644 --- a/helpers/foundation-deployer/utils/git_test.go +++ b/helpers/foundation-deployer/utils/git_test.go @@ -39,6 +39,24 @@ func createLocalRepo(t *testing.T, repo string) string { return local } +func TestGenericClone(t *testing.T) { + repo := "terraform-google-secret" + fileSystemRepo := createLocalRepo(t, repo) + + dir := t.TempDir() + path := filepath.Join(dir, repo) + err := os.MkdirAll(dir, 0755) + assert.NoError(t, err) + + local := GitClone(t, "Generic", "terraform-google-secret", "file://"+fileSystemRepo, path, "", logger.Discard) + err = local.CheckoutBranch("unit-test") + assert.NoError(t, err) + + localBranch, err := local.GetCurrentBranch() + assert.NoError(t, err) + assert.Equal(t, localBranch, "unit-test", "current branch should be 'unit-test'") +} + func TestGit(t *testing.T) { remote := "origin" repo := createLocalRepo(t, "my-git-repo") @@ -46,7 +64,7 @@ func TestGit(t *testing.T) { err := CopyDirectory(repo, originPath) assert.NoError(t, err) - local := CloneCSR(t, "my-git-repo", repo, "", logger.Discard) + local := GitClone(t, "CSR", "my-git-repo", "", repo, "", logger.Discard) err = local.AddRemote(remote, originPath) assert.NoError(t, err) @@ -70,7 +88,7 @@ func TestGit(t *testing.T) { assert.NoError(t, err) assert.True(t, hasUpstream, "branch 'unit-test' should have a remote") - origin := CloneCSR(t, "my-git-repo", originPath, "", logger.Discard) + origin := GitClone(t, "CSR", "my-git-repo", "", originPath, "", logger.Discard) files, err := FindFiles(originPath, "go.mod") assert.NoError(t, err) assert.Len(t, files, 0, "'go.mod' file should not exist on main branch") diff --git a/helpers/foundation-deployer/utils/retry.go b/helpers/foundation-deployer/utils/retry.go new file mode 100644 index 000000000..6c802bf37 --- /dev/null +++ b/helpers/foundation-deployer/utils/retry.go @@ -0,0 +1,53 @@ + +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package utils + +import ( + "fmt" + "regexp" + + "github.com/terraform-google-modules/terraform-example-foundation/test/integration/testutils" + + "github.com/mitchellh/go-testing-interface" +) + +var ( + retryRegexp = map[*regexp.Regexp]string{} +) + +func init() { + for e, m := range testutils.RetryableTransientErrors { + r, err := regexp.Compile(fmt.Sprintf("(?s)%s", e)) //(?s) enables dot (.) to match newline. + if err != nil { + panic(fmt.Sprintf("failed to compile regex %s: %s", e, err.Error())) + } + retryRegexp[r] = m + } +} + +// IsRetryableError checks the logs of a failed execution +// and verify if the error is a transient one and can be retried +func IsRetryableError(t testing.TB, logs string) bool { + found := false + for pattern, msg := range retryRegexp { + if pattern.MatchString(logs) { + found = true + fmt.Printf("error '%s' is worth of a retry\n", msg) + break + } + } + return found +} diff --git a/test/integration/testutils/api.go b/test/integration/testutils/api.go index 3672ce1cd..a047c153e 100644 --- a/test/integration/testutils/api.go +++ b/test/integration/testutils/api.go @@ -46,8 +46,8 @@ func CheckAPIEnabled(t *testing.T, projectID, api string) (bool, error) { resultName := result.Get("name").String() resultState := result.Get("state").String() if !strings.Contains(resultName, api) || resultState != "ENABLED" { - return true, fmt.Errorf("API %s is not enabled in project %s", api, projectID) + return true, fmt.Errorf("the API %s is not enabled in project %s", api, projectID) } - logger.Log(t, fmt.Sprintf("API %s is enabled in project %s", api, projectID)) + logger.Log(t, fmt.Sprintf("the API %s is enabled in project %s", api, projectID)) return false, nil }