From 219eceec7378ecb4f19ed0f40de2473ca67df12b Mon Sep 17 00:00:00 2001 From: admclamb Date: Tue, 6 Oct 2026 00:12:39 -0400 Subject: [PATCH] Merge branch 'main' into admclamb/aw --- .gitignore | 13 ++ README.md | 73 +--------- .../environments/prod/.terraform.lock.hcl | 22 +++ .../prod/ci-migration-identity.tf | 40 ++++++ terraform/environments/prod/database.tf | 58 ++++++++ terraform/environments/prod/main.tf | 32 +++++ terraform/environments/prod/outputs.tf | 34 +++++ terraform/environments/prod/provider.tf | 3 + terraform/environments/prod/variables.tf | 70 +++++++++ terraform/environments/prod/versions.tf | 16 +++ .../environments/scrum/.terraform.lock.hcl | 43 ++++++ terraform/environments/scrum/main.tf | 32 +++-- terraform/environments/scrum/outputs.tf | 27 +--- terraform/environments/scrum/provider.tf | 6 +- terraform/environments/scrum/variables.tf | 34 ++--- terraform/environments/scrum/versions.tf | 10 +- terraform/modules/app-platform/app-service.tf | 61 ++++++++ terraform/modules/app-platform/clerk.tf | 33 ----- .../modules/app-platform/container-app.tf | 6 +- terraform/modules/app-platform/database.tf | 53 ------- terraform/modules/app-platform/monitoring.tf | 65 ++++++--- terraform/modules/app-platform/outputs.tf | 50 +++---- terraform/modules/app-platform/server.tf | 41 ++++++ terraform/modules/app-platform/storage.tf | 16 +-- terraform/modules/app-platform/variables.tf | 134 ++++++++---------- terraform/modules/app-platform/versions.tf | 10 -- 26 files changed, 617 insertions(+), 365 deletions(-) create mode 100644 terraform/environments/prod/.terraform.lock.hcl create mode 100644 terraform/environments/prod/ci-migration-identity.tf create mode 100644 terraform/environments/prod/database.tf create mode 100644 terraform/environments/prod/main.tf create mode 100644 terraform/environments/prod/outputs.tf create mode 100644 terraform/environments/prod/provider.tf create mode 100644 terraform/environments/prod/variables.tf create mode 100644 terraform/environments/prod/versions.tf create mode 100644 terraform/environments/scrum/.terraform.lock.hcl create mode 100644 terraform/modules/app-platform/app-service.tf delete mode 100644 terraform/modules/app-platform/clerk.tf delete mode 100644 terraform/modules/app-platform/database.tf create mode 100644 terraform/modules/app-platform/server.tf delete mode 100644 terraform/modules/app-platform/versions.tf diff --git a/.gitignore b/.gitignore index 84e0d34..78e7733 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,19 @@ override.tf.json # Ignore transient lock info files created by terraform apply .terraform.tfstate.lock.info +# Include override files you do wish to add to version control using negated pattern +# !example_override.tf + +# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan +# example: *tfplan* + # Ignore CLI configuration files .terraformrc terraform.rc + +# Optional: ignore graph output files generated by `terraform graph` +# *.dot + +# Optional: ignore plan files saved before destroying Terraform configuration +# Uncomment the line below if you want to ignore planout files. +# planout \ No newline at end of file diff --git a/README.md b/README.md index 3c6bb1c..843bcfe 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,2 @@ # infrastructure - -Stack Duel Infrastructure - -## Layout - -``` -terraform/ - modules/ - app-platform/ Shared module: resource group(s), Container App (Linux - container, websockets work natively), Azure SQL - Database (serverless, Always Free limit), storage - account, static web app client, Log Analytics + App - Insights, subscription budget, Clerk auth resources - (redirect URLs, allowlist, JWT template, Svix). - environments/ - scrum/ Non-prod/dev environment. Container App scaled to - zero by default, Azure SQL serverless on the Always - Free limit. -``` - -Everything in the `app-platform` module targets Azure's Always Free tier -(Container Apps consumption grant, Azure SQL serverless free limit, Static -Web Apps Free plan, Blob Storage). There is no 12-month-free or trial-credit -resource anywhere in this config, so it runs the same way on a Pay-As-You-Go -subscription. - -## Setup - -To run this repository you need to first run `az login`. - -Create a `secret.tfvars` file in the environment directory you're working in -and fill in secret values (see that environment's `variables.tf` for the -full list). `*.tfvars` is gitignored — never commit one. - -### Scrum - -```bash -cd terraform/environments/scrum -terraform init -terraform plan -var-file="secret.tfvars" -terraform apply -var-file="secret.tfvars" -``` - -## Notes - -- The container app's `image` is set to a placeholder quickstart image and - then ignored (`lifecycle.ignore_changes`) — real deploys update the - running image via CI (`az containerapp update`), not via Terraform. -- SignalR runs self-hosted inside the API container, not via Azure SignalR - Service — keep `container_max_replicas = 1` until a Redis/SignalR Service - backplane is added, since a self-hosted hub can't broadcast across - replicas on its own. -- The SQL database is enrolled in Azure's Always Free limit - (`useFreeLimit = true`, `freeLimitExhaustionBehavior = "AutoPause"`) via - an `azapi_update_resource` patch, since the azurerm provider (checked up - to 4.81.0) doesn't expose those two properties itself. The behavior is - set to `AutoPause`, not `BillForUsage` — if the monthly free vCore-second - allowance is exhausted the database pauses instead of silently billing. - Only one free database is allowed per subscription per region, so don't - reuse `enable_sql = true` for a second environment in the same region. -- Clerk resources come from the unofficial `buildwithdeck/clerk` provider - (not published by Clerk itself — audit it before relying on it for prod). - `clerk_redirect_url` entries point at the **Next.js** static web app - (`/sso-callback`), since that's where the browser lands after OAuth — - your .NET API is never involved in that flow. `clerk_svix_webhook` only - turns on the Svix integration; it can't create the actual endpoint, so - after `apply`, open the Clerk Dashboard's Webhooks page once and add an - endpoint using the `clerk_webhook_endpoint_url` output — that one *does* - point at the .NET container app, and your API needs a route there - (`POST /webhooks/clerk`) that verifies the Svix signature. -- `clerk_allowlist_identifiers` is empty by default (open sign-ups). Add - your own email(s) there to lock scrum down to invite-only while testing. +Infrastructure for Stack Duel diff --git a/terraform/environments/prod/.terraform.lock.hcl b/terraform/environments/prod/.terraform.lock.hcl new file mode 100644 index 0000000..12ba752 --- /dev/null +++ b/terraform/environments/prod/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/azurerm" { + version = "4.61.0" + constraints = "~> 4.61.0" + hashes = [ + "h1:vk+opwjBXTMjH6i75x85ZFUaBo4GVVoAZShES4hbPWI=", + "zh:314ce5459fd4a93aada8e99a2e2528d7df60f7145da0843d182a1d48f0c98a72", + "zh:428bf3b41b6310dcd5f410a4be24dbd8b4d003aa82c0ac31718a7d9004fdc30e", + "zh:46f8159dc46ff5675ea7c5ea952331b2109e605b32798739adfdb2f2e121a3f4", + "zh:5270f2a81c7f7d1b36283bb16f3ab17b4e01bf29c79dafb47f71859bee471a36", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:9c49059a3d88fc5c9eca4d2b6c925de201bedca387ab36c54c2e5998b921e4d6", + "zh:9e7d5df1d65b5f0338d866e1095649a85a259f0899d918237861d17ef1568bc5", + "zh:bb50d117d87b86c57f824893a8d4c931a73228a51d97a20b58060db78a2eb734", + "zh:c94a91baab1cbeaf0e9840dd08e6cda1ff4fbcb23fc8e15770b5737d697c6d2c", + "zh:d679632f7ba14aede25e632b5307891dbca2cac12f7a21406f4ea32698f5f980", + "zh:e171676dd8c7c1bdd35b3f35a5f9416918a7629fba9b3cbfa620b1987ad88f2e", + "zh:f7f37b4112d91a3fa245bd9e1a1f36269beb24a5c4e8edc8434024ef20eb66c9", + ] +} diff --git a/terraform/environments/prod/ci-migration-identity.tf b/terraform/environments/prod/ci-migration-identity.tf new file mode 100644 index 0000000..21657f4 --- /dev/null +++ b/terraform/environments/prod/ci-migration-identity.tf @@ -0,0 +1,40 @@ +data "azurerm_client_config" "current" {} + +resource "azurerm_user_assigned_identity" "github_migrator" { + name = "id-gh-migrator-prod-centralus-01" + resource_group_name = module.prod.resource_group_names["centralus"] + location = module.prod.resource_group_locations["centralus"] +} + +resource "azurerm_federated_identity_credential" "github_migrator_production" { + name = "gh-server-production" + parent_id = azurerm_user_assigned_identity.github_migrator.id + audience = ["api://AzureADTokenExchange"] + issuer = "https://token.actions.githubusercontent.com" + subject = "repo:Stack-Duel/server:environment:production" +} + +resource "azurerm_role_definition" "postgres_firewall_manager" { + name = "Postgres Flexible Server Firewall Manager" + scope = azurerm_postgresql_flexible_server.prod.id + description = "Allows adding and removing firewall rules on the prod Postgres flexible server only. Used by CI to open a rule for the duration of a migration/seed job." + + permissions { + actions = [ + "Microsoft.DBforPostgreSQL/flexibleServers/firewallRules/read", + "Microsoft.DBforPostgreSQL/flexibleServers/firewallRules/write", + "Microsoft.DBforPostgreSQL/flexibleServers/firewallRules/delete", + ] + not_actions = [] + } + + assignable_scopes = [ + azurerm_postgresql_flexible_server.prod.id, + ] +} + +resource "azurerm_role_assignment" "github_migrator_firewall" { + scope = azurerm_postgresql_flexible_server.prod.id + role_definition_id = azurerm_role_definition.postgres_firewall_manager.role_definition_resource_id + principal_id = azurerm_user_assigned_identity.github_migrator.principal_id +} diff --git a/terraform/environments/prod/database.tf b/terraform/environments/prod/database.tf new file mode 100644 index 0000000..530ccdd --- /dev/null +++ b/terraform/environments/prod/database.tf @@ -0,0 +1,58 @@ +resource "azurerm_postgresql_flexible_server" "prod" { + name = "psql-sdapi-prod-centralus-01" + resource_group_name = module.prod.resource_group_names["centralus"] + location = module.prod.resource_group_locations["centralus"] + + version = "16" + administrator_login = var.postgres_admin_username + administrator_password = var.postgres_admin_password + + storage_mb = 32768 + sku_name = "B_Standard_B1ms" + zone = var.postgres_zone + + backup_retention_days = 7 + geo_redundant_backup_enabled = false + + public_network_access_enabled = true +} + +resource "azurerm_postgresql_flexible_server_database" "stackduel" { + name = "stackduel" + server_id = azurerm_postgresql_flexible_server.prod.id + collation = "en_US.utf8" + charset = "UTF8" +} + +resource "azurerm_postgresql_flexible_server_firewall_rule" "app_outbound" { + for_each = toset(module.prod.web_app_possible_outbound_ips) + + name = "allow-app-${replace(each.value, ".", "-")}" + server_id = azurerm_postgresql_flexible_server.prod.id + start_ip_address = each.value + end_ip_address = each.value +} + +resource "azurerm_postgresql_flexible_server_firewall_rule" "migration_client" { + count = local.migration_client_ip_set ? 1 : 0 + + name = "allow-migration-client" + server_id = azurerm_postgresql_flexible_server.prod.id + start_ip_address = var.migration_client_ip + end_ip_address = var.migration_client_ip +} + +resource "azurerm_postgresql_flexible_server_firewall_rule" "developer_client" { + count = local.developer_client_ip_set ? 1 : 0 + + name = "allow-developer-client" + server_id = azurerm_postgresql_flexible_server.prod.id + start_ip_address = var.developer_client_ip + end_ip_address = var.developer_client_ip +} + +locals { + migration_client_ip_set = var.migration_client_ip != null && var.migration_client_ip != "" + developer_client_ip_set = var.developer_client_ip != null && var.developer_client_ip != "" + db_connection_string = "Host=${azurerm_postgresql_flexible_server.prod.fqdn};Port=5432;Database=${azurerm_postgresql_flexible_server_database.stackduel.name};Username=${var.postgres_admin_username};Password=${var.postgres_admin_password};SSL Mode=Require;Trust Server Certificate=true" +} diff --git a/terraform/environments/prod/main.tf b/terraform/environments/prod/main.tf new file mode 100644 index 0000000..5573715 --- /dev/null +++ b/terraform/environments/prod/main.tf @@ -0,0 +1,32 @@ +module "prod" { + source = "../../modules/app-platform" + + environment_key = "prod" + region_configurations = var.region_configurations + primary_region_key = var.primary_region_key + client_region_key = var.client_region_key + + compute_type = "app_service" + os_type = "Linux" + sku_name = "B1" + always_on = true + compute_name_suffix = var.compute_name_suffix + resource_name_suffix = "01" + service_bus_name_suffix = "02" + + enable_app_insights = true + enable_service_bus = true + service_bus_sku = "Basic" + + enable_avatar_storage = true + + enable_budget = true + enable_alerts = true + subscription_id = var.subscription_id + budget_name = "monthly-budget" + budget_amount = var.budget_amount + budget_start_date = var.budget_start_date + budget_end_date = var.budget_end_date + budget_thresholds = [50, 80, 100] + contact_emails = var.contact_emails +} diff --git a/terraform/environments/prod/outputs.tf b/terraform/environments/prod/outputs.tf new file mode 100644 index 0000000..fffb409 --- /dev/null +++ b/terraform/environments/prod/outputs.tf @@ -0,0 +1,34 @@ +output "db_connection_string" { + value = local.db_connection_string + sensitive = true +} + +output "app_insights_connection_string" { + value = module.prod.app_insights_connection_string + sensitive = true +} + +output "service_bus_connection_string" { + value = module.prod.service_bus_connection_string + sensitive = true +} + +output "web_app_default_hostname" { + value = module.prod.web_app_default_hostname +} + +output "github_migrator_client_id" { + value = azurerm_user_assigned_identity.github_migrator.client_id +} + +output "github_migrator_tenant_id" { + value = azurerm_user_assigned_identity.github_migrator.tenant_id +} + +output "postgres_server_name" { + value = azurerm_postgresql_flexible_server.prod.name +} + +output "postgres_resource_group" { + value = module.prod.resource_group_names["centralus"] +} diff --git a/terraform/environments/prod/provider.tf b/terraform/environments/prod/provider.tf new file mode 100644 index 0000000..ab91b24 --- /dev/null +++ b/terraform/environments/prod/provider.tf @@ -0,0 +1,3 @@ +provider "azurerm" { + features {} +} diff --git a/terraform/environments/prod/variables.tf b/terraform/environments/prod/variables.tf new file mode 100644 index 0000000..03e8c5c --- /dev/null +++ b/terraform/environments/prod/variables.tf @@ -0,0 +1,70 @@ +variable "region_configurations" { + type = list(object({ + key = string + location = string + resource_group = string + })) +} + +variable "primary_region_key" { + type = string + default = "centralus" +} + +variable "client_region_key" { + type = string + default = "eastus2" +} + +variable "subscription_id" { + type = string + sensitive = true +} + +variable "contact_emails" { + type = list(string) + sensitive = true +} + +variable "budget_amount" { + type = number + default = 5 +} + +variable "budget_start_date" { + type = string +} + +variable "budget_end_date" { + type = string +} + +variable "postgres_admin_username" { + type = string + sensitive = true +} + +variable "postgres_admin_password" { + type = string + sensitive = true +} + +variable "compute_name_suffix" { + type = string + default = "02" +} + +variable "migration_client_ip" { + type = string + default = null +} + +variable "developer_client_ip" { + type = string + sensitive = true + default = null +} + +variable "postgres_zone" { + type = string +} diff --git a/terraform/environments/prod/versions.tf b/terraform/environments/prod/versions.tf new file mode 100644 index 0000000..6ab0dfd --- /dev/null +++ b/terraform/environments/prod/versions.tf @@ -0,0 +1,16 @@ +terraform { + cloud { + organization = "stack-duel" + workspaces { + name = "Prod" + } + } + + required_version = ">= 1.14.3" + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.61.0" + } + } +} diff --git a/terraform/environments/scrum/.terraform.lock.hcl b/terraform/environments/scrum/.terraform.lock.hcl new file mode 100644 index 0000000..0e65b4d --- /dev/null +++ b/terraform/environments/scrum/.terraform.lock.hcl @@ -0,0 +1,43 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/aiven/aiven" { + version = "4.63.0" + constraints = "~> 4.0" + hashes = [ + "h1:VY18rUhy1pOQc8eXH2HjILEX+SWBo2EUHd/KTg/Kegg=", + "zh:0220fbb5e6c4293f76f03e784709d260ff9ce25bc74785dd029094d75c0b1143", + "zh:18b03fb6ce06a70361327d50f93c3656875f2bfd39ca86be02f41a52eb9a8dd6", + "zh:1ca25a78c0ad1c3bd450858f66185f345016664b132ae2885645ecebdae7149d", + "zh:2fc19f086c5ff358171850313d745482d326c9325e2f0e986e58c47a976c0f7f", + "zh:4633207ede89f9845c7b5e21af2393271a4e9255fdd8d7f8326a79d19a5dab57", + "zh:7a2ec43a2fe44339ebadc10ac792e63cdf2718d65784425073fc707301490ca2", + "zh:8753fa02ab702a0a49aef261ae7b4e5eec874dba230177d1b0477d538f09bf03", + "zh:8a571aff3806e4a544089c1d01899407993d557eff6e59caeafa70f2a4ce9ee3", + "zh:8c152139f92c5df46621ab4ab7d5d98c7675ab53bf5e1962840aa1d37f59ce3e", + "zh:a0e4105074a32f04af1409136dd560bbd2eedb7793b565fdff4f3b8a366ba2a5", + "zh:df7503bf36595ed0fff73f59e12a1c566a7bf5e3b53339cfdc8737f1ff304e1f", + "zh:f16c5dd5ac1cb86aefe091f822d09e2c71ee304d4e455795f946e0441070fd84", + "zh:fe4f78aad7ec174f614fb5fb5482335203306cd02caeed9d758229e0d0498437", + ] +} + +provider "registry.terraform.io/hashicorp/azurerm" { + version = "4.61.0" + constraints = "~> 4.61.0" + hashes = [ + "h1:vk+opwjBXTMjH6i75x85ZFUaBo4GVVoAZShES4hbPWI=", + "zh:314ce5459fd4a93aada8e99a2e2528d7df60f7145da0843d182a1d48f0c98a72", + "zh:428bf3b41b6310dcd5f410a4be24dbd8b4d003aa82c0ac31718a7d9004fdc30e", + "zh:46f8159dc46ff5675ea7c5ea952331b2109e605b32798739adfdb2f2e121a3f4", + "zh:5270f2a81c7f7d1b36283bb16f3ab17b4e01bf29c79dafb47f71859bee471a36", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:9c49059a3d88fc5c9eca4d2b6c925de201bedca387ab36c54c2e5998b921e4d6", + "zh:9e7d5df1d65b5f0338d866e1095649a85a259f0899d918237861d17ef1568bc5", + "zh:bb50d117d87b86c57f824893a8d4c931a73228a51d97a20b58060db78a2eb734", + "zh:c94a91baab1cbeaf0e9840dd08e6cda1ff4fbcb23fc8e15770b5737d697c6d2c", + "zh:d679632f7ba14aede25e632b5307891dbca2cac12f7a21406f4ea32698f5f980", + "zh:e171676dd8c7c1bdd35b3f35a5f9416918a7629fba9b3cbfa620b1987ad88f2e", + "zh:f7f37b4112d91a3fa245bd9e1a1f36269beb24a5c4e8edc8434024ef20eb66c9", + ] +} diff --git a/terraform/environments/scrum/main.tf b/terraform/environments/scrum/main.tf index e631104..bf04215 100644 --- a/terraform/environments/scrum/main.tf +++ b/terraform/environments/scrum/main.tf @@ -6,31 +6,33 @@ module "scrum" { primary_region_key = var.primary_region_key client_region_key = var.client_region_key - resource_name_suffix = "01" + os_type = "Linux" + sku_name = "F1" + always_on = false + compute_name_suffix = "03" + resource_name_suffix = "01" + service_bus_name_suffix = "01" enable_app_insights = true + enable_service_bus = true + service_bus_sku = "Standard" - enable_storage = true - storage_container_name = "uploads" - - enable_sql = true - sql_admin_login = "sqladmin" - sql_admin_password = var.sql_admin_password - - container_min_replicas = 0 - container_max_replicas = 1 - - enable_clerk = true - clerk_allowlist_identifiers = var.clerk_allowlist_identifiers - clerk_additional_redirect_urls = ["http://localhost:3000/sso-callback"] + enable_avatar_storage = true enable_budget = true enable_alerts = true subscription_id = var.subscription_id budget_name = "monthly-budget" budget_amount = 5 - budget_start_date = "2026-10-01T00:00:00Z" + budget_start_date = "2026-02-01T00:00:00Z" budget_end_date = "2026-12-31T23:59:59Z" budget_thresholds = [3, 5] contact_emails = var.contact_emails } + +resource "aiven_pg" "sql" { + project = var.aiven_project_name + cloud_name = var.aiven_cloud_name + plan = var.aiven_pg_plan + service_name = "pg-3c675c6d" +} diff --git a/terraform/environments/scrum/outputs.tf b/terraform/environments/scrum/outputs.tf index 16cf2aa..8f6685b 100644 --- a/terraform/environments/scrum/outputs.tf +++ b/terraform/environments/scrum/outputs.tf @@ -1,30 +1,13 @@ -output "container_app_fqdn" { - value = module.scrum.container_app_fqdn -} - -output "static_web_app_default_hostname" { - value = module.scrum.static_web_app_default_hostname -} - -output "static_web_app_api_key" { - value = module.scrum.static_web_app_api_key - sensitive = true -} - output "app_insights_connection_string" { value = module.scrum.app_insights_connection_string sensitive = true } -output "sql_server_fqdn" { - value = module.scrum.sql_server_fqdn -} - -output "storage_account_name" { - value = module.scrum.storage_account_name +output "service_bus_connection_string" { + value = module.scrum.service_bus_connection_string + sensitive = true } -output "clerk_webhook_endpoint_url" { - description = "Paste this into the Clerk Dashboard's Webhooks page — the provider can't set it directly." - value = module.scrum.clerk_webhook_endpoint_url +output "web_app_default_hostname" { + value = module.scrum.web_app_default_hostname } diff --git a/terraform/environments/scrum/provider.tf b/terraform/environments/scrum/provider.tf index 7f8a909..d471f6c 100644 --- a/terraform/environments/scrum/provider.tf +++ b/terraform/environments/scrum/provider.tf @@ -2,8 +2,6 @@ provider "azurerm" { features {} } -provider "azapi" {} - -provider "clerk" { - api_key = var.clerk_api_key +provider "aiven" { + api_token = var.aiven_api_token } diff --git a/terraform/environments/scrum/variables.tf b/terraform/environments/scrum/variables.tf index 124f23c..1db60f1 100644 --- a/terraform/environments/scrum/variables.tf +++ b/terraform/environments/scrum/variables.tf @@ -9,18 +9,6 @@ variable "region_configurations" { location = string resource_group = string })) - default = [ - { - key = "centralus" - location = "centralus" - resource_group = "rg-sd-scrum-centralus-01" - }, - { - key = "eastus2" - location = "eastus2" - resource_group = "rg-sd-scrum-eastus2-01" - } - ] } variable "primary_region_key" { @@ -33,23 +21,29 @@ variable "client_region_key" { default = "eastus2" } +variable "resource_group" { + type = string +} + variable "subscription_id" { type = string sensitive = true } -variable "sql_admin_password" { +variable "aiven_project_name" { type = string sensitive = true } -variable "clerk_api_key" { - type = string - sensitive = true +variable "aiven_cloud_name" { + type = string } -variable "clerk_allowlist_identifiers" { - type = list(string) - description = "Email addresses (or *@domain wildcards) allowed to sign up. Leave empty to leave scrum sign-ups open." - default = [] +variable "aiven_pg_plan" { + type = string +} + +variable "aiven_api_token" { + type = string + sensitive = true } diff --git a/terraform/environments/scrum/versions.tf b/terraform/environments/scrum/versions.tf index 74da766..3762c84 100644 --- a/terraform/environments/scrum/versions.tf +++ b/terraform/environments/scrum/versions.tf @@ -12,12 +12,10 @@ terraform { source = "hashicorp/azurerm" version = "~> 4.61.0" } - azapi = { - source = "Azure/azapi" - version = "~> 2.0" - } - clerk = { - source = "buildwithdeck/clerk" + + aiven = { + source = "aiven/aiven" + version = "~> 4.0" } } } diff --git a/terraform/modules/app-platform/app-service.tf b/terraform/modules/app-platform/app-service.tf new file mode 100644 index 0000000..8f3303a --- /dev/null +++ b/terraform/modules/app-platform/app-service.tf @@ -0,0 +1,61 @@ +resource "azurerm_service_plan" "api" { + count = var.compute_type == "app_service" ? 1 : 0 + name = "asp-sdapi-${var.environment_key}-${var.primary_region_key}-${var.compute_name_suffix}" + location = azurerm_resource_group.this[var.primary_region_key].location + resource_group_name = azurerm_resource_group.this[var.primary_region_key].name + os_type = var.os_type + sku_name = var.sku_name +} + +resource "azurerm_windows_web_app" "api" { + count = var.compute_type == "app_service" && var.os_type == "Windows" ? 1 : 0 + name = "app-sdapi-${var.environment_key}-${var.primary_region_key}-${var.compute_name_suffix}" + location = azurerm_resource_group.this[var.primary_region_key].location + resource_group_name = azurerm_resource_group.this[var.primary_region_key].name + service_plan_id = azurerm_service_plan.api[0].id + https_only = true + + site_config { + always_on = var.always_on + websockets_enabled = true + application_stack { + current_stack = "dotnet" + dotnet_version = var.dotnet_version + } + } + + lifecycle { + ignore_changes = [ + app_settings, + tags, + logs, + sticky_settings + ] + } +} + +resource "azurerm_linux_web_app" "api" { + count = var.compute_type == "app_service" && var.os_type == "Linux" ? 1 : 0 + name = "app-sdapi-${var.environment_key}-${var.primary_region_key}-${var.compute_name_suffix}" + location = azurerm_resource_group.this[var.primary_region_key].location + resource_group_name = azurerm_resource_group.this[var.primary_region_key].name + service_plan_id = azurerm_service_plan.api[0].id + https_only = true + + site_config { + always_on = var.always_on + websockets_enabled = true + application_stack { + dotnet_version = trimprefix(var.dotnet_version, "v") + } + } + + lifecycle { + ignore_changes = [ + app_settings, + tags, + logs, + sticky_settings + ] + } +} diff --git a/terraform/modules/app-platform/clerk.tf b/terraform/modules/app-platform/clerk.tf deleted file mode 100644 index 74bcad9..0000000 --- a/terraform/modules/app-platform/clerk.tf +++ /dev/null @@ -1,33 +0,0 @@ -locals { - clerk_redirect_urls = var.enable_clerk ? toset(concat( - ["https://${azurerm_static_web_app.client.default_host_name}/sso-callback"], - var.clerk_additional_redirect_urls - )) : toset([]) -} - -resource "clerk_redirect_url" "this" { - for_each = local.clerk_redirect_urls - url = each.value -} - -resource "clerk_allowlist_identifier" "this" { - for_each = var.enable_clerk ? toset(var.clerk_allowlist_identifiers) : toset([]) - identifier = each.value - notify = true -} - -resource "clerk_jwt_template" "this" { - count = var.enable_clerk ? 1 : 0 - name = var.clerk_jwt_template_name - claims = var.clerk_jwt_template_claims - lifetime = var.clerk_jwt_lifetime -} - -# Enables the Svix integration on the Clerk instance. This provider has no -# resource for the actual endpoint (URL + event subscriptions) — after -# apply, add the webhook endpoint once by hand in the Clerk Dashboard's -# Webhooks page, pointed at the URL in the `clerk_webhook_endpoint_url` -# output. Your API must implement that route and verify the Svix signature. -resource "clerk_svix_webhook" "main" { - count = var.enable_clerk && var.enable_clerk_svix_webhook ? 1 : 0 -} diff --git a/terraform/modules/app-platform/container-app.tf b/terraform/modules/app-platform/container-app.tf index c835cfb..38d6c43 100644 --- a/terraform/modules/app-platform/container-app.tf +++ b/terraform/modules/app-platform/container-app.tf @@ -1,4 +1,5 @@ resource "azurerm_container_app_environment" "api" { + count = var.compute_type == "container_app" ? 1 : 0 name = "cae-sdapi-${var.environment_key}-${var.primary_region_key}-${var.resource_name_suffix}" location = azurerm_resource_group.this[var.primary_region_key].location resource_group_name = azurerm_resource_group.this[var.primary_region_key].name @@ -6,8 +7,9 @@ resource "azurerm_container_app_environment" "api" { } resource "azurerm_container_app" "api" { - name = "ca-sdapi-${var.environment_key}-${var.primary_region_key}-${var.resource_name_suffix}" - container_app_environment_id = azurerm_container_app_environment.api.id + count = var.compute_type == "container_app" ? 1 : 0 + name = "ca-sdapi-${var.environment_key}-${var.primary_region_key}-${var.compute_name_suffix}" + container_app_environment_id = azurerm_container_app_environment.api[0].id resource_group_name = azurerm_resource_group.this[var.primary_region_key].name revision_mode = "Single" diff --git a/terraform/modules/app-platform/database.tf b/terraform/modules/app-platform/database.tf deleted file mode 100644 index ec9376e..0000000 --- a/terraform/modules/app-platform/database.tf +++ /dev/null @@ -1,53 +0,0 @@ -resource "azurerm_mssql_server" "this" { - count = var.enable_sql ? 1 : 0 - name = "sql-sd-${var.environment_key}-${var.primary_region_key}-${var.resource_name_suffix}" - resource_group_name = azurerm_resource_group.this[var.primary_region_key].name - location = azurerm_resource_group.this[var.primary_region_key].location - version = "12.0" - administrator_login = var.sql_admin_login - administrator_login_password = var.sql_admin_password - minimum_tls_version = "1.2" - - public_network_access_enabled = true - - identity { - type = "SystemAssigned" - } -} - -resource "azurerm_mssql_firewall_rule" "allow_azure_services" { - count = var.enable_sql ? 1 : 0 - name = "AllowAzureServices" - server_id = azurerm_mssql_server.this[0].id - start_ip_address = "0.0.0.0" - end_ip_address = "0.0.0.0" -} - -resource "azurerm_mssql_database" "this" { - count = var.enable_sql ? 1 : 0 - name = "sqldb-sd-${var.environment_key}-${var.resource_name_suffix}" - server_id = azurerm_mssql_server.this[0].id - - sku_name = "GP_S_Gen5_2" - max_size_gb = var.sql_database_max_size_gb - - min_capacity = var.sql_database_min_capacity - auto_pause_delay_in_minutes = var.sql_database_auto_pause_delay_in_minutes - - lifecycle { - ignore_changes = [tags] - } -} - -resource "azapi_update_resource" "sql_free_limit" { - count = var.enable_sql ? 1 : 0 - type = "Microsoft.Sql/servers/databases@2023-08-01-preview" - resource_id = azurerm_mssql_database.this[0].id - - body = { - properties = { - useFreeLimit = true - freeLimitExhaustionBehavior = "AutoPause" - } - } -} diff --git a/terraform/modules/app-platform/monitoring.tf b/terraform/modules/app-platform/monitoring.tf index f034069..63bb516 100644 --- a/terraform/modules/app-platform/monitoring.tf +++ b/terraform/modules/app-platform/monitoring.tf @@ -1,22 +1,3 @@ -resource "azurerm_log_analytics_workspace" "api" { - count = var.enable_app_insights ? 1 : 0 - name = "log-sdapi-${var.environment_key}-${var.primary_region_key}-${var.resource_name_suffix}" - location = azurerm_resource_group.this[var.primary_region_key].location - resource_group_name = azurerm_resource_group.this[var.primary_region_key].name - sku = "PerGB2018" - retention_in_days = var.log_retention_in_days -} - -resource "azurerm_application_insights" "api" { - count = var.enable_app_insights ? 1 : 0 - name = "appi-sdapi-${var.environment_key}-${var.primary_region_key}-${var.resource_name_suffix}" - location = azurerm_resource_group.this[var.primary_region_key].location - resource_group_name = azurerm_resource_group.this[var.primary_region_key].name - workspace_id = azurerm_log_analytics_workspace.api[0].id - application_type = "web" - daily_data_cap_in_gb = var.daily_data_cap_in_gb -} - resource "azurerm_monitor_action_group" "api" { count = var.enable_alerts ? 1 : 0 name = "ag-sdapi-${var.environment_key}-${var.primary_region_key}-${var.resource_name_suffix}" @@ -32,3 +13,49 @@ resource "azurerm_monitor_action_group" "api" { } } } + +resource "azurerm_monitor_metric_alert" "http_5xx" { + count = var.enable_alerts && var.compute_type == "app_service" ? 1 : 0 + name = "alert-sdapi-${var.environment_key}-${var.primary_region_key}-http5xx" + resource_group_name = azurerm_resource_group.this[var.primary_region_key].name + scopes = [var.os_type == "Windows" ? azurerm_windows_web_app.api[0].id : azurerm_linux_web_app.api[0].id] + description = "Fires when the API returns a burst of HTTP 5xx responses." + severity = 2 + frequency = "PT5M" + window_size = "PT5M" + + criteria { + metric_namespace = "Microsoft.Web/sites" + metric_name = "Http5xx" + aggregation = "Total" + operator = "GreaterThan" + threshold = 5 + } + + action { + action_group_id = azurerm_monitor_action_group.api[0].id + } +} + +resource "azurerm_monitor_metric_alert" "response_time" { + count = var.enable_alerts && var.compute_type == "app_service" ? 1 : 0 + name = "alert-sdapi-${var.environment_key}-${var.primary_region_key}-response-time" + resource_group_name = azurerm_resource_group.this[var.primary_region_key].name + scopes = [var.os_type == "Windows" ? azurerm_windows_web_app.api[0].id : azurerm_linux_web_app.api[0].id] + description = "Fires when average response time stays elevated, which is also what a stuck DB/dependency connection looks like." + severity = 2 + frequency = "PT5M" + window_size = "PT5M" + + criteria { + metric_namespace = "Microsoft.Web/sites" + metric_name = "HttpResponseTime" + aggregation = "Average" + operator = "GreaterThan" + threshold = 10 + } + + action { + action_group_id = azurerm_monitor_action_group.api[0].id + } +} diff --git a/terraform/modules/app-platform/outputs.tf b/terraform/modules/app-platform/outputs.tf index 6f1d80c..54504f7 100644 --- a/terraform/modules/app-platform/outputs.tf +++ b/terraform/modules/app-platform/outputs.tf @@ -10,21 +10,28 @@ output "resource_group_ids" { value = { for k, rg in azurerm_resource_group.this : k => rg.id } } -output "container_app_id" { - value = azurerm_container_app.api.id +output "web_app_id" { + value = ( + var.compute_type == "container_app" ? azurerm_container_app.api[0].id : + var.os_type == "Windows" ? azurerm_windows_web_app.api[0].id : + azurerm_linux_web_app.api[0].id + ) } -output "container_app_fqdn" { - value = azurerm_container_app.api.latest_revision_fqdn +output "web_app_default_hostname" { + value = ( + var.compute_type == "container_app" ? azurerm_container_app.api[0].latest_revision_fqdn : + var.os_type == "Windows" ? azurerm_windows_web_app.api[0].default_hostname : + azurerm_linux_web_app.api[0].default_hostname + ) } -output "static_web_app_default_hostname" { - value = azurerm_static_web_app.client.default_host_name -} - -output "static_web_app_api_key" { - value = azurerm_static_web_app.client.api_key - sensitive = true +output "web_app_possible_outbound_ips" { + value = ( + var.compute_type == "container_app" ? null : + var.os_type == "Windows" ? azurerm_windows_web_app.api[0].possible_outbound_ip_address_list : + azurerm_linux_web_app.api[0].possible_outbound_ip_address_list + ) } output "app_insights_connection_string" { @@ -32,24 +39,11 @@ output "app_insights_connection_string" { sensitive = true } -output "sql_server_fqdn" { - value = var.enable_sql ? azurerm_mssql_server.this[0].fully_qualified_domain_name : null -} - -output "sql_database_name" { - value = var.enable_sql ? azurerm_mssql_database.this[0].name : null -} - -output "storage_account_name" { - value = var.enable_storage ? azurerm_storage_account.this[0].name : null -} - -output "storage_account_primary_connection_string" { - value = var.enable_storage ? azurerm_storage_account.this[0].primary_connection_string : null +output "service_bus_connection_string" { + value = var.enable_service_bus ? azurerm_servicebus_namespace_authorization_rule.api[0].primary_connection_string : null sensitive = true } -output "clerk_webhook_endpoint_url" { - description = "Paste this into the Clerk Dashboard's Webhooks page as the endpoint URL — the provider can't set it directly." - value = "https://${azurerm_container_app.api.latest_revision_fqdn}/webhooks/clerk" +output "servicebus_namespace_id" { + value = var.enable_service_bus ? azurerm_servicebus_namespace.this[0].id : null } diff --git a/terraform/modules/app-platform/server.tf b/terraform/modules/app-platform/server.tf new file mode 100644 index 0000000..6c436ef --- /dev/null +++ b/terraform/modules/app-platform/server.tf @@ -0,0 +1,41 @@ +resource "azurerm_log_analytics_workspace" "api" { + count = var.enable_app_insights ? 1 : 0 + name = "log-sdapi-${var.environment_key}-${var.primary_region_key}-${var.resource_name_suffix}" + location = azurerm_resource_group.this[var.primary_region_key].location + resource_group_name = azurerm_resource_group.this[var.primary_region_key].name + sku = "PerGB2018" + retention_in_days = var.log_retention_in_days +} + +resource "azurerm_application_insights" "api" { + count = var.enable_app_insights ? 1 : 0 + name = "appi-sdapi-${var.environment_key}-${var.primary_region_key}-${var.resource_name_suffix}" + location = azurerm_resource_group.this[var.primary_region_key].location + resource_group_name = azurerm_resource_group.this[var.primary_region_key].name + workspace_id = azurerm_log_analytics_workspace.api[0].id + application_type = "web" + daily_data_cap_in_gb = var.daily_data_cap_in_gb +} + +resource "azurerm_servicebus_namespace" "this" { + count = var.enable_service_bus ? 1 : 0 + name = "sbns-sd-${var.environment_key}-${var.primary_region_key}-${var.service_bus_name_suffix}" + location = azurerm_resource_group.this[var.primary_region_key].location + resource_group_name = azurerm_resource_group.this[var.primary_region_key].name + sku = var.service_bus_sku +} + +resource "azurerm_servicebus_namespace_authorization_rule" "api" { + count = var.enable_service_bus ? 1 : 0 + name = "api-send-listen" + namespace_id = azurerm_servicebus_namespace.this[0].id + listen = true + send = true + manage = false +} + +resource "azurerm_servicebus_queue" "this" { + for_each = var.enable_service_bus ? toset(var.service_bus_queue_names) : [] + name = each.value + namespace_id = azurerm_servicebus_namespace.this[0].id +} diff --git a/terraform/modules/app-platform/storage.tf b/terraform/modules/app-platform/storage.tf index 07380cf..fd91f42 100644 --- a/terraform/modules/app-platform/storage.tf +++ b/terraform/modules/app-platform/storage.tf @@ -1,20 +1,16 @@ -resource "azurerm_storage_account" "this" { - count = var.enable_storage ? 1 : 0 +resource "azurerm_storage_account" "avatars" { + count = var.enable_avatar_storage ? 1 : 0 name = "stsd${var.environment_key}${var.primary_region_key}${var.storage_account_name_suffix}" location = azurerm_resource_group.this[var.primary_region_key].location resource_group_name = azurerm_resource_group.this[var.primary_region_key].name account_tier = "Standard" account_replication_type = "LRS" allow_nested_items_to_be_public = true - - identity { - type = "SystemAssigned" - } } -resource "azurerm_storage_container" "this" { - count = var.enable_storage ? 1 : 0 - name = var.storage_container_name - storage_account_id = azurerm_storage_account.this[0].id +resource "azurerm_storage_container" "avatars" { + count = var.enable_avatar_storage ? 1 : 0 + name = var.avatar_container_name + storage_account_id = azurerm_storage_account.avatars[0].id container_access_type = "blob" } diff --git a/terraform/modules/app-platform/variables.tf b/terraform/modules/app-platform/variables.tf index a6d0950..a584730 100644 --- a/terraform/modules/app-platform/variables.tf +++ b/terraform/modules/app-platform/variables.tf @@ -13,7 +13,7 @@ variable "region_configurations" { variable "primary_region_key" { type = string - description = "Key (from region_configurations) of the region that hosts the API, database, storage, and App Insights." + description = "Key (from region_configurations) of the region that hosts the API, App Insights, and Service Bus." } variable "client_region_key" { @@ -21,11 +21,55 @@ variable "client_region_key" { description = "Key (from region_configurations) of the region that hosts the static web app client." } +variable "compute_type" { + type = string + default = "app_service" + validation { + condition = contains(["app_service", "container_app"], var.compute_type) + error_message = "compute_type must be \"app_service\" or \"container_app\"." + } +} + +variable "os_type" { + type = string + default = "Windows" + validation { + condition = contains(["Windows", "Linux"], var.os_type) + error_message = "os_type must be \"Windows\" or \"Linux\"." + } +} + +variable "sku_name" { + type = string + default = "F1" +} + +variable "always_on" { + type = bool + default = false +} + +variable "dotnet_version" { + type = string + default = "v10.0" +} + + +variable "compute_name_suffix" { + type = string + default = "01" +} + variable "resource_name_suffix" { type = string default = "01" } +variable "service_bus_name_suffix" { + type = string + default = "01" +} + variable "enable_app_insights" { type = bool default = true @@ -41,6 +85,21 @@ variable "log_retention_in_days" { default = 30 } +variable "enable_service_bus" { + type = bool + default = true +} + +variable "service_bus_sku" { + type = string + default = "Basic" +} + +variable "service_bus_queue_names" { + type = list(string) + default = ["submission-created", "submission-job-continuation", "game-time-expired"] +} + variable "static_web_app_sku_tier" { type = string default = "Free" @@ -144,7 +203,7 @@ variable "container_registry_password" { default = null } -variable "enable_storage" { +variable "enable_avatar_storage" { type = bool default = true } @@ -154,74 +213,7 @@ variable "storage_account_name_suffix" { default = "01" } -variable "storage_container_name" { - type = string - default = "uploads" -} - -variable "enable_sql" { - type = bool - default = true -} - -variable "sql_admin_login" { +variable "avatar_container_name" { type = string - default = "sqladmin" -} - -variable "sql_admin_password" { - type = string - sensitive = true -} - -variable "sql_database_max_size_gb" { - type = number - default = 32 -} - -variable "sql_database_min_capacity" { - type = number - default = 0.5 -} - -variable "sql_database_auto_pause_delay_in_minutes" { - type = number - default = 60 -} - -variable "enable_clerk" { - type = bool - default = true -} - -variable "clerk_additional_redirect_urls" { - type = list(string) - description = "Redirect URLs beyond the static web app's own hostname, e.g. localhost for local dev." - default = ["http://localhost:3000/sso-callback"] -} - -variable "clerk_allowlist_identifiers" { - type = list(string) - description = "Email addresses (or *@domain wildcards) allowed to sign up while the instance is restricted. Leave empty to leave sign-ups open." - default = [] -} - -variable "clerk_jwt_template_name" { - type = string - default = "stack-duel" -} - -variable "clerk_jwt_template_claims" { - type = string - default = "{\"email\":\"{{user.primary_email_address}}\"}" -} - -variable "clerk_jwt_lifetime" { - type = number - default = 60 -} - -variable "enable_clerk_svix_webhook" { - type = bool - default = true + default = "avatars" } diff --git a/terraform/modules/app-platform/versions.tf b/terraform/modules/app-platform/versions.tf deleted file mode 100644 index 1485f09..0000000 --- a/terraform/modules/app-platform/versions.tf +++ /dev/null @@ -1,10 +0,0 @@ -terraform { - required_providers { - azapi = { - source = "Azure/azapi" - } - clerk = { - source = "buildwithdeck/clerk" - } - } -}