diff --git a/.github/workflows/terraform-apply.yml b/.github/workflows/terraform-apply.yml new file mode 100644 index 0000000..82a69e6 --- /dev/null +++ b/.github/workflows/terraform-apply.yml @@ -0,0 +1,57 @@ +name: Terraform Apply + +on: + push: + branches: [main] + paths: + - "Iac/**" + +# Required for OIDC federated identity authentication — no client secrets used +permissions: + id-token: write + contents: read + +env: + TF_WORKING_DIR: Iac + +jobs: + terraform: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # ── Authenticate to Azure via OIDC ──────────────────────────────────── + - name: Azure Login (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + # ── Set up Terraform ────────────────────────────────────────────────── + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "~1.6" + + # ── Init — connect to azurerm backend in dbstfstate01 ───────────────── + - name: Terraform Init + working-directory: ${{ env.TF_WORKING_DIR }} + run: terraform init + + # ── Validate ────────────────────────────────────────────────────────── + - name: Terraform Validate + working-directory: ${{ env.TF_WORKING_DIR }} + run: terraform validate + + # ── Apply ───────────────────────────────────────────────────────────── + # Sensitive variables are passed as TF_VAR_* environment variables so + # they never appear in the plan output or logs. + - name: Terraform Apply + working-directory: ${{ env.TF_WORKING_DIR }} + env: + TF_VAR_eventhub_connection_string: ${{ secrets.EVENTHUB_CONNECTION_STRING }} + TF_VAR_bot_api_sql_connection_string: ${{ secrets.BOT_API_SQL_CONNECTION_STRING }} + run: terraform apply -auto-approve diff --git a/Iac/backend.tf b/Iac/backend.tf new file mode 100644 index 0000000..e485cb8 --- /dev/null +++ b/Iac/backend.tf @@ -0,0 +1,8 @@ +terraform { + backend "azurerm" { + resource_group_name = "ewu-deliverybotsystem-rg" + storage_account_name = "dbstfstate01" + container_name = "tfstate" + key = "deliverybotsystem.tfstate" + } +} diff --git a/Iac/bootstrap/main.tf b/Iac/bootstrap/main.tf new file mode 100644 index 0000000..c95257f --- /dev/null +++ b/Iac/bootstrap/main.tf @@ -0,0 +1,35 @@ +# Bootstrap — creates the Azure Storage Account used as the Terraform remote backend +# for all other configurations in this project. +# +# Usage (run once per environment): +# cd Iac/bootstrap +# terraform init +# terraform apply +# +# The resource group must already exist before running this. + +data "azurerm_resource_group" "rg" { + name = var.resource_group_name +} + +# ── State Storage Account ────────────────────────────────────────────────────── +resource "azurerm_storage_account" "tfstate" { + name = "dbstfstate01" + resource_group_name = data.azurerm_resource_group.rg.name + location = var.location + account_tier = "Standard" + account_replication_type = "LRS" + + allow_nested_items_to_be_public = false + + blob_properties { + versioning_enabled = true + } +} + +# ── Blob Container ───────────────────────────────────────────────────────────── +resource "azurerm_storage_container" "tfstate" { + name = "tfstate" + storage_account_id = azurerm_storage_account.tfstate.id + container_access_type = "private" +} diff --git a/Iac/bootstrap/outputs.tf b/Iac/bootstrap/outputs.tf new file mode 100644 index 0000000..1c580c6 --- /dev/null +++ b/Iac/bootstrap/outputs.tf @@ -0,0 +1,14 @@ +output "storage_account_name" { + description = "Name of the Terraform state storage account." + value = azurerm_storage_account.tfstate.name +} + +output "container_name" { + description = "Blob container that holds .tfstate files." + value = azurerm_storage_container.tfstate.name +} + +output "resource_group_name" { + description = "Resource group containing the state storage account." + value = data.azurerm_resource_group.rg.name +} diff --git a/Iac/bootstrap/providers.tf b/Iac/bootstrap/providers.tf new file mode 100644 index 0000000..ce668f8 --- /dev/null +++ b/Iac/bootstrap/providers.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">= 1.6" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } + + # Bootstrap uses local state — it creates the remote backend used by everything else. + # Do NOT add a remote backend block here. +} + +provider "azurerm" { + features {} + subscription_id = var.subscription_id +} diff --git a/Iac/bootstrap/variables.tf b/Iac/bootstrap/variables.tf new file mode 100644 index 0000000..8aa7dcd --- /dev/null +++ b/Iac/bootstrap/variables.tf @@ -0,0 +1,17 @@ +variable "subscription_id" { + description = "Azure subscription ID." + type = string + default = "a06983f7-7384-4a09-a092-b13a3896be85" +} + +variable "resource_group_name" { + description = "Resource group that holds the tfstate storage account (must already exist)." + type = string + default = "ewu-deliverybotsystem-rg" +} + +variable "location" { + description = "Azure region for the state storage account." + type = string + default = "westus2" +} diff --git a/Iac/main.tf b/Iac/main.tf new file mode 100644 index 0000000..21da3c2 --- /dev/null +++ b/Iac/main.tf @@ -0,0 +1,332 @@ +# ── Resource Group ───────────────────────────────────────────────────────────── + +resource "azurerm_resource_group" "rg" { + name = var.resource_group_name + location = var.location +} + +# ── Azure Container Registry ─────────────────────────────────────────────────── + +resource "azurerm_container_registry" "acr" { + name = "DeliverybotCR" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + sku = "Standard" + admin_enabled = true +} + +# ── Log Analytics Workspace ──────────────────────────────────────────────────── + +resource "azurerm_log_analytics_workspace" "logs" { + name = "workspaceewudeliverybotsystemrg8609" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + sku = "PerGB2018" + retention_in_days = 30 +} + +# ── Event Hub Namespace ──────────────────────────────────────────────────────── + +resource "azurerm_eventhub_namespace" "simulator" { + name = "DeliverybotSimulator-EVHNS" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + sku = "Standard" + capacity = 1 + zone_redundant = true +} + +resource "azurerm_eventhub" "robot_input" { + name = "robot-input" + namespace_id = azurerm_eventhub_namespace.simulator.id + partition_count = 1 + message_retention = 1 +} + +resource "azurerm_eventhub" "robot_output" { + name = "robot-output" + namespace_id = azurerm_eventhub_namespace.simulator.id + partition_count = 2 + message_retention = 1 +} + +# ── Container Apps Managed Environment ──────────────────────────────────────── + +resource "azurerm_container_app_environment" "env" { + name = "managedEnvironment-ewudeliverybots-aa2f" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + log_analytics_workspace_id = azurerm_log_analytics_workspace.logs.id +} + +# ── SQL Server ───────────────────────────────────────────────────────────────── + +resource "azurerm_mssql_server" "sql" { + name = "deliverybotsystem-sql" + resource_group_name = azurerm_resource_group.rg.name + location = var.sql_location + version = "12.0" + + # Azure AD-only authentication — no SQL login allowed. + azuread_administrator { + login_username = var.sql_ad_admin_login + object_id = var.sql_ad_admin_object_id + tenant_id = var.tenant_id + azuread_authentication_only = true + } +} + +# Allow Azure services (e.g. Container Apps) to reach the SQL server. +resource "azurerm_mssql_firewall_rule" "allow_azure_services" { + name = "AllowAzureServices" + server_id = azurerm_mssql_server.sql.id + start_ip_address = "0.0.0.0" + end_ip_address = "0.0.0.0" +} + +# ── SQL Databases ────────────────────────────────────────────────────────────── + +# Serverless — auto-pauses when idle, scales vCores on demand. +resource "azurerm_mssql_database" "botnetapi_db" { + name = "BotNetApiDb" + server_id = azurerm_mssql_server.sql.id + sku_name = "GP_S_Gen5_2" + + max_size_gb = 32 + min_capacity = 0.5 + auto_pause_delay_in_minutes = 60 + zone_redundant = false +} + +# Provisioned General Purpose — always-on for the order service. +resource "azurerm_mssql_database" "order_service_db" { + name = "OrderServiceDb" + server_id = azurerm_mssql_server.sql.id + sku_name = "GP_Gen5_2" + + max_size_gb = 2 +} + +# ── Container App: Bot API ───────────────────────────────────────────────────── + +resource "azurerm_container_app" "bot_api" { + name = "ewu-deliverybotsystem-api" + resource_group_name = azurerm_resource_group.rg.name + container_app_environment_id = azurerm_container_app_environment.env.id + revision_mode = "Single" + + identity { + type = "SystemAssigned" + } + + # Pull images from ACR using admin credentials stored as a secret. + secret { + name = "acr-password" + value = azurerm_container_registry.acr.admin_password + } + + secret { + name = "sql-connection-string" + value = var.bot_api_sql_connection_string + } + + registry { + server = azurerm_container_registry.acr.login_server + username = azurerm_container_registry.acr.admin_username + password_secret_name = "acr-password" + } + + ingress { + external_enabled = true + target_port = 8080 + + traffic_weight { + percentage = 100 + latest_revision = true + } + } + + template { + min_replicas = 0 + max_replicas = 3 + + container { + name = "botnetapi" + image = "${azurerm_container_registry.acr.login_server}/botnetapi:latest" + cpu = 0.5 + memory = "1Gi" + + env { + name = "ASPNETCORE_ENVIRONMENT" + value = "Production" + } + + env { + name = "ConnectionStrings__DefaultConnection" + secret_name = "sql-connection-string" + } + } + } +} + +# ── Container App: Robot Simulator ──────────────────────────────────────────── + +resource "azurerm_container_app" "robot_simulator" { + name = "deliverybot-robot-simulator" + resource_group_name = azurerm_resource_group.rg.name + container_app_environment_id = azurerm_container_app_environment.env.id + revision_mode = "Single" + + secret { + name = "acr-password" + value = azurerm_container_registry.acr.admin_password + } + + secret { + name = "eventhub-connection-string" + value = var.eventhub_connection_string + } + + registry { + server = azurerm_container_registry.acr.login_server + username = azurerm_container_registry.acr.admin_username + password_secret_name = "acr-password" + } + + ingress { + external_enabled = true + target_port = 8080 + + traffic_weight { + percentage = 100 + latest_revision = true + } + } + + template { + min_replicas = 0 + max_replicas = 10 + + container { + name = "robot-simulator" + image = "${azurerm_container_registry.acr.login_server}/deliverybot-robot-simulator:v1" + cpu = 0.5 + memory = "1Gi" + + env { + name = "ASPNETCORE_ENVIRONMENT" + value = "Development" + } + + env { + name = "EventTransport__Mode" + value = "AzureEventHub" + } + + env { + name = "EventTransport__InputEventHubName" + value = azurerm_eventhub.robot_input.name + } + + env { + name = "EventTransport__OutputEventHubName" + value = azurerm_eventhub.robot_output.name + } + + env { + name = "EventTransport__ConsumerGroup" + value = "$Default" + } + + env { + name = "EventTransport__EnableInputConsumer" + value = "true" + } + + env { + name = "EventTransport__ConnectionString" + secret_name = "eventhub-connection-string" + } + + env { + name = "Simulator__InitialBotCount" + value = "3" + } + + env { + name = "Simulator__BotIdPrefix" + value = "bot" + } + + env { + name = "Simulator__DefaultBotModel" + value = "DeliveryBot-V1" + } + + env { + name = "Simulator__DefaultLatitude" + value = "47.65837359646208" + } + + env { + name = "Simulator__DefaultLongitude" + value = "-117.40215401730164" + } + + env { + name = "Simulation__TickIntervalSeconds" + value = "1" + } + + env { + name = "Simulation__TelemetryIntervalSeconds" + value = "5" + } + + env { + name = "Simulation__DeliverySpeedMetersPerSecond" + value = "8" + } + + env { + name = "Simulation__DestinationArrivalThresholdMeters" + value = "5" + } + } + } +} + +# ── Container App: Order Service ─────────────────────────────────────────────── + +resource "azurerm_container_app" "order_service" { + name = "deliverybot-order-service" + resource_group_name = azurerm_resource_group.rg.name + container_app_environment_id = azurerm_container_app_environment.env.id + revision_mode = "Single" + + identity { + type = "SystemAssigned" + } + + ingress { + external_enabled = true + target_port = 8080 + + traffic_weight { + percentage = 100 + latest_revision = true + } + } + + template { + min_replicas = 0 + max_replicas = 10 + + container { + name = "order-service" + image = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest" + cpu = 0.5 + memory = "1Gi" + } + } +} diff --git a/Iac/outputs.tf b/Iac/outputs.tf new file mode 100644 index 0000000..261522a --- /dev/null +++ b/Iac/outputs.tf @@ -0,0 +1,39 @@ +output "bot_api_url" { + description = "Public HTTPS URL of the Bot API Container App." + value = "https://${azurerm_container_app.bot_api.ingress[0].fqdn}" +} + +output "robot_simulator_url" { + description = "Public HTTPS URL of the Robot Simulator Container App." + value = "https://${azurerm_container_app.robot_simulator.ingress[0].fqdn}" +} + +output "order_service_url" { + description = "Public HTTPS URL of the Order Service Container App." + value = "https://${azurerm_container_app.order_service.ingress[0].fqdn}" +} + +output "acr_login_server" { + description = "ACR login server hostname." + value = azurerm_container_registry.acr.login_server +} + +output "sql_server_fqdn" { + description = "Fully-qualified domain name of the SQL server." + value = azurerm_mssql_server.sql.fully_qualified_domain_name +} + +output "eventhub_namespace_fqdn" { + description = "AMQP endpoint of the Event Hub namespace." + value = "${azurerm_eventhub_namespace.simulator.name}.servicebus.windows.net" +} + +output "bot_api_principal_id" { + description = "Managed identity principal ID of the Bot API (use for SQL db_owner role assignment)." + value = azurerm_container_app.bot_api.identity[0].principal_id +} + +output "order_service_principal_id" { + description = "Managed identity principal ID of the Order Service." + value = azurerm_container_app.order_service.identity[0].principal_id +} diff --git a/Iac/providers.tf b/Iac/providers.tf new file mode 100644 index 0000000..95ca204 --- /dev/null +++ b/Iac/providers.tf @@ -0,0 +1,16 @@ +terraform { + required_version = ">= 1.6" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } +} + +provider "azurerm" { + features {} + subscription_id = var.subscription_id + tenant_id = var.tenant_id +} diff --git a/Iac/variables.tf b/Iac/variables.tf new file mode 100644 index 0000000..4bb32f4 --- /dev/null +++ b/Iac/variables.tf @@ -0,0 +1,64 @@ +# ── Identity ─────────────────────────────────────────────────────────────────── + +variable "subscription_id" { + description = "Azure subscription ID." + type = string + default = "a06983f7-7384-4a09-a092-b13a3896be85" +} + +variable "tenant_id" { + description = "Azure Active Directory tenant ID." + type = string + default = "37321907-14a5-4390-987d-ec0c66c655cd" +} + +# ── Locations ───────────────────────────────────────────────────────────────── + +variable "location" { + description = "Primary Azure region for most resources." + type = string + default = "westus2" +} + +variable "sql_location" { + description = "Azure region for the SQL server and databases." + type = string + default = "southeastasia" +} + +# ── Resource Group ───────────────────────────────────────────────────────────── + +variable "resource_group_name" { + description = "Name of the shared resource group." + type = string + default = "ewu-deliverybotsystem-rg" +} + +# ── SQL ──────────────────────────────────────────────────────────────────────── + +variable "sql_ad_admin_login" { + description = "UPN of the Azure AD user set as SQL server administrator." + type = string + default = "wmiller17@ewu.edu" +} + +variable "sql_ad_admin_object_id" { + description = "Object ID of the Azure AD SQL administrator." + type = string + default = "0b83fd03-d44e-4731-8ee0-790b50b715db" +} + +# ── Secrets (sensitive — supply via environment variables or a .tfvars file) ── + +variable "eventhub_connection_string" { + description = "Event Hub namespace connection string used by the robot simulator." + type = string + sensitive = true +} + +variable "bot_api_sql_connection_string" { + description = "SQL connection string injected into the bot API container app." + type = string + sensitive = true + default = "Server=tcp:deliverybotsystem-sql.database.windows.net,1433;Initial Catalog=BotNetApiDb;Authentication=Active Directory Managed Identity;" +}