From 8a283d429f9d8c9380df7ed72acd3e206cb0a6ad Mon Sep 17 00:00:00 2001 From: Matt Moore Date: Sat, 23 Dec 2023 13:38:09 -0500 Subject: [PATCH] Add a module to encapsulate some of our Go service best practices. This module encapsulates much of the boilerplate to stand up a regionalized Go service from source using `ko_build`. Signed-off-by: Matt Moore --- .github/workflows/documentation.yaml | 1 + cloudevent-broker/ingress.tf | 71 ++++----------- cloudevent-broker/outputs.tf | 2 +- cloudevent-recorder/recorder.tf | 117 ++++++++----------------- regional-go-service/README.md | 41 +++++++++ regional-go-service/main.tf | 125 +++++++++++++++++++++++++++ regional-go-service/outputs.tf | 0 regional-go-service/variables.tf | 77 +++++++++++++++++ 8 files changed, 299 insertions(+), 135 deletions(-) create mode 100644 regional-go-service/README.md create mode 100644 regional-go-service/main.tf create mode 100644 regional-go-service/outputs.tf create mode 100644 regional-go-service/variables.tf diff --git a/.github/workflows/documentation.yaml b/.github/workflows/documentation.yaml index 587f7701..9c923672 100644 --- a/.github/workflows/documentation.yaml +++ b/.github/workflows/documentation.yaml @@ -14,6 +14,7 @@ jobs: - cloudevent-broker - cloudevent-trigger - cloudevent-recorder + - regional-go-service - otel-collector - networking - dashboard/service diff --git a/cloudevent-broker/ingress.tf b/cloudevent-broker/ingress.tf index 9a0dfed2..f287f02e 100644 --- a/cloudevent-broker/ingress.tf +++ b/cloudevent-broker/ingress.tf @@ -21,69 +21,32 @@ resource "google_pubsub_topic_iam_binding" "ingress-publishes-events" { members = ["serviceAccount:${google_service_account.this.email}"] } -// Build the ingress image using our minimal hardened base image. -resource "ko_build" "this" { - base_image = "cgr.dev/chainguard/static:latest-glibc" - importpath = "./cmd/ingress" - working_dir = path.module -} - -// Sign the image, assuming a keyless signing identity is available. -resource "cosign_sign" "this" { - image = ko_build.this.image_ref - - # Only keep the latest signature. - conflict = "REPLACE" -} - -module "otel-collector" { - source = "../otel-collector" - - project_id = var.project_id - service_account = google_service_account.this.email -} - -resource "google_cloud_run_v2_service" "this" { - for_each = var.regions - - // Explicitly wait for the iam binding before provisioning the service, - // since the service functionally depends on being able to publish events - // to the topic. In practice, GCP IAM is "eventually consistent" and there - // will still invariably be some latency after even the service is created - // where publishing may fail. - depends_on = [google_pubsub_topic_iam_binding.ingress-publishes-events] - - project = var.project_id - name = var.name - location = each.key +module "this" { + source = "../regional-go-service" + project_id = var.project_id + name = var.name + regions = var.regions // The ingress service is an internal service, and so it should only // be exposed to the internal network. ingress = "INGRESS_TRAFFIC_INTERNAL_ONLY" - launch_stage = "BETA" // Needed for vpc_access below + // Route all egress into the VPC + egress = "ALL_TRAFFIC" - template { - vpc_access { - network_interfaces { - network = each.value.network - subnetwork = each.value.subnet + service_account = google_service_account.this.email + containers = { + "ingress" = { + source = { + working_dir = path.module + importpath = "./cmd/ingress" } - egress = "ALL_TRAFFIC" // This should not egress - } - - service_account = google_service_account.this.email - containers { - image = cosign_sign.this.signed_ref - - ports { container_port = 8080 } - - env { + ports = [{ container_port = 8080 }] + regional-env = [{ name = "PUBSUB_TOPIC" - value = google_pubsub_topic.this[each.key].name - } + value = { for k, v in google_pubsub_topic.this : k => v.name } + }] } - containers { image = module.otel-collector.image } } } diff --git a/cloudevent-broker/outputs.tf b/cloudevent-broker/outputs.tf index 5c88b9cc..a56d6394 100644 --- a/cloudevent-broker/outputs.tf +++ b/cloudevent-broker/outputs.tf @@ -1,5 +1,5 @@ output "ingress" { - depends_on = [google_cloud_run_v2_service.this] + depends_on = [module.this] description = "An object holding the name of the ingress service, which can be used to authorize callers to publish cloud events." value = { name = var.name diff --git a/cloudevent-recorder/recorder.tf b/cloudevent-recorder/recorder.tf index 51e4aa34..c90e274c 100644 --- a/cloudevent-recorder/recorder.tf +++ b/cloudevent-recorder/recorder.tf @@ -16,100 +16,59 @@ resource "google_storage_bucket_iam_member" "recorder-writes-to-gcs-buckets" { member = "serviceAccount:${google_service_account.recorder.email}" } -resource "ko_build" "recorder-image" { - base_image = "cgr.dev/chainguard/static:latest-glibc" - importpath = "./cmd/recorder" - working_dir = path.module -} - -resource "cosign_sign" "recorder-image" { - image = ko_build.recorder-image.image_ref - - # Only keep the latest signature. - conflict = "REPLACE" -} - -resource "ko_build" "logrotate-image" { - base_image = "cgr.dev/chainguard/static:latest-glibc" - importpath = "./cmd/logrotate" - working_dir = path.module -} - -resource "cosign_sign" "logrotate-image" { - image = ko_build.logrotate-image.image_ref - - # Only keep the latest signature. - conflict = "REPLACE" -} - -module "otel-collector" { - source = "../otel-collector" - - project_id = var.project_id - service_account = google_service_account.recorder.email -} - -resource "google_cloud_run_v2_service" "recorder-service" { - for_each = var.regions +module "this" { + source = "../regional-go-service" + project_id = var.project_id + name = var.name + regions = var.regions - provider = google-beta # For empty_dir - project = var.project_id - name = var.name - location = each.key // This service should only be called by our Pub/Sub // subscription, so flag it as internal only. ingress = "INGRESS_TRAFFIC_INTERNAL_ONLY" - launch_stage = "BETA" + // Route all egress into the VPC + egress = "ALL_TRAFFIC" - template { - vpc_access { - network_interfaces { - network = each.value.network - subnetwork = each.value.subnet - } - egress = "ALL_TRAFFIC" // This should not egress - } - - service_account = google_service_account.recorder.email - containers { - image = cosign_sign.recorder-image.signed_ref - - ports { - container_port = 8080 + service_account = google_service_account.recorder.email + containers = { + "recorder" = { + source = { + working_dir = path.module + importpath = "./cmd/recorder" } - - env { + ports = [{ container_port = 8080 }] + env = [{ name = "LOG_PATH" value = "/logs" - } - volume_mounts { + }] + volume_mounts = [{ name = "logs" mount_path = "/logs" - } + }] } - containers { - image = cosign_sign.logrotate-image.signed_ref - - env { - name = "BUCKET" - value = google_storage_bucket.recorder[each.key].url + "logrotate" = { + source = { + working_dir = path.module + importpath = "./cmd/logrotate" } - env { + env = [{ name = "LOG_PATH" value = "/logs" - } - volume_mounts { + }] + regional-env = [{ + name = "BUCKET" + value = { for k, v in google_storage_bucket.recorder : k => v.url } + }] + volume_mounts = [{ name = "logs" mount_path = "/logs" - } - } - containers { image = module.otel-collector.image } - volumes { - name = "logs" - empty_dir {} + }] } } + volumes = [{ + name = "logs" + empty_dir = {} + }] } resource "random_id" "trigger-suffix" { @@ -128,10 +87,10 @@ module "triggers" { broker = var.broker[each.value.region] filter = { "type" : each.value.type } - depends_on = [google_cloud_run_v2_service.recorder-service] + depends_on = [module.this] private-service = { region = each.value.region - name = google_cloud_run_v2_service.recorder-service[each.value.region].name + name = var.name } } @@ -139,9 +98,7 @@ module "recorder-dashboard" { source = "../dashboard/cloudevent-receiver" service_name = var.name - labels = { - for type, schema in var.types : replace(type, ".", "_") => "" - } + labels = { for type, schema in var.types : replace(type, ".", "_") => "" } triggers = { for type, schema in var.types : "type: ${type}" => "${var.name}-${random_id.trigger-suffix[type].hex}" diff --git a/regional-go-service/README.md b/regional-go-service/README.md new file mode 100644 index 00000000..602657cb --- /dev/null +++ b/regional-go-service/README.md @@ -0,0 +1,41 @@ +# `regional-go-service` + +DO NOT SUBMIT + + +## Requirements + +No requirements. + +## Providers + +| Name | Version | +|------|---------| +| [google](#provider\_google) | n/a | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [google_cloud_run_v2_service_iam_member.authorize-calls](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/cloud_run_v2_service_iam_member) | resource | +| [google_cloud_run_v2_service.this](https://registry.terraform.io/providers/hashicorp/google/latest/docs/data-sources/cloud_run_v2_service) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [name](#input\_name) | The name of the Cloud Run service in this region. | `string` | n/a | yes | +| [project\_id](#input\_project\_id) | n/a | `string` | n/a | yes | +| [region](#input\_region) | The region in which this Cloud Run service is based. | `string` | n/a | yes | +| [service-account](#input\_service-account) | The email of the service account being authorized to invoke the private Cloud Run service. | `string` | n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| [uri](#output\_uri) | The URI of the private Cloud Run service. | + diff --git a/regional-go-service/main.tf b/regional-go-service/main.tf new file mode 100644 index 00000000..bdf9e411 --- /dev/null +++ b/regional-go-service/main.tf @@ -0,0 +1,125 @@ +terraform { + required_providers { + ko = { source = "ko-build/ko" } + cosign = { source = "chainguard-dev/cosign" } + } +} + +// Build each of the application images from source. +resource "ko_build" "this" { + for_each = var.containers + base_image = each.value.source.base_image + working_dir = each.value.source.working_dir + importpath = each.value.source.importpath +} + +// Sign each of the application images. +resource "cosign_sign" "this" { + for_each = var.containers + image = ko_build.this[each.key].image_ref + conflict = "REPLACE" +} + +// Build our otel-collector sidecar image. +module "otel-collector" { + source = "../otel-collector" + + project_id = var.project_id + service_account = var.service_account +} + +// Deploy the service into each of our regions. +resource "google_cloud_run_v2_service" "this" { + for_each = var.regions + + provider = google-beta # For empty_dir + project = var.project_id + name = var.name + location = each.key + ingress = var.ingress + + launch_stage = "BETA" // Needed for vpc_access below + + template { + vpc_access { + network_interfaces { + network = each.value.network + subnetwork = each.value.subnet + } + egress = var.egress + // TODO(mattmoor): When direct VPC egress supports network tags + // for NAT egress, then we should incorporate those here. + } + + service_account = var.service_account + dynamic "containers" { + for_each = var.containers + content { + image = cosign_sign.this[containers.key].signed_ref + + dynamic "ports" { + for_each = containers.value.ports + content { + name = ports.value.name + container_port = ports.value.container_port + } + } + + dynamic "env" { + for_each = containers.value.env + content { + name = env.value.name + value = env.value.value + } + } + + // Iterate over regional environment variables and look up the + // appropriate value to pass to each region. + dynamic "env" { + for_each = containers.value.regional-env + content { + name = env.value.name + value = env.value.value[each.key] + } + } + + dynamic "volume_mounts" { + for_each = containers.value.volume_mounts + content { + name = volume_mounts.value.name + mount_path = volume_mounts.value.mount_path + } + } + } + } + containers { image = module.otel-collector.image } + + dynamic "volumes" { + for_each = var.volumes + content { + name = volumes.value.name + + dynamic "secret" { + for_each = volumes.value.secret != null ? { "" : volumes.value.secret } : {} + content { + secret = secret.value.secret + dynamic "items" { + for_each = secret.value.items + content { + version = items.value.version + path = items.value.path + } + } + } + } + + dynamic "empty_dir" { + for_each = volumes.value.empty_dir != null ? { "" : volumes.value.empty_dir } : {} + content { + medium = empty_dir.value.medium + } + } + } + } + } +} \ No newline at end of file diff --git a/regional-go-service/outputs.tf b/regional-go-service/outputs.tf new file mode 100644 index 00000000..e69de29b diff --git a/regional-go-service/variables.tf b/regional-go-service/variables.tf new file mode 100644 index 00000000..38223cde --- /dev/null +++ b/regional-go-service/variables.tf @@ -0,0 +1,77 @@ +variable "project_id" { + type = string +} + +variable "name" { + type = string +} + +variable "regions" { + description = "A map from region names to a network and subnetwork. A pub/sub topic and ingress service (publishing to the respective topic) will be created in each region, with the ingress service configured to egress all traffic via the specified subnetwork." + type = map(object({ + network = string + subnet = string + })) +} + +variable "ingress" { + type = string + description = "The ingress mode for the service. Must be one of INGRESS_TRAFFIC_ALL, INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER, or INGRESS_TRAFFIC_INTERNAL_ONLY." + default = "INGRESS_TRAFFIC_INTERNAL_ONLY" +} + +variable "egress" { + type = string + description = "The egress mode for the service. Must be one of ALL_TRAFFIC, or PRIVATE_RANGES_ONLY. Egress traffic is routed through the regional VPC network from var.regions." + default = "ALL_TRAFFIC" +} + +variable "service_account" { + type = string + description = "The service account as which to run the service." +} + +variable "containers" { + description = "The containers to run in the service. Each container will be run in each region." + type = map(object({ + source = object({ + base_image = optional(string, "cgr.dev/chainguard/static:latest-glibc") + working_dir = string + importpath = string + }) + ports = optional(list(object({ + name = optional(string, "h2c") + container_port = number + })), []) + env = optional(list(object({ + name = string + value = string + })), []) + regional-env = optional(list(object({ + name = string + value = map(string) + })), []) + volume_mounts = optional(list(object({ + name = string + mount_path = string + })), []) + })) +} + +variable "volumes" { + description = "The volumes to make available to the containers in the service for mounting." + type = list(object({ + name = string + empty_dir = optional(object({ + medium = optional(string, "MEMORY") + })) + secret = optional(object({ + secret = string + items = list(object({ + version = string + path = string + })) + })) + })) + default = [] +}