diff --git a/build/int.cloudbuild.yaml b/build/int.cloudbuild.yaml index d748f5b6..475c5378 100644 --- a/build/int.cloudbuild.yaml +++ b/build/int.cloudbuild.yaml @@ -26,16 +26,19 @@ steps: - 'TF_VAR_billing_account=$_BILLING_ACCOUNT' - 'TF_VAR_group_org_admins=test-gcp-org-admins@test.blueprints.joonix.net' - 'TF_VAR_group_billing_admins=test-gcp-billing-admins@test.blueprints.joonix.net' + secretEnv: ['IM_GITHUB_PAT'] - id: init-all waitFor: - prepare name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' args: ['/bin/bash', '-c', 'cft test run all --stage init --verbose'] + secretEnv: ['IM_GITHUB_PAT'] - id: create-all waitFor: - init-all name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' args: ['/bin/bash', '-c', 'source /usr/local/bin/task_helper_functions.sh && kitchen_do create'] + secretEnv: ['IM_GITHUB_PAT'] - id: converge-simple name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' args: ['/bin/bash', '-c', 'source /usr/local/bin/task_helper_functions.sh && kitchen_do converge simple-default'] @@ -132,6 +135,29 @@ steps: name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' args: ['/bin/bash', '-c', 'cft test run TestTFCloudBuildWorkspaceSimple --stage teardown --verbose'] +- id: apply-imworkspace-github + waitFor: + - create-all + name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' + args: ['/bin/bash', '-c', 'cft test run TestIMCloudBuildWorkspaceGitHub --stage apply --verbose'] + secretEnv: ['IM_GITHUB_PAT'] +- id: verify-imworkspace-github + waitFor: + - apply-imworkspace-github + name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' + args: ['/bin/bash', '-c', 'cft test run TestIMCloudBuildWorkspaceGitHub --stage verify --verbose'] + secretEnv: ['IM_GITHUB_PAT'] +- id: teardown-imworkspace-github + waitFor: + - verify-imworkspace-github + name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' + args: ['/bin/bash', '-c', 'cft test run TestIMCloudBuildWorkspaceGitHub --stage teardown --verbose'] + secretEnv: ['IM_GITHUB_PAT'] + +availableSecrets: + secretManager: + - versionName: $_IM_GITHUB_PAT_SECRET_ID/versions/latest + env: 'IM_GITHUB_PAT' tags: - 'ci' - 'integration' diff --git a/examples/im_cloudbuild_workspace_github/README.md b/examples/im_cloudbuild_workspace_github/README.md new file mode 100644 index 00000000..e7abcb5d --- /dev/null +++ b/examples/im_cloudbuild_workspace_github/README.md @@ -0,0 +1,25 @@ +## Overview + +This example demonstrates the simplest usage of the [im_cloudbuild_workspace](../../modules/im_cloudbuild_workspace/) module. + + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| im\_github\_pat | GitHub personal access token. | `string` | n/a | yes | +| project\_id | The ID of the project in which to provision resources. | `string` | n/a | yes | +| repository\_url | The URI of the repo where the Terraform configs are stored. | `string` | n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| cloudbuild\_apply\_trigger\_id | Trigger used for running IM apply | +| cloudbuild\_preview\_trigger\_id | Trigger used for creating IM previews | +| cloudbuild\_sa | Service account used by the Cloud Build triggers | +| github\_secret\_id | The secret ID for the GitHub secret containing the personal access token. | +| infra\_manager\_sa | Service account used by Infrastructure Manager | +| project\_id | n/a | + + diff --git a/examples/im_cloudbuild_workspace_github/apis.tf b/examples/im_cloudbuild_workspace_github/apis.tf new file mode 100644 index 00000000..7ac4dac5 --- /dev/null +++ b/examples/im_cloudbuild_workspace_github/apis.tf @@ -0,0 +1,31 @@ +/** + * Copyright 2024 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. + */ + +module "enabled_google_apis" { + source = "terraform-google-modules/project-factory/google//modules/project_services" + version = "~> 14.0" + + project_id = var.project_id + disable_services_on_destroy = false + + activate_apis = [ + "iam.googleapis.com", + "secretmanager.googleapis.com", + "compute.googleapis.com", + "cloudbuild.googleapis.com", + "config.googleapis.com", + ] +} diff --git a/examples/im_cloudbuild_workspace_github/main.tf b/examples/im_cloudbuild_workspace_github/main.tf new file mode 100644 index 00000000..ac83e91c --- /dev/null +++ b/examples/im_cloudbuild_workspace_github/main.tf @@ -0,0 +1,35 @@ +/** + * Copyright 2024 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. + */ + +module "im_workspace" { + source = "../../modules/im_cloudbuild_workspace" + + project_id = var.project_id + deployment_id = "im-example-github-deployment" + + tf_repo_type = "GITHUB" + im_deployment_repo_uri = var.repository_url + im_deployment_ref = "main" + im_tf_variables = "project_id=${var.project_id}" + infra_manager_sa_roles = ["roles/compute.networkAdmin"] + tf_cloudbuilder = "hashicorp/terraform:1.2.3" + + // Found in the URL of your Cloud Build GitHub app configuration settings + // https://cloud.google.com/build/docs/automating-builds/github/connect-repo-github?generation=2nd-gen#connecting_a_github_host_programmatically + github_app_installation_id = "47590865" + + github_personal_access_token = var.im_github_pat +} diff --git a/examples/im_cloudbuild_workspace_github/outputs.tf b/examples/im_cloudbuild_workspace_github/outputs.tf new file mode 100644 index 00000000..b30487b3 --- /dev/null +++ b/examples/im_cloudbuild_workspace_github/outputs.tf @@ -0,0 +1,45 @@ +/** + * Copyright 2024 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. + */ + +output "project_id" { + value = var.project_id +} + +output "cloudbuild_preview_trigger_id" { + description = "Trigger used for creating IM previews" + value = module.im_workspace.cloudbuild_preview_trigger_id +} + +output "cloudbuild_apply_trigger_id" { + description = "Trigger used for running IM apply" + value = module.im_workspace.cloudbuild_apply_trigger_id +} + +output "cloudbuild_sa" { + description = "Service account used by the Cloud Build triggers" + value = module.im_workspace.cloudbuild_sa +} + +output "infra_manager_sa" { + description = "Service account used by Infrastructure Manager" + value = module.im_workspace.infra_manager_sa +} + +output "github_secret_id" { + description = "The secret ID for the GitHub secret containing the personal access token." + value = module.im_workspace.github_secret_id + sensitive = true +} diff --git a/examples/im_cloudbuild_workspace_github/variables.tf b/examples/im_cloudbuild_workspace_github/variables.tf new file mode 100644 index 00000000..ef719fc5 --- /dev/null +++ b/examples/im_cloudbuild_workspace_github/variables.tf @@ -0,0 +1,31 @@ +/** + * Copyright 2024 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. + */ + +variable "project_id" { + description = "The ID of the project in which to provision resources." + type = string +} + +variable "repository_url" { + description = "The URI of the repo where the Terraform configs are stored." + type = string +} + +variable "im_github_pat" { + description = "GitHub personal access token." + type = string + sensitive = true +} diff --git a/modules/im_cloudbuild_workspace/README.md b/modules/im_cloudbuild_workspace/README.md new file mode 100644 index 00000000..8409d4ef --- /dev/null +++ b/modules/im_cloudbuild_workspace/README.md @@ -0,0 +1,113 @@ +## Overview + +This IM Cloud Build Workspace blueprint creates an opinionated workflow for actuating Terraform +resources on Cloud Build using Infrastructure Manager. A set of Cloud Build triggers manage +preview and apply operations on a configuration stored in a GitHub repository. +The Cloud Build triggers use a per-workspace Service Account which can be configured with a +minimal set of permissions for calling Infrastructure Manager. Infrastructure Manager uses a separate +service account with a set of permissions required by the given Terraform configuration. + +## Usage + +Basic usage of this module is as follows: + +```hcl +module "im-workspace" { + source = "terraform-google-modules/bootstrap/google//modules/im_cloudbuild_workspace" + version = "~> 7.0" + + project_id = var.project_id + deployment_id = var.deployment_id + im_deployment_repo_uri = var.im_deployment_repo_uri + im_deployment_ref = var.im_deployment_ref + + github_app_installation_id = var.github_app_installation_id + github_personal_access_token = var.github_personal_access_token +} +``` + +## Resources Created + +This module creates: +- Two Cloud Build triggers with an inline build configuration for planning and applying Terraform configurations +using Infrastructure Manger. Additional optional build configurations can be specified. +- Optional custom Service Accounts and roles to be used for invoking Cloud Build and used in Infrastructure Manager +for actuating resources. +- Connections to GitHub or GitLab repositories. + +![](./assets/arch.png) + +## Notes + + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| cloudbuild\_apply\_filename | Optional Cloud Build YAML definition used for Cloud Build triggers of Infra Manager apply. Defaults to using inline definition. | `string` | `""` | no | +| cloudbuild\_ignored\_files | Optional list. Changes only affecting ignored files will not invoke a build. | `list(string)` | `[]` | no | +| cloudbuild\_included\_files | Optional list. Changes affecting at least one of these files will invoke a build. | `list(string)` | `[]` | no | +| cloudbuild\_preview\_filename | Optional Cloud Build YAML definition used for Cloud Build triggers of Infra Manager preview. Defaults to using inline definition. | `string` | `""` | no | +| cloudbuild\_sa | Custom SA ID of form projects/{{project}}/serviceAccounts/{{email}} to be used for creating Cloud Build triggers. Creates one if not given. | `string` | `""` | no | +| custom\_cloudbuild\_sa\_name | Custom name to be used if creating a Cloud Build service account. Defaults to generated name if empty. | `string` | `""` | no | +| custom\_infra\_manager\_sa\_name | Custom name to be used if creating an Infrastructure Manager service account. Defaults to generated name if empty. | `string` | `""` | no | +| deployment\_id | Custom ID to be used for the Infrastructure Manager deployment. | `string` | n/a | yes | +| github\_app\_installation\_id | Installation ID of the Cloud Build GitHub app used for pull and push request triggers. | `string` | `""` | no | +| github\_pat\_secret | The secret ID within Secret Manager for an existing personal access token for GitHub. | `string` | `""` | no | +| github\_pat\_secret\_version | The secret version ID or alias for the GitHub PAT secret. Uses the latest if not provided. | `string` | `""` | no | +| github\_personal\_access\_token | Personal access token for a GitHub repository. If provided, creates a secret within Secret Manager. | `string` | `""` | no | +| host\_connection\_name | Name for the VCS connection. Generated if not given. | `string` | `""` | no | +| im\_deployment\_ref | Git branch or ref configured to run infra-manager apply. All other refs will run plan by default. | `string` | n/a | yes | +| im\_deployment\_repo\_dir | The directory inside the repo where the Terraform root config is located. If empty defaults to repo root. | `string` | `""` | no | +| im\_deployment\_repo\_uri | The URI of the repo where the Terraform configs are stored. | `string` | n/a | yes | +| im\_tf\_variables | Optional list of Terraform variables to pass to Infrastructure Manager, if the configuration exists in a different repo. List of strings of form KEY=VALUE expected. | `string` | `""` | no | +| infra\_manager\_sa | Custom SA id of form projects/{{project}}/serviceAccounts/{{email}} to be used by Infra Manager. Defaults to generated name if empty. | `string` | `""` | no | +| infra\_manager\_sa\_roles | List of roles to grant to Infrastructure Manager SA for actuating resources defined in the Terraform configuration. | `list(string)` | `[]` | no | +| location | Location for Infrastructure Manager deployment. | `string` | `"us-central1"` | no | +| project\_id | GCP project for Infrastructure Manager deployments and Cloud Build triggers. | `string` | n/a | yes | +| pull\_request\_comment\_control | Configure builds to run whether a repository owner or collaborator needs to comment /gcbrun. | `string` | `"COMMENTS_ENABLED_FOR_EXTERNAL_CONTRIBUTORS_ONLY"` | no | +| repo\_connection\_name | Connection name for linked repository. Generated if not given. | `string` | `""` | no | +| substitutions | Optional map of substitutions to use in builds if using a custom Cloud Build YAML definition. | `map(string)` | `{}` | no | +| tf\_cloudbuilder | Name of the Cloud Builder image used for running build steps. | `string` | `"hashicorp/terraform:1.5.7"` | no | +| tf\_repo\_type | Type of repo | `string` | `"GITHUB"` | no | +| trigger\_location | Location of for Cloud Build triggers created in the workspace. Matches `location` if not given. | `string` | `"us-central1"` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| cloudbuild\_apply\_trigger\_id | Trigger used for running infra-manager apply | +| cloudbuild\_preview\_trigger\_id | Trigger used for running infra-manager preview | +| cloudbuild\_sa | Service account used by the Cloud Build triggers | +| github\_secret\_id | The secret ID for the GitHub secret containing the personal access token. | +| infra\_manager\_sa | Service account used by Infrastructure Manager | +| repo\_connection\_id | The Cloud Build repository connection ID | +| vcs\_connection\_id | The Cloud Build VCS host connection ID | + + + +## Requirements + +### Software + +- [Terraform](https://www.terraform.io/downloads.html) ~> 1.2.3 +- [terraform-provider-google] plugin >= 3.50.x + +### Permissions + +### APIs + +A project with the following APIs enabled must be used to host the +resources of this module: + +```hcl +"config.googleapis.com", +"iam.googleapis.com", +"cloudbuild.googleapis.com", +"storage.googleapis.com", +``` + +## Contributing + +Refer to the [contribution guidelines](../../CONTRIBUTING.md) for +information on contributing to this module. diff --git a/modules/im_cloudbuild_workspace/assets/arch.png b/modules/im_cloudbuild_workspace/assets/arch.png new file mode 100644 index 00000000..0df9b990 Binary files /dev/null and b/modules/im_cloudbuild_workspace/assets/arch.png differ diff --git a/modules/im_cloudbuild_workspace/cb.tf b/modules/im_cloudbuild_workspace/cb.tf new file mode 100644 index 00000000..58e0a828 --- /dev/null +++ b/modules/im_cloudbuild_workspace/cb.tf @@ -0,0 +1,119 @@ +/** + * Copyright 2024 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. + */ + +locals { + default_create_preview_script = templatefile("${path.module}/templates/create-preview.sh.tftpl", { + project_id = var.project_id + location = var.location + deployment_id = var.deployment_id + service_account = local.im_sa + source_repo = var.im_deployment_repo_uri + source_repo_dir = var.im_deployment_repo_dir + tf_vars = var.im_tf_variables + }) + + default_preview_steps = [ + { id = "git_setup", name = "gcr.io/cloud-builders/git", args = ["config", "--global", "init.defaultBranch", "main"] }, + { id = "create_preview", name = "gcr.io/cloud-builders/gcloud", script = local.default_create_preview_script }, + { id = "download_preview", name = "gcr.io/cloud-builders/gcloud", args = ["infra-manager", "previews", "export", "projects/${var.project_id}/locations/${var.location}/previews/preview-$SHORT_SHA", "--file", "plan"] }, + { id = "terraform_init", name = var.tf_cloudbuilder, args = ["init", "-no-color"] }, + { id = "terraform_show", name = var.tf_cloudbuilder, args = ["show", "/workspace/plan.tfplan", "-no-color"] }, + ] + + default_apply_steps = [ + { + id = "apply" + name = "gcr.io/cloud-builders/gcloud", + args = compact([ + "infra-manager", + "deployments", + "apply", + "projects/${var.project_id}/locations/${var.location}/deployments/${var.deployment_id}", + "--service-account=${local.im_sa}", + "--git-source-repo=${var.im_deployment_repo_uri}", + var.im_deployment_repo_dir != "" ? "--git-source-directory=${var.im_deployment_repo_dir}" : "", + var.im_deployment_ref != "" ? "--git-source-ref=${var.im_deployment_ref}" : "", + var.im_tf_variables != "" ? "--input-values=${var.im_tf_variables}" : "" + ]) + } + ] + + default_triggers_steps = { + "preview" = local.default_preview_steps, + "apply" = local.default_apply_steps + } +} + +resource "google_cloudbuild_trigger" "triggers" { + for_each = local.default_triggers_steps + + project = var.project_id + location = var.trigger_location + name = "im-${random_id.resources_random_id.dec}-${local.default_prefix}-${each.key}" + description = "${title(each.key)} Terraform configs for ${var.im_deployment_repo_uri} ${var.im_deployment_repo_dir}" + include_build_logs = local.is_gh_repo ? "INCLUDE_BUILD_LOGS_WITH_STATUS" : null + + repository_event_config { + repository = google_cloudbuildv2_repository.repository_connection.id + dynamic "pull_request" { + for_each = each.key == "preview" ? [1] : [] + content { + branch = var.im_deployment_ref + invert_regex = false + comment_control = var.pull_request_comment_control + } + } + dynamic "push" { + for_each = each.key == "apply" ? [1] : [] + content { + branch = var.im_deployment_ref + invert_regex = false + } + } + } + + dynamic "build" { + # only enable inline build config if no explicit config specified + for_each = var.cloudbuild_preview_filename == "" && var.cloudbuild_apply_filename == "" ? [1] : [] + content { + dynamic "step" { + for_each = each.value + content { + id = step.value.id + name = step.value.name + entrypoint = try(step.value.entrypoint, null) + args = try(step.value.args, null) + script = try(step.value.script, null) + env = [ + "SHORT_SHA=$SHORT_SHA" + ] + } + } + options { + logging = "CLOUD_LOGGING_ONLY" + } + } + } + + service_account = local.cloudbuild_sa + substitutions = var.substitutions + included_files = var.cloudbuild_included_files + ignored_files = var.cloudbuild_ignored_files + + depends_on = [ + google_project_iam_member.im_sa_roles, + ] +} diff --git a/modules/im_cloudbuild_workspace/github.tf b/modules/im_cloudbuild_workspace/github.tf new file mode 100644 index 00000000..02a13153 --- /dev/null +++ b/modules/im_cloudbuild_workspace/github.tf @@ -0,0 +1,69 @@ +/** + * Copyright 2024 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. + */ + +locals { + # GitHub repo url of form "github.com/owner/name" + is_gh_repo = var.tf_repo_type == "GITHUB" + gh_repo_url_split = local.is_gh_repo ? split("/", local.url) : [] + gh_name = local.is_gh_repo ? local.gh_repo_url_split[length(local.gh_repo_url_split) - 1] : "" + + create_github_secret = local.is_gh_repo && var.github_personal_access_token != "" + existing_github_secret_version = local.is_gh_repo && var.github_pat_secret != "" ? data.google_secret_manager_secret_version.existing_github_pat_secret_version[0].name : "" + github_secret_version_id = local.create_github_secret ? google_secret_manager_secret_version.github_token_secret_version[0].name : local.existing_github_secret_version + + secret_id = var.github_personal_access_token != "" ? google_secret_manager_secret.github_token_secret[0].id : data.google_secret_manager_secret.existing_github_pat_secret[0].secret_id +} + +// Create a secret containing the personal access token and grant permissions to the Service Agent. +resource "google_secret_manager_secret" "github_token_secret" { + count = local.create_github_secret ? 1 : 0 + project = var.project_id + secret_id = "im-github-${random_id.resources_random_id.dec}-${local.gh_name}" + + labels = { + label = "im-${var.deployment_id}" + } + + replication { + auto {} + } +} + +// Personal access token from VCS. +resource "google_secret_manager_secret_version" "github_token_secret_version" { + count = local.create_github_secret ? 1 : 0 + secret = google_secret_manager_secret.github_token_secret[0].id + secret_data = var.github_personal_access_token +} + +data "google_secret_manager_secret" "existing_github_pat_secret" { + count = var.github_pat_secret != "" ? 1 : 0 + project = var.project_id + secret_id = var.github_pat_secret +} + +data "google_secret_manager_secret_version" "existing_github_pat_secret_version" { + count = var.github_pat_secret != "" ? 1 : 0 + project = var.project_id + secret = data.google_secret_manager_secret.existing_github_pat_secret[0].secret_id + version = var.github_pat_secret_version != "" ? var.github_pat_secret_version : null +} + +resource "google_secret_manager_secret_iam_member" "github_token_iam_member" { + secret_id = local.secret_id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:service-${data.google_project.project.number}@gcp-sa-cloudbuild.iam.gserviceaccount.com" +} diff --git a/modules/im_cloudbuild_workspace/outputs.tf b/modules/im_cloudbuild_workspace/outputs.tf new file mode 100644 index 00000000..a32cee15 --- /dev/null +++ b/modules/im_cloudbuild_workspace/outputs.tf @@ -0,0 +1,51 @@ +/** + * Copyright 2024 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. + */ + +output "cloudbuild_preview_trigger_id" { + description = "Trigger used for running infra-manager preview" + value = google_cloudbuild_trigger.triggers["preview"].id +} + +output "cloudbuild_apply_trigger_id" { + description = "Trigger used for running infra-manager apply" + value = google_cloudbuild_trigger.triggers["apply"].id +} + +output "cloudbuild_sa" { + description = "Service account used by the Cloud Build triggers" + value = local.cloudbuild_sa +} + +output "infra_manager_sa" { + description = "Service account used by Infrastructure Manager" + value = local.im_sa +} + +output "vcs_connection_id" { + description = "The Cloud Build VCS host connection ID" + value = google_cloudbuildv2_connection.vcs_connection.id +} + +output "repo_connection_id" { + description = "The Cloud Build repository connection ID" + value = google_cloudbuildv2_repository.repository_connection.id +} + +output "github_secret_id" { + description = "The secret ID for the GitHub secret containing the personal access token." + value = local.secret_id + sensitive = true +} diff --git a/modules/im_cloudbuild_workspace/repo.tf b/modules/im_cloudbuild_workspace/repo.tf new file mode 100644 index 00000000..31266532 --- /dev/null +++ b/modules/im_cloudbuild_workspace/repo.tf @@ -0,0 +1,62 @@ +/** + * Copyright 2024 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. + */ + +locals { + # Remove ".git" suffix if it's included + url = trimsuffix(var.im_deployment_repo_uri, ".git") + + repo = local.gh_name + default_prefix = local.repo + + host_connection_name = var.host_connection_name != "" ? var.host_connection_name : "im-${random_id.resources_random_id.dec}-${var.project_id}-${var.deployment_id}" + repo_connection_name = var.repo_connection_name != "" ? var.repo_connection_name : "im-${random_id.resources_random_id.dec}-${local.repo}" +} + +data "google_project" "project" { + project_id = var.project_id +} + +// Added to various IDs to prevent potential conflicts for deployments targeting the same repository. +resource "random_id" "resources_random_id" { + byte_length = 4 +} + +// Create the VCS connection. +resource "google_cloudbuildv2_connection" "vcs_connection" { + project = var.project_id + location = var.location + + name = local.host_connection_name + + dynamic "github_config" { + for_each = local.is_gh_repo ? [1] : [] + content { + app_installation_id = var.github_app_installation_id + authorizer_credential { + oauth_token_secret_version = local.github_secret_version_id + } + } + } +} + +// Create the repository connection. +resource "google_cloudbuildv2_repository" "repository_connection" { + project = var.project_id + location = var.location + name = local.repo_connection_name + parent_connection = google_cloudbuildv2_connection.vcs_connection.name + remote_uri = var.im_deployment_repo_uri +} diff --git a/modules/im_cloudbuild_workspace/sa.tf b/modules/im_cloudbuild_workspace/sa.tf new file mode 100644 index 00000000..38d21dec --- /dev/null +++ b/modules/im_cloudbuild_workspace/sa.tf @@ -0,0 +1,93 @@ +/** + * Copyright 2024 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. + */ + +locals { + create_cloudbuild_sa = var.cloudbuild_sa == "" + cloudbuild_sa = local.create_cloudbuild_sa ? google_service_account.cb_sa[0].id : var.cloudbuild_sa + // Service account format is projects/PROJECT_ID/serviceAccounts/SERVICE_ACCOUNT_EMAIL + cloudbuild_sa_email = element(split("/", local.cloudbuild_sa), length(split("/", local.cloudbuild_sa)) - 1) + create_infra_manager_sa = var.infra_manager_sa == "" + im_sa = local.create_infra_manager_sa ? google_service_account.im_sa[0].id : var.infra_manager_sa + im_sa_email = element(split("/", local.im_sa), length(split("/", local.im_sa)) - 1) +} + +resource "google_service_account" "cb_sa" { + count = local.create_cloudbuild_sa ? 1 : 0 + project = var.project_id + account_id = trimsuffix(substr(var.custom_cloudbuild_sa_name != "" ? var.custom_cloudbuild_sa_name : "cb-sa-${random_id.resources_random_id.dec}-${local.default_prefix}", 0, 30), "-") + description = "SA used for Cloud Build triggers invoking Infrastructure Manager." +} + +# https://cloud.google.com/infrastructure-manager/docs/configure-service-account +resource "google_project_iam_member" "cb_config_admin_role" { + count = local.create_cloudbuild_sa ? 1 : 0 + project = var.project_id + role = "roles/config.admin" + member = "serviceAccount:${local.cloudbuild_sa_email}" +} + +# Allow trigger logs to be written +resource "google_project_iam_member" "cb_logWriter_role" { + count = local.create_cloudbuild_sa ? 1 : 0 + project = var.project_id + role = "roles/logging.logWriter" + member = "serviceAccount:${local.cloudbuild_sa_email}" +} + +# Allows the Cloud Build service account to act as the Infra Manger service account +resource "google_project_iam_member" "cb_serviceAccountUser_role" { + count = local.create_cloudbuild_sa ? 1 : 0 + project = var.project_id + role = "roles/iam.serviceAccountUser" + member = "serviceAccount:${local.cloudbuild_sa_email}" +} + +resource "google_project_iam_member" "cb_storage_objects_viewer" { + count = local.create_cloudbuild_sa ? 1 : 0 + project = var.project_id + role = "roles/storage.objectViewer" + member = "serviceAccount:${local.cloudbuild_sa_email}" +} + +resource "google_service_account" "im_sa" { + count = local.create_infra_manager_sa ? 1 : 0 + project = var.project_id + account_id = trimsuffix(substr(var.custom_infra_manager_sa_name != "" ? var.custom_infra_manager_sa_name : "im-sa-${random_id.resources_random_id.dec}-${local.default_prefix}", 0, 30), "-") + description = "SA used by Infrastructure Manager for actuating resources." +} + +# https://cloud.google.com/infrastructure-manager/docs/configure-service-account +resource "google_project_iam_member" "im_config_agent_role" { + count = local.create_infra_manager_sa ? 1 : 0 + project = var.project_id + role = "roles/config.agent" + member = "serviceAccount:${local.im_sa_email}" +} + +# https://cloud.google.com/build/docs/securing-builds/configure-user-specified-service-accounts#permissions +resource "google_project_iam_member" "im_sa_logging" { + count = local.create_infra_manager_sa ? 1 : 0 + project = var.project_id + role = "roles/logging.logWriter" + member = "serviceAccount:${local.im_sa_email}" +} + +resource "google_project_iam_member" "im_sa_roles" { + for_each = toset(var.infra_manager_sa_roles) + project = var.project_id + role = each.value + member = "serviceAccount:${local.im_sa_email}" +} diff --git a/modules/im_cloudbuild_workspace/templates/create-preview.sh.tftpl b/modules/im_cloudbuild_workspace/templates/create-preview.sh.tftpl new file mode 100644 index 00000000..2f0a2c35 --- /dev/null +++ b/modules/im_cloudbuild_workspace/templates/create-preview.sh.tftpl @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Copyright 2024 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. + +echo "Checking if deployment ${deployment_id} already exists" +DEPLOYMENT_EXISTS=$(gcloud infra-manager deployments describe projects/${project_id}/locations/${location}/deployments/${deployment_id} | tail -n +2 | wc -l) + +echo "Deleting previous preview if it already exists" +gcloud infra-manager previews delete projects/${project_id}/locations/${location}/previews/preview-$SHORT_SHA --quiet + +CREATE_PREVIEW_CMD="gcloud infra-manager previews create projects/${project_id}/locations/${location}/previews/preview-$SHORT_SHA \ + --service-account=${service_account} \ + --git-source-repo=${source_repo} \ + --git-source-ref=$SHORT_SHA" + +if [[ "${source_repo_dir}" != "" ]]; then + CREATE_PREVIEW_CMD+=" --git-source-directory=${source_repo_dir}" +fi + +if [[ "${tf_vars}" != "" ]]; then + CREATE_PREVIEW_CMD+=" --input-values=${tf_vars}" +fi + +if [[ $DEPLOYMENT_EXISTS -eq 1 ]]; then + CREATE_PREVIEW_CMD+=" --deployment projects/${project_id}/locations/${location}/deployments/${deployment_id}" +fi + +$CREATE_PREVIEW_CMD + +if [[ $(echo $?) -ne 0 ]]; then + gcloud infra-manager previews describe projects/${project_id}/locations/${location}/previews/preview-$SHORT_SHA + exit 1 +else + exit 0 +fi diff --git a/modules/im_cloudbuild_workspace/variables.tf b/modules/im_cloudbuild_workspace/variables.tf new file mode 100644 index 00000000..2388e456 --- /dev/null +++ b/modules/im_cloudbuild_workspace/variables.tf @@ -0,0 +1,184 @@ +/** + * Copyright 2024 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. + */ + +variable "project_id" { + description = "GCP project for Infrastructure Manager deployments and Cloud Build triggers." + type = string +} + +variable "location" { + description = "Location for Infrastructure Manager deployment." + type = string + default = "us-central1" +} + +variable "trigger_location" { + description = "Location of for Cloud Build triggers created in the workspace. Matches `location` if not given." + type = string + default = "us-central1" +} + +variable "deployment_id" { + description = "Custom ID to be used for the Infrastructure Manager deployment." + type = string +} + +variable "host_connection_name" { + description = "Name for the VCS connection. Generated if not given." + type = string + default = "" +} + +variable "repo_connection_name" { + description = "Connection name for linked repository. Generated if not given." + type = string + default = "" +} + +variable "cloudbuild_sa" { + description = "Custom SA ID of form projects/{{project}}/serviceAccounts/{{email}} to be used for creating Cloud Build triggers. Creates one if not given." + type = string + default = "" +} + +variable "custom_cloudbuild_sa_name" { + description = "Custom name to be used if creating a Cloud Build service account. Defaults to generated name if empty." + type = string + default = "" +} + +variable "infra_manager_sa" { + description = "Custom SA id of form projects/{{project}}/serviceAccounts/{{email}} to be used by Infra Manager. Defaults to generated name if empty." + type = string + default = "" +} + +variable "custom_infra_manager_sa_name" { + description = "Custom name to be used if creating an Infrastructure Manager service account. Defaults to generated name if empty." + type = string + default = "" +} + +variable "infra_manager_sa_roles" { + description = "List of roles to grant to Infrastructure Manager SA for actuating resources defined in the Terraform configuration." + type = list(string) + default = [] +} + +variable "im_deployment_repo_uri" { + description = "The URI of the repo where the Terraform configs are stored." + type = string +} + +variable "im_deployment_repo_dir" { + description = "The directory inside the repo where the Terraform root config is located. If empty defaults to repo root." + type = string + default = "" +} + +variable "im_deployment_ref" { + description = "Git branch or ref configured to run infra-manager apply. All other refs will run plan by default." + type = string +} + +variable "im_tf_variables" { + description = "Optional list of Terraform variables to pass to Infrastructure Manager, if the configuration exists in a different repo. List of strings of form KEY=VALUE expected." + type = string + default = "" +} + +variable "cloudbuild_preview_filename" { + description = "Optional Cloud Build YAML definition used for Cloud Build triggers of Infra Manager preview. Defaults to using inline definition." + type = string + default = "" +} + +variable "cloudbuild_apply_filename" { + description = "Optional Cloud Build YAML definition used for Cloud Build triggers of Infra Manager apply. Defaults to using inline definition." + type = string + default = "" +} + +variable "substitutions" { + description = "Optional map of substitutions to use in builds if using a custom Cloud Build YAML definition." + type = map(string) + default = {} +} + +variable "cloudbuild_included_files" { + description = "Optional list. Changes affecting at least one of these files will invoke a build." + type = list(string) + default = [] +} + +variable "cloudbuild_ignored_files" { + description = "Optional list. Changes only affecting ignored files will not invoke a build." + type = list(string) + default = [] +} + +variable "tf_cloudbuilder" { + description = "Name of the Cloud Builder image used for running build steps." + type = string + default = "hashicorp/terraform:1.5.7" +} + +variable "tf_repo_type" { + description = "Type of repo" + type = string + default = "GITHUB" + validation { + condition = contains(["GITHUB"], var.tf_repo_type) + error_message = "Must be one of GITHUB" + } +} + +variable "pull_request_comment_control" { + description = "Configure builds to run whether a repository owner or collaborator needs to comment /gcbrun." + type = string + default = "COMMENTS_ENABLED_FOR_EXTERNAL_CONTRIBUTORS_ONLY" + validation { + condition = contains(["COMMENTS_DISABLED", "COMMENTS_ENABLED", "COMMENTS_ENABLED_FOR_EXTERNAL_CONTRIBUTORS_ONLY"], var.pull_request_comment_control) + error_message = "Must be one of COMMENTS_DISABLED, COMMENTS_ENABLED, or COMMENTS_ENABLED_FOR_EXTERNAL_CONTRIBUTORS_ONLY" + } +} + +# GitHub specific variables + +variable "github_app_installation_id" { + description = "Installation ID of the Cloud Build GitHub app used for pull and push request triggers." + type = string + default = "" +} + +variable "github_personal_access_token" { + description = "Personal access token for a GitHub repository. If provided, creates a secret within Secret Manager." + type = string + sensitive = true + default = "" +} + +variable "github_pat_secret" { + description = "The secret ID within Secret Manager for an existing personal access token for GitHub." + type = string + default = "" +} + +variable "github_pat_secret_version" { + description = "The secret version ID or alias for the GitHub PAT secret. Uses the latest if not provided." + type = string + default = "" +} diff --git a/modules/im_cloudbuild_workspace/versions.tf b/modules/im_cloudbuild_workspace/versions.tf new file mode 100644 index 00000000..e2e2becb --- /dev/null +++ b/modules/im_cloudbuild_workspace/versions.tf @@ -0,0 +1,40 @@ +/** + * Copyright 2024 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. + */ + +terraform { + required_version = ">= 1.2.3" + + required_providers { + google = { + source = "hashicorp/google" + # Exclude 4.31.0 for https://github.com/hashicorp/terraform-provider-google/issues/12226 + version = ">= 4.17, != 4.31.0, < 6" + } + google-beta = { + source = "hashicorp/google-beta" + # Exclude 4.31.0 for https://github.com/hashicorp/terraform-provider-google/issues/12226 + version = ">= 4.17, != 4.31.0, < 6" + } + random = { + source = "hashicorp/random" + version = ">= 3.6.0" + } + } + + provider_meta "google" { + module_name = "blueprints/terraform/terraform-google-bootstrap:im_cloudbuild_workspace/v7.0.0" + } +} diff --git a/test/integration/go.mod b/test/integration/go.mod index 48821125..189b80de 100644 --- a/test/integration/go.mod +++ b/test/integration/go.mod @@ -6,6 +6,8 @@ toolchain go1.21.6 require ( github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test v0.12.0 + github.com/google/go-github/v60 v60.0.0 + github.com/gruntwork-io/terratest v0.46.11 github.com/stretchr/testify v1.8.4 ) @@ -28,13 +30,13 @@ require ( github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/gnostic-models v0.6.8 // indirect - github.com/google/go-cmp v0.5.9 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/s2a-go v0.1.7 // indirect github.com/google/uuid v1.3.1 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.5 // indirect github.com/googleapis/gax-go/v2 v2.12.0 // indirect - github.com/gruntwork-io/terratest v0.46.11 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-getter v1.7.2 // indirect diff --git a/test/integration/go.sum b/test/integration/go.sum index 486b8739..69c2108e 100644 --- a/test/integration/go.sum +++ b/test/integration/go.sum @@ -187,8 +187,6 @@ cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoIS dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test v0.11.1 h1:S4Y7o5RKRC9Bk71VszCx9NeheWjdSAn5ejPuD1W6lNE= -github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test v0.11.1/go.mod h1:v4TFK9TmX4mYyXL3v9wFXVN3A5vrt2LaVDBX2/OVU7Y= github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test v0.12.0 h1:j3lJhu+RyDR+QUO/7rG1byJGpu5Q37DC8CY4aG9/cLo= github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test v0.12.0/go.mod h1:iipeDC0VhxKtIVOZyz0ofiJ2x6sh9+VYEbG3JKsPqPw= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= @@ -305,8 +303,13 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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-github/v60 v60.0.0 h1:oLG98PsLauFvvu4D/YPxq374jhSxFYdzQGNCyONLfn8= +github.com/google/go-github/v60 v60.0.0/go.mod h1:ByhX2dP9XT9o/ll2yXAu2VD8l5eNVg8hD4Cr0S/LmQk= +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/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= @@ -355,8 +358,6 @@ github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56 github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/gruntwork-io/terratest v0.46.8 h1:rgK7z6Dy/eMGFaclKR0WVG9Z54tR+Ehl7S09+8Y25j0= -github.com/gruntwork-io/terratest v0.46.8/go.mod h1:6MxfmOFQQEpQZjpuWRwuAK8qm836hYgAOCzSIZIWTmg= github.com/gruntwork-io/terratest v0.46.11 h1:1Z9G18I2FNuH87Ro0YtjW4NH9ky4GDpfzE7+ivkPeB8= github.com/gruntwork-io/terratest v0.46.11/go.mod h1:DVZG/s7eP1u3KOQJJfE6n7FDriMWpDvnj85XIlZMEM8= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -430,8 +431,8 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -522,8 +523,6 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= -golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -616,8 +615,6 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -683,8 +680,6 @@ golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/test/integration/im_cloudbuild_workspace_github/files/main.tf b/test/integration/im_cloudbuild_workspace_github/files/main.tf new file mode 100644 index 00000000..ee73b2c2 --- /dev/null +++ b/test/integration/im_cloudbuild_workspace_github/files/main.tf @@ -0,0 +1,51 @@ +/** + * Copyright 2024 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. + */ + +variable "project_id" { + description = "The project ID to host the network in" +} + +module "test-vpc-module" { + source = "terraform-google-modules/network/google" + version = "2.6.0" + project_id = var.project_id + network_name = "my-example-custom-network" + + subnets = [ + { + subnet_name = "example-subnet-01" + subnet_ip = "10.10.10.0/24" + subnet_region = "us-west1" + }, + { + subnet_name = "example-subnet-02" + subnet_ip = "10.10.20.0/24" + subnet_region = "us-west1" + subnet_private_access = "true" + subnet_flow_logs = "true" + }, + { + subnet_name = "example-subnet-03" + subnet_ip = "10.10.30.0/24" + subnet_region = "us-west1" + subnet_flow_logs = "true" + subnet_flow_logs_interval = "INTERVAL_10_MIN" + subnet_flow_logs_sampling = 0.7 + subnet_flow_logs_metadata = "INCLUDE_ALL_METADATA" + subnet_flow_logs_filter = "false" + } + ] +} diff --git a/test/integration/im_cloudbuild_workspace_github/im_cloudbuild_workspace_github_test.go b/test/integration/im_cloudbuild_workspace_github/im_cloudbuild_workspace_github_test.go new file mode 100644 index 00000000..bded1ceb --- /dev/null +++ b/test/integration/im_cloudbuild_workspace_github/im_cloudbuild_workspace_github_test.go @@ -0,0 +1,284 @@ +// Copyright 2024 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 im_cloudbuild_workspace_github + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test/pkg/gcloud" + "github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test/pkg/git" + "github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test/pkg/tft" + "github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test/pkg/utils" + "github.com/google/go-github/v60/github" + "github.com/gruntwork-io/terratest/modules/logger" + "github.com/stretchr/testify/assert" +) + +type GitHubClient struct { + t *testing.T + client *github.Client + owner string + repoName string + repository *github.Repository +} + +func NewGitHubClient(t *testing.T, token, owner, repo string) *GitHubClient { + t.Helper() + client := github.NewClient(nil).WithAuthToken(token) + return &GitHubClient{ + t: t, + client: client, + owner: owner, + repoName: repo, + } +} + +// GetOpenPullRequest gets an open pull request for a given branch if it exists. +func (gh *GitHubClient) GetOpenPullRequest(ctx context.Context, branch string) *github.PullRequest { + opts := &github.PullRequestListOptions{ + State: "open", + Head: branch, + } + prs, resp, err := gh.client.PullRequests.List(ctx, gh.owner, gh.repoName, opts) + if resp.StatusCode != 422 && err != nil { + gh.t.Fatal(err.Error()) + } + if len(prs) == 0 { + return nil + } + return prs[0] +} + +func (gh *GitHubClient) CreatePullRequest(ctx context.Context, title, branch, base string) *github.PullRequest { + newPR := &github.NewPullRequest{ + Title: github.String(title), + Head: github.String(branch), + Base: github.String(base), + } + pr, _, err := gh.client.PullRequests.Create(ctx, gh.owner, gh.repoName, newPR) + if err != nil { + gh.t.Fatal(err.Error()) + } + return pr +} + +func (gh *GitHubClient) MergePullRequest(ctx context.Context, pr *github.PullRequest, commitTitle, commitMessage string) *github.PullRequestMergeResult { + result, _, err := gh.client.PullRequests.Merge(ctx, gh.owner, gh.repoName, *pr.Number, commitMessage, nil) + if err != nil { + gh.t.Fatal(err.Error()) + } + return result +} + +func (gh *GitHubClient) ClosePullRequest(ctx context.Context, pr *github.PullRequest) { + pr.State = github.String("closed") + _, _, err := gh.client.PullRequests.Edit(ctx, gh.owner, gh.repoName, *pr.Number, pr) + if err != nil { + gh.t.Fatal(err.Error()) + } +} + +func (gh *GitHubClient) GetRepository(ctx context.Context) *github.Repository { + repo, resp, err := gh.client.Repositories.Get(ctx, gh.owner, gh.repoName) + if resp.StatusCode != 404 && err != nil { + gh.t.Fatal(err.Error()) + } + gh.repository = repo + return repo +} + +func (gh *GitHubClient) CreateRepository(ctx context.Context, org, repoName string) *github.Repository { + newRepo := &github.Repository{ + Name: github.String(repoName), + AutoInit: github.Bool(true), + } + repo, _, err := gh.client.Repositories.Create(ctx, org, newRepo) + if err != nil { + gh.t.Fatal(err.Error()) + } + gh.repository = repo + return repo +} + +func (gh *GitHubClient) AddFileToRepository(ctx context.Context, file []byte) { + opts := &github.RepositoryContentFileOptions{ + Content: file, + Message: github.String("Setup commit"), + } + _, _, err := gh.client.Repositories.CreateFile(ctx, gh.owner, gh.repoName, "main.tf", opts) + if err != nil { + gh.t.Fatal(err.Error()) + } +} + +func (gh *GitHubClient) DeleteRepository(ctx context.Context) { + _, err := gh.client.Repositories.Delete(ctx, gh.owner, *gh.repository.Name) + if err != nil { + gh.t.Fatal(err.Error()) + } +} + +func TestIMCloudBuildWorkspaceGitHub(t *testing.T) { + ctx := context.Background() + + githubPAT := utils.ValFromEnv(t, "IM_GITHUB_PAT") + client := NewGitHubClient(t, githubPAT, "im-goose", "im-blueprint-test") + + repo := client.GetRepository(ctx) + if repo == nil { + client.CreateRepository(ctx, client.owner, client.repoName) + client.AddFileToRepository(ctx, getTerraformExample(t)) + } + + vars := map[string]interface{}{ + "im_github_pat": githubPAT, + "repository_url": client.repository.GetCloneURL(), + } + bpt := tft.NewTFBlueprintTest(t, tft.WithVars(vars)) + + bpt.DefineVerify(func(assert *assert.Assertions) { + bpt.DefaultVerify(assert) + + projectID := bpt.GetStringOutput("project_id") + secretID := bpt.GetStringOutput("github_secret_id") + triggerLocation := "us-central1" + repoURLSplit := strings.Split(client.repository.GetCloneURL(), "/") + + // CB P4SA IAM + projectNum := gcloud.Runf(t, "projects describe %s --format='value(projectNumber)'", projectID).Get("projectNumber") + iamOP := gcloud.Runf(t, "secrets get-iam-policy %s --project %s --flatten bindings --filter bindings.members:'serviceAccount:service-%s@gcp-sa-cloudbuild.iam.gserviceaccount.com'", secretID, projectID, projectNum).Array() + utils.GetFirstMatchResult(t, iamOP, "bindings.role", "roles/secretmanager.secretAccessor") + + // CB SA IAM + cbSA := lastElem(bpt.GetStringOutput("cloudbuild_sa"), "/") + iamOP = gcloud.Runf(t, "projects get-iam-policy %s --flatten bindings --filter bindings.members:'serviceAccount:%s'", projectID, cbSA).Array() + utils.GetFirstMatchResult(t, iamOP, "bindings.role", "roles/config.admin") + + // IM SA IAM + imSA := lastElem(bpt.GetStringOutput("infra_manager_sa"), "/") + iamOP = gcloud.Runf(t, "projects get-iam-policy %s --flatten bindings --filter bindings.members:'serviceAccount:%s'", projectID, imSA).Array() + utils.GetFirstMatchResult(t, iamOP, "bindings.role", "roles/config.agent") + + // e2e test for testing actuation through both preview/apply branches + previewTrigger := lastElem(bpt.GetStringOutput("cloudbuild_preview_trigger_id"), "/") + applyTrigger := lastElem(bpt.GetStringOutput("cloudbuild_apply_trigger_id"), "/") + + // set up repo + tmpDir := t.TempDir() + git := git.NewCmdConfig(t, git.WithDir(tmpDir)) + gitRun := func(args ...string) { + _, err := git.RunCmdE(args...) + if err != nil { + t.Fatal(err) + } + } + + repo := strings.TrimSuffix(repoURLSplit[len(repoURLSplit)-1], ".git") + user := repoURLSplit[len(repoURLSplit)-2] + gitRun("clone", fmt.Sprintf("https://%s@github.com/%s/%s", githubPAT, user, repo), tmpDir) + gitRun("config", "user.email", "tf-robot@example.com") + gitRun("config", "user.name", "TF Robot") + + // push commits on preview and main branches + // preview branch should trigger preview trigger + // main branch should trigger apply trigger + var pullRequest *github.PullRequest + branches := []string{"preview", "main"} + for _, branch := range branches { + _, err := git.RunCmdE("checkout", branch) + if err != nil { + git.RunCmdE("checkout", "-b", branch) + } + + var lastCommit string + switch branch { + case "preview": + git.CommitWithMsg(fmt.Sprintf("%s commit", branch), []string{"--allow-empty"}) + gitRun("push", "--set-upstream", "origin", branch, "-f") + + // Close existing pull requests (if they exist) + pr := client.GetOpenPullRequest(ctx, branch) + if pr != nil { + client.ClosePullRequest(ctx, pr) + } + pullRequest = client.CreatePullRequest(ctx, "preview PR", branch, "main") + lastCommit = git.GetLatestCommit() + case "main": + mergedPr := client.MergePullRequest(ctx, pullRequest, "main commit", "main message") + lastCommit = *mergedPr.SHA + } + + // filter builds triggered based on pushed commit sha + buildListCmd := fmt.Sprintf("builds list --filter substitutions.COMMIT_SHA='%s' --project %s --region %s --limit 1", lastCommit, projectID, triggerLocation) + // poll build until complete + pollCloudBuild := func(cmd string) func() (bool, error) { + return func() (bool, error) { + build := gcloud.Run(t, cmd, gcloud.WithLogger(logger.Discard)).Array() + if len(build) < 1 { + return true, nil + } + latestWorkflowRunStatus := build[0].Get("status").String() + if latestWorkflowRunStatus == "SUCCESS" { + return false, nil + } + if latestWorkflowRunStatus == "TIMEOUT" || latestWorkflowRunStatus == "FAILURE" { + t.Logf("%v", build[0]) + t.Fatalf("workflow %s failed with failureInfo %s", build[0].Get("id"), build[0].Get("failureInfo")) + } + return true, nil + } + } + utils.Poll(t, pollCloudBuild(buildListCmd), 20, 10*time.Second) + build := gcloud.Run(t, buildListCmd, gcloud.WithLogger(logger.Discard)).Array()[0] + + switch branch { + case "preview": + assert.Equal(previewTrigger, build.Get("buildTriggerId").String(), "was triggered by preview trigger") + case "main": + assert.Equal(applyTrigger, build.Get("buildTriggerId").String(), "was triggered by apply trigger") + } + } + }) + + bpt.DefineTeardown(func(assert *assert.Assertions) { + projectID := bpt.GetStringOutput("project_id") + gcloud.Runf(t, "infra-manager deployments delete projects/%s/locations/us-central1/deployments/im-example-github-deployment --project %s --quiet", projectID, projectID) + client.DeleteRepository(ctx) + bpt.DefaultTeardown(assert) + }) + + bpt.Test() +} + +// lastElem gets the last element in a string separated by sep. +// Typically used to grab a resource ID from a full resource name. +func lastElem(name, sep string) string { + return strings.Split(name, sep)[len(strings.Split(name, sep))-1] +} + +// getTerraformExample returns the contents of the example main.tf file. +func getTerraformExample(t *testing.T) []byte { + t.Helper() + contents, err := os.ReadFile("files/main.tf") + if err != nil { + t.Fatal(err.Error()) + } + return contents +} diff --git a/test/setup/main.tf b/test/setup/main.tf index afb5eb10..6bbb5c81 100644 --- a/test/setup/main.tf +++ b/test/setup/main.tf @@ -35,7 +35,15 @@ module "project" { "cloudkms.googleapis.com", "artifactregistry.googleapis.com", "workflows.googleapis.com", - "cloudscheduler.googleapis.com" + "cloudscheduler.googleapis.com", + "secretmanager.googleapis.com", + ] + + activate_api_identities = [ + { + api = "cloudbuild.googleapis.com", + roles = ["roles/cloudbuild.builds.builder"] + } ] } @@ -47,3 +55,20 @@ resource "google_folder" "bootstrap" { display_name = "ci-bootstrap-folder-${random_id.suffix.hex}" parent = "folders/${var.folder_id}" } + +data "google_client_config" "default" {} + +resource "terracurl_request" "poke" { + name = "poke-cb" + url = "https://cloudbuild.googleapis.com/v1/projects/${module.project.project_id}/locations/us-central1/builds" + method = "POST" + headers = { + Authorization = "Bearer ${data.google_client_config.default.access_token}" + Content-Type = "application/json", + } + response_codes = [400] + depends_on = [ + module.project + ] +} + diff --git a/test/setup/versions.tf b/test/setup/versions.tf index 30651cd2..1b72f4f8 100644 --- a/test/setup/versions.tf +++ b/test/setup/versions.tf @@ -28,5 +28,9 @@ terraform { random = { source = "hashicorp/random" } + terracurl = { + source = "devops-rob/terracurl" + version = "~> 1.0" + } } }