Discord Bot Pipeline - Discord Specific Terraform Setup
This post is part 7 of a 10 part series:
- Part 1 - Discord Bot Pipeline - Intro
- Part 2 - Discord Bot Pipeline - GCP Setup
- Part 3 - Discord Bot Pipeline - GitHub Setup
- Part 4 - Discord Bot Pipeline - Terraform Setup
- Part 5 - Discord Bot Pipeline - GitHub Actions Setup
- Part 6 - Discord Bot Pipeline - Making Terraform Changes
- Part 7 - Discord Bot Pipeline - Discord Specific Terraform Setup (This Post)
- Part 8 - Discord Bot Pipeline - More Setup
- Part 9 - Discord Bot Pipeline - Creating The Bot
- Part 10 - Discord Bot Pipeline - Conclusion
Now that we have a pipeline that can generate new projects and apply Terraform
for us, let’s customize our new discord-bots
project and create some specific
Terraform configs for bot development.
The first thing is to update terraform/project.tf
again:
// Allow the Cloud Run Service Agent to access the Artifact Registry.
resource "google_project_iam_member" "serverless_robot_prod_roles" {
for_each = toset([
"roles/artifactregistry.reader",
"roles/run.serviceAgent"
])
project = google_project.project.project_id
role = each.key
member = "serviceAccount:service-${google_project.project.number}@serverless-robot-prod.iam.gserviceaccount.com"
}
// Grant Cloud Run Admin and Builder roles to the Cloud Build service account.
// This is needed to build new releases and deploy them to Cloud Run.
resource "google_project_iam_member" "cloudbuild_roles" {
for_each = toset([
"roles/cloudbuild.builds.builder",
"roles/run.admin"
])
project = google_project.project.project_id
role = each.key
member = "serviceAccount:${google_project.project.number}@cloudbuild.gserviceaccount.com"
}
// Give the default compute service account the Cloud Run Admin role.
// This is needed for deploying Cloud Run services.
resource "google_project_iam_member" "compute_roles" {
for_each = toset([
"roles/run.admin"
])
project = google_project.project.project_id
role = each.key
member = "serviceAccount:${google_project.project.number}-compute@developer.gserviceaccount.com"
}
// Allow Cloud Build to manage Cloud Run services
resource "google_project_iam_member" "gcp_sa_roles" {
for_each = toset([
"roles/cloudbuild.serviceAgent"
])
project = google_project.project.project_id
role = each.key
member = "serviceAccount:service-${google_project.project.number}@gcp-sa-cloudbuild.iam.gserviceaccount.com"
}
// Allow CloudBuild to act on behalf of another service account.
// This is needed to deploy services since each bot will use its own service account.
resource "google_service_account_iam_binding" "admin-account-iam" {
service_account_id = "${google_project.project.id}/serviceAccounts/${google_project.project.number}-compute@developer.gserviceaccount.com"
role = "roles/iam.serviceAccountUser"
members = [
"serviceAccount:${google_project.project.number}@cloudbuild.gserviceaccount.com",
]
}
// Needed to automate generating new Cloud Run revisions
resource "google_service_account_iam_binding" "run-service-agent" {
service_account_id = "${google_project.project.id}/serviceAccounts/${google_project.project.number}-compute@developer.gserviceaccount.com"
role = "roles/run.serviceAgent"
members = [
"serviceAccount:service-${google_project.project.number}@serverless-robot-prod.iam.gserviceaccount.com",
]
}
// Create a bucket for Cloud Build
resource "google_storage_bucket" "cloud-build-bucket" {
project = google_project.project.project_id
name = "${google_project.project.project_id}_cloudbuild"
location = "US"
force_destroy = true
}
// Create a new Secret to hold a GitHub key for automation.
// This is only needed if your repository is private.
resource "google_secret_manager_secret" "github-key" {
project = google_project.project.project_id
secret_id = "github-private-repo-key"
replication {
user_managed {
replicas {
location = var.zone
}
}
}
}
// Allow Cloud Build to access the GitHub key secret.
resource "google_secret_manager_secret_iam_binding" "github-access" {
project = google_project.project.project_id
secret_id = google_secret_manager_secret.github-key.secret_id
role = "roles/secretmanager.secretAccessor"
members = [
"serviceAccount:${google_project.project.number}@cloudbuild.gserviceaccount.com",
]
}
// Create a notification channel to be used by all bots.
// Replace the email address with your own.
resource "google_monitoring_notification_channel" "email-owner" {
project = google_project.project.project_id
display_name = "Email Me"
type = "email"
labels = {
email_address = "joshua@thompsonja.com"
}
}
Terraform modules
Terraform modules
organize groups of Terraform resources together in a cohesive and reusable way.
They are particularly useful if we want to create multiple Discord bots so that
we can define the configuration for a single bot, and then instantiate them
easily for each bot, avoiding a lot of duplicate Terraform config. Create a new
folder in your terraform
folder called modules/discord_bot
. The first file
we’ll want to create in this folder is one to define the variables that
configure an individual bot. Create variables.tf
and add the following to it:
variable "bot_name" {
description = "Name of the discord bot"
type = string
}
variable "github_trigger" {
type = object({
branch = string
included_files = list(string)
repo_owner = string
repo_name = string
cloudbuild_file = string
})
description = <<EOT
github_trigger = {
branch: "Branch to use to trigger builds"
included_files: "Optional regex glob of files to use to trigger builds"
repo_owner: "GitHub repository owner to use to trigger builds"
repo_name: "GitHub repository name to use to trigger builds"
cloudbuild_file: "GCP Cloud Build config file"
}
EOT
}
variable "gcp" {
type = object({
additional_roles = list(string)
additional_secrets = list(string)
artifact_repository_id = string
notification_channels = list(string)
owner = string
project_id = string
project_number = string
zone = string
})
description = <<EOT
gcp = {
additional_roles: = "Additional GCP roles to generate for this bot"
additional_secrets: = "Additional GCP Cloud Secrets to generate for this bot"
artifact_repository_id: = "GCP Artifact Registry Repository ID"
notification_channels: = "List of GCP monitoring notification channel IDs"
owner: "Owner to grant build/deploy access to"
project_id: "GCP Project ID"
project_number: "GCP Project Number"
zone: "GCP Zone to deploy bot resource to"
}
EOT
}
Note that we can define nested variables, useful for organizing variables into cohesive groups. In this case, we will define a Discord bot with a name, some GCP configuration, and some GitHub configuration. Most of the variables are relatively straightforward, but I want to highlight a few notable ones:
github_trigger.included_files
- The intent is to include all of our Discord bots in a single repository, meaning we’ll need to differentiate which files should trigger a new build based on filepath globs.github_trigger.cloudbuild_file
- We will define a top levelcloudbuild.yaml
file that configures GCP’s Cloud Build to build our bots whenever we push a new commit to GitHub.gcp.additional_roles
- Any additional roles necessary for a Discord bot. This may be necessary depending on what kind of interactions the bot needs. For example, if a bot will publish a GCP Pub Sub message, then it will need appropriate roles to access Pub Sub.gcp.additional_secrets
- Similarly, there may be secrets, like API keys to other services, that you may need to specify here.
Now let’s define a file in the same folder called main.tf
which defines the
resources needed for a Discord bot, using the variables we defined in
variables.tf
:
// Create a new Service Account. This is needed so that each Discord bot has a
// separate identity to act as, allowing us to separate access to different
// parts of our cloud infrastructure.
resource "google_service_account" "bot-service-account" {
project = var.gcp.project_id
account_id = "${var.bot_name}-sa"
display_name = "Service Account for ${var.bot_name} discord bot"
}
// Grant yourself and the Cloud Build service account the ability to act as the
// bot Service Account. This is needed for deploying bots.
resource "google_service_account_iam_binding" "bot-iam-binding" {
service_account_id = google_service_account.bot-service-account.name
role = "roles/iam.serviceAccountUser"
members = [
"serviceAccount:${var.gcp.project_number}@cloudbuild.gserviceaccount.com",
"user:${var.gcp.owner}"
]
}
// Grant the bot Service Account the ability to write logs, in addition to any
// specified as a var.
resource "google_project_iam_member" "bot-role" {
for_each = toset(concat(var.gcp.additional_roles, ["roles/logging.logWriter"]))
project = var.gcp.project_id
role = each.key
member = "serviceAccount:${google_service_account.bot-service-account.email}"
}
// Every bot gets at least a single secret created to store the Discord bot key
// generated when you create a new bot.
resource "google_secret_manager_secret" "bot-key" {
project = var.gcp.project_id
secret_id = "${var.bot_name}-key"
replication {
user_managed {
replicas {
location = var.gcp.zone
}
}
}
}
// Create any additional secrets specified as a var.
resource "google_secret_manager_secret" "bot-secrets" {
for_each = toset(var.gcp.additional_secrets)
project = var.gcp.project_id
secret_id = each.key
replication {
user_managed {
replicas {
location = var.gcp.zone
}
}
}
}
// Make sure you and the bot can access these secrets.
resource "google_secret_manager_secret_iam_binding" "bot-secrets-access" {
for_each = merge({ "bot-key" = google_secret_manager_secret.bot-key }, google_secret_manager_secret.bot-secrets)
project = var.gcp.project_id
secret_id = each.value.secret_id
role = "roles/secretmanager.secretAccessor"
members = [
"serviceAccount:${google_service_account.bot-service-account.email}",
"user:${var.gcp.owner}"
]
}
resource "google_secret_manager_secret_iam_binding" "bot-secrets-viewer" {
for_each = merge({ "bot-key" = google_secret_manager_secret.bot-key }, google_secret_manager_secret.bot-secrets)
project = var.gcp.project_id
secret_id = each.value.secret_id
role = "roles/secretmanager.viewer"
members = [
"serviceAccount:${google_service_account.bot-service-account.email}",
"user:${var.gcp.owner}"
]
}
// This lets you be able to update the secret when you obtain it from Discord.
resource "google_secret_manager_secret_iam_binding" "bot-secrets-writer" {
for_each = merge({ "bot-key" = google_secret_manager_secret.bot-key }, google_secret_manager_secret.bot-secrets)
project = var.gcp.project_id
secret_id = each.value.secret_id
role = "roles/secretmanager.secretVersionManager"
members = [
"user:${var.gcp.owner}"
]
}
// Define a Cloud Run service
resource "google_cloud_run_service" "service" {
name = var.bot_name
location = var.gcp.zone
project = var.gcp.project_id
template {
spec {
containers {
image = "us-docker.pkg.dev/cloudrun/container/hello:latest"
command = ["/server"]
args = ["--project_id=${var.gcp.project_id}"]
}
service_account_name = google_service_account.bot-service-account.email
}
metadata {
annotations = {
// For simplicity, I'm scaling from 0 to 1, if you anticipate more
// traffic you will want to update this and make it a variable.
"autoscaling.knative.dev/minScale" = "0",
"autoscaling.knative.dev/maxScale" = "1"
}
}
}
autogenerate_revision_name = true
traffic {
percent = 100
latest_revision = true
}
}
// This allows us to make the service public facing, necessary for Discord bots.
data "google_iam_policy" "noauth" {
binding {
role = "roles/run.invoker"
members = [
"allUsers",
]
}
}
resource "google_cloud_run_service_iam_policy" "noauth" {
location = google_cloud_run_service.service.location
project = google_cloud_run_service.service.project
service = google_cloud_run_service.service.name
policy_data = data.google_iam_policy.noauth.policy_data
}
// Create a Cloud Build trigger that fires when the GitHub repo is updated.
resource "google_cloudbuild_trigger" "service-trigger" {
// Make location global in order to avoid any quota issues with a zone
location = "global"
project = google_cloud_run_service.service.project
name = "${google_cloud_run_service.service.name}-trigger"
included_files = var.github_trigger.included_files != null ? var.github_trigger.included_files : []
github {
owner = var.github_trigger.repo_owner
name = var.github_trigger.repo_name
push {
branch = var.github_trigger.branch
}
}
substitutions = {
_APP = var.bot_name
_ZONE = var.gcp.zone
}
filename = var.github_trigger.cloudbuild_file
include_build_logs = "INCLUDE_BUILD_LOGS_WITH_STATUS"
}
// Create a GCP logging metric to count errors
resource "google_logging_metric" "error_logging_metric" {
name = "${var.bot_name}_errors"
project = var.gcp.project_id
filter = "severity >= ERROR AND logName=\"projects/${var.gcp.project_id}/logs/${var.bot_name}-logs\""
description = "Logged errors for ${var.bot_name} bot"
metric_descriptor {
metric_kind = "DELTA"
value_type = "INT64"
}
}
// Monitor the logging metric and send emails whenever this alert fires.
resource "google_monitoring_alert_policy" "error_logging_alert_policy" {
display_name = "${var.bot_name} Error Alert"
project = var.gcp.project_id
combiner = "OR"
conditions {
display_name = "Error logs"
condition_threshold {
filter = "metric.type=\"logging.googleapis.com/user/${var.bot_name}_errors\" AND resource.type=\"cloud_run_revision\""
duration = "300s"
comparison = "COMPARISON_GT"
threshold_value = 0.5
aggregations {
alignment_period = "600s"
per_series_aligner = "ALIGN_SUM"
}
}
}
notification_channels = var.gcp.notification_channels != null ? var.gcp.notification_channels : []
alert_strategy {
auto_close = "3600s"
}
}
Nice! That was a lot of configuration, but you should be able to make another pull request and merge it much the same way you did before:
git add .
git commit -m "Add discord specific Terraform config"
git checkout -b bot_config
git push
You should be able to view the output of the Terraform Plan
workflow, and it
should look reasonable based on all the resources we added in this section.
The only terraform piece we’re missing now is the definition of the actual bot
itself. But for now, we’ll switch gears into finally writing the bot!