locals { env = module.envs.envs[var.env] project = local.env.project region = local.env.region # a guest's host owns the project-wide parts, so a guest only adds its own identities and grants guest = try(local.env.guest_of, null) != null blueprints = "${local.project}-blueprints" # this module sits inside the tree it uploads blueprint_dir = "${path.module}/../.." # infra manager's terraform, which the blueprint has to stay compatible with tf_version = "=1.5.7" } module "envs" { source = "../envs" } data "archive_file" "blueprint" { type = "zip" source_dir = local.blueprint_dir output_path = "${local.blueprint_dir}/.build/${var.env}.zip" excludes = [ ".build", ".gitignore", ".terraform", ".terraform.lock.hcl", "*.tfplan", "README.md", "control", "modules/control", "modules/nodes", "modules/workers", "nodes", "terraform.tfstate", "terraform.tfstate.backup", "workers", ] } resource "google_storage_bucket" "blueprints" { count = local.guest ? 0 : 1 project = local.project name = local.blueprints location = local.region uniform_bucket_level_access = true public_access_prevention = "enforced" versioning { enabled = true } # revisions fetch their blueprint by name, so superseded generations only # need to outlive the audit window, not forever lifecycle_rule { condition { num_newer_versions = 10 } action { type = "Delete" } } } # from before guests, until dev and prod have both applied past it moved { from = google_storage_bucket.blueprints to = google_storage_bucket.blueprints[0] } resource "google_storage_bucket_object" "blueprint" { bucket = coalesce(one(google_storage_bucket.blueprints[*].name), local.blueprints) name = "${var.env}-${data.archive_file.blueprint.output_sha}.zip" source = data.archive_file.blueprint.output_path } # the deployment identity is created and granted HERE, outside the revisions # that run as it - the blueprint never declares its own powers resource "google_service_account" "deploy" { project = local.project account_id = "im-deployer-${local.env.env_suffix}" display_name = "infra manager deployment identity for ${var.env}" } resource "google_service_account" "ci" { project = local.project account_id = "ci-deployer-${local.env.env_suffix}" display_name = "ci deploy identity for ${var.env}" } resource "google_project_iam_member" "ci" { for_each = toset([ "roles/config.admin", "roles/config.agent", "roles/artifactregistry.writer", ]) project = local.project role = each.value member = google_service_account.ci.member } resource "google_service_account_iam_member" "ci_acts_as_deployment" { service_account_id = google_service_account.deploy.name role = "roles/iam.serviceAccountUser" member = google_service_account.ci.member } resource "google_project_service" "this" { for_each = local.guest ? toset([]) : toset(["certificatemanager.googleapis.com", "secretmanager.googleapis.com"]) project = local.project service = each.value disable_on_destroy = false } # the secrets services read live here, outside the blueprint, so a value can be added # (gcloud secrets versions add) before any revision needs it and never reaches state. # readers are named the way modules/environment names its service accounts locals { secret_reads = merge([ for svc, s in local.env.services : { for _, id in try(s.secrets, {}) : "${id}/${svc}" => { id = id, svc = svc } } ]...) } resource "google_secret_manager_secret" "this" { for_each = local.guest ? toset([]) : setunion([for r in local.secret_reads : r.id], local.worker_secrets) project = local.project secret_id = each.value replication { auto {} } depends_on = [google_project_service.this] } resource "google_secret_manager_secret_iam_member" "readers" { for_each = local.secret_reads project = local.project secret_id = each.value.id role = "roles/secretmanager.secretAccessor" member = "serviceAccount:${each.value.svc}-${local.env.env_suffix}@${local.project}.iam.gserviceaccount.com" depends_on = [google_secret_manager_secret.this] } locals { ci_oidc = try(local.env.ci_oidc, null) ci_wif = local.ci_oidc == null ? toset([]) : toset(["spindle"]) # one pool per project, made by the host, with a provider in it per env ci_pool = "tangled-ci" ci_provider = local.guest ? "spindle-${local.env.env_suffix}" : "spindle" } data "google_project" "this" { project_id = local.project } resource "google_project_service" "sts" { for_each = local.guest ? toset([]) : local.ci_wif project = local.project service = "sts.googleapis.com" disable_on_destroy = false } resource "google_iam_workload_identity_pool" "ci" { for_each = local.guest ? toset([]) : local.ci_wif project = local.project workload_identity_pool_id = local.ci_pool display_name = "tangled ci" } resource "google_iam_workload_identity_pool_provider" "spindle" { for_each = local.ci_wif project = local.project workload_identity_pool_id = local.ci_pool workload_identity_pool_provider_id = local.ci_provider display_name = "tangled spindle" attribute_mapping = { "google.subject" = "assertion.sub" "attribute.repository" = "assertion.repository" "attribute.ref" = "assertion.ref" } # manual runs carry an empty ref, so repository alone would admit them attribute_condition = "assertion.repository == '${local.ci_oidc.repository}' && assertion.ref == '${local.ci_oidc.ref}'" oidc { issuer_uri = local.ci_oidc.issuer } depends_on = [google_project_service.sts, google_iam_workload_identity_pool.ci] } resource "google_service_account_iam_member" "ci_federated" { for_each = local.ci_wif service_account_id = google_service_account.ci.name role = "roles/iam.workloadIdentityUser" member = "principal://iam.googleapis.com/projects/${data.google_project.this.number}/locations/global/workloadIdentityPools/${local.ci_pool}/subject/${local.ci_oidc.repository}:${local.ci_oidc.ref}" } resource "google_project_iam_member" "deploy" { for_each = toset([ "roles/config.agent", "roles/run.admin", "roles/compute.networkAdmin", "roles/compute.loadBalancerAdmin", "roles/artifactregistry.admin", "roles/iam.serviceAccountUser", "roles/iam.serviceAccountCreator", "roles/certificatemanager.editor", ]) project = local.project role = each.value member = google_service_account.deploy.member } resource "google_config_deployment" "this" { project = local.project name = "tangled-${var.env}" location = local.region service_account = google_service_account.deploy.id tf_version_constraint = local.tf_version deletion_policy = "ABANDON" terraform_blueprint { gcs_source = "gs://${google_storage_bucket_object.blueprint.bucket}/${google_storage_bucket_object.blueprint.name}" input_values { variable_name = "env_name" input_value = jsonencode(var.env) } # an empty map comes back from infra manager as "", which would plan as a change forever dynamic "input_values" { for_each = length(var.image_refs) == 0 ? [] : [var.image_refs] content { variable_name = "image_refs" input_value = jsonencode(input_values.value) } } } labels = { env = local.env.env_suffix } } resource "google_storage_bucket_iam_member" "deployment_reads_blueprint" { bucket = google_storage_bucket_object.blueprint.bucket role = "roles/storage.objectViewer" member = google_service_account.deploy.member }