diff --git a/infra/modules/azure_app_service/README.md b/infra/modules/azure_app_service/README.md new file mode 100644 index 000000000..57f42ba3f --- /dev/null +++ b/infra/modules/azure_app_service/README.md @@ -0,0 +1,65 @@ +# DX - Azure App Service Module + + + +## Requirements + +| Name | Version | +|------|---------| +| [azurerm](#requirement\_azurerm) | >= 3.100.0 | + +## Providers + +| Name | Version | +|------|---------| +| [azurerm](#provider\_azurerm) | 3.105.0 | + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [naming\_convention](#module\_naming\_convention) | ../azure_naming_convention | n/a | + +## Resources + +| Name | Type | +|------|------| +| [azurerm_linux_web_app.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/linux_web_app) | resource | +| [azurerm_linux_web_app_slot.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/linux_web_app_slot) | resource | +| [azurerm_private_endpoint.app_service_sites](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/private_endpoint) | resource | +| [azurerm_private_endpoint.staging_app_service_sites](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/private_endpoint) | resource | +| [azurerm_service_plan.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/service_plan) | resource | +| [azurerm_subnet.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/subnet) | resource | +| [azurerm_private_dns_zone.app_service](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/private_dns_zone) | data source | +| [azurerm_virtual_network.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/virtual_network) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [app\_service\_plan\_id](#input\_app\_service\_plan\_id) | (Optional) Set the AppService Id where you want to host the Function App | `string` | `null` | no | +| [app\_settings](#input\_app\_settings) | Application settings | `map(string)` | n/a | yes | +| [application\_insights\_connection\_string](#input\_application\_insights\_connection\_string) | (Optional) Application Insights connection string | `string` | `null` | no | +| [application\_insights\_sampling\_percentage](#input\_application\_insights\_sampling\_percentage) | (Optional) The sampling percentage of Application Insights. Default is 5 | `number` | `5` | no | +| [environment](#input\_environment) | Values which are used to generate resource names and location short names. They are all mandatory except for domain, which should not be used only in the case of a resource used by multiple domains. |
object({
prefix = string
env_short = string
location = string
domain = optional(string)
app_name = string
instance_number = string
})
| n/a | yes | +| [health\_check\_path](#input\_health\_check\_path) | Endpoint where health probe is exposed | `string` | n/a | yes | +| [java\_version](#input\_java\_version) | Java version to use | `string` | `17` | no | +| [node\_version](#input\_node\_version) | Node version to use | `number` | `20` | no | +| [private\_dns\_zone\_resource\_group\_name](#input\_private\_dns\_zone\_resource\_group\_name) | (Optional) The name of the resource group holding private DNS zone to use for private endpoints. Default is Virtual Network resource group | `string` | `null` | no | +| [resource\_group\_name](#input\_resource\_group\_name) | Resource group to deploy resources to | `string` | n/a | yes | +| [slot\_app\_settings](#input\_slot\_app\_settings) | Staging slot application settings | `map(string)` | `{}` | no | +| [stack](#input\_stack) | n/a | `string` | `"node"` | no | +| [sticky\_app\_setting\_names](#input\_sticky\_app\_setting\_names) | (Optional) A list of application setting names that are not swapped between slots | `list(string)` | `[]` | no | +| [subnet\_cidr](#input\_subnet\_cidr) | CIDR block to use for the subnet the Function App uses for outbound connectivity | `string` | n/a | yes | +| [subnet\_pep\_id](#input\_subnet\_pep\_id) | Id of the subnet which holds private endpoints | `string` | n/a | yes | +| [tags](#input\_tags) | Resources tags | `map(any)` | n/a | yes | +| [tier](#input\_tier) | Resource tiers depending on demanding workload. Allowed values are 'premium', 'standard', 'test'. Note, "test" does not support deployment slots. | `string` | `"premium"` | no | +| [virtual\_network](#input\_virtual\_network) | Virtual network in which to create the subnet |
object({
name = string
resource_group_name = string
})
| n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| [app\_service](#output\_app\_service) | n/a | +| [subnet](#output\_subnet) | n/a | + diff --git a/infra/modules/azure_app_service/app_service.tf b/infra/modules/azure_app_service/app_service.tf new file mode 100644 index 000000000..e4dce841d --- /dev/null +++ b/infra/modules/azure_app_service/app_service.tf @@ -0,0 +1,66 @@ +resource "azurerm_linux_web_app" "this" { + name = "${local.project}-${var.environment.domain}-${var.environment.app_name}-app-${var.environment.instance_number}" + location = var.environment.location + resource_group_name = var.resource_group_name + + service_plan_id = local.app_service_plan.enable ? azurerm_service_plan.this[0].id : var.app_service_plan_id + + https_only = true + public_network_access_enabled = false + virtual_network_subnet_id = azurerm_subnet.this.id + + identity { + type = "SystemAssigned" + } + + site_config { + http2_enabled = true + always_on = true + vnet_route_all_enabled = true + health_check_path = var.health_check_path + health_check_eviction_time_in_min = 2 + ip_restriction_default_action = "Deny" + + application_stack { + node_version = var.stack == "node" ? "${var.node_version}-lts" : null + java_version = var.stack == "java" ? var.java_version : null + } + } + + app_settings = merge( + { + # https://github.com/projectkudu/kudu/wiki/Configurable-settings#attempt-to-rename-dlls-if-they-cant-be-copied-during-a-webdeploy-deployment-1 + WEBSITE_ADD_SITENAME_BINDINGS_IN_APPHOST_CONFIG = 1 + # https://learn.microsoft.com/en-us/azure/azure-functions/run-functions-from-deployment-package#using-website_run_from_package--1 + WEBSITE_RUN_FROM_PACKAGE = 1 + # https://docs.microsoft.com/en-us/azure/virtual-network/what-is-ip-address-168-63-129-16 + WEBSITE_DNS_SERVER = "168.63.129.16" + }, + var.app_settings, + local.application_insights.enable ? { + # https://learn.microsoft.com/en-us/azure/azure-functions/functions-app-settings#applicationinsights_connection_string + APPLICATIONINSIGHTS_CONNECTION_STRING = var.application_insights_connection_string + # https://docs.microsoft.com/en-us/azure/azure-monitor/app/sampling + APPINSIGHTS_SAMPLING_PERCENTAGE = var.application_insights_sampling_percentage + } : {} + ) + + dynamic "sticky_settings" { + for_each = length(var.sticky_app_setting_names) == 0 ? [] : [1] + content { + app_setting_names = var.sticky_app_setting_names + } + } + + lifecycle { + ignore_changes = [ + app_settings["DOCKER_CUSTOM_IMAGE_NAME"], + app_settings["WEBSITE_HEALTHCHECK_MAXPINGFAILURES"], + tags["hidden-link: /app-insights-conn-string"], + tags["hidden-link: /app-insights-instrumentation-key"], + tags["hidden-link: /app-insights-resource-id"] + ] + } + + tags = var.tags +} diff --git a/infra/modules/azure_app_service/app_service_plan.tf b/infra/modules/azure_app_service/app_service_plan.tf new file mode 100644 index 000000000..068ed79ee --- /dev/null +++ b/infra/modules/azure_app_service/app_service_plan.tf @@ -0,0 +1,12 @@ +resource "azurerm_service_plan" "this" { + count = local.app_service_plan.enable ? 1 : 0 + + name = "${local.project}-${var.environment.domain}-${var.environment.app_name}-asp-${var.environment.instance_number}" + location = var.environment.location + resource_group_name = var.resource_group_name + os_type = "Linux" + sku_name = local.app_service.sku_name + zone_balancing_enabled = local.app_service.zone_balancing_enabled + + tags = var.tags +} diff --git a/infra/modules/azure_app_service/app_service_slot.tf b/infra/modules/azure_app_service/app_service_slot.tf new file mode 100644 index 000000000..fab5797e6 --- /dev/null +++ b/infra/modules/azure_app_service/app_service_slot.tf @@ -0,0 +1,59 @@ +resource "azurerm_linux_web_app_slot" "this" { + count = local.app_service.is_slot_enabled + + name = "${local.project}-${var.environment.domain}-${var.environment.app_name}-staging-app-${var.environment.instance_number}" + + app_service_id = azurerm_linux_web_app.this.id + + https_only = true + public_network_access_enabled = false + virtual_network_subnet_id = azurerm_subnet.this.id + + identity { + type = "SystemAssigned" + } + + site_config { + http2_enabled = true + always_on = true + vnet_route_all_enabled = true + health_check_path = var.health_check_path + health_check_eviction_time_in_min = 2 + ip_restriction_default_action = "Deny" + + application_stack { + node_version = var.stack == "node" ? var.node_version : null + java_version = var.stack == "java" ? var.java_version : null + } + } + + app_settings = merge( + { + # https://github.com/projectkudu/kudu/wiki/Configurable-settings#attempt-to-rename-dlls-if-they-cant-be-copied-during-a-webdeploy-deployment-1 + WEBSITE_ADD_SITENAME_BINDINGS_IN_APPHOST_CONFIG = 1 + # https://learn.microsoft.com/en-us/azure/azure-functions/run-functions-from-deployment-package#using-website_run_from_package--1 + WEBSITE_RUN_FROM_PACKAGE = 1 + # https://docs.microsoft.com/en-us/azure/virtual-network/what-is-ip-address-168-63-129-16 + WEBSITE_DNS_SERVER = "168.63.129.16" + }, + var.slot_app_settings, + local.application_insights.enable ? { + # https://learn.microsoft.com/en-us/azure/azure-functions/functions-app-settings#applicationinsights_connection_string + APPLICATIONINSIGHTS_CONNECTION_STRING = var.application_insights_connection_string + # https://docs.microsoft.com/en-us/azure/azure-monitor/app/sampling + APPINSIGHTS_SAMPLING_PERCENTAGE = var.application_insights_sampling_percentage + } : {} + ) + + lifecycle { + ignore_changes = [ + app_settings["DOCKER_CUSTOM_IMAGE_NAME"], + app_settings["WEBSITE_HEALTHCHECK_MAXPINGFAILURES"], + tags["hidden-link: /app-insights-conn-string"], + tags["hidden-link: /app-insights-instrumentation-key"], + tags["hidden-link: /app-insights-resource-id"] + ] + } + + tags = var.tags +} diff --git a/infra/modules/azure_app_service/data.tf b/infra/modules/azure_app_service/data.tf new file mode 100644 index 000000000..ffa6c4ae5 --- /dev/null +++ b/infra/modules/azure_app_service/data.tf @@ -0,0 +1,9 @@ +data "azurerm_virtual_network" "this" { + name = var.virtual_network.name + resource_group_name = var.virtual_network.resource_group_name +} + +data "azurerm_private_dns_zone" "app_service" { + name = "privatelink.azurewebsites.net" + resource_group_name = local.private_dns_zone.resource_group_name +} diff --git a/infra/modules/azure_app_service/locals.tf b/infra/modules/azure_app_service/locals.tf new file mode 100644 index 000000000..323557c60 --- /dev/null +++ b/infra/modules/azure_app_service/locals.tf @@ -0,0 +1,22 @@ +locals { + location_short = var.environment.location == "italynorth" ? "itn" : var.environment.location == "westeurope" ? "weu" : var.environment.location == "germanywestcentral" ? "gwc" : "neu" + project = "${var.environment.prefix}-${var.environment.env_short}-${local.location_short}" + + app_service_plan = { + enable = var.app_service_plan_id == null + } + + app_service = { + sku_name = var.tier == "test" ? "B1" : var.tier == "standard" ? "P0v3" : "P1v3" + zone_balancing_enabled = var.tier != "test" + is_slot_enabled = var.tier == "test" ? 0 : 1 + } + + application_insights = { + enable = var.application_insights_connection_string != null + } + + private_dns_zone = { + resource_group_name = var.private_dns_zone_resource_group_name == null ? var.virtual_network.resource_group_name : var.private_dns_zone_resource_group_name + } +} diff --git a/infra/modules/azure_app_service/main.tf b/infra/modules/azure_app_service/main.tf new file mode 100644 index 000000000..0289865fe --- /dev/null +++ b/infra/modules/azure_app_service/main.tf @@ -0,0 +1,25 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.100.0" + } + } +} + +provider "azurerm" { + features {} +} + +module "naming_convention" { + source = "../azure_naming_convention" + + environment = { + prefix = var.environment.prefix + env_short = var.environment.env_short + location = var.environment.location + domain = var.environment.domain + app_name = var.environment.app_name + instance_number = var.environment.instance_number + } +} diff --git a/infra/modules/azure_app_service/networking.tf b/infra/modules/azure_app_service/networking.tf new file mode 100644 index 000000000..3e0434c7a --- /dev/null +++ b/infra/modules/azure_app_service/networking.tf @@ -0,0 +1,43 @@ +resource "azurerm_private_endpoint" "app_service_sites" { + name = "${local.project}-${var.environment.domain}-${var.environment.app_name}-app-pep-${var.environment.instance_number}" + location = var.environment.location + resource_group_name = var.resource_group_name + subnet_id = var.subnet_pep_id + + private_service_connection { + name = "${local.project}-${var.environment.domain}-${var.environment.app_name}-app-pep-${var.environment.instance_number}" + private_connection_resource_id = azurerm_linux_web_app.this.id + is_manual_connection = false + subresource_names = ["sites"] + } + + private_dns_zone_group { + name = "private-dns-zone-group" + private_dns_zone_ids = [data.azurerm_private_dns_zone.app_service.id] + } + + tags = var.tags +} + +resource "azurerm_private_endpoint" "staging_app_service_sites" { + count = local.app_service.is_slot_enabled + + name = "${local.project}-${var.environment.domain}-${var.environment.app_name}-staging-app-pep-${var.environment.instance_number}" + location = var.environment.location + resource_group_name = var.resource_group_name + subnet_id = var.subnet_pep_id + + private_service_connection { + name = "${local.project}-${var.environment.domain}-${var.environment.app_name}-staging-app-pep-${var.environment.instance_number}" + private_connection_resource_id = azurerm_linux_web_app.this.id + is_manual_connection = false + subresource_names = ["sites-${azurerm_linux_web_app_slot.this[0].name}"] + } + + private_dns_zone_group { + name = "private-dns-zone-group" + private_dns_zone_ids = [data.azurerm_private_dns_zone.app_service.id] + } + + tags = var.tags +} diff --git a/infra/modules/azure_app_service/outputs.tf b/infra/modules/azure_app_service/outputs.tf new file mode 100644 index 000000000..7eb743b75 --- /dev/null +++ b/infra/modules/azure_app_service/outputs.tf @@ -0,0 +1,26 @@ +output "subnet" { + value = { + id = azurerm_subnet.this.id + name = azurerm_subnet.this.name + } +} + +output "app_service" { + value = { + resource_group_name = azurerm_linux_web_app.this.resource_group_name + plan = { + id = try(azurerm_service_plan.this[0].id, null) + name = try(azurerm_service_plan.this[0].name, null) + } + app_service = { + id = azurerm_linux_web_app.this.id + name = azurerm_linux_web_app.this.name + principal_id = azurerm_linux_web_app.this.identity[0].principal_id + slot = { + id = try(azurerm_linux_web_app_slot.this[0].id, null) + name = try(azurerm_linux_web_app_slot.this[0].name, null) + principal_id = try(azurerm_linux_web_app_slot.this[0].identity[0].principal_id, null) + } + } + } +} diff --git a/infra/modules/azure_app_service/subnets.tf b/infra/modules/azure_app_service/subnets.tf new file mode 100644 index 000000000..e07bcea31 --- /dev/null +++ b/infra/modules/azure_app_service/subnets.tf @@ -0,0 +1,14 @@ +resource "azurerm_subnet" "this" { + name = "${local.project}-${var.environment.domain}-${var.environment.app_name}-app-snet-${var.environment.instance_number}" + virtual_network_name = data.azurerm_virtual_network.this.name + resource_group_name = data.azurerm_virtual_network.this.resource_group_name + address_prefixes = [var.subnet_cidr] + + delegation { + name = "default" + service_delegation { + name = "Microsoft.Web/serverFarms" + actions = ["Microsoft.Network/virtualNetworks/subnets/action"] + } + } +} diff --git a/infra/modules/azure_app_service/variables.tf b/infra/modules/azure_app_service/variables.tf new file mode 100644 index 000000000..63ede9c1f --- /dev/null +++ b/infra/modules/azure_app_service/variables.tf @@ -0,0 +1,120 @@ +variable "tags" { + type = map(any) + description = "Resources tags" +} + +variable "environment" { + type = object({ + prefix = string + env_short = string + location = string + domain = optional(string) + app_name = string + instance_number = string + }) + + description = "Values which are used to generate resource names and location short names. They are all mandatory except for domain, which should not be used only in the case of a resource used by multiple domains." +} + +variable "resource_group_name" { + type = string + description = "Resource group to deploy resources to" +} + +variable "app_service_plan_id" { + type = string + default = null + description = "(Optional) Set the AppService Id where you want to host the Function App" +} + +variable "application_insights_connection_string" { + type = string + sensitive = true + default = null + description = "(Optional) Application Insights connection string" +} + +variable "health_check_path" { + type = string + description = "Endpoint where health probe is exposed" +} + +variable "tier" { + type = string + description = "Resource tiers depending on demanding workload. Allowed values are 'premium', 'standard', 'test'. Note, \"test\" does not support deployment slots." + default = "premium" + + validation { + condition = contains(["premium", "standard", "test"], var.tier) + error_message = "Allowed values for \"tier\" are \"premium\", \"standard\", or \"test\". Note, \"test\" does not support deployment slots." + } +} + +variable "stack" { + type = string + default = "node" + + validation { + condition = contains(["node", "java"], var.stack) + error_message = "Allowed values for \"stack\" are \"node\", \"java\". Note, you can select the version using \"node_version\" and \"java_version\" variables." + } +} + +variable "node_version" { + type = number + default = 20 + description = "Node version to use" +} + +variable "java_version" { + type = string + default = 17 + description = "Java version to use" +} + +variable "application_insights_sampling_percentage" { + type = number + default = 5 + description = "(Optional) The sampling percentage of Application Insights. Default is 5" +} + +variable "app_settings" { + type = map(string) + description = "Application settings" +} + +variable "slot_app_settings" { + type = map(string) + description = "Staging slot application settings" + default = {} +} + +variable "sticky_app_setting_names" { + type = list(string) + description = "(Optional) A list of application setting names that are not swapped between slots" + default = [] +} + +variable "subnet_cidr" { + type = string + description = "CIDR block to use for the subnet the Function App uses for outbound connectivity" +} + +variable "subnet_pep_id" { + type = string + description = "Id of the subnet which holds private endpoints" +} + +variable "virtual_network" { + type = object({ + name = string + resource_group_name = string + }) + description = "Virtual network in which to create the subnet" +} + +variable "private_dns_zone_resource_group_name" { + type = string + default = null + description = "(Optional) The name of the resource group holding private DNS zone to use for private endpoints. Default is Virtual Network resource group" +}