From 9a4111df7ec6f009f30014e9c03ed9671de55f3f Mon Sep 17 00:00:00 2001 From: Sam C <156680559+sam-c-dfe@users.noreply.github.com> Date: Thu, 1 Feb 2024 14:41:25 +0000 Subject: [PATCH] Initial commit of the terraform scripts (#8) * Initial commit of the terraform scripts to start creating the initial environment * Added the azure remote state scripts to store terraform state in blob storage * Added network resources * Added web app infrastructure scripts * Updated to add in temp values for the contentful key vault secrets * Updated secrets permission * Updated file names, removed some of the variables we don't need and changed a couple of minor things in the scripts * Removed app settings that is not needed * Removed concurrency setting as not needed * Updated the tags and added the new terraform.tf file --- terraform/README.md | 6 + terraform/azure-network/README.md | 3 + terraform/azure-network/key-vault.tf | 203 +++++++++++++ terraform/azure-network/outputs.tf | 39 +++ terraform/azure-network/public-ip.tf | 19 ++ terraform/azure-network/variables.tf | 69 +++++ terraform/azure-network/vnet.tf | 45 +++ terraform/azure-remote-state/README.md | 17 ++ terraform/azure-remote-state/local.tf | 9 + terraform/azure-remote-state/main.tf | 84 +++++ terraform/azure-remote-state/variables.tf | 11 + terraform/azure-web/README.md | 3 + terraform/azure-web/app-gateway.tf | 189 ++++++++++++ terraform/azure-web/variables.tf | 134 ++++++++ terraform/azure-web/web-app.tf | 355 ++++++++++++++++++++++ terraform/local.tf | 30 ++ terraform/main.tf | 78 +++++ terraform/terraform.tf | 15 + terraform/variables.tf | 124 ++++++++ 19 files changed, 1433 insertions(+) create mode 100644 terraform/README.md create mode 100644 terraform/azure-network/README.md create mode 100644 terraform/azure-network/key-vault.tf create mode 100644 terraform/azure-network/outputs.tf create mode 100644 terraform/azure-network/public-ip.tf create mode 100644 terraform/azure-network/variables.tf create mode 100644 terraform/azure-network/vnet.tf create mode 100644 terraform/azure-remote-state/README.md create mode 100644 terraform/azure-remote-state/local.tf create mode 100644 terraform/azure-remote-state/main.tf create mode 100644 terraform/azure-remote-state/variables.tf create mode 100644 terraform/azure-web/README.md create mode 100644 terraform/azure-web/app-gateway.tf create mode 100644 terraform/azure-web/variables.tf create mode 100644 terraform/azure-web/web-app.tf create mode 100644 terraform/local.tf create mode 100644 terraform/main.tf create mode 100644 terraform/terraform.tf create mode 100644 terraform/variables.tf diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 00000000..83e43188 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,6 @@ +# Azure Root Module + +This module provisions a new Azure Resource Group that assembles together the infrastructure for hosting the web application. + +## Prerequisites +* The [Remote State module](./azure-remote-state/README.md) must first be applied **independently** before this configuration can be used. \ No newline at end of file diff --git a/terraform/azure-network/README.md b/terraform/azure-network/README.md new file mode 100644 index 00000000..0545b363 --- /dev/null +++ b/terraform/azure-network/README.md @@ -0,0 +1,3 @@ +# Azure Network Module + +This module provisions the necessary network related resources such as a VNET, Subnets & KeyVault. \ No newline at end of file diff --git a/terraform/azure-network/key-vault.tf b/terraform/azure-network/key-vault.tf new file mode 100644 index 00000000..8122b941 --- /dev/null +++ b/terraform/azure-network/key-vault.tf @@ -0,0 +1,203 @@ +# Create Key Vault +data "azurerm_client_config" "az_config" {} + +resource "azurerm_key_vault" "kv" { + name = "${var.resource_name_prefix}-kv" + resource_group_name = var.resource_group + location = var.location + tenant_id = data.azurerm_client_config.az_config.tenant_id + enabled_for_disk_encryption = true + soft_delete_retention_days = 7 + purge_protection_enabled = true + sku_name = "standard" + + lifecycle { + ignore_changes = [tags] + } + + #checkov:skip=CKV_AZURE_109:Access Policies configured + #checkov:skip=CKV_AZURE_189:Access Policies configured + #checkov:skip=CKV2_AZURE_32:VNET configuration adequate +} + +resource "azurerm_user_assigned_identity" "kv_mi" { + # Key Vault only deployed to the Test and Production subscription + count = var.environment != "development" ? 1 : 0 + + name = "${var.resource_name_prefix}-agw-mi" + location = var.location + resource_group_name = var.resource_group +} + +resource "azurerm_key_vault_access_policy" "kv_ap" { + # Key Vault only deployed to the Test and Production subscription + count = var.environment != "development" ? 1 : 0 + + key_vault_id = azurerm_key_vault.kv[0].id + tenant_id = data.azurerm_client_config.az_config.tenant_id + object_id = data.azurerm_client_config.az_config.object_id + + secret_permissions = [ + "Get" + ] + + certificate_permissions = [ + "Create", + "Get", + "GetIssuers", + "Import", + "List", + "ListIssuers", + "ManageContacts", + "ManageIssuers", + "SetIssuers", + "Update" + ] + + lifecycle { + ignore_changes = [object_id] + } +} + +# Access Policy for GitHub Actions +resource "azurerm_key_vault_access_policy" "kv_gh_ap" { + # Key Vault only deployed to the Test and Production subscription + count = var.environment != "development" ? 1 : 0 + + key_vault_id = azurerm_key_vault.kv[0].id + tenant_id = data.azurerm_client_config.az_config.tenant_id + object_id = data.azurerm_client_config.az_config.object_id + + secret_permissions = [ + "Get" + ] + + certificate_permissions = [ + "Create", + "Get", + "GetIssuers", + "Import", + "List", + "ListIssuers", + "ManageContacts", + "ManageIssuers", + "SetIssuers", + "Update" + ] +} + +resource "azurerm_key_vault_access_policy" "kv_mi_ap" { + # Key Vault only deployed to the Test and Production subscription + count = var.environment != "development" ? 1 : 0 + + key_vault_id = azurerm_key_vault.kv[0].id + tenant_id = data.azurerm_client_config.az_config.tenant_id + object_id = azurerm_user_assigned_identity.kv_mi[0].principal_id + + secret_permissions = [ + "Get" + ] + + certificate_permissions = [ + "Get" + ] +} + +resource "azurerm_key_vault_certificate_issuer" "kv_ca" { + # Key Vault only deployed to the Test and Production subscription + count = var.environment != "development" ? 1 : 0 + + name = var.kv_certificate_authority_label + key_vault_id = azurerm_key_vault.kv[0].id + provider_name = var.kv_certificate_authority_name + account_id = var.kv_certificate_authority_username + password = var.kv_certificate_authority_password + + admin { + email_address = var.kv_certificate_authority_admin_email + first_name = var.kv_certificate_authority_admin_first_name + last_name = var.kv_certificate_authority_admin_last_name + phone = var.kv_certificate_authority_admin_phone_no + } +} + +resource "azurerm_key_vault_certificate" "kv_cert" { + # Key Vault only deployed to the Test and Production subscription + count = var.environment != "development" ? 1 : 0 + + name = var.kv_certificate_label + key_vault_id = azurerm_key_vault.kv[0].id + + certificate_policy { + issuer_parameters { + name = var.kv_certificate_authority_label + } + + key_properties { + exportable = true + key_size = 2048 + key_type = "RSA" + reuse_key = true + } + + lifetime_action { + action { + action_type = "AutoRenew" + } + + trigger { + days_before_expiry = 30 + } + } + + secret_properties { + content_type = "application/x-pkcs12" + } + + x509_certificate_properties { + extended_key_usage = ["1.3.6.1.5.5.7.3.1", "1.3.6.1.5.5.7.3.2"] + key_usage = ["digitalSignature", "keyEncipherment"] + subject = var.kv_certificate_subject + validity_in_months = 12 + } + } +} + +resource "azurerm_key_vault_secret" "vault_secret_contentful_deliveryapikey" { + key_vault_id = azurerm_key_vault.kv[0].id + name = "ContentfulOptions--DeliveryApiKey" + value = "temp value" + + lifecycle { + ignore_changes = [ + value, + expiration_date + ] + } +} + +resource "azurerm_key_vault_secret" "vault_secret_contentful_previewapikey" { + key_vault_id = azurerm_key_vault.kv[0].id + name = "ContentfulOptions--PreviewApiKey" + value = "temp value" + + lifecycle { + ignore_changes = [ + value, + expiration_date + ] + } +} + +resource "azurerm_key_vault_secret" "vault_secret_contentful_spaceid" { + key_vault_id = azurerm_key_vault.kv[0].id + name = "ContentfulOptions--SpaceId" + value = "temp value" + + lifecycle { + ignore_changes = [ + value, + expiration_date + ] + } +} \ No newline at end of file diff --git a/terraform/azure-network/outputs.tf b/terraform/azure-network/outputs.tf new file mode 100644 index 00000000..b7b69ae0 --- /dev/null +++ b/terraform/azure-network/outputs.tf @@ -0,0 +1,39 @@ +output "vnet_id" { + description = "ID of the Virtual Network" + value = azurerm_virtual_network.vnet.id +} + +output "vnet_name" { + description = "Name of the Virtual Network" + value = azurerm_virtual_network.vnet.name +} + +output "webapp_subnet_id" { + description = "ID of the delegated Subnet for the Web Application" + value = azurerm_subnet.webapp_snet.id +} + +output "agw_subnet_id" { + description = "ID of the Subnet for the App Gateway" + value = var.environment != "development" ? azurerm_subnet.agw_snet[0].id : null +} + +output "agw_pip_id" { + description = "ID of the Public IP address for the App Gateway" + value = var.environment != "development" ? azurerm_public_ip.agw_pip[0].id : null +} + +output "kv_id" { + description = "ID of the Key Vault" + value = azurerm_key_vault.kv[0].id +} + +output "kv_cert_secret_id" { + description = "SSL certificate Secret ID" + value = var.environment != "development" ? azurerm_key_vault_certificate.kv_cert[0].secret_id : null +} + +output "kv_mi_id" { + description = "ID of the Managed Identity for the Key Vault" + value = azurerm_user_assigned_identity.kv_mi[0].id +} \ No newline at end of file diff --git a/terraform/azure-network/public-ip.tf b/terraform/azure-network/public-ip.tf new file mode 100644 index 00000000..f806e72a --- /dev/null +++ b/terraform/azure-network/public-ip.tf @@ -0,0 +1,19 @@ +# Create PIP for App Gateway +resource "azurerm_public_ip" "agw_pip" { + # Application Gateway is not deployed to the Development subscription + count = var.environment != "development" ? 1 : 0 + + name = "${var.resource_name_prefix}-agw-pip" + resource_group_name = var.resource_group + location = var.location + allocation_method = "Static" + ip_version = "IPv4" + sku = "Standard" + sku_tier = "Regional" + zones = [] + idle_timeout_in_minutes = 4 + + lifecycle { + ignore_changes = [tags] + } +} diff --git a/terraform/azure-network/variables.tf b/terraform/azure-network/variables.tf new file mode 100644 index 00000000..c9ea42b3 --- /dev/null +++ b/terraform/azure-network/variables.tf @@ -0,0 +1,69 @@ +variable "environment" { + description = "Environment to deploy resources" + type = string +} + +variable "location" { + description = "Name of the Azure region to deploy resources" + type = string +} + +variable "resource_group" { + description = "Name of the Azure Resource Group to deploy resources" + type = string +} + +variable "resource_name_prefix" { + description = "Prefix for resource names" + type = string +} + +variable "kv_certificate_authority_label" { + description = "Label for the Certificate Authority" + type = string +} + +variable "kv_certificate_authority_name" { + description = "Name of the Certificate Authority" + type = string +} + +variable "kv_certificate_authority_username" { + description = "Username for the Certificate provider" + type = string +} + +variable "kv_certificate_authority_password" { + description = "Password the Certificate provider" + type = string +} + +variable "kv_certificate_authority_admin_email" { + description = "Email Address of the Certificate Authority Admin" + type = string +} + +variable "kv_certificate_authority_admin_first_name" { + description = "First Name of the Certificate Authority Admin" + type = string +} + +variable "kv_certificate_authority_admin_last_name" { + description = "Last Name of the Certificate Authority Admin" + type = string +} + +variable "kv_certificate_authority_admin_phone_no" { + description = "Phone No. of the Certificate Authority Admin" + type = string +} + +variable "kv_certificate_label" { + description = "Label for the Certificate" + type = string +} + +variable "kv_certificate_subject" { + description = "Subject of the Certificate" + type = string +} \ No newline at end of file diff --git a/terraform/azure-network/vnet.tf b/terraform/azure-network/vnet.tf new file mode 100644 index 00000000..59d5f2c2 --- /dev/null +++ b/terraform/azure-network/vnet.tf @@ -0,0 +1,45 @@ +# Create Virtual Network +resource "azurerm_virtual_network" "vnet" { + name = "${var.resource_name_prefix}-vnet" + location = var.location + resource_group_name = var.resource_group + address_space = ["172.1.0.0/16"] + + lifecycle { + ignore_changes = [tags] + } +} + +# Create Subnet for Web App +resource "azurerm_subnet" "webapp_snet" { + name = "${var.resource_name_prefix}-webapp-snet" + virtual_network_name = azurerm_virtual_network.vnet.name + resource_group_name = var.resource_group + address_prefixes = ["172.1.1.0/26"] + service_endpoints = ["Microsoft.Storage"] + + delegation { + name = "${var.resource_name_prefix}-webapp-dn" + + service_delegation { + name = "Microsoft.Web/serverFarms" + actions = ["Microsoft.Network/virtualNetworks/subnets/action"] + } + } + + #checkov:skip=CKV2_AZURE_31:NSG not required +} + +# Create Subnet for App Gateway +resource "azurerm_subnet" "agw_snet" { + # Subnet only deployed to the Test and Production subscription + count = var.environment != "development" ? 1 : 0 + + name = "${var.resource_name_prefix}-agw-snet" + virtual_network_name = azurerm_virtual_network.vnet.name + resource_group_name = var.resource_group + address_prefixes = ["172.1.3.0/24"] + service_endpoints = ["Microsoft.Storage", "Microsoft.Web"] + + #checkov:skip=CKV2_AZURE_31:NSG not required +} \ No newline at end of file diff --git a/terraform/azure-remote-state/README.md b/terraform/azure-remote-state/README.md new file mode 100644 index 00000000..2c401475 --- /dev/null +++ b/terraform/azure-remote-state/README.md @@ -0,0 +1,17 @@ +# Azure Remote State Module + +This module provisions a new Azure Storage Account to store Terraform state remotely to allow colloboration between the development team. + +**This is a one-off exercise that must applied ahead of all other resources in the configuration.** + +1. Navigate to the terraform-azure-remote-state directory using your CLI +2. Run the init command `terraform init`, then `terraform apply` to provision the Azure storage account and container +3. Initialise the root terraform-azure module with the terraform-init command + + terraform init \ + -backend-config="resource_group_name=" \ + -backend-config="storage_account_name=" \ + -backend-config="container_name=" \ + -backend-config="key=" + +ref. https://learn.microsoft.com/en-us/azure/developer/terraform/store-state-in-azure-storage?tabs=terraform \ No newline at end of file diff --git a/terraform/azure-remote-state/local.tf b/terraform/azure-remote-state/local.tf new file mode 100644 index 00000000..ab054de8 --- /dev/null +++ b/terraform/azure-remote-state/local.tf @@ -0,0 +1,9 @@ +locals { + # Common tags to be assigned to all resources + common_tags = { + "Environment" = var.default_environment + "Parent Business" = "Children’s Care" + "Product" = "Early Years Qualifications" + "Service Offering" = "Early Years Qualifications" + } +} \ No newline at end of file diff --git a/terraform/azure-remote-state/main.tf b/terraform/azure-remote-state/main.tf new file mode 100644 index 00000000..ae37a239 --- /dev/null +++ b/terraform/azure-remote-state/main.tf @@ -0,0 +1,84 @@ +# Configure the Azure provider +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.65.0" + } + } + + required_version = ">= 1.5.0" +} + +provider "azurerm" { + skip_provider_registration = "true" + + features {} +} + +# Create Remote State Storage +resource "azurerm_resource_group" "tfstate" { + name = "${var.resource_name_prefix}-tfstate-rg" + location = var.default_azure_region + + tags = merge(local.common_tags, { + "Region" = var.default_azure_region + }) +} + +resource "random_string" "resource_code" { + length = 5 + special = false + upper = false +} + +resource "azurerm_storage_account" "tfstate" { + name = "eyqualificationtfstate${random_string.resource_code.result}st" + resource_group_name = azurerm_resource_group.tfstate.name + location = var.default_azure_region + account_tier = "Standard" + min_tls_version = "TLS1_2" + account_replication_type = "LRS" + allow_nested_items_to_be_public = false + + queue_properties { + logging { + delete = true + read = true + write = true + version = "1.0" + retention_policy_days = 10 + } + hour_metrics { + enabled = true + include_apis = true + version = "1.0" + retention_policy_days = 10 + } + minute_metrics { + enabled = true + include_apis = true + version = "1.0" + retention_policy_days = 10 + } + } + + tags = merge(local.common_tags, { + "Region" = var.default_azure_region + }) + + #checkov:skip=CKV_AZURE_206:GRS not required + #checkov:skip=CKV_AZURE_59:Argument has been deprecated + #checkov:skip=CKV2_AZURE_18:Microsoft Managed keys are sufficient + #checkov:skip=CKV2_AZURE_1:Microsoft Managed keys are sufficient + #checkov:skip=CKV2_AZURE_38:Soft-delete not required + #checkov:skip=CKV2_AZURE_33:VNet not configured +} + +resource "azurerm_storage_container" "tfstate" { + name = "${var.resource_name_prefix}-tfstate-stc" + storage_account_name = azurerm_storage_account.tfstate.name + container_access_type = "private" + + #checkov:skip=CKV2_AZURE_21:Logging not required +} \ No newline at end of file diff --git a/terraform/azure-remote-state/variables.tf b/terraform/azure-remote-state/variables.tf new file mode 100644 index 00000000..2895c9d5 --- /dev/null +++ b/terraform/azure-remote-state/variables.tf @@ -0,0 +1,11 @@ +variable "default_azure_region" { + default = "westeurope" +} + +variable "default_environment" { + default = "dev" +} + +variable "resource_name_prefix" { + default = "s202d01-earlyyearsqualifications" +} diff --git a/terraform/azure-web/README.md b/terraform/azure-web/README.md new file mode 100644 index 00000000..d9e60334 --- /dev/null +++ b/terraform/azure-web/README.md @@ -0,0 +1,3 @@ +# Azure Web Module + +This module provisions a new Azure App Service & Application Gateway to host a Docker container for a Web Application. \ No newline at end of file diff --git a/terraform/azure-web/app-gateway.tf b/terraform/azure-web/app-gateway.tf new file mode 100644 index 00000000..1eae2af4 --- /dev/null +++ b/terraform/azure-web/app-gateway.tf @@ -0,0 +1,189 @@ +# Create App Gateway +resource "azurerm_web_application_firewall_policy" "agw_wafp" { + # App Gateway only deployed to the Test and Production subscription + count = var.environment != "development" ? 1 : 0 + + name = "${var.resource_name_prefix}-agw-wafp" + location = var.location + resource_group_name = var.resource_group + + managed_rules { + managed_rule_set { + type = "OWASP" + version = "3.2" + + rule_group_override { + rule_group_name = "REQUEST-920-PROTOCOL-ENFORCEMENT" + rule { + id = "920420" + enabled = false + } + } + } + + managed_rule_set { + type = "Microsoft_BotManagerRuleSet" + version = "0.1" + } + + exclusion { + match_variable = "RequestCookieNames" + selector = var.webapp_session_cookie_name + selector_match_operator = "Equals" + } + } + + policy_settings { + enabled = true + file_upload_limit_in_mb = 100 + max_request_body_size_in_kb = 128 + mode = "Prevention" + request_body_check = true + } + + lifecycle { + ignore_changes = [tags] + } +} + +locals { + backend_address_pool_name = "${var.resource_name_prefix}-agw-beap" + frontend_port_name = "${var.resource_name_prefix}-agw-feport" + frontend_ip_configuration_name = "${var.resource_name_prefix}-agw-feip" + http_setting_name = "${var.resource_name_prefix}-agw-best" + health_probe_name = "${var.resource_name_prefix}-agw-hp" + listener_name = "${var.resource_name_prefix}-agw-lstn" + ssl_certificate_name = "${var.resource_name_prefix}-agw-cert" +} + +resource "azurerm_application_gateway" "agw" { + # App Gateway only deployed to the Test and Production subscription + count = var.environment != "development" ? 1 : 0 + + name = "${var.resource_name_prefix}-agw" + location = var.location + resource_group_name = var.resource_group + enable_http2 = true + firewall_policy_id = azurerm_web_application_firewall_policy.agw_wafp[0].id + force_firewall_policy_association = true + + sku { + name = "WAF_v2" + tier = "WAF_v2" + } + + autoscale_configuration { + min_capacity = 2 + max_capacity = 10 + } + + gateway_ip_configuration { + name = "${var.resource_name_prefix}-agw-ipc" + subnet_id = var.agw_subnet_id + } + + identity { + type = "UserAssigned" + identity_ids = [var.kv_mi_id] + } + + ssl_certificate { + name = local.ssl_certificate_name + key_vault_secret_id = var.kv_cert_secret_id + } + + ssl_policy { + policy_type = "Predefined" + policy_name = "AppGwSslPolicy20220101" + } + + probe { + host = var.webapp_custom_domain_name + name = local.health_probe_name + interval = 30 + path = "/health" + protocol = "Https" + timeout = 30 + unhealthy_threshold = 3 + + match { + status_code = [200] + body = null + } + } + + frontend_port { + name = local.frontend_port_name + port = 443 + } + + frontend_ip_configuration { + name = local.frontend_ip_configuration_name + public_ip_address_id = var.agw_pip_id + } + + backend_address_pool { + name = local.backend_address_pool_name + fqdns = [azurerm_linux_web_app.webapp.default_hostname] + } + + backend_http_settings { + name = local.http_setting_name + cookie_based_affinity = "Disabled" + port = 443 + protocol = "Https" + probe_name = local.health_probe_name + request_timeout = 300 + + connection_draining { + enabled = true + drain_timeout_sec = 60 + } + } + + http_listener { + name = local.listener_name + frontend_ip_configuration_name = local.frontend_ip_configuration_name + frontend_port_name = local.frontend_port_name + firewall_policy_id = azurerm_web_application_firewall_policy.agw_wafp[0].id + protocol = "Https" + ssl_certificate_name = local.ssl_certificate_name + } + + request_routing_rule { + name = "${var.resource_name_prefix}-agw-rr" + rule_type = "Basic" + priority = 100 + http_listener_name = local.listener_name + backend_address_pool_name = local.backend_address_pool_name + backend_http_settings_name = local.http_setting_name + } + + lifecycle { + ignore_changes = [tags] + } + + #checkov:skip=CKV_AZURE_218:Secure transit protocols used + #checkov:skip=CKV_AZURE_120:WAF is enabled +} + +resource "azurerm_monitor_diagnostic_setting" "agw_logs_monitor" { + # App Gateway only deployed to the Test and Production subscription + count = var.environment != "development" ? 1 : 0 + + name = "${var.resource_name_prefix}-agw-mon" + target_resource_id = azurerm_application_gateway.agw[0].id + log_analytics_workspace_id = azurerm_log_analytics_workspace.webapp_logs.id + + enabled_log { + category = "ApplicationGatewayFirewallLog" + } + + timeouts { + read = "30m" + } + + lifecycle { + ignore_changes = [metric] + } +} \ No newline at end of file diff --git a/terraform/azure-web/variables.tf b/terraform/azure-web/variables.tf new file mode 100644 index 00000000..94e56b41 --- /dev/null +++ b/terraform/azure-web/variables.tf @@ -0,0 +1,134 @@ +variable "environment" { + description = "Environment to deploy resources" + type = string +} + +variable "location" { + description = "Name of the Azure region to deploy resources" + type = string +} + +variable "resource_group" { + description = "Name of the Azure Resource Group to deploy resources" + type = string +} + +variable "resource_name_prefix" { + description = "Prefix for resource names" + type = string +} + +variable "as_service_principal_object_id" { + description = "Object ID of the service principal for App Service" + type = string + sensitive = true +} + +variable "asp_sku" { + description = "SKU name for the App Service Plan" + type = string +} + +variable "webapp_admin_email_address" { + description = "Email Address of the Admin" + type = string + sensitive = true +} + +variable "webapp_worker_count" { + description = "Number of Workers for the App Service Plan" + type = string +} + +variable "webapp_name" { + description = "Name for the Web Application" + type = string +} + +variable "webapp_subnet_id" { + description = "ID of the delegated Subnet for the Web Application" + type = string +} + +variable "webapp_app_settings" { + description = "App Settings are exposed as environment variables" + type = map(string) +} + +variable "webapp_slot_app_settings" { + description = "App Settings are exposed as environment variables" + type = map(string) +} + +variable "webapp_docker_registry_url" { + description = "URL to the Docker Registry" + type = string +} + +variable "webapp_docker_image" { + description = "Docker Image to deploy" + type = string +} + +variable "webapp_docker_image_tag" { + description = "Tag for the Docker Image" + type = string +} + +variable "webapp_session_cookie_name" { + description = "Name of the user session Cookie" + type = string +} + +variable "webapp_health_check_path" { + default = null + description = "Path to health check endpoint" + type = string +} + +variable "webapp_health_check_eviction_time_in_min" { + default = null + description = "Minutes before considering an instance unhealthy" + type = number +} + +variable "webapp_custom_domain_name" { + description = "Custom domain hostname" + type = string +} + +variable "webapp_custom_domain_cert_secret_label" { + description = "Label for the Certificate" + type = string +} + +variable "webapp_startup_command" { + default = null + description = "Startup command to pass into the Web Application" + type = string +} + +variable "agw_subnet_id" { + description = "ID of the Subnet for the App Gateway" + type = string +} + +variable "agw_pip_id" { + description = "ID of the Public IP address for the App Gateway" + type = string +} + +variable "kv_id" { + description = "ID of the Key Vault" + type = string +} + +variable "kv_cert_secret_id" { + description = "SSL certificate Secret ID" + type = string +} + +variable "kv_mi_id" { + description = "ID of the Managed Identity for the Key Vault" + type = string +} \ No newline at end of file diff --git a/terraform/azure-web/web-app.tf b/terraform/azure-web/web-app.tf new file mode 100644 index 00000000..87364c67 --- /dev/null +++ b/terraform/azure-web/web-app.tf @@ -0,0 +1,355 @@ +# Create App Service Plan +resource "azurerm_service_plan" "asp" { + name = "${var.resource_name_prefix}-asp" + location = var.location + resource_group_name = var.resource_group + os_type = "Linux" + sku_name = var.asp_sku + worker_count = var.webapp_worker_count + + lifecycle { + ignore_changes = [tags] + } + + #checkov:skip=CKV_AZURE_212:Argument not available + #checkov:skip=CKV_AZURE_225:Ensure the App Service Plan is zone redundant +} + +# Create Web Application +resource "azurerm_linux_web_app" "webapp" { + name = var.webapp_name + location = var.location + resource_group_name = var.resource_group + service_plan_id = azurerm_service_plan.asp.id + https_only = true + virtual_network_subnet_id = var.webapp_subnet_id + app_settings = var.webapp_app_settings + + site_config { + app_command_line = var.webapp_startup_command + health_check_path = var.webapp_health_check_path + health_check_eviction_time_in_min = var.webapp_health_check_eviction_time_in_min + http2_enabled = true + vnet_route_all_enabled = true + + application_stack { + docker_image_name = "${var.webapp_docker_image}:${var.webapp_docker_image_tag}" + docker_registry_url = var.webapp_docker_registry_url + } + + dynamic "ip_restriction" { + # Deploy App Gateway rules only to the Test and Production subscription + for_each = var.environment != "development" ? [1] : [] + content { + name = "Allow app gateway" + action = "Allow" + priority = 300 + virtual_network_subnet_id = var.agw_subnet_id + } + } + + dynamic "ip_restriction" { + # Deploy App Gateway rules only to the Test and Production subscription + for_each = var.environment != "development" ? [1] : [] + content { + name = "Deny public" + action = "Deny" + priority = 500 + ip_address = "0.0.0.0/0" + } + } + } + + sticky_settings { + app_setting_names = keys(var.webapp_app_settings) + } + + logs { + detailed_error_messages = true + failed_request_tracing = true + + application_logs { + file_system_level = "Warning" + } + + http_logs { + file_system { + retention_in_days = 1 + retention_in_mb = 25 + } + } + } + + lifecycle { + ignore_changes = [tags, site_config.0.application_stack] + } + + #checkov:skip=CKV_AZURE_13:App uses built-in authentication + #checkov:skip=CKV_AZURE_88:Using Docker + #checkov:skip=CKV_AZURE_17:Argument not available + #checkov:skip=CKV_AZURE_78:Disabled by default in Terraform version used + #checkov:skip=CKV_AZURE_16:Using VNET Integration + #checkov:skip=CKV_AZURE_71:Using VNET Integration + #checkov:skip=CKV_AZURE_222:Network access rules configured +} + +# Create Web Application Deployment Slot +resource "azurerm_linux_web_app_slot" "webapp_slot" { + name = "green" + app_service_id = azurerm_linux_web_app.webapp.id + https_only = true + virtual_network_subnet_id = var.webapp_subnet_id + app_settings = var.webapp_slot_app_settings + + site_config { + app_command_line = var.webapp_startup_command + health_check_path = var.webapp_health_check_path + health_check_eviction_time_in_min = var.webapp_health_check_eviction_time_in_min + http2_enabled = true + vnet_route_all_enabled = true + + application_stack { + docker_image_name = "${var.webapp_docker_image}:${var.webapp_docker_image_tag}" + docker_registry_url = var.webapp_docker_registry_url + } + } + + logs { + detailed_error_messages = true + failed_request_tracing = true + + application_logs { + file_system_level = "Warning" + } + + http_logs { + file_system { + retention_in_days = 1 + retention_in_mb = 25 + } + } + } + + lifecycle { + ignore_changes = [tags, site_config.0.application_stack] + } +} + +# Create Log Analytics +resource "azurerm_log_analytics_workspace" "webapp_logs" { + name = "${var.resource_name_prefix}-log" + location = var.location + resource_group_name = var.resource_group + sku = "PerGB2018" + retention_in_days = 30 + daily_quota_gb = 1 + + lifecycle { + ignore_changes = [tags] + } +} + +resource "azurerm_monitor_diagnostic_setting" "webapp_logs_monitor" { + name = "${var.resource_name_prefix}-webapp-mon" + target_resource_id = azurerm_linux_web_app.webapp.id + log_analytics_workspace_id = azurerm_log_analytics_workspace.webapp_logs.id + + enabled_log { + category = "AppServiceConsoleLogs" + } + + enabled_log { + category = "AppServicePlatformLogs" + } + + timeouts { + read = "30m" + } + + lifecycle { + ignore_changes = [metric] + } +} + +resource "azurerm_monitor_diagnostic_setting" "webapp_slot_logs_monitor" { + name = "${var.resource_name_prefix}-webapp-green-mon" + target_resource_id = azurerm_linux_web_app_slot.webapp_slot.id + log_analytics_workspace_id = azurerm_log_analytics_workspace.webapp_logs.id + + enabled_log { + category = "AppServiceConsoleLogs" + } + + enabled_log { + category = "AppServicePlatformLogs" + } + + timeouts { + read = "30m" + } + + lifecycle { + ignore_changes = [metric] + } +} + +# Configure Web App Autoscaling +resource "azurerm_monitor_autoscale_setting" "asp_as" { + # Autoscaling rules only deployed to the Test and Production subscription + count = var.environment != "development" ? 1 : 0 + + name = "${var.resource_name_prefix}-asp-as" + location = var.location + resource_group_name = var.resource_group + target_resource_id = azurerm_service_plan.asp.id + + profile { + name = "Autoscaling conditions" + + capacity { + default = 2 + minimum = 2 + maximum = 5 + } + + rule { + metric_trigger { + metric_name = "CpuPercentage" + metric_namespace = "microsoft.web/serverfarms" + metric_resource_id = azurerm_service_plan.asp.id + statistic = "Average" + operator = "GreaterThan" + threshold = 75 + time_aggregation = "Average" + time_grain = "PT1M" + time_window = "PT10M" + } + + scale_action { + direction = "Increase" + type = "ChangeCount" + value = "1" + cooldown = "PT5M" + } + } + + rule { + metric_trigger { + metric_name = "CpuPercentage" + metric_namespace = "microsoft.web/serverfarms" + metric_resource_id = azurerm_service_plan.asp.id + statistic = "Average" + operator = "LessThan" + threshold = 20 + time_aggregation = "Average" + time_grain = "PT1M" + time_window = "PT10M" + } + + scale_action { + direction = "Decrease" + type = "ChangeCount" + value = "1" + cooldown = "PT5M" + } + } + + rule { + metric_trigger { + metric_name = "MemoryPercentage" + metric_namespace = "microsoft.web/serverfarms" + metric_resource_id = azurerm_service_plan.asp.id + statistic = "Average" + operator = "GreaterThan" + threshold = 80 + time_aggregation = "Average" + time_grain = "PT1M" + time_window = "PT10M" + } + + scale_action { + direction = "Increase" + type = "ChangeCount" + value = "1" + cooldown = "PT5M" + } + } + + rule { + metric_trigger { + metric_name = "MemoryPercentage" + metric_namespace = "microsoft.web/serverfarms" + metric_resource_id = azurerm_service_plan.asp.id + statistic = "Average" + operator = "LessThan" + threshold = 65 + time_aggregation = "Average" + time_grain = "PT1M" + time_window = "PT10M" + } + + scale_action { + direction = "Decrease" + type = "ChangeCount" + value = "1" + cooldown = "PT5M" + } + } + } + + notification { + email { + send_to_subscription_administrator = true + send_to_subscription_co_administrator = true + custom_emails = [var.webapp_admin_email_address] + } + } + + lifecycle { + ignore_changes = [tags] + } +} + +# Create Custom Domain Name +resource "azurerm_app_service_custom_hostname_binding" "webapp_custom_domain" { + # Custom hostname only deployed to the Test and Production subscription + count = var.environment != "development" ? 1 : 0 + + resource_group_name = var.resource_group + hostname = var.webapp_custom_domain_name + app_service_name = azurerm_linux_web_app.webapp.name + + lifecycle { + ignore_changes = [ssl_state, thumbprint] + } +} + +data "azurerm_client_config" "az_config" {} + +resource "azurerm_key_vault_access_policy" "webapp_kv_ap" { + key_vault_id = var.kv_id + tenant_id = data.azurerm_client_config.az_config.tenant_id + # Can be retrieved using 'az ad sp show --id abfa0a7c-a6b6-4736-8310-5855508787cd --query id' + object_id = var.as_service_principal_object_id + secret_permissions = ["Get", "List"] + certificate_permissions = ["Get"] +} + +resource "azurerm_app_service_certificate" "webapp_custom_domain_cert" { + # Custom hostname only deployed to the Test and Production subscription + count = var.environment != "development" ? 1 : 0 + + name = var.webapp_custom_domain_cert_secret_label + resource_group_name = var.resource_group + location = var.location + key_vault_secret_id = var.kv_cert_secret_id +} + +resource "azurerm_app_service_certificate_binding" "webapp_custom_domain_cert_bind" { + # Custom hostname only deployed to the Test and Production subscription + count = var.environment != "development" ? 1 : 0 + + hostname_binding_id = azurerm_app_service_custom_hostname_binding.webapp_custom_domain[0].id + certificate_id = azurerm_app_service_certificate.webapp_custom_domain_cert[0].id + ssl_state = "SniEnabled" +} \ No newline at end of file diff --git a/terraform/local.tf b/terraform/local.tf new file mode 100644 index 00000000..5fee12e1 --- /dev/null +++ b/terraform/local.tf @@ -0,0 +1,30 @@ +locals { + # Common tags to be assigned to resources + common_tags = { + "Environment" = var.environment + "Parent Business" = "Children’s Care" + "Product" = "Early Years Qualifications" + "Service Offering" = "Early Years Qualifications" + } + + # Web Application Configuration + webapp_app_settings = { + "ENVIRONMENT" = var.environment + "WEBSITES_ENABLE_APP_SERVICE_STORAGE" = "false" + "BOT_TOKEN" = var.webapp_config_bot_token + "DOMAIN" = var.webapp_config_domain + "FEEDBACK_URL" = var.webapp_config_feedback_url + "TRACKING_ID" = var.tracking_id + "WEBSITES_CONTAINER_START_TIME_LIMIT" = 720 + } + + webapp_slot_app_settings = { + "ENVIRONMENT" = var.environment + "WEBSITES_ENABLE_APP_SERVICE_STORAGE" = "false" + "BOT_TOKEN" = var.webapp_config_bot_token + "DOMAIN" = var.webapp_config_domain + "FEEDBACK_URL" = var.webapp_config_feedback_url + "TRACKING_ID" = var.tracking_id + "WEBSITES_CONTAINER_START_TIME_LIMIT" = 720 + } +} \ No newline at end of file diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 00000000..caacc457 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,78 @@ +provider "azurerm" { + skip_provider_registration = "true" + + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + + key_vault { + purge_soft_delete_on_destroy = true + recover_soft_deleted_key_vaults = true + } + } +} + +# Create Resource Group +resource "azurerm_resource_group" "rg" { + name = "${var.resource_name_prefix}-rg" + location = var.azure_region + + tags = local.common_tags + + lifecycle { + ignore_changes = [tags] + } +} + +# Create Network Resources +module "network" { + source = "./azure-network" + + environment = var.environment + location = var.azure_region + resource_group = azurerm_resource_group.rg.name + resource_name_prefix = var.resource_name_prefix + kv_certificate_authority_label = "GlobalSignCA" + kv_certificate_authority_name = "GlobalSign" + kv_certificate_authority_username = var.kv_certificate_authority_username + kv_certificate_authority_password = var.kv_certificate_authority_password + kv_certificate_authority_admin_email = var.admin_email_address + kv_certificate_authority_admin_first_name = var.kv_certificate_authority_admin_first_name + kv_certificate_authority_admin_last_name = var.kv_certificate_authority_admin_last_name + kv_certificate_authority_admin_phone_no = var.kv_certificate_authority_admin_phone_no + kv_certificate_label = var.kv_certificate_label + kv_certificate_subject = var.kv_certificate_subject +} + +# Create Web Application resources +module "webapp" { + source = "./azure-web" + + environment = var.environment + location = var.azure_region + resource_group = azurerm_resource_group.rg.name + resource_name_prefix = var.resource_name_prefix + as_service_principal_object_id = var.as_service_principal_object_id + asp_sku = var.asp_sku + webapp_admin_email_address = var.admin_email_address + webapp_worker_count = var.webapp_worker_count + webapp_subnet_id = module.network.webapp_subnet_id + webapp_name = var.webapp_name + webapp_app_settings = local.webapp_app_settings + webapp_slot_app_settings = local.webapp_slot_app_settings + webapp_docker_image = var.webapp_docker_image + webapp_docker_image_tag = var.webapp_docker_image_tag + webapp_docker_registry_url = var.webapp_docker_registry_url + webapp_session_cookie_name = "_early_years_qualification_session" + webapp_custom_domain_name = var.custom_domain_name + webapp_custom_domain_cert_secret_label = var.kv_certificate_label + webapp_health_check_path = "/health" + webapp_health_check_eviction_time_in_min = 10 + agw_subnet_id = module.network.agw_subnet_id + agw_pip_id = module.network.agw_pip_id + kv_id = module.network.kv_id + kv_cert_secret_id = module.network.kv_cert_secret_id + kv_mi_id = module.network.kv_mi_id + depends_on = [module.network] +} \ No newline at end of file diff --git a/terraform/terraform.tf b/terraform/terraform.tf new file mode 100644 index 00000000..0cf6c8e7 --- /dev/null +++ b/terraform/terraform.tf @@ -0,0 +1,15 @@ +# Configure the Azure provider +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "= 3.71.0" + } + } + + required_version = ">= 1.5.0" + + backend "azurerm" { + use_oidc = true + } +} \ No newline at end of file diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 00000000..7671e30a --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,124 @@ +variable "azure_region" { + default = "westeurope" + description = "Name of the Azure region to deploy resources" + type = string +} + +variable "environment" { + default = "development" + description = "Environment to deploy resources" + type = string +} + +variable "resource_name_prefix" { + description = "Prefix for resource names" + type = string +} + +variable "admin_email_address" { + description = "Email Address of the Admin" + type = string + sensitive = true +} + +variable "kv_certificate_authority_username" { + description = "Username for the Certificate provider" + type = string + sensitive = true +} + +variable "kv_certificate_authority_password" { + description = "Password the Certificate provider" + type = string + sensitive = true +} + +variable "kv_certificate_authority_admin_first_name" { + description = "First Name of the Certificate Authority Admin" + type = string + sensitive = true +} + +variable "kv_certificate_authority_admin_last_name" { + description = "Last Name of the Certificate Authority Admin" + type = string + sensitive = true +} + +variable "kv_certificate_authority_admin_phone_no" { + description = "Phone No. of the Certificate Authority Admin" + type = string + sensitive = true +} + +variable "kv_certificate_label" { + description = "Label for the Certificate" + type = string +} + +variable "kv_certificate_subject" { + description = "Subject of the Certificate" + type = string +} + +variable "as_service_principal_object_id" { + description = "Object ID of the service principal for App Service" + type = string + sensitive = true +} + +variable "asp_sku" { + default = "S1" + description = "SKU name for the App Service Plan" + type = string +} + +variable "webapp_worker_count" { + default = 1 + description = "Number of Workers for the App Service Plan" + type = string +} + +variable "webapp_name" { + description = "Name for the Web Application" + type = string +} + +variable "webapp_docker_registry_url" { + description = "URL to the Docker Registry" + type = string +} + +variable "webapp_docker_image" { + description = "Docker Image to deploy" + type = string +} + +variable "webapp_docker_image_tag" { + default = "latest" + description = "Tag for the Docker Image" + type = string +} + +variable "custom_domain_name" { + description = "Custom domain hostname" + type = string +} + +variable "tracking_id" { + description = "Google Tag Manager tracking ID" + type = string +} + +variable "webapp_config_bot_token" { + type = string + sensitive = true +} + +variable "webapp_config_domain" { + type = string +} + +variable "webapp_config_feedback_url" { + type = string +} \ No newline at end of file