diff --git a/.gitignore b/.gitignore index 50296e2e..32f776b4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ **/bin/* **/obj/* + # Ignore .env, except in web-frontend, where an # empty copy is needed for vite-envs to work. .env @@ -24,3 +25,48 @@ e2e/.env # Load test user pool (contains credentials) load-tests/data/users.json + +# Local .terraform directories +.terraform/ + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data, such as +# password, private keys, and other secrets. These should not be part of version +# control as they are data points which are potentially sensitive and subject +# to change depending on the environment. +*.tfvars +*.tfvars.json + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_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/shared/infra/azure/.terraform.lock.hcl b/shared/infra/azure/.terraform.lock.hcl new file mode 100644 index 00000000..807c0e12 --- /dev/null +++ b/shared/infra/azure/.terraform.lock.hcl @@ -0,0 +1,42 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/azurerm" { + version = "4.58.0" + constraints = "~> 4.27" + hashes = [ + "h1:k0a/JkkhIEGrJ/oR7MZWbTZsUXHQ18JIPcVWBdW+V58=", + "zh:041c2a778ab4dd5a9af174b1d6f75409e5aabfc359cb386dfea3fb09e3f32709", + "zh:0a302531a61e7383acf99a6202d7984b2ea559306f45021381665c827a830d46", + "zh:0c69f132c7609683d907e87b89210a298d84c5b0121b62278949931bc54ca952", + "zh:0cadf48e9d2d9daed43212a3c9d886d7faaf68787b6e955456cbe4f43e4a17ec", + "zh:35ef4293d7731f6ff1f8bcba2c4529f987b7fac243c1ac1c154bbc02c9703c25", + "zh:3cb2679e1d56865e0ee0cf4c5d1404dbad0db42d11425e7bf0580a026cc64287", + "zh:4e56411f5119042d4962acff5c6d64224a49a69154ba80e6df63fa57b1e6d284", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:ca4626411a111720c220f9849c7d2e1fcd5d380f56459e096d835a9dbf9e6e13", + "zh:d31c4e65dcb096974479b2d548fffb86fc9a5262aff1b01fe62ef442ce536c6b", + "zh:d9631602999c1853e53ee2c5aef7476e23c7787beddc3599c10dbaa4891ba166", + "zh:f31ba7c9341037ceb7d49467946c01b2b0930404ed1d5643c1451f734a613a03", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.8.1" + constraints = "~> 3.8" + hashes = [ + "h1:u8AKlWVDTH5r9YLSeswoVEjiY72Rt4/ch7U+61ZDkiQ=", + "zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4", + "zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae", + "zh:229665ddf060aa0ed315597908483eee5b818a17d09b6417a0f52fd9405c4f57", + "zh:2469d2e48f28076254a2a3fc327f184914566d9e40c5780b8d96ebf7205f8bc0", + "zh:37d7eb334d9561f335e748280f5535a384a88675af9a9eac439d4cfd663bcb66", + "zh:741101426a2f2c52dee37122f0f4a2f2d6af6d852cb1db634480a86398fa3511", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:a902473f08ef8df62cfe6116bd6c157070a93f66622384300de235a533e9d4a9", + "zh:b85c511a23e57a2147355932b3b6dce2a11e856b941165793a0c3d7578d94d05", + "zh:c5172226d18eaac95b1daac80172287b69d4ce32750c82ad77fa0768be4ea4b8", + "zh:dab4434dba34aad569b0bc243c2d3f3ff86dd7740def373f2a49816bd2ff819b", + "zh:f49fd62aa8c5525a5c17abd51e27ca5e213881d58882fd42fec4a545b53c9699", + ] +} diff --git a/shared/infra/azure/main.tf b/shared/infra/azure/main.tf new file mode 100644 index 00000000..ab045e5d --- /dev/null +++ b/shared/infra/azure/main.tf @@ -0,0 +1,16 @@ +# Shared Infrastructure Stack +# Deploys shared resources for integrated environments (dev, prod) + +module "shared" { + source = "./modules/shared-resources" + + env = var.env + location = var.location + dd_api_key = var.dd_api_key + dd_site = var.dd_site + vnet_address_space = var.vnet_address_space + container_apps_subnet_prefix = var.container_apps_subnet_prefix + private_endpoints_subnet_prefix = var.private_endpoints_subnet_prefix + postgresql_subnet_prefix = var.postgresql_subnet_prefix + postgresql_admin_username = var.postgresql_admin_username +} diff --git a/shared/infra/azure/modules/shared-resources/container-apps.tf b/shared/infra/azure/modules/shared-resources/container-apps.tf new file mode 100644 index 00000000..f733512e --- /dev/null +++ b/shared/infra/azure/modules/shared-resources/container-apps.tf @@ -0,0 +1,12 @@ +# Shared Container App Environment +# All services in this environment can communicate internally via: +# http://.internal...azurecontainerapps.io +resource "azurerm_container_app_environment" "shared" { + name = "cae-stickerlandia-${var.env}" + location = azurerm_resource_group.shared.location + resource_group_name = azurerm_resource_group.shared.name + log_analytics_workspace_id = azurerm_log_analytics_workspace.main.id + infrastructure_subnet_id = azurerm_subnet.container_apps.id + internal_load_balancer_enabled = false + tags = local.tags +} diff --git a/shared/infra/azure/modules/shared-resources/database.tf b/shared/infra/azure/modules/shared-resources/database.tf new file mode 100644 index 00000000..05228a9e --- /dev/null +++ b/shared/infra/azure/modules/shared-resources/database.tf @@ -0,0 +1,21 @@ +# PostgreSQL Flexible Server (shared cluster - services create their own databases) +resource "azurerm_postgresql_flexible_server" "shared" { + name = "psql-stickerlandia-${var.env}" + location = azurerm_resource_group.shared.location + resource_group_name = azurerm_resource_group.shared.name + version = "16" + delegated_subnet_id = azurerm_subnet.postgresql.id + private_dns_zone_id = azurerm_private_dns_zone.postgresql.id + public_network_access_enabled = false + administrator_login = var.postgresql_admin_username + administrator_password = random_password.postgresql_admin_password.result + zone = "1" + storage_mb = 32768 + storage_tier = "P4" + sku_name = var.postgresql_sku_name + tags = local.tags + + depends_on = [ + azurerm_private_dns_zone_virtual_network_link.postgresql + ] +} diff --git a/shared/infra/azure/modules/shared-resources/frontdoor.tf b/shared/infra/azure/modules/shared-resources/frontdoor.tf new file mode 100644 index 00000000..d55afb3c --- /dev/null +++ b/shared/infra/azure/modules/shared-resources/frontdoor.tf @@ -0,0 +1,14 @@ +# Azure Front Door Standard (shared entry point for all services) +resource "azurerm_cdn_frontdoor_profile" "shared" { + name = "afd-stickerlandia-${var.env}" + resource_group_name = azurerm_resource_group.shared.name + sku_name = "Standard_AzureFrontDoor" + tags = local.tags +} + +# Shared Front Door Endpoint +resource "azurerm_cdn_frontdoor_endpoint" "shared" { + name = "stickerlandia-api-${var.env}" + cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.shared.id + tags = local.tags +} diff --git a/shared/infra/azure/modules/shared-resources/keyvault.tf b/shared/infra/azure/modules/shared-resources/keyvault.tf new file mode 100644 index 00000000..b84c227b --- /dev/null +++ b/shared/infra/azure/modules/shared-resources/keyvault.tf @@ -0,0 +1,85 @@ +# Random suffix for globally unique Key Vault name +resource "random_string" "kv_suffix" { + length = 6 + special = false + upper = false +} + +# Shared Key Vault for secrets management +resource "azurerm_key_vault" "shared" { + name = "kv-sticker-${var.env}-${random_string.kv_suffix.result}" + location = azurerm_resource_group.shared.location + resource_group_name = azurerm_resource_group.shared.name + tenant_id = data.azurerm_client_config.current.tenant_id + sku_name = "standard" + soft_delete_retention_days = 7 + purge_protection_enabled = false + public_network_access_enabled = true + rbac_authorization_enabled = true + tags = local.tags + + network_acls { + bypass = "AzureServices" + default_action = "Allow" + } +} + +# Private Endpoint for Key Vault +resource "azurerm_private_endpoint" "keyvault" { + name = "pe-keyvault-${var.env}" + location = azurerm_resource_group.shared.location + resource_group_name = azurerm_resource_group.shared.name + subnet_id = azurerm_subnet.private_endpoints.id + tags = local.tags + + private_service_connection { + name = "keyvault-connection" + private_connection_resource_id = azurerm_key_vault.shared.id + is_manual_connection = false + subresource_names = ["vault"] + } + + private_dns_zone_group { + name = "keyvault-dns-group" + private_dns_zone_ids = [azurerm_private_dns_zone.keyvault.id] + } +} + +# Role assignment for current user to manage secrets during deployment +resource "azurerm_role_assignment" "keyvault_admin" { + scope = azurerm_key_vault.shared.id + role_definition_name = "Key Vault Secrets Officer" + principal_id = data.azurerm_client_config.current.object_id +} + +# Store Datadog API Key in Key Vault (shared across all services) +resource "azurerm_key_vault_secret" "dd_api_key" { + name = "dd-api-key" + value = var.dd_api_key + key_vault_id = azurerm_key_vault.shared.id + tags = local.tags + + depends_on = [ + azurerm_role_assignment.keyvault_admin, + azurerm_private_endpoint.keyvault + ] +} + +# Store PostgreSQL admin password in Key Vault +resource "random_password" "postgresql_admin_password" { + length = 32 + special = true + override_special = "!#$%&*()-_=+[]{}<>:?" +} + +resource "azurerm_key_vault_secret" "postgresql_admin_password" { + name = "postgresql-admin-password" + value = random_password.postgresql_admin_password.result + key_vault_id = azurerm_key_vault.shared.id + tags = local.tags + + depends_on = [ + azurerm_role_assignment.keyvault_admin, + azurerm_private_endpoint.keyvault + ] +} diff --git a/shared/infra/azure/modules/shared-resources/main.tf b/shared/infra/azure/modules/shared-resources/main.tf new file mode 100644 index 00000000..3aaaa767 --- /dev/null +++ b/shared/infra/azure/modules/shared-resources/main.tf @@ -0,0 +1,43 @@ +# Shared Resources Module +# This module can be called from: +# 1. The shared infrastructure stack (for integrated environments like dev/prod) +# 2. Service stacks directly (for ephemeral/test environments) + +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.27" + } + random = { + source = "hashicorp/random" + version = "~> 3.8" + } + } +} + +# Local values +locals { + resource_group_name = coalesce(var.resource_group_name, "rg-stickerlandia-shared-${var.env}") + + default_tags = { + env = var.env + project = "stickerlandia" + scope = "shared" + source = "terraform" + } + + tags = merge(local.default_tags, var.tags) +} + +# Data sources +data "azurerm_subscription" "current" {} + +data "azurerm_client_config" "current" {} + +# Resource Group +resource "azurerm_resource_group" "shared" { + name = local.resource_group_name + location = var.location + tags = local.tags +} diff --git a/shared/infra/azure/modules/shared-resources/messaging.tf b/shared/infra/azure/modules/shared-resources/messaging.tf new file mode 100644 index 00000000..74e2ca2c --- /dev/null +++ b/shared/infra/azure/modules/shared-resources/messaging.tf @@ -0,0 +1,8 @@ +# Service Bus Namespace (shared - services create their own queues/topics) +resource "azurerm_servicebus_namespace" "shared" { + name = "sb-stickerlandia-${var.env}" + location = azurerm_resource_group.shared.location + resource_group_name = azurerm_resource_group.shared.name + sku = "Standard" + tags = local.tags +} diff --git a/shared/infra/azure/modules/shared-resources/monitoring.tf b/shared/infra/azure/modules/shared-resources/monitoring.tf new file mode 100644 index 00000000..4aaa3cf5 --- /dev/null +++ b/shared/infra/azure/modules/shared-resources/monitoring.tf @@ -0,0 +1,9 @@ +# Log Analytics Workspace (required by Container Apps) +resource "azurerm_log_analytics_workspace" "main" { + name = "log-stickerlandia-${var.env}" + location = azurerm_resource_group.shared.location + resource_group_name = azurerm_resource_group.shared.name + sku = "PerGB2018" + retention_in_days = 30 + tags = local.tags +} diff --git a/shared/infra/azure/modules/shared-resources/networking.tf b/shared/infra/azure/modules/shared-resources/networking.tf new file mode 100644 index 00000000..e2f43a8b --- /dev/null +++ b/shared/infra/azure/modules/shared-resources/networking.tf @@ -0,0 +1,83 @@ +# Virtual Network +resource "azurerm_virtual_network" "main" { + name = "vnet-stickerlandia-${var.env}" + location = azurerm_resource_group.shared.location + resource_group_name = azurerm_resource_group.shared.name + address_space = [var.vnet_address_space] + tags = local.tags +} + +# Container App Environment Subnet (minimum /23 required by Azure) +resource "azurerm_subnet" "container_apps" { + name = "snet-container-apps" + resource_group_name = azurerm_resource_group.shared.name + virtual_network_name = azurerm_virtual_network.main.name + address_prefixes = [var.container_apps_subnet_prefix] + + delegation { + name = "container-apps-delegation" + service_delegation { + name = "Microsoft.App/environments" + actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"] + } + } +} + +# Private Endpoint Subnet +resource "azurerm_subnet" "private_endpoints" { + name = "snet-private-endpoints" + resource_group_name = azurerm_resource_group.shared.name + virtual_network_name = azurerm_virtual_network.main.name + address_prefixes = [var.private_endpoints_subnet_prefix] +} + +# PostgreSQL Flexible Server Subnet (requires delegation) +resource "azurerm_subnet" "postgresql" { + name = "snet-postgresql" + resource_group_name = azurerm_resource_group.shared.name + virtual_network_name = azurerm_virtual_network.main.name + address_prefixes = [var.postgresql_subnet_prefix] + + delegation { + name = "postgresql-delegation" + service_delegation { + name = "Microsoft.DBforPostgreSQL/flexibleServers" + actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"] + } + } +} + +# Private DNS Zone for PostgreSQL Flexible Server with VNet Integration +# Note: VNet-integrated Flexible Servers require a zone ending in .postgres.database.azure.com +# The zone name cannot match the server name, so we use the format: stickerlandia-{env}.postgres.database.azure.com +# This is different from privatelink.postgres.database.azure.com which is for Private Endpoint connections +resource "azurerm_private_dns_zone" "postgresql" { + name = "stickerlandia-${var.env}.postgres.database.azure.com" + resource_group_name = azurerm_resource_group.shared.name + tags = local.tags +} + +resource "azurerm_private_dns_zone_virtual_network_link" "postgresql" { + name = "postgresql-vnet-link" + resource_group_name = azurerm_resource_group.shared.name + private_dns_zone_name = azurerm_private_dns_zone.postgresql.name + virtual_network_id = azurerm_virtual_network.main.id + registration_enabled = false + tags = local.tags +} + +# Private DNS Zone for Key Vault +resource "azurerm_private_dns_zone" "keyvault" { + name = "privatelink.vaultcore.azure.net" + resource_group_name = azurerm_resource_group.shared.name + tags = local.tags +} + +resource "azurerm_private_dns_zone_virtual_network_link" "keyvault" { + name = "keyvault-vnet-link" + resource_group_name = azurerm_resource_group.shared.name + private_dns_zone_name = azurerm_private_dns_zone.keyvault.name + virtual_network_id = azurerm_virtual_network.main.id + registration_enabled = false + tags = local.tags +} diff --git a/shared/infra/azure/modules/shared-resources/outputs.tf b/shared/infra/azure/modules/shared-resources/outputs.tf new file mode 100644 index 00000000..9df4b0d6 --- /dev/null +++ b/shared/infra/azure/modules/shared-resources/outputs.tf @@ -0,0 +1,174 @@ +# Resource Group +output "resource_group_name" { + description = "The name of the shared resource group" + value = azurerm_resource_group.shared.name +} + +output "resource_group_id" { + description = "The ID of the shared resource group" + value = azurerm_resource_group.shared.id +} + +output "location" { + description = "The Azure region" + value = azurerm_resource_group.shared.location +} + +# Networking +output "vnet_id" { + description = "The ID of the shared Virtual Network" + value = azurerm_virtual_network.main.id +} + +output "vnet_name" { + description = "The name of the shared Virtual Network" + value = azurerm_virtual_network.main.name +} + +output "container_apps_subnet_id" { + description = "The ID of the Container Apps subnet" + value = azurerm_subnet.container_apps.id +} + +output "private_endpoints_subnet_id" { + description = "The ID of the Private Endpoints subnet" + value = azurerm_subnet.private_endpoints.id +} + +output "postgresql_subnet_id" { + description = "The ID of the PostgreSQL subnet" + value = azurerm_subnet.postgresql.id +} + +# Monitoring +output "log_analytics_workspace_id" { + description = "The ID of the Log Analytics Workspace" + value = azurerm_log_analytics_workspace.main.id +} + +output "log_analytics_workspace_name" { + description = "The name of the Log Analytics Workspace" + value = azurerm_log_analytics_workspace.main.name +} + +# Key Vault +output "key_vault_id" { + description = "The ID of the shared Key Vault" + value = azurerm_key_vault.shared.id +} + +output "key_vault_name" { + description = "The name of the shared Key Vault" + value = azurerm_key_vault.shared.name +} + +output "key_vault_uri" { + description = "The URI of the shared Key Vault" + value = azurerm_key_vault.shared.vault_uri +} + +output "key_vault_suffix" { + description = "The random suffix used for the Key Vault name" + value = random_string.kv_suffix.result +} + +output "dd_api_key_secret_id" { + description = "The Key Vault secret ID for the Datadog API key" + value = azurerm_key_vault_secret.dd_api_key.id +} + +output "dd_api_key_secret_name" { + description = "The Key Vault secret name for the Datadog API key" + value = azurerm_key_vault_secret.dd_api_key.name +} + +output "postgresql_admin_password_secret_id" { + description = "The Key Vault secret ID for the PostgreSQL admin password" + value = azurerm_key_vault_secret.postgresql_admin_password.id +} + +output "postgresql_admin_password_secret_name" { + description = "The Key Vault secret name for the PostgreSQL admin password" + value = azurerm_key_vault_secret.postgresql_admin_password.name +} + +output "postgresql_admin_password" { + description = "The PostgreSQL admin password (sensitive)" + value = random_password.postgresql_admin_password.result + sensitive = true +} + +# Database +output "postgresql_server_id" { + description = "The ID of the PostgreSQL Flexible Server" + value = azurerm_postgresql_flexible_server.shared.id +} + +output "postgresql_server_fqdn" { + description = "The FQDN of the PostgreSQL Flexible Server" + value = azurerm_postgresql_flexible_server.shared.fqdn +} + +output "postgresql_server_name" { + description = "The name of the PostgreSQL Flexible Server" + value = azurerm_postgresql_flexible_server.shared.name +} + +output "postgresql_admin_username" { + description = "The administrator username for PostgreSQL" + value = azurerm_postgresql_flexible_server.shared.administrator_login +} + +# Messaging +output "servicebus_namespace_id" { + description = "The ID of the Service Bus Namespace" + value = azurerm_servicebus_namespace.shared.id +} + +output "servicebus_namespace_name" { + description = "The name of the Service Bus Namespace" + value = azurerm_servicebus_namespace.shared.name +} + +output "servicebus_connection_string" { + description = "The primary connection string for the Service Bus Namespace" + value = azurerm_servicebus_namespace.shared.default_primary_connection_string + sensitive = true +} + +# Container Apps +output "container_app_environment_id" { + description = "The ID of the shared Container App Environment" + value = azurerm_container_app_environment.shared.id +} + +output "container_app_environment_name" { + description = "The name of the shared Container App Environment" + value = azurerm_container_app_environment.shared.name +} + +output "container_app_environment_default_domain" { + description = "The default domain of the Container App Environment" + value = azurerm_container_app_environment.shared.default_domain +} + +# Front Door +output "frontdoor_profile_id" { + description = "The ID of the Front Door Profile" + value = azurerm_cdn_frontdoor_profile.shared.id +} + +output "frontdoor_profile_name" { + description = "The name of the Front Door Profile" + value = azurerm_cdn_frontdoor_profile.shared.name +} + +output "frontdoor_endpoint_id" { + description = "The ID of the Front Door Endpoint" + value = azurerm_cdn_frontdoor_endpoint.shared.id +} + +output "frontdoor_endpoint_host_name" { + description = "The hostname of the Front Door Endpoint" + value = azurerm_cdn_frontdoor_endpoint.shared.host_name +} diff --git a/shared/infra/azure/modules/shared-resources/variables.tf b/shared/infra/azure/modules/shared-resources/variables.tf new file mode 100644 index 00000000..2032a80e --- /dev/null +++ b/shared/infra/azure/modules/shared-resources/variables.tf @@ -0,0 +1,71 @@ +# Required variables +variable "env" { + description = "The environment (dev, staging, prod, or ephemeral name)" + type = string +} + +variable "location" { + description = "The Azure Region for all resources" + type = string +} + +variable "dd_api_key" { + description = "The Datadog API key" + type = string + sensitive = true +} + +# Optional variables with defaults +variable "resource_group_name" { + description = "Override the resource group name (defaults to rg-stickerlandia-shared-{env})" + type = string + default = null +} + +variable "dd_site" { + description = "The Datadog site" + type = string + default = "datadoghq.com" +} + +variable "vnet_address_space" { + description = "The address space for the Virtual Network" + type = string + default = "10.0.0.0/16" +} + +variable "container_apps_subnet_prefix" { + description = "The address prefix for the Container Apps subnet (minimum /23)" + type = string + default = "10.0.0.0/23" +} + +variable "private_endpoints_subnet_prefix" { + description = "The address prefix for the Private Endpoints subnet" + type = string + default = "10.0.2.0/24" +} + +variable "postgresql_subnet_prefix" { + description = "The address prefix for the PostgreSQL subnet" + type = string + default = "10.0.3.0/24" +} + +variable "postgresql_admin_username" { + description = "The administrator username for PostgreSQL" + type = string + default = "pgadmin" +} + +variable "postgresql_sku_name" { + description = "The SKU name for PostgreSQL Flexible Server" + type = string + default = "B_Standard_B1ms" +} + +variable "tags" { + description = "Additional tags to apply to all resources" + type = map(string) + default = {} +} diff --git a/shared/infra/azure/outputs.tf b/shared/infra/azure/outputs.tf new file mode 100644 index 00000000..c4144d3f --- /dev/null +++ b/shared/infra/azure/outputs.tf @@ -0,0 +1,160 @@ +# Re-export all outputs from the shared module + +# Resource Group +output "resource_group_name" { + description = "The name of the shared resource group" + value = module.shared.resource_group_name +} + +output "resource_group_id" { + description = "The ID of the shared resource group" + value = module.shared.resource_group_id +} + +output "location" { + description = "The Azure region" + value = module.shared.location +} + +# Networking +output "vnet_id" { + description = "The ID of the shared Virtual Network" + value = module.shared.vnet_id +} + +output "vnet_name" { + description = "The name of the shared Virtual Network" + value = module.shared.vnet_name +} + +output "container_apps_subnet_id" { + description = "The ID of the Container Apps subnet" + value = module.shared.container_apps_subnet_id +} + +output "private_endpoints_subnet_id" { + description = "The ID of the Private Endpoints subnet" + value = module.shared.private_endpoints_subnet_id +} + +output "postgresql_subnet_id" { + description = "The ID of the PostgreSQL subnet" + value = module.shared.postgresql_subnet_id +} + +# Monitoring +output "log_analytics_workspace_id" { + description = "The ID of the Log Analytics Workspace" + value = module.shared.log_analytics_workspace_id +} + +output "log_analytics_workspace_name" { + description = "The name of the Log Analytics Workspace" + value = module.shared.log_analytics_workspace_name +} + +# Key Vault +output "key_vault_id" { + description = "The ID of the shared Key Vault" + value = module.shared.key_vault_id +} + +output "key_vault_name" { + description = "The name of the shared Key Vault" + value = module.shared.key_vault_name +} + +output "key_vault_uri" { + description = "The URI of the shared Key Vault" + value = module.shared.key_vault_uri +} + +output "key_vault_suffix" { + description = "The random suffix used for the Key Vault name" + value = module.shared.key_vault_suffix +} + +output "dd_api_key_secret_id" { + description = "The Key Vault secret ID for the Datadog API key" + value = module.shared.dd_api_key_secret_id +} + +output "postgresql_admin_password_secret_id" { + description = "The Key Vault secret ID for the PostgreSQL admin password" + value = module.shared.postgresql_admin_password_secret_id +} + +# Database +output "postgresql_server_id" { + description = "The ID of the PostgreSQL Flexible Server" + value = module.shared.postgresql_server_id +} + +output "postgresql_server_fqdn" { + description = "The FQDN of the PostgreSQL Flexible Server" + value = module.shared.postgresql_server_fqdn +} + +output "postgresql_server_name" { + description = "The name of the PostgreSQL Flexible Server" + value = module.shared.postgresql_server_name +} + +output "postgresql_admin_username" { + description = "The administrator username for PostgreSQL" + value = module.shared.postgresql_admin_username +} + +# Messaging +output "servicebus_namespace_id" { + description = "The ID of the Service Bus Namespace" + value = module.shared.servicebus_namespace_id +} + +output "servicebus_namespace_name" { + description = "The name of the Service Bus Namespace" + value = module.shared.servicebus_namespace_name +} + +output "servicebus_connection_string" { + description = "The primary connection string for the Service Bus Namespace" + value = module.shared.servicebus_connection_string + sensitive = true +} + +# Container Apps +output "container_app_environment_id" { + description = "The ID of the shared Container App Environment" + value = module.shared.container_app_environment_id +} + +output "container_app_environment_name" { + description = "The name of the shared Container App Environment" + value = module.shared.container_app_environment_name +} + +output "container_app_environment_default_domain" { + description = "The default domain of the Container App Environment" + value = module.shared.container_app_environment_default_domain +} + +# Front Door +output "frontdoor_profile_id" { + description = "The ID of the Front Door Profile" + value = module.shared.frontdoor_profile_id +} + +output "frontdoor_profile_name" { + description = "The name of the Front Door Profile" + value = module.shared.frontdoor_profile_name +} + +output "frontdoor_endpoint_id" { + description = "The ID of the Front Door Endpoint" + value = module.shared.frontdoor_endpoint_id +} + +output "frontdoor_endpoint_host_name" { + description = "The hostname of the Front Door Endpoint" + value = module.shared.frontdoor_endpoint_host_name +} diff --git a/shared/infra/azure/providers.tf b/shared/infra/azure/providers.tf new file mode 100644 index 00000000..97a5e0d0 --- /dev/null +++ b/shared/infra/azure/providers.tf @@ -0,0 +1,18 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.27" + } + random = { + source = "hashicorp/random" + version = "~> 3.8" + } + } +} + +provider "azurerm" { + features {} + subscription_id = var.subscription_id + resource_provider_registrations = "none" +} diff --git a/shared/infra/azure/variables.tf b/shared/infra/azure/variables.tf new file mode 100644 index 00000000..51dfafc4 --- /dev/null +++ b/shared/infra/azure/variables.tf @@ -0,0 +1,59 @@ +variable "subscription_id" { + description = "The Azure Subscription ID" + type = string +} + +variable "location" { + description = "The Azure Region for all resources" + type = string + default = "uksouth" +} + +variable "env" { + description = "The environment (dev, staging, prod)" + type = string +} + +variable "dd_api_key" { + description = "The Datadog API key" + type = string + sensitive = true +} + +variable "dd_site" { + description = "The Datadog site" + type = string + default = "datadoghq.com" +} + +# Networking +variable "vnet_address_space" { + description = "The address space for the Virtual Network" + type = string + default = "10.0.0.0/16" +} + +variable "container_apps_subnet_prefix" { + description = "The address prefix for the Container Apps subnet (minimum /23)" + type = string + default = "10.0.0.0/23" +} + +variable "private_endpoints_subnet_prefix" { + description = "The address prefix for the Private Endpoints subnet" + type = string + default = "10.0.2.0/24" +} + +variable "postgresql_subnet_prefix" { + description = "The address prefix for the PostgreSQL subnet" + type = string + default = "10.0.3.0/24" +} + +# Database +variable "postgresql_admin_username" { + description = "The administrator username for PostgreSQL" + type = string + default = "pgadmin" +} diff --git a/user-management/docs/adr/ADR-001-azure-container-apps-production-architecture.md b/user-management/docs/adr/ADR-001-azure-container-apps-production-architecture.md new file mode 100644 index 00000000..6b5c6dfe --- /dev/null +++ b/user-management/docs/adr/ADR-001-azure-container-apps-production-architecture.md @@ -0,0 +1,138 @@ +# ADR-001: Azure Container Apps Production Architecture + +Date: 2026-02-02 +Status: Accepted + +## Context + +The User Management Service needs to be deployed to Azure in a production-ready configuration. The service currently has: + +1. **AWS CDK deployment** - Full production deployment with ECS Fargate, Aurora PostgreSQL, Lambda workers, CloudFront, and comprehensive Datadog integration +2. **Azure Terraform (existing)** - Minimal deployment with single-replica Container App, no managed database, plaintext secrets, and no network isolation + +The Azure deployment needs to reach production parity with appropriate security controls, high availability, and observability while remaining cost-effective and maintainable. + +### Key Requirements +- Azure Container Apps as the compute platform (not AKS) +- Azure Database for PostgreSQL Flexible Server +- Integration with existing Service Bus messaging +- Datadog as the primary observability platform +- Terraform as IaC tool (existing pattern) + +### Alternatives Evaluated +See [design-azure-container-apps-production.md](../design-azure-container-apps-production.md) for detailed analysis of: +- **Option A**: Minimal Production (public endpoints, basic security) +- **Option B**: Secure Production (VNet integrated, private endpoints) +- **Option C**: AWS-Parity Production (full feature set with Functions) + +## Decision + +We will implement **Option B: Secure Production (VNet Integrated)** architecture. + +### Architecture Components + +1. **Networking** + - Virtual Network (10.0.0.0/16) with dedicated subnets + - Container App Environment subnet (10.0.0.0/23 - /23 required by Azure) + - Private Endpoint subnet (10.0.2.0/24) + - Private DNS Zones for Azure PaaS services + +2. **Compute** + - Container App Environment with VNet integration (Consumption workload profile) + - API Container App (2-10 replicas, HTTP scaling) + - Worker Container App (1-3 replicas, Service Bus KEDA scaling) + - Container App Job for database migrations + +3. **Data** + - Azure Database for PostgreSQL Flexible Server (General Purpose D2s_v3) + - Private endpoint (no public access) + - Automated backups enabled + +4. **Messaging** + - Azure Service Bus Standard (cost-optimized) + - VNet Service Endpoint (traffic via Azure backbone) + - Firewall rules restricting access to VNet only + - Existing queues and topics preserved + +5. **Security** + - Azure Key Vault with private endpoint + - Managed identities for all services + - RBAC-based access to Key Vault secrets + - No plaintext secrets in configuration + +6. **Container Registry** + - Azure Container Registry Premium (private endpoint) + - Managed identity pull authentication + +7. **External Access** + - Azure Front Door (Standard or Premium based on WAF needs) + - Origin pointing to Container App Environment + +8. **Observability** + - Datadog sidecar containers in all apps + - Log Analytics Workspace (required by Container Apps) + - Datadog APM, logs, and infrastructure metrics + +## Consequences + +### Positive +- **Enterprise Security**: All backend services accessible only via private endpoints +- **Compliance Ready**: Network isolation meets SOC2, HIPAA, and similar requirements +- **Scalable Foundation**: VNet infrastructure supports future growth and additional services +- **Consistent with AWS**: Similar security posture to existing AWS deployment +- **Cost Predictable**: Fixed infrastructure costs with consumption-based compute + +### Negative +- **Higher Cost**: Premium SKUs required for private endpoints (~$400-800/month vs ~$150-300) +- **Increased Complexity**: More Terraform resources and networking concepts to manage +- **Debugging Difficulty**: Private networking complicates troubleshooting +- **Environment Recreation Risk**: VNet configuration errors require full environment rebuild + +### Neutral +- Service Bus upgrade from Standard to Premium required +- Log Analytics Workspace added (Azure requirement, not used for primary monitoring) +- Azure Front Door replaces direct Container App ingress + +## Implementation Notes + +### Terraform Module Structure +``` +infra/azure/ + main.tf # Resource group, locals + networking.tf # VNet, subnets, NSGs, private DNS + database.tf # PostgreSQL Flexible Server + keyvault.tf # Key Vault and secrets + registry.tf # Azure Container Registry + messaging.tf # Service Bus (updated to Premium) + container-apps.tf # Environment, API, Worker, Migration Job + frontdoor.tf # Azure Front Door + monitoring.tf # Log Analytics Workspace + variables.tf # Input variables + outputs.tf # Output values + providers.tf # Azure provider config +``` + +### Key Configuration Decisions +1. **PostgreSQL Tier**: Burstable B1ms (1 vCore, 2GB RAM) - cheapest tier, can scale up later +2. **Azure Front Door**: Standard tier (no WAF) - cost-effective for current needs +3. **Container App Scaling**: HTTP concurrent requests (API), Service Bus message count (Worker) +4. **Migration Strategy**: Container App Job runs on every deployment +5. **Secrets Pattern**: Key Vault references in Container App configuration +6. **Custom Domain**: Not required - will use Azure-provided Front Door domain + +### Migration Path from Current State +1. Deploy new VNet-integrated environment in parallel +2. Migrate database (dump/restore or Azure Database Migration Service) +3. Update DNS/Front Door to point to new environment +4. Decommission old environment + +## Related Decisions +- Service Bus was chosen over Kafka for Azure (existing decision) +- Datadog over Azure Monitor (organizational standard) +- Terraform over Bicep (team preference and AWS CDK parity) + +## Notes +- PostgreSQL Flexible Server supports high availability with zone redundancy (not enabled initially) +- Container Apps Jobs are GA and preferred over one-time containers for migrations +- Azure Front Door supports managed certificates for custom domains +- KEDA Service Bus scaler is built into Container Apps (no additional KEDA installation needed) diff --git a/user-management/docs/adr/ADR-002-optional-acr-for-local-builds.md b/user-management/docs/adr/ADR-002-optional-acr-for-local-builds.md new file mode 100644 index 00000000..c7764e16 --- /dev/null +++ b/user-management/docs/adr/ADR-002-optional-acr-for-local-builds.md @@ -0,0 +1,87 @@ +# ADR-002: Optional Azure Container Registry for Local Builds + +Date: 2026-02-02 +Status: Accepted + +## Context + +The current Azure Terraform configuration deploys Container Apps using images from the public GitHub Container Registry (ghcr.io/datadog/stickerlandia). While this works well for CI/CD pipelines that automatically publish to GHCR, it creates friction for local development and testing scenarios where developers want to: + +1. Build container images locally +2. Push them to a private registry +3. Deploy to Azure for testing + +The challenge is that ACR must exist before images can be pushed, but Container Apps need images to exist before they can be deployed. This requires a two-phase deployment approach. + +Additionally, developers using Apple Silicon Macs or other ARM-based machines must ensure images are built for AMD64 architecture, as Azure Container Apps runs on AMD64 infrastructure. + +## Decision + +We will add optional ACR support to the service infrastructure with explicit two-phase deployment control. + +### Implementation + +1. **New Variables**: + - `create_acr` (bool, default: false) - Controls ACR creation + - `deploy_container_apps` (bool, default: true) - Controls Container App creation + +2. **New Resources** (when `create_acr=true`): + - Azure Container Registry (Basic SKU) + - AcrPull role assignment for the service managed identity + +3. **Modified Resources** (when `deploy_container_apps=false`): + - Container Apps (API, Worker, Migration Job) are not created + +4. **Registry Authentication**: + - Managed identity authentication (no admin credentials) + - Registry block added to Container Apps when using ACR + +### Deployment Workflow + +```bash +# Phase 1: Create infrastructure including ACR +terraform apply -var="create_acr=true" -var="deploy_container_apps=false" + +# Phase 2: Build and push AMD64 images +docker buildx build --platform linux/amd64 -t .azurecr.io/image:tag --push . + +# Phase 3: Deploy Container Apps +terraform apply -var="create_acr=true" -var="deploy_container_apps=true" +``` + +## Consequences + +### Positive +- Enables local build and test workflows without modifying CI/CD +- No changes required to shared infrastructure +- Backward compatible - existing deployments work unchanged +- Managed identity authentication is secure (no secrets) +- Clear two-phase model prevents ordering issues + +### Negative +- ACR is per-service rather than shared across services +- Requires explicit variable coordination between deployment phases +- Additional cost (~$5/month for Basic ACR) when enabled + +### Neutral +- README documentation required for the workflow +- Developers must remember to use `--platform linux/amd64` on ARM machines + +## Alternatives Considered + +### ACR in Shared Infrastructure +Adding ACR to the shared-resources module would follow the established pattern for PostgreSQL, Service Bus, and Key Vault. This was rejected because: +- Expands scope beyond the user-management service +- Adds complexity for a feature currently needed by one service +- Can be migrated to shared infrastructure later if needed + +### Single Variable Control +Using a single variable like `use_local_registry` was considered but rejected because it doesn't clearly separate the "create ACR" step from the "deploy apps" step, making the two-phase workflow confusing. + +## Related Decisions +- [ADR-001](ADR-001-azure-container-apps-production-architecture.md): References ACR Premium with private endpoint as part of the secure production architecture + +## Notes +- Basic SKU ACR is sufficient for development; upgrade to Standard/Premium for production features like geo-replication or private endpoints +- The `container_image_registry` variable must be updated to point to ACR login server when using local builds +- Future enhancement: Consider adding ACR private endpoint for VNet-only access diff --git a/user-management/docs/design-azure-container-apps-production.md b/user-management/docs/design-azure-container-apps-production.md new file mode 100644 index 00000000..2d77377b --- /dev/null +++ b/user-management/docs/design-azure-container-apps-production.md @@ -0,0 +1,472 @@ +# Design: Azure Container Apps Production Deployment + +Generated: 2026-02-02 +Status: Approved + +## Decisions Made +- **Azure Front Door**: Standard tier (no WAF) +- **PostgreSQL Tier**: Burstable B1ms (cheapest) +- **Custom Domain**: Not required +- **Migration Strategy**: Run on every deployment +- **Service Bus**: Standard tier with VNet Service Endpoints (not Premium with Private Endpoints) + +## Problem Statement + +### Goal +Deploy the User Management Service to Azure Container Apps in a "production-ready" configuration that provides comparable capabilities to the existing AWS deployment, including: +- High availability and auto-scaling +- Secure networking and secrets management +- Managed PostgreSQL database +- Reliable messaging with Service Bus +- Comprehensive observability with Datadog +- Database migration strategy +- Background worker processing + +### Constraints +- Must use Azure Container Apps (not AKS or App Service) +- Must use Azure Database for PostgreSQL (Flexible Server) +- Must integrate with existing Service Bus messaging +- Must maintain Datadog observability parity with AWS deployment +- Should follow Azure best practices and Well-Architected Framework +- Infrastructure defined in Terraform (existing pattern) + +### Success Criteria +- [ ] API service running with auto-scaling (2-10 replicas) +- [ ] Worker service processing Service Bus messages +- [ ] Database migrations run before service deployment +- [ ] Secrets stored in Azure Key Vault (not plaintext) +- [ ] Network isolation with VNet integration +- [ ] Health checks and liveness probes configured +- [ ] Datadog APM, logs, and metrics flowing +- [ ] Infrastructure cost-optimized for production workloads + +## Context + +### Current State + +The existing Azure Terraform defines a **minimal, development-grade** deployment: + +| Component | Current State | Production Gap | +|-----------|---------------|----------------| +| Container App (API) | Single container, 1 replica | No scaling, no health checks | +| Container App (Worker) | **Not deployed** | Missing entirely | +| Database | Connection string passed as variable | No managed database, secrets in plaintext | +| Networking | Default Container App Environment | No VNet, no private endpoints | +| Secrets | Plaintext in environment variables | No Key Vault integration | +| Monitoring | Datadog sidecar (basic) | No Log Analytics, incomplete APM | +| Migrations | **Not deployed** | No migration strategy | +| Container Registry | Public ECR | No ACR, security concern | + +### AWS Reference Architecture + +The AWS CDK deployment includes: +- **ECS Fargate** with auto-scaling and health checks +- **Aurora PostgreSQL Serverless v2** with credentials in Secrets Manager +- **VPC** with private subnets, NAT Gateway, security groups +- **CloudFront** distribution for API access +- **Lambda workers** for background processing (SQS-triggered) +- **Migration task** that runs before services start +- **EventBridge** integration for event-driven patterns +- **Datadog** full APM with profiling enabled + +### Related Decisions +- Application uses ports and adapters architecture with `DRIVEN=AZURE` for Azure-specific implementations +- Service Bus is the messaging backbone (already deployed) +- Datadog is the observability platform (non-negotiable) + +## Alternatives Considered + +--- + +### Option A: Minimal Production (Incremental Enhancement) + +**Summary**: Add essential production features to existing Terraform while minimizing complexity. + +**Architecture**: +``` ++------------------------------------------------------------------+ +| Azure Resource Group | ++------------------------------------------------------------------+ +| | +| +------------------------------------------------------------+ | +| | Container App Environment (Consumption) | | +| | +-------------------+ +-----------------------------+ | | +| | | API Service | | Worker Service | | | +| | | (2-10 replicas) | | (1-3 replicas) | | | +| | | + DD sidecar | | + DD sidecar | | | +| | +---------+---------+ +-------------+---------------+ | | +| +------------|---------------------------|--------------------+ | +| | | | +| +------------v---------------------------v--------------------+ | +| | Azure Database for PostgreSQL | | +| | (Flexible Server) | | +| +-------------------------------------------------------------+ | +| | +| +-------------------------------------------------------------+ | +| | Service Bus Namespace | | +| | [users.stickerClaimed.v1] [users.userRegistered.v1] | | +| +-------------------------------------------------------------+ | +| | +| +-------------------------------------------------------------+ | +| | Key Vault | | +| | [db-password] [dd-api-key] [servicebus-connection] | | +| +-------------------------------------------------------------+ | +| | ++-------------------------------------------------------------------+ +``` + +**What's Added**: +1. Azure Database for PostgreSQL Flexible Server (Burstable B1ms) +2. Azure Key Vault for secrets +3. Worker Container App with Service Bus scale rule +4. Container App Job for migrations +5. Health probes and scaling rules +6. Log Analytics workspace (required by Container Apps) + +**Pros**: +- Fastest path to production +- Lowest complexity +- Builds on existing Terraform +- Cost-effective (consumption plan + burstable DB) + +**Cons**: +- No network isolation (public endpoints) +- Limited security controls +- No private container registry +- Database publicly accessible (firewall rules only) + +**Coupling Analysis**: +| Component | Afferent (Ca) | Efferent (Ce) | Instability (I) | +|-----------|---------------|---------------|-----------------| +| API Container App | 1 (ingress) | 3 (DB, SB, KV) | 0.75 | +| Worker Container App | 1 (SB trigger) | 3 (DB, SB, KV) | 0.75 | +| PostgreSQL | 2 (API, Worker) | 0 | 0 | +| Key Vault | 2 (API, Worker) | 0 | 0 | + +New dependencies introduced: Key Vault, Log Analytics, PostgreSQL Flexible Server +Coupling impact: **Low** - Standard Azure PaaS coupling + +**Failure Modes**: +| Mode | Severity | Occurrence | Detection | RPN | +|------|----------|------------|-----------|-----| +| Database connection failure | High (8) | Low (2) | High (2) | 32 | +| Key Vault access denied | High (8) | Low (2) | Medium (4) | 64 | +| Service Bus throttling | Medium (5) | Medium (4) | High (2) | 40 | +| Container App cold start | Low (3) | Medium (5) | High (2) | 30 | + +**Evolvability Assessment**: +- Adding VNet later: **Hard** - Requires recreation of Container App Environment +- Adding ACR: **Easy** - Just change image source +- Scaling database: **Easy** - Change SKU +- Adding more workers: **Easy** - Add scale rules + +**Effort Estimate**: Small + +--- + +### Option B: Secure Production (VNet Integrated) + +**Summary**: Full network isolation with private endpoints, VNet integration, and enterprise security controls. + +**Architecture**: +``` ++----------------------------------------------------------------------+ +| Azure Resource Group | ++----------------------------------------------------------------------+ +| +----------------------------------------------------------------+ | +| | Virtual Network (10.0.0.0/16) | | +| | +----------------------------------------------------------+ | | +| | | Container App Environment Subnet | | | +| | | (10.0.0.0/23) | | | +| | | +-------------------+ +---------------------------+ | | | +| | | | API Service | | Worker Service | | | | +| | | | (2-10 replicas) | | (1-3 replicas) | | | | +| | | +-------------------+ +---------------------------+ | | | +| | +----------------------------------------------------------+ | | +| | | | +| | +----------------------------------------------------------+ | | +| | | Private Endpoint Subnet (10.0.2.0/24) | | | +| | | +------------+ +------------+ +--------------------+ | | | +| | | | PostgreSQL | | Key Vault | | Service Bus | | | | +| | | | Endpoint | | Endpoint | | Endpoint | | | | +| | | +------------+ +------------+ +--------------------+ | | | +| | +----------------------------------------------------------+ | | +| +----------------------------------------------------------------+ | +| | +| +----------------------------------------------------------------+ | +| | Azure Container Registry (Premium - Private Endpoint) | | +| +----------------------------------------------------------------+ | +| | +| +----------------------------------------------------------------+ | +| | Azure Front Door (Global Load Balancer) | | +| +----------------------------------------------------------------+ | +| | ++-----------------------------------------------------------------------+ +``` + +**What's Added** (on top of Option A): +1. Virtual Network with dedicated subnets +2. Container App Environment with VNet integration +3. Private endpoints for PostgreSQL, Key Vault, Service Bus +4. Azure Container Registry (Premium) with private endpoint +5. Azure Front Door for global routing and WAF +6. Private DNS zones for name resolution +7. Network Security Groups + +**Pros**: +- Enterprise-grade security +- No public endpoints for backend services +- Defense in depth +- Compliance-ready (SOC2, HIPAA eligible) +- Private container registry + +**Cons**: +- Higher complexity +- Higher cost (Premium SKUs required for private endpoints) +- Longer deployment time +- More difficult to debug + +**Coupling Analysis**: +| Component | Afferent (Ca) | Efferent (Ce) | Instability (I) | +|-----------|---------------|---------------|-----------------| +| API Container App | 2 (FD, internal) | 4 (DB, SB, KV, ACR) | 0.67 | +| Worker Container App | 1 (SB trigger) | 4 (DB, SB, KV, ACR) | 0.80 | +| VNet | 5 (all services) | 0 | 0 | +| Front Door | 1 (internet) | 1 (Container App) | 0.50 | + +New dependencies introduced: VNet, NSGs, Private DNS, Front Door, ACR +Coupling impact: **Medium** - More components but better isolation + +**Failure Modes**: +| Mode | Severity | Occurrence | Detection | RPN | +|------|----------|------------|-----------|-----| +| Private DNS resolution failure | Critical (9) | Low (2) | Medium (4) | 72 | +| VNet misconfiguration | Critical (9) | Low (2) | Low (6) | 108 | +| Front Door origin failure | High (7) | Low (2) | High (2) | 28 | +| ACR pull failure | High (8) | Low (2) | High (2) | 32 | + +**Evolvability Assessment**: +- Adding new services: **Easy** - VNet already in place +- Multi-region: **Medium** - Front Door supports it, DB needs geo-replica +- Adding AKS later: **Easy** - VNet ready for additional subnets +- Changing database tier: **Easy** - No networking changes + +**Effort Estimate**: Medium + +--- + +### Option C: AWS-Parity Production (Full Feature Set) + +**Summary**: Match AWS deployment capabilities exactly, including managed containers, serverless workers, CDN, and full observability. + +**Architecture**: +``` ++--------------------------------------------------------------------------+ +| Azure Resource Group | ++--------------------------------------------------------------------------+ +| | +| +---------------------------------------------------------------------+ | +| | Azure Front Door (Premium) | | +| | WAF Policy + Custom Domain + SSL/TLS | | +| +--------------------------------+------------------------------------+ | +| | | +| +--------------------------------v------------------------------------+ | +| | Virtual Network (10.0.0.0/16) | | +| | +---------------------------------------------------------------+ | | +| | | Container App Environment (Workload) | | | +| | | (10.0.0.0/23) | | | +| | | +----------------+ +----------------+ +-----------------+ | | | +| | | | API Service | | Outbox Worker | | Migration | | | | +| | | | (2-10 replicas)| | (KEDA scaled) | | Job | | | | +| | | | + DD sidecar | | + DD sidecar | | (one-time) | | | | +| | | +----------------+ +----------------+ +-----------------+ | | | +| | +---------------------------------------------------------------+ | | +| | | | +| | +---------------------------------------------------------------+ | | +| | | Azure Functions (Flex Consumption) | | | +| | | +-------------------------+ +----------------------------+ | | | +| | | | StickerClaimedFunction | | OutboxTimerFunction | | | | +| | | | (Service Bus Trigger) | | (Timer Trigger - backup) | | | | +| | | +-------------------------+ +----------------------------+ | | | +| | +---------------------------------------------------------------+ | | +| | | | +| | +---------------------------------------------------------------+ | | +| | | Private Endpoint Subnet (10.0.2.0/24) | | | +| | +---------------------------------------------------------------+ | | +| +----------------------------------------------------------------------+ | +| | +| +---------------------------------------------------------------------+ | +| | Data and Messaging | | +| | +------------------+ +-----------------+ +--------------------+ | | +| | | PostgreSQL | | Service Bus | | Key Vault | | | +| | | Flexible Server | | Premium | | (Secrets) | | | +| | | (General Purpose| | (Private EP) | | (Private EP) | | | +| | | + Read Replica)| | | | | | | +| | +------------------+ +-----------------+ +--------------------+ | | +| +---------------------------------------------------------------------+ | +| | +| +---------------------------------------------------------------------+ | +| | Observability | | +| | +------------------+ +-----------------+ +--------------------+ | | +| | | Log Analytics | | App Insights | | Datadog | | | +| | | Workspace | | (Backup) | | (Primary) | | | +| | +------------------+ +-----------------+ +--------------------+ | | +| +---------------------------------------------------------------------+ | +| | +| +---------------------------------------------------------------------+ | +| | Azure Container Registry (Premium) | | +| +---------------------------------------------------------------------+ | +| | ++---------------------------------------------------------------------------+ +``` + +**What's Added** (on top of Option B): +1. Azure Functions (Flex Consumption) for serverless workers +2. KEDA scaling rules matching AWS Lambda behavior +3. Azure Front Door Premium with WAF +4. PostgreSQL read replica for scaling +5. Application Insights as backup observability +6. Dedicated workload profiles for Container Apps +7. Container App Jobs for migrations +8. Service Bus Premium (for private endpoints) + +**Pros**: +- Full feature parity with AWS +- Enterprise-grade everything +- Best performance characteristics +- Multi-region ready +- Serverless cost optimization + +**Cons**: +- Highest complexity +- Highest cost +- Over-engineered for most use cases +- Longer time to implement +- More components to maintain + +**Coupling Analysis**: +| Component | Afferent (Ca) | Efferent (Ce) | Instability (I) | +|-----------|---------------|---------------|-----------------| +| API Container App | 2 | 5 | 0.71 | +| Azure Functions | 2 | 4 | 0.67 | +| Worker Container App | 1 | 4 | 0.80 | +| Front Door | 1 | 1 | 0.50 | +| PostgreSQL | 4 | 0 | 0 | + +New dependencies: Functions, KEDA, WAF, App Insights, Read Replica +Coupling impact: **High** - Many interdependent components + +**Failure Modes**: +| Mode | Severity | Occurrence | Detection | RPN | +|------|----------|------------|-----------|-----| +| Function cold start | Low (3) | Medium (5) | High (2) | 30 | +| KEDA scaling lag | Medium (5) | Medium (4) | Medium (4) | 80 | +| Read replica lag | Medium (5) | Low (3) | High (2) | 30 | +| WAF false positive | Medium (5) | Medium (4) | Medium (4) | 80 | + +**Evolvability Assessment**: +- Adding new event handlers: **Easy** - Add new Azure Function +- Multi-region: **Easy** - All components support it +- Changing compute model: **Hard** - Significant refactoring +- Cost optimization: **Medium** - Many knobs to tune + +**Effort Estimate**: Large + +--- + +## Comparison Matrix + +| Criterion | Option A (Minimal) | Option B (Secure) | Option C (Full) | +|-----------|-------------------|-------------------|-----------------| +| Complexity | Low | Medium | High | +| Security | Basic | Enterprise | Enterprise | +| Cost (monthly) | ~$150-300 | ~$400-800 | ~$800-1500+ | +| Time to Implement | Days | 1-2 weeks | 2-4 weeks | +| Coupling Impact | Low | Medium | High | +| Failure Resilience | Medium | High | High | +| Evolvability | Medium | High | High | +| AWS Feature Parity | 60% | 80% | 95% | + +## Recommendation + +**Recommended Option**: **Option B (Secure Production)** + +### Rationale + +Option B provides the best balance of security, cost, and complexity for a production deployment: + +1. **Security First**: VNet integration and private endpoints are table stakes for production workloads handling user data. Option A's public endpoints are a non-starter for compliance-conscious organizations. + +2. **Right-Sized Complexity**: Option B adds necessary security controls without the over-engineering of Option C. The Azure Functions layer in Option C is redundant when Container Apps with KEDA can handle the same workloads. + +3. **Cost Efficiency**: While more expensive than Option A, Option B's ~$400-800/month is reasonable for production. Option C's Premium SKUs (Service Bus Premium alone is ~$600/month) are overkill. + +4. **Evolvability**: Starting with VNet integration means future enhancements (AKS migration, additional services, multi-region) won't require infrastructure recreation. + +5. **Team Capability**: Option B is achievable with standard Azure/Terraform knowledge. Option C requires deep expertise in KEDA, Azure Functions, and complex networking. + +### Tradeoffs Accepted + +- **No serverless workers**: Container App workers with KEDA provide equivalent functionality. Lambda-style serverless isn't necessary. +- **No read replica**: For the expected load, a single PostgreSQL instance is sufficient. Replicas can be added later. +- **No Application Insights**: Datadog is the primary observability platform. Native Azure monitoring is redundant. + +### Risks to Monitor + +1. **Container App Environment Recreation**: If VNet configuration is wrong, the entire environment must be recreated. Mitigation: Thorough testing in dev environment first. + +2. **Private DNS Resolution**: Misconfigurations can cause complete outages. Mitigation: Use Azure-provided private DNS zones, validate with nslookup tests. + +3. **Key Vault Access**: RBAC misconfigurations can prevent service startup. Mitigation: Use managed identities with explicit role assignments, test access before deployment. + +## Implementation Plan + +### Phase 1: Foundation Infrastructure +- [ ] Create Virtual Network with subnets +- [ ] Deploy Azure Key Vault with private endpoint +- [ ] Deploy Azure Container Registry with private endpoint +- [ ] Create Log Analytics Workspace +- [ ] Set up Private DNS Zones + +### Phase 2: Data Layer +- [ ] Deploy PostgreSQL Flexible Server with private endpoint +- [ ] Configure firewall rules (VNet only) +- [ ] Store credentials in Key Vault +- [ ] Update Service Bus with private endpoint (Premium SKU) + +### Phase 3: Container App Environment +- [ ] Create Container App Environment with VNet integration +- [ ] Configure workload profile (Consumption) +- [ ] Set up managed identity with Key Vault access +- [ ] Create Container App Job for migrations + +### Phase 4: Application Services +- [ ] Deploy API Container App with: + - Health probes (liveness, readiness, startup) + - Auto-scaling rules (HTTP concurrent requests) + - Key Vault references for secrets + - Datadog sidecar +- [ ] Deploy Worker Container App with: + - Service Bus KEDA scale rule + - Key Vault references + - Datadog sidecar + +### Phase 5: External Access +- [ ] Deploy Azure Front Door +- [ ] Configure origin to Container App +- [ ] Set up custom domain and SSL +- [ ] (Optional) Configure WAF rules + +### Phase 6: Validation +- [ ] Run integration tests +- [ ] Verify Datadog traces and metrics +- [ ] Load test scaling behavior +- [ ] Security scan with Azure Security Center + +## Open Questions + +- [x] Should we use Azure Front Door Standard or Premium? **Decision: Standard (no WAF needed)** +- [x] What is the expected request volume for sizing the PostgreSQL tier? **Decision: Burstable B1ms (cheapest, scale later)** +- [x] Is there a custom domain requirement for the API endpoint? **Decision: No, use Azure-provided domain** +- [x] Should the migration job run on every deployment or only when schema changes? **Decision: Run on every deployment** +- [ ] What is the retention policy for Log Analytics data? (Default 30 days is acceptable) diff --git a/user-management/docs/design-azure-shared-infrastructure.md b/user-management/docs/design-azure-shared-infrastructure.md new file mode 100644 index 00000000..a4d0f6c2 --- /dev/null +++ b/user-management/docs/design-azure-shared-infrastructure.md @@ -0,0 +1,244 @@ +# Design: Azure Shared Infrastructure Split + +Generated: 2026-02-02 +Status: Draft + +## Problem Statement + +### Goal +Split the Azure Terraform infrastructure into shared (environment-wide) and service-specific components, following the pattern established in the AWS CDK shared infrastructure. + +### Constraints +- Must follow the existing AWS shared infrastructure patterns +- Shared resources should be deployed once per environment +- Services should reference shared resources via data sources or outputs +- Each service should be independently deployable after shared infra exists + +### Success Criteria +- [ ] Clear separation between shared and service-specific Terraform +- [ ] Services can be added without modifying shared infrastructure +- [ ] Reduced cost through resource sharing (VNet, PostgreSQL cluster, Service Bus) +- [ ] Consistent with AWS shared infrastructure patterns + +## Context + +### AWS Shared Infrastructure Pattern + +The AWS deployment splits infrastructure as follows: + +**Shared (`shared/infra/aws/`):** +- VPC with subnets (public, private, isolated) +- API Gateway + VPC Link +- CloudFront distribution +- Aurora PostgreSQL cluster (shared database server) +- EventBridge Event Bus +- Service Discovery namespace +- SSM Parameters for cross-stack references + +**Service-Specific (`user-management/infra/aws/`):** +- ECS Cluster +- Service's database (within shared cluster) +- SNS topics / SQS queues +- Lambda functions / ECS services +- API Gateway routes + +### Current Azure State + +All resources are currently in one service-specific folder (`user-management/infra/azure/`). + +## Recommended Split + +### Shared Infrastructure (`shared/infra/azure/`) + +| Resource | File | Rationale | +|----------|------|-----------| +| Resource Group | `main.tf` | Single RG for all shared resources | +| Virtual Network | `networking.tf` | One VNet per environment, services share subnets | +| Subnets | `networking.tf` | Container Apps, Private Endpoints, PostgreSQL | +| Private DNS Zones | `networking.tf` | Shared DNS for private endpoints | +| Log Analytics Workspace | `monitoring.tf` | Single workspace for all services | +| PostgreSQL Flexible Server | `database.tf` | Shared database server, services create own DBs | +| Service Bus Namespace | `messaging.tf` | Shared namespace, services create own queues/topics | +| Key Vault (shared) | `keyvault.tf` | Shared secrets (Datadog API key, etc.) | +| Azure Front Door Profile | `frontdoor.tf` | Single CDN/load balancer entry point | +| Front Door Endpoint | `frontdoor.tf` | Shared endpoint, services add routes | + +### Service-Specific Infrastructure (`user-management/infra/azure/`) + +| Resource | File | Rationale | +|----------|------|-----------| +| Container App Environment | `application.tf` | Could be shared, but isolation is safer | +| Container Apps (API, Worker) | `application.tf` | Service-specific workloads | +| Container App Job (Migration) | `application.tf` | Service-specific migrations | +| User Assigned Identity | `application.tf` | Service-specific RBAC | +| PostgreSQL Database | `database.tf` | Database within shared server | +| Service Bus Queue/Topic | `messaging.tf` | Service-specific messaging | +| Key Vault Secrets | `keyvault.tf` | Service-specific secrets (DB connection string) | +| Front Door Origin + Routes | `frontdoor.tf` | Service-specific routing | + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ SHARED INFRASTRUCTURE (per environment) │ +│ shared/infra/azure/ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────────────────────────────────────────────────────────┐ │ +│ │ Azure Front Door Profile │ │ +│ │ (users-api-dev endpoint) │ │ +│ └────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────────────────────┐ │ +│ │ Virtual Network (10.0.0.0/16) │ │ +│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────┐ │ │ +│ │ │ Container Apps │ │ Private Endpts │ │ PostgreSQL Subnet │ │ │ +│ │ │ (10.0.0.0/23) │ │ (10.0.2.0/24) │ │ (10.0.3.0/24) │ │ │ +│ │ └─────────────────┘ └─────────────────┘ └─────────────────────────┘ │ │ +│ └────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────────┐ │ +│ │ PostgreSQL │ │ Service Bus │ │ Key Vault (shared) │ │ +│ │ Flexible Server │ │ Namespace │ │ - dd-api-key │ │ +│ │ (shared cluster) │ │ (Standard) │ │ - shared secrets │ │ +│ └──────────────────┘ └──────────────────┘ └──────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ Log Analytics Workspace │ Private DNS Zones │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ OUTPUTS: vnet_id, subnet_ids, postgresql_fqdn, servicebus_namespace_id, │ +│ keyvault_id, frontdoor_profile_id, log_analytics_workspace_id │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ SERVICE-SPECIFIC (user-management/infra/azure/) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ DATA SOURCES: References shared infra via terraform_remote_state or │ +│ data "azurerm_*" lookups │ +│ │ +│ ┌────────────────────────────────────────────────────────────────────────┐ │ +│ │ Container App Environment │ │ +│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────┐ │ │ +│ │ │ API Container │ │ Worker Container│ │ Migration Job │ │ │ +│ │ │ App (2-10) │ │ App (1-3) │ │ (on deploy) │ │ │ +│ │ └─────────────────┘ └─────────────────┘ └─────────────────────────┘ │ │ +│ └────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────────┐ │ +│ │ PostgreSQL DB │ │ Service Bus │ │ Key Vault Secrets │ │ +│ │ stickerlandia_ │ │ - queue: sticker │ │ - db-connection-string │ │ +│ │ users │ │ - topic: user- │ │ - servicebus-conn-str │ │ +│ │ (in shared srv) │ │ registered │ │ │ │ +│ └──────────────────┘ └──────────────────┘ └──────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ Front Door Origin + Routes: /api/users/*, /auth/*, /.well-known/* │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## Cross-Stack Reference Pattern + +### Option A: Terraform Remote State (Recommended) + +```hcl +# In service Terraform +data "terraform_remote_state" "shared" { + backend = "azurerm" + config = { + resource_group_name = "tfstate-rg" + storage_account_name = "tfstatestickerlandia" + container_name = "tfstate" + key = "shared/${var.env}/terraform.tfstate" + } +} + +# Reference shared resources +resource "azurerm_container_app_environment" "main" { + infrastructure_subnet_id = data.terraform_remote_state.shared.outputs.container_apps_subnet_id + log_analytics_workspace_id = data.terraform_remote_state.shared.outputs.log_analytics_workspace_id +} +``` + +### Option B: Data Source Lookups + +```hcl +# In service Terraform - lookup by naming convention +data "azurerm_virtual_network" "shared" { + name = "vnet-stickerlandia-${var.env}" + resource_group_name = "rg-shared-${var.env}" +} + +data "azurerm_subnet" "container_apps" { + name = "snet-container-apps" + virtual_network_name = data.azurerm_virtual_network.shared.name + resource_group_name = data.azurerm_virtual_network.shared.resource_group_name +} +``` + +## File Structure + +``` +stickerlandia/ +├── shared/ +│ └── infra/ +│ ├── aws/ # Existing AWS CDK +│ └── azure/ # NEW: Shared Azure Terraform +│ ├── main.tf # Resource group, locals +│ ├── networking.tf # VNet, subnets, private DNS +│ ├── database.tf # PostgreSQL Flexible Server +│ ├── messaging.tf # Service Bus namespace +│ ├── keyvault.tf # Shared Key Vault +│ ├── frontdoor.tf # Front Door profile + endpoint +│ ├── monitoring.tf # Log Analytics +│ ├── variables.tf # Input variables +│ ├── outputs.tf # Outputs for services +│ └── providers.tf # Provider config +│ +└── user-management/ + └── infra/ + ├── aws/ # Existing AWS CDK + └── azure/ # Service-specific Azure Terraform + ├── main.tf # Data sources for shared infra + ├── application.tf # Container Apps + ├── database.tf # PostgreSQL database (not server) + ├── messaging.tf # Service Bus queues/topics + ├── keyvault.tf # Service-specific secrets + ├── frontdoor.tf # Service-specific routes + ├── variables.tf # Input variables + ├── outputs.tf # Service outputs + └── providers.tf # Provider config +``` + +## Migration Plan + +### Phase 1: Create Shared Infrastructure +1. Create `shared/infra/azure/` directory +2. Move shared resources from current user-management Terraform +3. Add outputs for all shared resources +4. Deploy shared infrastructure + +### Phase 2: Refactor Service Infrastructure +1. Update user-management Terraform to use data sources +2. Remove resources that are now shared +3. Add database creation within shared PostgreSQL server +4. Test deployment + +### Phase 3: Validate +1. Verify all services can access shared resources +2. Test adding a hypothetical second service +3. Document the pattern for future services + +## Open Questions + +- [ ] Should Container App Environment be shared or per-service? + - **Shared**: Cost savings, simpler networking + - **Per-service**: Better isolation, independent scaling +- [ ] Should each service have its own Key Vault or share one? + - **Shared**: Simpler management + - **Per-service**: Better secret isolation +- [ ] Terraform state backend configuration for shared vs service states? diff --git a/user-management/docs/design-optional-acr-local-builds.md b/user-management/docs/design-optional-acr-local-builds.md new file mode 100644 index 00000000..bbc312c7 --- /dev/null +++ b/user-management/docs/design-optional-acr-local-builds.md @@ -0,0 +1,375 @@ +# Design: Optional Azure Container Registry for Local Builds + +Generated: 2026-02-02 +Status: Approved + +## Problem Statement + +### Goal +Enable optional deployment from locally-built container images via Azure Container Registry (ACR), while maintaining the existing public GHCR deployment path as the default. This supports development workflows where: +1. First deploy creates infrastructure including ACR +2. Developer builds and pushes images to ACR +3. Second deploy creates Container Apps using those images + +### Constraints +- Must maintain backward compatibility with existing GHCR workflow +- Container images must ALWAYS be built for AMD64 architecture regardless of host OS (Apple Silicon, ARM, etc.) +- Must support a two-phase deployment: infrastructure-only (ACR), then full deployment (apps) +- ACR should be created in shared infrastructure when using shared mode, or inline for ephemeral environments +- Managed identity authentication preferred over admin credentials + +### Success Criteria +- [ ] Existing deployments continue to work unchanged (default behavior) +- [ ] New `create_acr` variable controls ACR creation +- [ ] New `deploy_container_apps` variable controls Container App creation +- [ ] ACR uses managed identity for image pull (no secrets) +- [ ] README includes clear instructions for local build + push workflow +- [ ] All images built as AMD64 regardless of host architecture + +## Context + +### Current State +The Terraform configuration under `infra/azure/` currently: +- Uses `container_image_registry` variable defaulting to `ghcr.io/datadog/stickerlandia` +- Creates three Container Apps: API, Worker, and Migration Job +- No ACR exists in either shared or service-specific infrastructure +- Container Apps have no registry block (uses anonymous pull from public registries) + +### Related Decisions +- [ADR-001](adr/ADR-001-azure-container-apps-production-architecture.md): Mentions ACR Premium with private endpoint as part of Option B (Secure Production) + +## Alternatives Considered + +### Option A: ACR in Service Infrastructure (Simple) + +**Summary**: Add optional ACR resource directly to the user-management service Terraform, controlled by a single variable. + +**Architecture**: +``` +user-management/infra/azure/ +├── main.tf +├── application.tf +├── registry.tf (NEW - optional ACR) +└── variables.tf (add create_acr, deploy_container_apps) +``` + +**Pros**: +- Simplest implementation - single directory to modify +- No coordination with shared infrastructure +- Self-contained within the service + +**Cons**: +- ACR created per-service (wasteful if multiple services need it) +- Doesn't follow established pattern of shared resources (PostgreSQL, Service Bus, Key Vault) +- Registry naming must include service prefix to avoid conflicts + +**Coupling Analysis**: +| Component | Ca | Ce | I | +|-----------|----|----|---| +| registry.tf | 1 | 2 | 0.67 | +| application.tf | 0 | 3 | 1.0 | + +New dependencies introduced: +- application.tf -> registry.tf (when ACR enabled) +- app_identity -> ACR (AcrPull role assignment) + +Coupling impact: Low (self-contained within service) + +**Failure Modes**: +| Mode | Severity | Occurrence | Detection | RPN | +|------|----------|------------|-----------|-----| +| ACR not created before push | Medium | High (first deploy) | Easy (az acr fails) | 6 | +| Apps created before images pushed | High | High (first deploy) | Medium (deploy fails) | 12 | +| Identity lacks AcrPull role | Medium | Low | Easy (pull fails) | 3 | + +**Evolvability Assessment**: +- Adding more services: Hard - each service creates own ACR +- Moving to shared ACR later: Medium - requires state migration +- Adding private endpoint: Easy - add to same module + +### Option B: ACR in Shared Infrastructure (Recommended) + +**Summary**: Add optional ACR to the shared-resources module, following the established pattern for PostgreSQL, Service Bus, and Key Vault. Service-specific Terraform looks up the ACR when enabled. + +**Architecture**: +``` +shared/infra/azure/modules/shared-resources/ +├── registry.tf (NEW - optional ACR) +├── outputs.tf (add ACR outputs) +└── variables.tf (add create_acr) + +user-management/infra/azure/ +├── main.tf (add ACR lookup) +├── application.tf (add registry block conditionally) +└── variables.tf (add use_acr, deploy_container_apps) +``` + +**Pros**: +- Follows established pattern for shared resources +- Single ACR serves all services (cost-efficient) +- Consistent with ADR-001 architecture vision +- Clean separation of concerns + +**Cons**: +- Requires changes to two Terraform configurations +- More complex conditional logic +- Must coordinate shared and service deployments + +**Coupling Analysis**: +| Component | Ca | Ce | I | +|-----------|----|----|---| +| shared/registry.tf | 2+ | 2 | 0.5 | +| service/application.tf | 0 | 4 | 1.0 | +| service/main.tf | 2 | 3 | 0.6 | + +New dependencies introduced: +- All services -> shared ACR (when enabled) +- Service identities -> ACR via role assignments + +Coupling impact: Medium (adds cross-module dependency, but follows existing pattern) + +**Failure Modes**: +| Mode | Severity | Occurrence | Detection | RPN | +|------|----------|------------|-----------|-----| +| Shared ACR not created | Medium | Low (clear workflow) | Easy | 3 | +| Service lookup fails | Medium | Low | Easy | 3 | +| Cross-service image conflicts | Low | Low | Medium | 2 | +| Role assignment missing | Medium | Low | Easy | 3 | + +**Evolvability Assessment**: +- Adding more services: Easy - just add ACR lookup +- Adding private endpoint: Easy - add to shared module +- Switching services to ACR: Easy - flip variable per service + +### Option C: Service-Only with Deployment Phases (Pragmatic) + +**Summary**: Add ACR to service infrastructure with explicit two-phase deployment control. Simpler than Option B, avoids shared infrastructure changes, but supports the two-phase workflow cleanly. + +**Architecture**: +``` +user-management/infra/azure/ +├── main.tf +├── application.tf (conditional on deploy_container_apps) +├── registry.tf (NEW - conditional on create_acr) +└── variables.tf (add create_acr, deploy_container_apps) +``` + +Workflow: +1. `terraform apply -var="create_acr=true" -var="deploy_container_apps=false"` - Creates ACR only +2. Build and push images to ACR +3. `terraform apply -var="create_acr=true" -var="deploy_container_apps=true"` - Creates apps + +**Pros**: +- Self-contained - no shared infrastructure changes required +- Clear two-phase deployment model +- Simple to understand and operate +- Maintains backward compatibility (both variables default to appropriate values) + +**Cons**: +- ACR per service (though most deployments may only have one service) +- Diverges from shared resource pattern +- Requires explicit coordination of variable values + +**Coupling Analysis**: +| Component | Ca | Ce | I | +|-----------|----|----|---| +| registry.tf | 1 | 2 | 0.67 | +| application.tf | 0 | 3 | 1.0 | + +Coupling impact: Low + +**Failure Modes**: +| Mode | Severity | Occurrence | Detection | RPN | +|------|----------|------------|-----------|-----| +| Deploy apps before ACR | Medium | Low (clear docs) | Easy | 3 | +| Deploy apps before push | High | Medium | Medium (clear error) | 8 | +| Wrong variable combo | Low | Low | Easy | 2 | + +**Evolvability Assessment**: +- Adding private endpoint: Easy +- Multiple services sharing ACR: Would need migration to shared model +- Switching back to GHCR: Easy - just change variables + +## Comparison Matrix + +| Criterion | Option A | Option B | Option C | +|-----------|----------|----------|----------| +| Complexity | Low | Medium | Low | +| Follows Patterns | No | Yes | Partial | +| Changes Required | 1 directory | 2 directories | 1 directory | +| Multi-Service Ready | No | Yes | No | +| Two-Phase Deploy | Awkward | Clean | Clean | +| Failure Resilience | Medium | High | Medium | +| Backward Compat | Yes | Yes | Yes | + +## Recommendation + +**Recommended Option**: Option C (Service-Only with Deployment Phases) + +**Rationale**: +Given the constraints and current state: +1. The request is specifically for the user-management service, not a platform-wide change +2. Shared infrastructure module is in a separate directory and changing it would expand scope +3. The two-phase deployment workflow (ACR first, then apps) is a core requirement +4. Option C provides the cleanest implementation with the fewest moving parts + +If multi-service ACR sharing becomes a requirement later, the ACR can be migrated to shared infrastructure with a straightforward state move operation. + +**Tradeoffs Accepted**: +- Single-service ACR: Acceptable because this is the only service currently needing local builds +- Not following shared pattern: Acceptable because adding to shared infrastructure is out of scope for this request + +**Risks to Monitor**: +- If more services need local builds, consider migrating to Option B +- Monitor ACR costs if multiple ephemeral environments are created + +## Implementation Plan + +### Phase 1: Variables and Registry +- [ ] Add `create_acr` variable (default: false) +- [ ] Add `deploy_container_apps` variable (default: true) +- [ ] Create `registry.tf` with conditional ACR resource +- [ ] Add AcrPull role assignment for app identity + +### Phase 2: Application Updates +- [ ] Make Container App resources conditional on `deploy_container_apps` +- [ ] Add `registry` block to Container Apps when using ACR +- [ ] Update locals to compute correct image registry path + +### Phase 3: Outputs and Documentation +- [ ] Add ACR-related outputs (login server, name) +- [ ] Create/update README with deployment workflow +- [ ] Include AMD64 build instructions for all platforms + +## Detailed Implementation + +### New Variables (variables.tf) + +```hcl +variable "create_acr" { + description = "Whether to create an Azure Container Registry for local builds" + type = bool + default = false +} + +variable "deploy_container_apps" { + description = "Whether to deploy Container Apps. Set to false for first deploy to create ACR, then true after pushing images." + type = bool + default = true +} +``` + +### New Registry File (registry.tf) + +```hcl +resource "azurerm_container_registry" "acr" { + count = var.create_acr ? 1 : 0 + name = "acrstickerlandia${var.env}" + resource_group_name = local.shared.resource_group_name + location = local.shared.resource_group_location + sku = "Basic" + admin_enabled = false + tags = local.tags +} + +resource "azurerm_role_assignment" "acr_pull" { + count = var.create_acr ? 1 : 0 + scope = azurerm_container_registry.acr[0].id + role_definition_name = "AcrPull" + principal_id = azurerm_user_assigned_identity.app_identity.principal_id +} +``` + +### Container App Registry Block (application.tf) + +```hcl +# Add to each Container App when using ACR: +dynamic "registry" { + for_each = var.create_acr ? [1] : [] + content { + server = azurerm_container_registry.acr[0].login_server + identity = azurerm_user_assigned_identity.app_identity.id + } +} +``` + +### Conditional Resource Creation + +All Container App resources (`azurerm_container_app.api`, `azurerm_container_app.worker`, `azurerm_container_app_job.migration`) will be wrapped with: + +```hcl +count = var.deploy_container_apps ? 1 : 0 +``` + +### Image Registry Logic + +```hcl +locals { + # Compute the effective image registry + effective_registry = var.create_acr ? azurerm_container_registry.acr[0].login_server : var.container_image_registry +} +``` + +### README Section + +````markdown +## Deploying with Local Container Builds + +By default, this Terraform deploys Container Apps using images from the public GHCR registry. +To deploy from locally-built images, follow this two-phase process: + +### Phase 1: Create ACR + +```bash +cd infra/azure +terraform apply -var-file=dev.tfvars \ + -var="create_acr=true" \ + -var="deploy_container_apps=false" +``` + +Note the ACR login server from the outputs. + +### Phase 2: Build and Push Images (AMD64) + +**IMPORTANT**: Images must be built for AMD64 architecture regardless of your host OS. + +```bash +# Login to ACR +az acr login --name acrstickerlandiadev + +# Build and push AMD64 images (works on any host including Apple Silicon) +docker buildx build --platform linux/amd64 \ + -t acrstickerlandiadev.azurecr.io/user-management-service:latest \ + -f src/Stickerlandia.UserManagement.Api/Dockerfile \ + --push . + +docker buildx build --platform linux/amd64 \ + -t acrstickerlandiadev.azurecr.io/user-management-worker:latest \ + -f src/Stickerlandia.UserManagement.Worker/Dockerfile \ + --push . + +docker buildx build --platform linux/amd64 \ + -t acrstickerlandiadev.azurecr.io/user-management-migration:latest \ + -f src/Stickerlandia.UserManagement.MigrationService/Dockerfile \ + --push . +``` + +### Phase 3: Deploy Container Apps + +```bash +terraform apply -var-file=dev.tfvars \ + -var="create_acr=true" \ + -var="deploy_container_apps=true" \ + -var="container_image_registry=acrstickerlandiadev.azurecr.io" +``` + +### Subsequent Deployments + +For subsequent deployments with new images: +1. Build and push new images to ACR +2. Run terraform apply (apps will pull latest images on next revision) +```` + +## Open Questions +- [x] Should ACR use Basic or Standard SKU? **Decision: Basic SKU** - sufficient for development workflows +- [x] Should we add ACR private endpoint for VNet-only access? **Decision: No** - ACR should be publicly accessible for local pushes diff --git a/user-management/infra/azure/.terraform.lock.hcl b/user-management/infra/azure/.terraform.lock.hcl index 7102dec7..a862bd51 100644 --- a/user-management/infra/azure/.terraform.lock.hcl +++ b/user-management/infra/azure/.terraform.lock.hcl @@ -1,22 +1,61 @@ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. +provider "registry.terraform.io/azure/azapi" { + version = "2.8.0" + constraints = "~> 2.0" + hashes = [ + "h1:gJpLLWwcTgHPyyqDNaXZkCfFCAywGMAgVlqg7QHRRBk=", + "zh:048fa67ba123c6da65a7af12453328e36e1783cac1dbecc905d44ee7a1daa41c", + "zh:08dfb8c493a99aa54ea0c00f5d2e2389aac55d70b31bfc50a38e4ab61800aca8", + "zh:0d5bf53f356864567bf0855eb90b0b9aa4619b60fd1469210461ad88c0508a6f", + "zh:221cc52181d81bd741e8624ba9619ae20438f7a13828b72aa138a51b57bc1483", + "zh:51e7485e4f502cbbefe9b4ea991961eb9b19f41862593150905197bbb37cc6fb", + "zh:6e2d0986176bbeabdfa7dc3d1bf37d0a24549ebff29a3c9e8c5082e03cc38247", + "zh:87e46ceddcd3a4b7ed16f6b853c286840753d8af8ae8df0618ab5f29e950976b", + "zh:894998419943fadb3b85d1469665e9b7cdf492e6dc30907a77e32043e1d52b6a", + "zh:9f1efae3ad37510d947e7a27118a84bae55e35681b047d939781da96dd6ab6c7", + "zh:a201371f6c4c65b6976a8a360223c188ea91b7a33078fdd3a5f5f0ac7b438d35", + "zh:af3cc16bdfc545e61ce66449b9daaebfaa0c5e495777241c9414671a31e37ffa", + "zh:dbbb263a5f4c40624823fd3e68dc046b1f00325548393557384f0914a4694278", + ] +} + provider "registry.terraform.io/hashicorp/azurerm" { - version = "4.27.0" - constraints = "4.27.0" + version = "4.58.0" + constraints = "~> 4.27, ~> 4.58" + hashes = [ + "h1:k0a/JkkhIEGrJ/oR7MZWbTZsUXHQ18JIPcVWBdW+V58=", + "zh:041c2a778ab4dd5a9af174b1d6f75409e5aabfc359cb386dfea3fb09e3f32709", + "zh:0a302531a61e7383acf99a6202d7984b2ea559306f45021381665c827a830d46", + "zh:0c69f132c7609683d907e87b89210a298d84c5b0121b62278949931bc54ca952", + "zh:0cadf48e9d2d9daed43212a3c9d886d7faaf68787b6e955456cbe4f43e4a17ec", + "zh:35ef4293d7731f6ff1f8bcba2c4529f987b7fac243c1ac1c154bbc02c9703c25", + "zh:3cb2679e1d56865e0ee0cf4c5d1404dbad0db42d11425e7bf0580a026cc64287", + "zh:4e56411f5119042d4962acff5c6d64224a49a69154ba80e6df63fa57b1e6d284", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:ca4626411a111720c220f9849c7d2e1fcd5d380f56459e096d835a9dbf9e6e13", + "zh:d31c4e65dcb096974479b2d548fffb86fc9a5262aff1b01fe62ef442ce536c6b", + "zh:d9631602999c1853e53ee2c5aef7476e23c7787beddc3599c10dbaa4891ba166", + "zh:f31ba7c9341037ceb7d49467946c01b2b0930404ed1d5643c1451f734a613a03", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.8.1" hashes = [ - "h1:hmAzHk4XVbrGQ5iJJj1QdFx0aWNW9Hjh+GIE6S8G5I8=", - "zh:0c69edea1995bd3bd9e61980757169c35bf22281b660b5c755b6cb13d08d29d2", - "zh:25b86bf7b9678371d8573983954c571696f3e64a3967133be3b835da36307106", - "zh:49921cff4f26a49bafada60cd07dabb52c5eb35231059ed928a4f4722e269c82", - "zh:4b986166531f9fd1289f01d8220519443e74888a21da512c1b841b006dad6215", - "zh:53fb65b2ca4df637f03e4748a100a7d7fc77249e307c03e294d6259cec0310f6", - "zh:5c0d021a387ca4e2a5a01da009746a08c45f08e971c10d9bda54539d7264d671", - "zh:600043f2b20dc5a45275e43f175c19fe8b6e8e9557a0c884aef018f1f63de90e", - "zh:a0284f6f38912f67bb4cb7829fda3fa75be81fea6a9b21119965c2a839430092", - "zh:a7ac0576e2069ef77557042c6b5157ded364fbd355b2f9bf7f5441622424086e", - "zh:c5db0bcafe986868e28cc6225b68b2d1cf4bf631939d260ca845f17a9aa1677d", - "zh:ce620c0eb71b1fdd925828b30cf232a869abccf1c459180f2f991c4166315251", - "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "h1:u8AKlWVDTH5r9YLSeswoVEjiY72Rt4/ch7U+61ZDkiQ=", + "zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4", + "zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae", + "zh:229665ddf060aa0ed315597908483eee5b818a17d09b6417a0f52fd9405c4f57", + "zh:2469d2e48f28076254a2a3fc327f184914566d9e40c5780b8d96ebf7205f8bc0", + "zh:37d7eb334d9561f335e748280f5535a384a88675af9a9eac439d4cfd663bcb66", + "zh:741101426a2f2c52dee37122f0f4a2f2d6af6d852cb1db634480a86398fa3511", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:a902473f08ef8df62cfe6116bd6c157070a93f66622384300de235a533e9d4a9", + "zh:b85c511a23e57a2147355932b3b6dce2a11e856b941165793a0c3d7578d94d05", + "zh:c5172226d18eaac95b1daac80172287b69d4ce32750c82ad77fa0768be4ea4b8", + "zh:dab4434dba34aad569b0bc243c2d3f3ff86dd7740def373f2a49816bd2ff819b", + "zh:f49fd62aa8c5525a5c17abd51e27ca5e213881d58882fd42fec4a545b53c9699", ] } diff --git a/user-management/infra/azure/README.md b/user-management/infra/azure/README.md new file mode 100644 index 00000000..2d08f870 --- /dev/null +++ b/user-management/infra/azure/README.md @@ -0,0 +1,112 @@ +# Azure Infrastructure for User Management Service + +Terraform configuration for deploying the User Management Service to Azure Container Apps. + +## Prerequisites + +- Azure CLI installed and authenticated (`az login`) +- Terraform >= 1.0 +- Shared infrastructure deployed (see `shared/infra/azure/`) +- Docker with buildx support (for local builds) + +## Standard Deployment (from GHCR) + +Deploy using pre-built images from the public GitHub Container Registry: + +```bash +cd infra/azure +terraform init +terraform apply -var-file=dev.tfvars +``` + +## Deploying with Local Container Builds + +To deploy from locally-built images instead of GHCR, follow this two-phase process. + +### Phase 1: Create ACR (without Container Apps) + +```bash +cd infra/azure +terraform init +terraform apply -var-file=dev.tfvars \ + -var="create_acr=true" \ + -var="deploy_container_apps=false" +``` + +Note the `acr_login_server` from the outputs (e.g., `acrstickerlandiadev.azurecr.io`). + +### Phase 2: Build and Push Images (AMD64) + +**IMPORTANT**: Images must be built for AMD64 architecture regardless of your host OS (including Apple Silicon Macs). + +```bash +# Login to ACR +az acr login --name acrstickerlandiadev + +mise build:docker-dev +docker tag docker.io/stickerlandia/user-management-service:dev acrstickerlandiadev.azurecr.io/user-management-service:latest +docker tag docker.io/stickerlandia/user-management-worker:dev acrstickerlandiadev.azurecr.io/user-management-worker:latest +docker tag docker.io/stickerlandia/user-management-migration:dev acrstickerlandiadev.azurecr.io/user-management-migration:latest +docker push acrstickerlandiadev.azurecr.io/user-management-service:latest +docker push acrstickerlandiadev.azurecr.io/user-management-worker:latest +docker push acrstickerlandiadev.azurecr.io/user-management-migration:latest +``` + +### Phase 3: Deploy Container Apps + +```bash +terraform apply -var-file=dev.tfvars \ + -var="create_acr=true" \ + -var="deploy_container_apps=true" +``` + +### Subsequent Deployments + +For subsequent deployments with new images: + +1. Build and push new images to ACR (Phase 2 commands) +2. Run terraform apply to create a new revision: + ```bash + terraform apply -var-file=dev.tfvars \ + -var="create_acr=true" \ + -var="deploy_container_apps=true" \ + -var="app_version=v1.2.3" + ``` + +## Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `subscription_id` | Azure Subscription ID | (required) | +| `env` | Environment name (dev, staging, prod) | (required) | +| `location` | Azure region | `uksouth` | +| `app_version` | Container image tag | `latest` | +| `container_image_registry` | Registry base path | `ghcr.io/datadog/stickerlandia` | +| `create_acr` | Create Azure Container Registry | `false` | +| `deploy_container_apps` | Deploy Container Apps | `true` | +| `use_shared_infrastructure` | Use existing shared resources | `true` | +| `shared_keyvault_suffix` | Key Vault suffix (when using shared) | (required when shared) | + +## Outputs + +| Output | Description | +|--------|-------------| +| `api_url` | Public URL for the API | +| `acr_login_server` | ACR login server (when `create_acr=true`) | +| `acr_name` | ACR name (when `create_acr=true`) | +| `container_apps_deployed` | Whether apps were deployed | + +## Architecture + +See [ADR-001](../../docs/adr/ADR-001-azure-container-apps-production-architecture.md) for the full architecture decision. + +The deployment creates: +- User-assigned managed identity +- Azure Container Registry (optional, when `create_acr=true`) +- Container App Job for migrations +- API Container App (2-10 replicas) +- Worker Container App (1-3 replicas, Service Bus scaling) +- Front Door origin group, origin, and routes +- PostgreSQL database +- Service Bus queue and topic +- Key Vault secrets diff --git a/user-management/infra/azure/application.tf b/user-management/infra/azure/application.tf index e06d10e0..9b7725a0 100644 --- a/user-management/infra/azure/application.tf +++ b/user-management/infra/azure/application.tf @@ -1,52 +1,182 @@ -resource "azurerm_container_app_environment" "user_service_environment" { - name = var.env - location = azurerm_resource_group.rg.location - resource_group_name = azurerm_resource_group.rg.name +# User Assigned Identity for this service's Container Apps +resource "azurerm_user_assigned_identity" "app_identity" { + location = local.shared.resource_group_location + name = "id-users-${var.env}" + resource_group_name = local.shared.resource_group_name + tags = local.tags +} + +# Grant identity access to shared Key Vault secrets +resource "azurerm_role_assignment" "keyvault_secrets_user" { + scope = local.shared.key_vault_id + role_definition_name = "Key Vault Secrets User" + principal_id = azurerm_user_assigned_identity.app_identity.principal_id +} + +# Migration Job - runs on every deployment +resource "azurerm_container_app_job" "migration" { + count = var.deploy_container_apps ? 1 : 0 + name = "job-users-migration-${var.env}" + location = local.shared.resource_group_location + resource_group_name = local.shared.resource_group_name + container_app_environment_id = local.shared.container_app_environment_id + tags = local.tags - tags = { - source = "terraform" - env = var.env + replica_timeout_in_seconds = 300 + replica_retry_limit = 1 + + manual_trigger_config { + parallelism = 1 + replica_completion_count = 1 + } + + identity { + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.app_identity.id] + } + + # Registry configuration for ACR (managed identity auth) + dynamic "registry" { + for_each = var.create_acr ? [1] : [] + content { + server = azurerm_container_registry.acr[0].login_server + identity = azurerm_user_assigned_identity.app_identity.id + } + } + + secret { + name = "db-connection-string" + key_vault_secret_id = azurerm_key_vault_secret.db_connection_string.id + identity = azurerm_user_assigned_identity.app_identity.id + } + + secret { + name = "dd-api-key" + key_vault_secret_id = local.shared.dd_api_key_secret_id + identity = azurerm_user_assigned_identity.app_identity.id + } + + template { + container { + name = "migration" + image = "${local.effective_registry}/user-management-migration:${var.app_version}" + cpu = 0.5 + memory = "1Gi" + + env { + name = "ConnectionStrings__database" + secret_name = "db-connection-string" + } + env { + name = "DRIVING" + value = "ASPNET" + } + env { + name = "DRIVEN" + value = "AZURE" + } + env { + name = "DISABLE_SSL" + value = "true" + } + env { + name = "DD_API_KEY" + secret_name = "dd-api-key" + } + } } + + depends_on = [azurerm_role_assignment.keyvault_secrets_user, azurerm_role_assignment.acr_pull] } -resource "azurerm_user_assigned_identity" "app_identity" { - location = azurerm_resource_group.rg.location - name = "userServiceAppIdentity-${var.env}" - resource_group_name = azurerm_resource_group.rg.name +# Automatically start the migration job after creation +resource "azapi_resource_action" "start_migration_job" { + count = var.deploy_container_apps ? 1 : 0 + type = "Microsoft.App/jobs@2024-03-01" + resource_id = azurerm_container_app_job.migration[0].id + action = "start" + method = "POST" + + depends_on = [ + azurerm_container_app_job.migration, + azurerm_role_assignment.acr_pull + ] } -resource "azurerm_container_app" "user-management-api" { - name = "user-management" - container_app_environment_id = azurerm_container_app_environment.user_service_environment.id - resource_group_name = azurerm_resource_group.rg.name +# API Container App +resource "azurerm_container_app" "api" { + count = var.deploy_container_apps ? 1 : 0 + name = "ca-users-api-${var.env}" + container_app_environment_id = local.shared.container_app_environment_id + resource_group_name = local.shared.resource_group_name revision_mode = "Single" + tags = local.tags + identity { - identity_ids = [azurerm_user_assigned_identity.app_identity.id] type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.app_identity.id] + } + + # Registry configuration for ACR (managed identity auth) + dynamic "registry" { + for_each = var.create_acr ? [1] : [] + content { + server = azurerm_container_registry.acr[0].login_server + identity = azurerm_user_assigned_identity.app_identity.id + } + } + + secret { + name = "db-connection-string" + key_vault_secret_id = azurerm_key_vault_secret.db_connection_string.id + identity = azurerm_user_assigned_identity.app_identity.id } + + secret { + name = "servicebus-connection-string" + key_vault_secret_id = azurerm_key_vault_secret.servicebus_connection_string.id + identity = azurerm_user_assigned_identity.app_identity.id + } + + secret { + name = "dd-api-key" + key_vault_secret_id = local.shared.dd_api_key_secret_id + identity = azurerm_user_assigned_identity.app_identity.id + } + ingress { external_enabled = true target_port = 8080 + transport = "http" + traffic_weight { percentage = 100 latest_revision = true } } + template { - min_replicas = 1 - max_replicas = 1 + min_replicas = 2 + max_replicas = 10 + + http_scale_rule { + name = "http-scaling" + concurrent_requests = 100 + } + container { - name = "application" - image = "public.ecr.aws/h1j5s6w8/stickerlandia-user-management:${var.app_version}" - cpu = 0.25 - memory = "0.5Gi" + name = "api" + image = "${local.effective_registry}/user-management-service:${var.app_version}" + cpu = 0.5 + memory = "1Gi" + env { - name = "ConnectionStrings__database" - value = var.database_connection_string + name = "ConnectionStrings__database" + secret_name = "db-connection-string" } env { - name = "ConnectionStrings__messaging" - value = azurerm_servicebus_namespace.stickerlandia_users_service_bus.default_primary_connection_string + name = "ConnectionStrings__messaging" + secret_name = "servicebus-connection-string" } env { name = "DRIVING" @@ -57,10 +187,43 @@ resource "azurerm_container_app" "user-management-api" { value = "AZURE" } env { - name = "DISABLE_SSL" + name = "DISABLE_SSL" value = "true" } + env { + name = "DEPLOYMENT_HOST_URL" + value = "https://${local.shared.frontdoor_endpoint_host_name}" + } + + liveness_probe { + transport = "HTTP" + path = "/api/users/v1/health" + port = 8080 + initial_delay = 30 + interval_seconds = 30 + timeout = 5 + failure_count_threshold = 3 + } + + readiness_probe { + transport = "HTTP" + path = "/api/users/v1/health" + port = 8080 + interval_seconds = 10 + timeout = 5 + failure_count_threshold = 3 + } + + startup_probe { + transport = "HTTP" + path = "/api/users/v1/health" + port = 8080 + interval_seconds = 10 + timeout = 5 + failure_count_threshold = 30 + } } + container { name = "datadog" image = "index.docker.io/datadog/serverless-init:latest" @@ -68,61 +231,148 @@ resource "azurerm_container_app" "user-management-api" { memory = "0.5Gi" env { - name = "DD_SITE" - value = var.dd_site - } - env { - name = "DD_API_KEY" - value = var.dd_api_key + name = "DD_API_KEY" + secret_name = "dd-api-key" } - env { - name = "DD_ENV" - value = var.env + + dynamic "env" { + for_each = local.datadog_env + content { + name = env.value.name + value = env.value.value + } } - env { - name = "DD_VERSION" - value = var.app_version + } + } + + depends_on = [ + azapi_resource_action.start_migration_job, + azurerm_role_assignment.keyvault_secrets_user, + azurerm_role_assignment.acr_pull + ] +} + +# Worker Container App +resource "azurerm_container_app" "worker" { + count = var.deploy_container_apps ? 1 : 0 + name = "ca-users-worker-${var.env}" + container_app_environment_id = local.shared.container_app_environment_id + resource_group_name = local.shared.resource_group_name + revision_mode = "Single" + tags = local.tags + + identity { + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.app_identity.id] + } + + # Registry configuration for ACR (managed identity auth) + dynamic "registry" { + for_each = var.create_acr ? [1] : [] + content { + server = azurerm_container_registry.acr[0].login_server + identity = azurerm_user_assigned_identity.app_identity.id + } + } + + secret { + name = "db-connection-string" + key_vault_secret_id = azurerm_key_vault_secret.db_connection_string.id + identity = azurerm_user_assigned_identity.app_identity.id + } + + secret { + name = "servicebus-connection-string" + key_vault_secret_id = azurerm_key_vault_secret.servicebus_connection_string.id + identity = azurerm_user_assigned_identity.app_identity.id + } + + secret { + name = "dd-api-key" + key_vault_secret_id = local.shared.dd_api_key_secret_id + identity = azurerm_user_assigned_identity.app_identity.id + } + + template { + min_replicas = 1 + max_replicas = 3 + + custom_scale_rule { + name = "servicebus-scaling" + custom_rule_type = "azure-servicebus" + metadata = { + queueName = azurerm_servicebus_queue.sticker_claimed.name + namespace = local.shared.servicebus_namespace_name + messageCount = "10" } - env { - name = "DD_SERVICE" - value = "user-management" + authentication { + secret_name = "servicebus-connection-string" + trigger_parameter = "connection" } + } + + container { + name = "worker" + image = "${local.effective_registry}/user-management-worker:${var.app_version}" + cpu = 0.5 + memory = "1Gi" + env { - name = "DD_LOGS_ENABLED" - value = "true" + name = "ConnectionStrings__database" + secret_name = "db-connection-string" } env { - name = "DD_LOGS_INJECTION" - value = "true" + name = "ConnectionStrings__messaging" + secret_name = "servicebus-connection-string" } env { - name = "DD_APM_COMPUTE_STATS_BY_SPAN_KIND" - value = "true" + name = "DRIVING" + value = "ASPNET" } env { - name = "DD_APM_PEER_TAGS_AGGREGATION" - value = "true" + name = "DRIVEN" + value = "AZURE" } env { - name = "DD_TRACE_REMOVE_INTEGRATION_SERVICE_NAMES_ENABLED" + name = "DISABLE_SSL" value = "true" } - env { - name = "DD_APM_IGNORE_RESOURCES" - value = "/opentelemetry.proto.collector.trace.v1.TraceService/Export$" - } - env { - name = "DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT" - value = "0.0.0.0:4317" + + liveness_probe { + transport = "HTTP" + path = "/health" + port = 8080 + initial_delay = 30 + interval_seconds = 30 + timeout = 5 + failure_count_threshold = 3 } + } + + container { + name = "datadog" + image = "index.docker.io/datadog/serverless-init:latest" + cpu = 0.25 + memory = "0.5Gi" + env { - name = "DD_AZURE_SUBSCRIPTION_ID" - value = data.azurerm_subscription.primary.subscription_id + name = "DD_API_KEY" + secret_name = "dd-api-key" } - env { - name = "DD_AZURE_RESOURCE_GROUP" - value = azurerm_resource_group.rg.name + + dynamic "env" { + for_each = local.datadog_env + content { + name = env.value.name + value = env.value.value + } } } } -} \ No newline at end of file + + depends_on = [ + azapi_resource_action.start_migration_job, + azurerm_role_assignment.keyvault_secrets_user, + azurerm_role_assignment.acr_pull + ] +} diff --git a/user-management/infra/azure/data.tf b/user-management/infra/azure/data.tf deleted file mode 100644 index 4c51099f..00000000 --- a/user-management/infra/azure/data.tf +++ /dev/null @@ -1,2 +0,0 @@ -data "azurerm_subscription" "primary" { -} diff --git a/user-management/infra/azure/database.tf b/user-management/infra/azure/database.tf new file mode 100644 index 00000000..36a5eee0 --- /dev/null +++ b/user-management/infra/azure/database.tf @@ -0,0 +1,15 @@ +# Database for the user-management service (within shared PostgreSQL server) +resource "azurerm_postgresql_flexible_server_database" "users" { + name = "stickerlandia_users" + server_id = local.shared.postgresql_server_id + charset = "UTF8" + collation = "en_US.utf8" +} + +# Store the connection string in shared Key Vault +resource "azurerm_key_vault_secret" "db_connection_string" { + name = "users-db-connection-string" + value = "Host=${local.shared.postgresql_server_fqdn};Port=5432;Database=${azurerm_postgresql_flexible_server_database.users.name};Username=${local.shared.postgresql_admin_username};Password=${local.shared.postgresql_admin_password};SSL Mode=Require;Trust Server Certificate=true" + key_vault_id = local.shared.key_vault_id + tags = local.tags +} diff --git a/user-management/infra/azure/ephemeral.tfvars.example b/user-management/infra/azure/ephemeral.tfvars.example new file mode 100644 index 00000000..0602e475 --- /dev/null +++ b/user-management/infra/azure/ephemeral.tfvars.example @@ -0,0 +1,17 @@ +# User Management Service - Ephemeral/Test Environment +# Creates all shared infrastructure inline (no dependency on shared stack) + +subscription_id = "YOUR_SUBSCRIPTION_ID" +env = "test-feature-xyz" # Use a unique name for your ephemeral environment +location = "uksouth" + +# Application +app_version = "latest" +container_image_registry = "ghcr.io/datadog/stickerlandia" + +# Datadog +dd_site = "datadoghq.com" +dd_api_key = "YOUR_DATADOG_API_KEY" # Required for ephemeral environments + +# Shared infrastructure mode +use_shared_infrastructure = false # Creates all resources inline diff --git a/user-management/infra/azure/frontdoor.tf b/user-management/infra/azure/frontdoor.tf new file mode 100644 index 00000000..364759aa --- /dev/null +++ b/user-management/infra/azure/frontdoor.tf @@ -0,0 +1,52 @@ +# Origin Group for User Management API +resource "azurerm_cdn_frontdoor_origin_group" "users_api" { + count = var.deploy_container_apps ? 1 : 0 + name = "users-api-origin-group" + cdn_frontdoor_profile_id = local.shared.frontdoor_profile_id + session_affinity_enabled = false + + load_balancing { + sample_size = 4 + successful_samples_required = 3 + } + + health_probe { + path = "/api/users/v1/health" + request_type = "GET" + protocol = "Https" + interval_in_seconds = 30 + } +} + +# Origin pointing to User Management Container App +resource "azurerm_cdn_frontdoor_origin" "users_api" { + count = var.deploy_container_apps ? 1 : 0 + name = "users-api-origin" + cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.users_api[0].id + enabled = true + + certificate_name_check_enabled = true + host_name = azurerm_container_app.api[0].ingress[0].fqdn + http_port = 80 + https_port = 443 + origin_host_header = azurerm_container_app.api[0].ingress[0].fqdn + priority = 1 + weight = 1000 +} + +# Route for User Management API traffic +resource "azurerm_cdn_frontdoor_route" "users_api" { + count = var.deploy_container_apps ? 1 : 0 + name = "users-api-route" + cdn_frontdoor_endpoint_id = local.shared.frontdoor_endpoint_id + cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.users_api[0].id + cdn_frontdoor_origin_ids = [azurerm_cdn_frontdoor_origin.users_api[0].id] + enabled = true + + forwarding_protocol = "HttpsOnly" + https_redirect_enabled = true + patterns_to_match = ["/api/users/*", "/auth/*", "/.well-known/*"] + supported_protocols = ["Http", "Https"] + + link_to_default_domain = true +} diff --git a/user-management/infra/azure/main.tf b/user-management/infra/azure/main.tf index d02fd254..1943e4ad 100644 --- a/user-management/infra/azure/main.tf +++ b/user-management/infra/azure/main.tf @@ -1,113 +1,143 @@ -resource "azurerm_resource_group" "rg" { - location = var.location - name = "${var.resourceGroupName}-${var.env}" - tags = local.tags +# User Management Service Infrastructure +# Supports two modes: +# 1. use_shared_infrastructure=true → Looks up existing shared resources (dev/prod) +# 2. use_shared_infrastructure=false → Creates shared resources inline (ephemeral/test) + +# ============================================================================ +# MODE 1: Use existing shared infrastructure +# ============================================================================ + +data "azurerm_resource_group" "shared" { + count = var.use_shared_infrastructure ? 1 : 0 + name = "rg-stickerlandia-shared-${var.env}" +} + +data "azurerm_container_app_environment" "shared" { + count = var.use_shared_infrastructure ? 1 : 0 + name = "cae-stickerlandia-${var.env}" + resource_group_name = data.azurerm_resource_group.shared[0].name +} + +data "azurerm_key_vault" "shared" { + count = var.use_shared_infrastructure ? 1 : 0 + name = "kv-sticker-${var.env}-${var.shared_keyvault_suffix}" + resource_group_name = data.azurerm_resource_group.shared[0].name +} + +data "azurerm_key_vault_secret" "dd_api_key" { + count = var.use_shared_infrastructure ? 1 : 0 + name = "dd-api-key" + key_vault_id = data.azurerm_key_vault.shared[0].id } -resource "azurerm_storage_account" "functions_storage_account" { - name = "usersfunc${var.env}" - resource_group_name = azurerm_resource_group.rg.name - location = azurerm_resource_group.rg.location - account_tier = "Standard" - account_replication_type = "LRS" - tags = local.tags +data "azurerm_key_vault_secret" "postgresql_admin_password" { + count = var.use_shared_infrastructure ? 1 : 0 + name = "postgresql-admin-password" + key_vault_id = data.azurerm_key_vault.shared[0].id } -resource "azurerm_storage_container" "functions_storage_container" { - name = "users-container" - storage_account_id = azurerm_storage_account.functions_storage_account.id - container_access_type = "private" +data "azurerm_postgresql_flexible_server" "shared" { + count = var.use_shared_infrastructure ? 1 : 0 + name = "psql-stickerlandia-${var.env}" + resource_group_name = data.azurerm_resource_group.shared[0].name } -resource "azurerm_storage_container" "functions_flex_storage_container" { - name = "users-flex-container" - storage_account_id = azurerm_storage_account.functions_storage_account.id - container_access_type = "private" +data "azurerm_servicebus_namespace" "shared" { + count = var.use_shared_infrastructure ? 1 : 0 + name = "sb-stickerlandia-${var.env}" + resource_group_name = data.azurerm_resource_group.shared[0].name } -resource "azurerm_service_plan" "functions_app_service_plan" { - name = "stickerlandia-users-service-plan-${var.env}" - resource_group_name = azurerm_resource_group.rg.name - location = azurerm_resource_group.rg.location - os_type = "Linux" - sku_name = "Y1" - tags = local.tags +data "azurerm_cdn_frontdoor_profile" "shared" { + count = var.use_shared_infrastructure ? 1 : 0 + name = "afd-stickerlandia-${var.env}" + resource_group_name = data.azurerm_resource_group.shared[0].name +} + +data "azurerm_cdn_frontdoor_endpoint" "shared" { + count = var.use_shared_infrastructure ? 1 : 0 + name = "stickerlandia-api-${var.env}" + profile_name = data.azurerm_cdn_frontdoor_profile.shared[0].name + resource_group_name = data.azurerm_resource_group.shared[0].name +} + +# ============================================================================ +# MODE 2: Create shared resources inline (ephemeral environments) +# ============================================================================ + +module "inline_shared" { + count = var.use_shared_infrastructure ? 0 : 1 + source = "../../../shared/infra/azure/modules/shared-resources" + + env = var.env + location = var.location + dd_api_key = var.dd_api_key + dd_site = var.dd_site + + # Use a unique resource group name for ephemeral environments + resource_group_name = "rg-users-${var.env}" } +# ============================================================================ +# Unified locals - abstracts the source of shared resources +# ============================================================================ + +data "azurerm_subscription" "current" {} + +data "azurerm_client_config" "current" {} + locals { - environment_variables_base = { - DD_API_KEY = var.dd_api_key - DD_SITE = var.dd_site - DD_ENV = var.env - DD_SERVICE = "stickerlandia-users" - DD_VERSION = "latest" - WEBSITE_RUN_FROM_PACKAGE = 1 - } - environment_variables_flex_base = { - DD_API_KEY = var.dd_api_key - DD_SITE = var.dd_site - DD_ENV = var.env - DD_SERVICE = "stickerlandia-users" - DD_VERSION = "latest" - } - dotnet_tracer_home = "/home/site/wwwroot/datadog" - environment_variables_dotnet = { - CORECLR_ENABLE_PROFILING = "1" - CORECLR_PROFILER = "{846F5F1C-F9AE-4B07-969E-05C26BC060D8}" - CORECLR_PROFILER_PATH = "${local.dotnet_tracer_home}/linux-x64/Datadog.Trace.ClrProfiler.Native.so" - DD_DOTNET_TRACER_HOME = local.dotnet_tracer_home - DD_TRACE_HTTP_CLIENT_EXCLUDED_URL_SUBSTRINGS = "monitor.azure, applicationinsights.azure, metadata/instance/compute, admin/host, AzureFunctionsRpcMessages.FunctionRpc" - DD_TRACE_AspNetCore_ENABLED = true - FUNCTIONS_INPROC_NET8_ENABLED = 0 - } + # Unified references to shared resources (works for both modes) + shared = { + resource_group_name = var.use_shared_infrastructure ? data.azurerm_resource_group.shared[0].name : module.inline_shared[0].resource_group_name + resource_group_location = var.use_shared_infrastructure ? data.azurerm_resource_group.shared[0].location : module.inline_shared[0].location + + container_app_environment_id = var.use_shared_infrastructure ? data.azurerm_container_app_environment.shared[0].id : module.inline_shared[0].container_app_environment_id + + key_vault_id = var.use_shared_infrastructure ? data.azurerm_key_vault.shared[0].id : module.inline_shared[0].key_vault_id + key_vault_name = var.use_shared_infrastructure ? data.azurerm_key_vault.shared[0].name : module.inline_shared[0].key_vault_name - app_settings = { - "ConnectionStrings__messaging" = azurerm_servicebus_namespace.stickerlandia_users_service_bus.default_primary_connection_string - "ConnectionStrings__database" = var.database_connection_string, - "DRIVING" : "AZURE", - "DRIVEN" : "AZURE", + dd_api_key_secret_id = var.use_shared_infrastructure ? data.azurerm_key_vault_secret.dd_api_key[0].id : module.inline_shared[0].dd_api_key_secret_id + + postgresql_admin_password = var.use_shared_infrastructure ? data.azurerm_key_vault_secret.postgresql_admin_password[0].value : module.inline_shared[0].postgresql_admin_password + postgresql_admin_password_secret_id = var.use_shared_infrastructure ? data.azurerm_key_vault_secret.postgresql_admin_password[0].id : module.inline_shared[0].postgresql_admin_password_secret_id + postgresql_server_id = var.use_shared_infrastructure ? data.azurerm_postgresql_flexible_server.shared[0].id : module.inline_shared[0].postgresql_server_id + postgresql_server_fqdn = var.use_shared_infrastructure ? data.azurerm_postgresql_flexible_server.shared[0].fqdn : module.inline_shared[0].postgresql_server_fqdn + postgresql_admin_username = var.use_shared_infrastructure ? data.azurerm_postgresql_flexible_server.shared[0].administrator_login : module.inline_shared[0].postgresql_admin_username + + servicebus_namespace_id = var.use_shared_infrastructure ? data.azurerm_servicebus_namespace.shared[0].id : module.inline_shared[0].servicebus_namespace_id + servicebus_namespace_name = var.use_shared_infrastructure ? data.azurerm_servicebus_namespace.shared[0].name : module.inline_shared[0].servicebus_namespace_name + servicebus_connection_string = var.use_shared_infrastructure ? data.azurerm_servicebus_namespace.shared[0].default_primary_connection_string : module.inline_shared[0].servicebus_connection_string + + frontdoor_profile_id = var.use_shared_infrastructure ? data.azurerm_cdn_frontdoor_profile.shared[0].id : module.inline_shared[0].frontdoor_profile_id + frontdoor_endpoint_id = var.use_shared_infrastructure ? data.azurerm_cdn_frontdoor_endpoint.shared[0].id : module.inline_shared[0].frontdoor_endpoint_id + frontdoor_endpoint_host_name = var.use_shared_infrastructure ? data.azurerm_cdn_frontdoor_endpoint.shared[0].host_name : module.inline_shared[0].frontdoor_endpoint_host_name } tags = { env = var.env - service = "stickerlandia-users" - version = "latest" + service = "user-management" + version = var.app_version + source = "terraform" } - environment_variables = merge( - local.environment_variables_base, - local.environment_variables_dotnet, - local.app_settings - ) - flex_environment_variables = merge( - local.environment_variables_flex_base, - local.environment_variables_dotnet, - local.app_settings - ) -} -resource "azurerm_linux_function_app" "function_app" { - name = "stickerlandia-users-functions-${var.env}" - resource_group_name = azurerm_resource_group.rg.name - location = azurerm_resource_group.rg.location + # Compute the effective image registry (ACR if enabled, otherwise the variable default) + effective_registry = var.create_acr ? azurerm_container_registry.acr[0].login_server : var.container_image_registry - storage_account_name = azurerm_storage_account.functions_storage_account.name - storage_account_access_key = azurerm_storage_account.functions_storage_account.primary_access_key - service_plan_id = azurerm_service_plan.functions_app_service_plan.id - - site_config { - application_stack { - use_dotnet_isolated_runtime = true - dotnet_version = "8.0" - } - } - app_settings = local.environment_variables - tags = local.tags - - lifecycle { - ignore_changes = [ - tags["hidden-link: /app-insights-conn-string"], - tags["hidden-link: /app-insights-instrumentation-key"], - tags["hidden-link: /app-insights-resource-id"] - ] - } + # Datadog environment variables for sidecars + datadog_env = [ + { name = "DD_SITE", value = var.dd_site }, + { name = "DD_ENV", value = var.env }, + { name = "DD_VERSION", value = var.app_version }, + { name = "DD_SERVICE", value = "user-management" }, + { name = "DD_LOGS_ENABLED", value = "true" }, + { name = "DD_LOGS_INJECTION", value = "true" }, + { name = "DD_APM_COMPUTE_STATS_BY_SPAN_KIND", value = "true" }, + { name = "DD_APM_PEER_TAGS_AGGREGATION", value = "true" }, + { name = "DD_TRACE_REMOVE_INTEGRATION_SERVICE_NAMES_ENABLED", value = "true" }, + { name = "DD_APM_IGNORE_RESOURCES", value = "/opentelemetry.proto.collector.trace.v1.TraceService/Export$" }, + { name = "DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT", value = "0.0.0.0:4317" }, + { name = "DD_AZURE_SUBSCRIPTION_ID", value = data.azurerm_subscription.current.subscription_id }, + { name = "DD_AZURE_RESOURCE_GROUP", value = local.shared.resource_group_name }, + ] } diff --git a/user-management/infra/azure/messaging.tf b/user-management/infra/azure/messaging.tf index 8f97aef5..b7b33ec9 100644 --- a/user-management/infra/azure/messaging.tf +++ b/user-management/infra/azure/messaging.tf @@ -1,24 +1,23 @@ -resource "azurerm_servicebus_namespace" "stickerlandia_users_service_bus" { - name = "stickerlandia-${var.env}" - location = azurerm_resource_group.rg.location - resource_group_name = azurerm_resource_group.rg.name - sku = "Standard" - tags = { - source = "terraform" - env = var.env - } -} - -resource "azurerm_servicebus_queue" "sticker_claimed_queue" { +# Service-specific Service Bus Queue +resource "azurerm_servicebus_queue" "sticker_claimed" { name = "users.stickerClaimed.v1" - namespace_id = azurerm_servicebus_namespace.stickerlandia_users_service_bus.id + namespace_id = local.shared.servicebus_namespace_id partitioning_enabled = true } -resource "azurerm_servicebus_topic" "user_registered_topic" { +# Service-specific Service Bus Topic +resource "azurerm_servicebus_topic" "user_registered" { name = "users.userRegistered.v1" - namespace_id = azurerm_servicebus_namespace.stickerlandia_users_service_bus.id + namespace_id = local.shared.servicebus_namespace_id partitioning_enabled = true -} \ No newline at end of file +} + +# Store Service Bus connection string in shared Key Vault +resource "azurerm_key_vault_secret" "servicebus_connection_string" { + name = "users-servicebus-connection-string" + value = local.shared.servicebus_connection_string + key_vault_id = local.shared.key_vault_id + tags = local.tags +} diff --git a/user-management/infra/azure/outputs.tf b/user-management/infra/azure/outputs.tf new file mode 100644 index 00000000..4e25a07a --- /dev/null +++ b/user-management/infra/azure/outputs.tf @@ -0,0 +1,58 @@ +# Service Outputs + +output "api_url" { + description = "The public URL for the User Management API" + value = "https://${local.shared.frontdoor_endpoint_host_name}/api/users/v1" +} + +output "api_container_app_name" { + description = "The name of the API Container App" + value = var.deploy_container_apps ? azurerm_container_app.api[0].name : null +} + +output "api_container_app_fqdn" { + description = "The FQDN of the API Container App (internal)" + value = var.deploy_container_apps ? azurerm_container_app.api[0].ingress[0].fqdn : null +} + +output "worker_container_app_name" { + description = "The name of the Worker Container App" + value = var.deploy_container_apps ? azurerm_container_app.worker[0].name : null +} + +output "migration_job_name" { + description = "The name of the migration Container App Job" + value = var.deploy_container_apps ? azurerm_container_app_job.migration[0].name : null +} + +output "database_name" { + description = "The name of the PostgreSQL database" + value = azurerm_postgresql_flexible_server_database.users.name +} + +# Shared infrastructure mode indicator +output "using_shared_infrastructure" { + description = "Whether this deployment uses existing shared infrastructure or creates its own" + value = var.use_shared_infrastructure +} + +output "resource_group_name" { + description = "The name of the resource group containing the service" + value = local.shared.resource_group_name +} + +# ACR outputs (when enabled) +output "acr_login_server" { + description = "The login server URL for the Azure Container Registry (when create_acr=true)" + value = var.create_acr ? azurerm_container_registry.acr[0].login_server : null +} + +output "acr_name" { + description = "The name of the Azure Container Registry (when create_acr=true)" + value = var.create_acr ? azurerm_container_registry.acr[0].name : null +} + +output "container_apps_deployed" { + description = "Whether Container Apps were deployed in this run" + value = var.deploy_container_apps +} diff --git a/user-management/infra/azure/providers.tf b/user-management/infra/azure/providers.tf index 08bb4943..08244072 100644 --- a/user-management/infra/azure/providers.tf +++ b/user-management/infra/azure/providers.tf @@ -1,14 +1,24 @@ terraform { - required_providers { - azurerm = { - version = "4.27.0" - source = "hashicorp/azurerm" - } + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.58" } + azapi = { + source = "Azure/azapi" + version = "~> 2.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.8" + } + } } +provider "azapi" {} + provider "azurerm" { features {} - subscription_id = var.subscription_id + subscription_id = var.subscription_id resource_provider_registrations = "none" } \ No newline at end of file diff --git a/user-management/infra/azure/registry.tf b/user-management/infra/azure/registry.tf new file mode 100644 index 00000000..5b44cf1b --- /dev/null +++ b/user-management/infra/azure/registry.tf @@ -0,0 +1,20 @@ +# Azure Container Registry for local builds +# Only created when create_acr=true + +resource "azurerm_container_registry" "acr" { + count = var.create_acr ? 1 : 0 + name = "acrstickerlandia${replace(var.env, "-", "")}" + resource_group_name = local.shared.resource_group_name + location = local.shared.resource_group_location + sku = "Basic" + admin_enabled = false + tags = local.tags +} + +# Grant the app identity permission to pull images from ACR +resource "azurerm_role_assignment" "acr_pull" { + count = var.create_acr ? 1 : 0 + scope = azurerm_container_registry.acr[0].id + role_definition_name = "AcrPull" + principal_id = azurerm_user_assigned_identity.app_identity.principal_id +} diff --git a/user-management/infra/azure/variables.tf b/user-management/infra/azure/variables.tf index 48eedb99..6cd1a01e 100644 --- a/user-management/infra/azure/variables.tf +++ b/user-management/infra/azure/variables.tf @@ -1,33 +1,67 @@ variable "subscription_id" { - description = "The Azure Subscription ID in which all resources in this example should be created." + description = "The Azure Subscription ID" + type = string } -variable "resourceGroupName" { - description = "The Azure Resource Group name in which all resources in this example should be created." -} - -variable "database_connection_string" { - description = "The connection string to the database used by the application." +variable "env" { + description = "The environment (dev, staging, prod, or ephemeral name)" + type = string } variable "location" { - description = "The Azure Region in which all resources in this example should be created." -} - -variable "env" { - description = "The environment you are deploying to" + description = "The Azure Region (required when creating inline shared resources)" + type = string + default = "uksouth" } variable "app_version" { - description = "The version of the application to deploy" + description = "The version of the application to deploy (container image tag)" + type = string default = "latest" } -variable "dd_api_key" { - description = "The Datadog API key" +variable "container_image_registry" { + description = "The container image registry base path" + type = string + default = "ghcr.io/datadog/stickerlandia" } variable "dd_site" { - default = "datadoghq.com" description = "The Datadog site" -} \ No newline at end of file + type = string + default = "datadoghq.com" +} + +# Shared infrastructure mode +variable "use_shared_infrastructure" { + description = "If true, looks up existing shared infrastructure. If false, creates it inline." + type = bool + default = true +} + +variable "shared_keyvault_suffix" { + description = "The random suffix of the shared Key Vault (required when use_shared_infrastructure=true)" + type = string + default = "" +} + +# Required when creating inline shared resources (use_shared_infrastructure=false) +variable "dd_api_key" { + description = "The Datadog API key (required when use_shared_infrastructure=false)" + type = string + sensitive = true + default = "" +} + +# Local build support +variable "create_acr" { + description = "Whether to create an Azure Container Registry for local builds" + type = bool + default = false +} + +variable "deploy_container_apps" { + description = "Whether to deploy Container Apps. Set to false for first deploy to create ACR, then true after pushing images." + type = bool + default = true +} diff --git a/user-management/src/Stickerlandia.UserManagement.Agnostic/Migrations/20260202153814_AddDataProtectionKeys.Designer.cs b/user-management/src/Stickerlandia.UserManagement.Agnostic/Migrations/20260202153814_AddDataProtectionKeys.Designer.cs new file mode 100644 index 00000000..4517bf94 --- /dev/null +++ b/user-management/src/Stickerlandia.UserManagement.Agnostic/Migrations/20260202153814_AddDataProtectionKeys.Designer.cs @@ -0,0 +1,613 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Stickerlandia.UserManagement.Agnostic; + +#nullable disable + +namespace Stickerlandia.UserManagement.Agnostic.Migrations +{ + [DbContext(typeof(UserManagementDbContext))] + [Migration("20260202153814_AddDataProtectionKeys")] + partial class AddDataProtectionKeys + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("Stickerlandia.UserManagement.Agnostic.PostgresOutboxItem", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("EmailAddress") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email_address"); + + b.Property("EventData") + .IsRequired() + .HasColumnType("text") + .HasColumnName("event_data"); + + b.Property("EventTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("event_time"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("event_type"); + + b.Property("Failed") + .HasColumnType("boolean") + .HasColumnName("failed"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("Processed") + .HasColumnType("boolean") + .HasColumnName("processed"); + + b.Property("TraceId") + .HasColumnType("text") + .HasColumnName("trace_id"); + + b.HasKey("Id"); + + b.ToTable("outbox_items", (string)null); + }); + + modelBuilder.Entity("Stickerlandia.UserManagement.Core.PostgresUserAccount", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("AccountTier") + .HasColumnType("integer") + .HasColumnName("account_tier"); + + b.Property("AccountType") + .HasColumnType("integer") + .HasColumnName("account_type"); + + b.Property("ClaimedStickerCount") + .HasColumnType("integer") + .HasColumnName("claimed_sticker_count"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone") + .HasColumnName("date_created"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("first_name"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_name"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Stickerlandia.UserManagement.Core.PostgresUserAccount", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Stickerlandia.UserManagement.Core.PostgresUserAccount", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Stickerlandia.UserManagement.Core.PostgresUserAccount", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Stickerlandia.UserManagement.Core.PostgresUserAccount", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/user-management/src/Stickerlandia.UserManagement.Agnostic/Migrations/20260202153814_AddDataProtectionKeys.cs b/user-management/src/Stickerlandia.UserManagement.Agnostic/Migrations/20260202153814_AddDataProtectionKeys.cs new file mode 100644 index 00000000..67061563 --- /dev/null +++ b/user-management/src/Stickerlandia.UserManagement.Agnostic/Migrations/20260202153814_AddDataProtectionKeys.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Stickerlandia.UserManagement.Agnostic.Migrations +{ + /// + public partial class AddDataProtectionKeys : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.CreateTable( + name: "DataProtectionKeys", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + FriendlyName = table.Column(type: "text", nullable: true), + Xml = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DataProtectionKeys", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.DropTable( + name: "DataProtectionKeys"); + } + } +} diff --git a/user-management/src/Stickerlandia.UserManagement.Agnostic/Migrations/UserManagementDbContextModelSnapshot.cs b/user-management/src/Stickerlandia.UserManagement.Agnostic/Migrations/UserManagementDbContextModelSnapshot.cs index ad5fd690..be00e368 100644 --- a/user-management/src/Stickerlandia.UserManagement.Agnostic/Migrations/UserManagementDbContextModelSnapshot.cs +++ b/user-management/src/Stickerlandia.UserManagement.Agnostic/Migrations/UserManagementDbContextModelSnapshot.cs @@ -1,11 +1,4 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2025-Present Datadog, Inc. - */ - -#pragma warning disable - +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -29,6 +22,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { b.Property("Id") diff --git a/user-management/src/Stickerlandia.UserManagement.Agnostic/ServiceExtensions.cs b/user-management/src/Stickerlandia.UserManagement.Agnostic/ServiceExtensions.cs index 7341d958..1a7fe398 100644 --- a/user-management/src/Stickerlandia.UserManagement.Agnostic/ServiceExtensions.cs +++ b/user-management/src/Stickerlandia.UserManagement.Agnostic/ServiceExtensions.cs @@ -9,6 +9,7 @@ // Copyright 2025 Datadog, Inc. using Confluent.Kafka; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -135,6 +136,12 @@ public static IServiceCollection AddPostgresAuthServices(this IServiceCollection services.AddScoped(); + // Configure Data Protection to persist keys in the database + // This ensures keys are shared across all instances and persist across restarts + services.AddDataProtection() + .SetApplicationName("stickerlandia-user-management") + .PersistKeysToDbContext(); + return services; } } \ No newline at end of file diff --git a/user-management/src/Stickerlandia.UserManagement.Agnostic/Stickerlandia.UserManagement.Agnostic.csproj b/user-management/src/Stickerlandia.UserManagement.Agnostic/Stickerlandia.UserManagement.Agnostic.csproj index 564bc2a3..345406c9 100644 --- a/user-management/src/Stickerlandia.UserManagement.Agnostic/Stickerlandia.UserManagement.Agnostic.csproj +++ b/user-management/src/Stickerlandia.UserManagement.Agnostic/Stickerlandia.UserManagement.Agnostic.csproj @@ -18,6 +18,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/user-management/src/Stickerlandia.UserManagement.Agnostic/UserManagementDbContext.cs b/user-management/src/Stickerlandia.UserManagement.Agnostic/UserManagementDbContext.cs index 07ad10ff..95c62d60 100644 --- a/user-management/src/Stickerlandia.UserManagement.Agnostic/UserManagementDbContext.cs +++ b/user-management/src/Stickerlandia.UserManagement.Agnostic/UserManagementDbContext.cs @@ -8,17 +8,21 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2025 Datadog, Inc. +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Stickerlandia.UserManagement.Core; namespace Stickerlandia.UserManagement.Agnostic; -public class UserManagementDbContext : IdentityDbContext +public class UserManagementDbContext : IdentityDbContext, IDataProtectionKeyContext { public new DbSet Users { get; set; } = null!; public DbSet OutboxItems { get; set; } = null!; + // IDataProtectionKeyContext implementation - stores Data Protection keys in the database + public DbSet DataProtectionKeys { get; set; } = null!; + public UserManagementDbContext(DbContextOptions options) : base(options) { } diff --git a/user-management/src/Stickerlandia.UserManagement.Azure/ServiceExtensions.cs b/user-management/src/Stickerlandia.UserManagement.Azure/ServiceExtensions.cs index e6cfafb5..9d40a6f7 100644 --- a/user-management/src/Stickerlandia.UserManagement.Azure/ServiceExtensions.cs +++ b/user-management/src/Stickerlandia.UserManagement.Azure/ServiceExtensions.cs @@ -21,11 +21,13 @@ public static class ServiceExtensions public static IServiceCollection AddAzureAdapters(this IServiceCollection services, IConfiguration configuration, bool enableDefaultUi = true) { ArgumentNullException.ThrowIfNull(configuration, nameof(configuration)); - + + // AddPostgresAuthServices also configures Data Protection with EF Core + // This ensures keys are shared across all instances and persist across restarts services.AddPostgresAuthServices(configuration, enableDefaultUi); services.AddSingleton(); - + services.AddSingleton(sp => new ServiceBusClient(configuration["ConnectionStrings:messaging"])); @@ -33,4 +35,4 @@ public static IServiceCollection AddAzureAdapters(this IServiceCollection servic return services; } -} \ No newline at end of file +}