diff --git a/.github/workflows/AdminWebpage-Deploy-WF.yml b/.github/workflows/AdminWebpage-Deploy-WF.yml index 90981a9..700b8e9 100644 --- a/.github/workflows/AdminWebpage-Deploy-WF.yml +++ b/.github/workflows/AdminWebpage-Deploy-WF.yml @@ -1,8 +1,8 @@ name: AdminWebpage-Deploy-WF -# Provisions the Admin Web App App Service via Terraform, then builds and -# deploys the React SPA to it. Auth uses the same OIDC federated identity -# Phil configured for the BotNet API workflow, so no new secrets are needed. +# Builds and deploys the Admin Web App React SPA to Azure App Service. +# Infrastructure provisioning (App Service, etc.) is handled separately by +# .github/workflows/iac.yml — this workflow only builds and deploys the app. on: workflow_dispatch: @@ -10,13 +10,11 @@ on: branches: [main] paths: - "admin-webapp/**" - - "Iac/admin-webapp/**" - ".github/workflows/AdminWebpage-Deploy-WF.yml" push: branches: [main] paths: - "admin-webapp/**" - - "Iac/admin-webapp/**" - ".github/workflows/AdminWebpage-Deploy-WF.yml" permissions: @@ -26,13 +24,11 @@ permissions: env: RESOURCE_GROUP: ewu-deliverybotsystem-rg APP_SERVICE_NAME: WA-DeliveryBot-Admin-dev - TFSTATE_STORAGE_ACCOUNT: dbstfstate01 - TFSTATE_CONTAINER: tfstate BOTNET_API_URL: https://ewu-deliverybotsystem-api.mangocoast-332176b0.westus2.azurecontainerapps.io SIMULATOR_API_URL: https://deliverybot-robot-simulator.mangocoast-332176b0.westus2.azurecontainerapps.io jobs: - provision-and-deploy: + build-and-deploy: runs-on: ubuntu-latest steps: @@ -47,43 +43,7 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - # ── 2. Ensure the Terraform state container exists ──────────────────── - # `az storage container create` is idempotent; safe to run every time. - - name: Ensure TF state container exists - run: | - az storage container create \ - --name "$TFSTATE_CONTAINER" \ - --account-name "$TFSTATE_STORAGE_ACCOUNT" \ - --auth-mode login \ - --only-show-errors - - # ── 3. Provision App Service via Terraform ──────────────────────────── - - name: Setup Terraform - uses: hashicorp/setup-terraform@v3 - with: - terraform_version: "1.9.5" - - - name: Terraform Init - working-directory: ./Iac/admin-webapp - env: - ARM_USE_OIDC: "true" - ARM_USE_AZUREAD: "true" - ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - run: terraform init -input=false - - - name: Terraform Apply - working-directory: ./Iac/admin-webapp - env: - ARM_USE_OIDC: "true" - ARM_USE_AZUREAD: "true" - ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - run: terraform apply -input=false -auto-approve - - # ── 4. Build the SPA with upstream URLs baked in ────────────────────── + # ── 2. Build the SPA with upstream URLs baked in ────────────────────── - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -106,7 +66,7 @@ jobs: VITE_SIMULATOR_API_URL: ${{ env.SIMULATOR_API_URL }} run: npm run build - # ── 5. Deploy the build to the App Service ──────────────────────────── + # ── 3. Deploy the build to the App Service ───────────────────────────── - name: Deploy to Azure App Service uses: azure/webapps-deploy@v3 with: diff --git a/.github/workflows/iac.yml b/.github/workflows/iac.yml new file mode 100644 index 0000000..d4ea71c --- /dev/null +++ b/.github/workflows/iac.yml @@ -0,0 +1,123 @@ +name: Infrastructure — Apply All Services + +# Single IaC workflow for the entire DeliveryBot platform. +# Implements the layered module pattern: Iac/main.tf composes all per-service +# modules (shared-infra, admin-webapp, order-service, bot-api, frontend, +# simulator) into one unified plan + apply. +# +# Strategy: +# - pull_request → plan only (lets reviewers see the diff before merge) +# - push to main → plan + apply +# +# Auth: OIDC federated identity — no client secrets stored in GitHub. +# State: single state file "deliverybot.tfstate" in dbstfstate01/tfstate. + +on: + push: + branches: [main] + paths: + - "Iac/**" + - ".github/workflows/iac.yml" + pull_request: + branches: [main] + paths: + - "Iac/**" + - ".github/workflows/iac.yml" + workflow_dispatch: + +permissions: + id-token: write + contents: read + +env: + TFSTATE_STORAGE_ACCOUNT: dbstfstate01 + TFSTATE_CONTAINER: tfstate + +jobs: + terraform: + name: Terraform Plan / Apply + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./Iac + env: + ARM_USE_OIDC: "true" + ARM_USE_AZUREAD: "true" + ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + # ── Sensitive variable injection ───────────────────────────────────── + # Connection strings and secrets are injected as TF_VAR_* so they never + # appear in .tf files or terraform.tfvars. + + # Order Service — uses Managed Identity; the string contains no password. + TF_VAR_order_service_sql_connection_string: "Server=tcp:jacob-orderservice-sql2.database.windows.net,1433;Initial Catalog=OrderServiceDb;Authentication=Active Directory Managed Identity;" + + # Bot API — uses Managed Identity; the string contains no password. + TF_VAR_bot_api_sql_connection_string: "Server=tcp:deliverybotsystem-sql.database.windows.net,1433;Initial Catalog=BotNetApiDb;Authentication=Active Directory Managed Identity;" + + # Event Hub — shared by Order Service and Robot Simulator. + TF_VAR_eventhub_connection_string: ${{ secrets.AZURE_EVENTHUB_CONNECTION_STRING }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - 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 }} + + # Idempotent — creates the container if it doesn't already exist. + - name: Ensure TF state container exists + run: | + az storage container create \ + --name "$TFSTATE_CONTAINER" \ + --account-name "$TFSTATE_STORAGE_ACCOUNT" \ + --auth-mode login \ + --only-show-errors + + # ── One-time state decommission ─────────────────────────────────────── + # This repo previously used six isolated per-service Terraform stacks, + # each writing to its own *.tfstate blob. Those blobs are now orphaned + # (the unified root uses deliverybot.tfstate). Delete them so no one + # accidentally runs terraform against stale state. + # + # `az storage blob delete` is a no-op when the blob doesn't exist, so + # this step is safe to leave in permanently. + - name: Remove orphaned per-service state blobs + run: | + for key in \ + admin-webapp.tfstate \ + order-service.tfstate \ + bot-api.tfstate \ + frontend.tfstate \ + shared-infra.tfstate \ + simulator.tfstate; do + az storage blob delete \ + --container-name "$TFSTATE_CONTAINER" \ + --name "$key" \ + --account-name "$TFSTATE_STORAGE_ACCOUNT" \ + --auth-mode login \ + --only-show-errors \ + 2>/dev/null || true + done + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "1.9.5" + + - name: Terraform Init + run: terraform init -input=false + + - name: Terraform Plan + run: terraform plan -input=false -out=tfplan + + # Apply only on merge to main — PRs stop at plan for review. + - name: Terraform Apply + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: terraform apply -input=false tfplan diff --git a/.github/workflows/orderservice-iac.yml b/.github/workflows/orderservice-iac.yml deleted file mode 100644 index 61096ad..0000000 --- a/.github/workflows/orderservice-iac.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Order Service - Infrastructure - -# Provisions the Order Service Container App with Terraform. Runs `plan` on PRs -# (for review) and `apply` only on merge to main. Auth uses the OIDC federated -# identity Phil configured — no client secrets stored. - -on: - push: - branches: [main] - paths: - - "Iac/order-service/**" - - ".github/workflows/orderservice-iac.yml" - pull_request: - branches: [main] - paths: - - "Iac/order-service/**" - - ".github/workflows/orderservice-iac.yml" - workflow_dispatch: - -permissions: - id-token: write - contents: read - -env: - TFSTATE_STORAGE_ACCOUNT: dbstfstate01 - TFSTATE_CONTAINER: tfstate - -jobs: - terraform: - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./Iac/order-service - env: - ARM_USE_OIDC: "true" - ARM_USE_AZUREAD: "true" - ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - TF_VAR_sql_connection_string: "Server=tcp:jacob-orderservice-sql2.database.windows.net,1433;Initial Catalog=OrderServiceDb;Authentication=Active Directory Managed Identity;" - TF_VAR_eventhub_connection_string: ${{ secrets.AZURE_EVENTHUB_CONNECTION_STRING }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - 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 }} - - # Idempotent — safe to run every time. - - name: Ensure TF state container exists - run: | - az storage container create \ - --name "$TFSTATE_CONTAINER" \ - --account-name "$TFSTATE_STORAGE_ACCOUNT" \ - --auth-mode login \ - --only-show-errors - - - name: Setup Terraform - uses: hashicorp/setup-terraform@v3 - with: - terraform_version: "1.9.5" - - - name: Terraform Init - run: terraform init -input=false - - - name: Terraform Plan - run: terraform plan -input=false -out=tfplan - - # Apply only on merge to main — PRs stop at plan for review. - - name: Terraform Apply - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: terraform apply -input=false tfplan diff --git a/Iac/admin-webapp/main.tf b/Iac/admin-webapp/main.tf index 303fc08..a10bad4 100644 --- a/Iac/admin-webapp/main.tf +++ b/Iac/admin-webapp/main.tf @@ -14,11 +14,3 @@ module "admin_webapp" { simulator_api_url = var.simulator_api_url tags = var.tags } - -# The App Service was originally declared at the root before the module -# refactor. Tell Terraform it simply moved addresses so the existing live -# resource is preserved instead of destroyed and recreated. -moved { - from = azurerm_linux_web_app.admin - to = module.admin_webapp.azurerm_linux_web_app.admin -} diff --git a/Iac/admin-webapp/providers.tf b/Iac/admin-webapp/providers.tf deleted file mode 100644 index 0d07e86..0000000 --- a/Iac/admin-webapp/providers.tf +++ /dev/null @@ -1,37 +0,0 @@ -# Provider + state backend for the Admin Web App App Service. -# -# Auth: assumed to be set by the surrounding GitHub Actions workflow via -# `azure/login@v2` (OIDC) and the ARM_USE_OIDC / ARM_USE_AZUREAD env vars. -# State: lives in the team's pre-existing storage account `dbstfstate01` -# under a unique key so we don't collide with Bill's root-level Iac (#74). - -terraform { - required_version = ">= 1.6.0" - - required_providers { - azurerm = { - source = "hashicorp/azurerm" - version = "~> 4.0" - } - } - - backend "azurerm" { - resource_group_name = "ewu-deliverybotsystem-rg" - storage_account_name = "dbstfstate01" - container_name = "tfstate" - key = "admin-webapp.tfstate" - use_oidc = true - use_azuread_auth = true - } -} - -provider "azurerm" { - features {} - use_oidc = true - - # The CI service principal has scoped roles (RG Contributor + Blob Data - # Contributor) but no subscription-level resource-provider registration - # rights. Microsoft.Web is already registered for the subscription, so skip - # the provider's default auto-registration to avoid a 403 on apply. - resource_provider_registrations = "none" -} diff --git a/Iac/backend.tf b/Iac/backend.tf new file mode 100644 index 0000000..a152909 --- /dev/null +++ b/Iac/backend.tf @@ -0,0 +1,23 @@ +# Terraform remote state backend. +# +# State is stored in the pre-existing Azure Blob Storage account: +# Storage account : dbstfstate01 +# Resource group : ewu-deliverybotsystem-rg +# Container : tfstate +# Blob key : deliverybot.tfstate +# +# Auth uses OIDC + Azure AD (no SAS tokens or storage keys). +# The storage account and container were verified via: +# az storage account show --name dbstfstate01 +# az storage container list --account-name dbstfstate01 --auth-mode login + +terraform { + backend "azurerm" { + resource_group_name = "ewu-deliverybotsystem-rg" + storage_account_name = "dbstfstate01" + container_name = "tfstate" + key = "deliverybot.tfstate" + use_oidc = true + use_azuread_auth = true + } +} diff --git a/Iac/bot-api/main.tf b/Iac/bot-api/main.tf new file mode 100644 index 0000000..1b02c95 --- /dev/null +++ b/Iac/bot-api/main.tf @@ -0,0 +1,76 @@ +# Bot API infrastructure. +# +# Reuses the team's shared resource group, Container App Environment, ACR, and +# SQL server (all managed by Iac/shared-infra). This stack owns the Bot API +# Container App and its SQL database. + +data "azurerm_resource_group" "rg" { + name = var.resource_group_name +} + +data "azurerm_container_app_environment" "env" { + name = var.container_app_environment_name + resource_group_name = data.azurerm_resource_group.rg.name +} + +data "azurerm_container_registry" "acr" { + name = var.acr_name + resource_group_name = data.azurerm_resource_group.rg.name +} + +data "azurerm_mssql_server" "sql" { + name = var.sql_server_name + resource_group_name = data.azurerm_resource_group.rg.name +} + +# ── SQL Database ───────────────────────────────────────────────────────────── +# +# Serverless General Purpose — auto-pauses when idle, scales vCores on demand. +# The Container App's managed identity is granted db_owner out-of-band via the +# deploy workflow (EF Core migrate runs on startup using Managed Identity auth). + +resource "azurerm_mssql_database" "botnetapi_db" { + name = "BotNetApiDb" + server_id = data.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 +} + +# ── Container App ───────────────────────────────────────────────────────────── + +module "bot_api_app" { + source = "./modules/container-app" + + name = var.container_app_name + resource_group_name = data.azurerm_resource_group.rg.name + container_app_environment_id = data.azurerm_container_app_environment.env.id + + acr_login_server = data.azurerm_container_registry.acr.login_server + acr_username = data.azurerm_container_registry.acr.admin_username + acr_password = data.azurerm_container_registry.acr.admin_password + + container_name = "botnetapi" + image = "${data.azurerm_container_registry.acr.login_server}/${var.image_name}:latest" + target_port = 8080 + + secrets = { + "sql-connection-string" = var.sql_connection_string + } + + env_vars = { + "ASPNETCORE_ENVIRONMENT" = "Production" + } + + secret_env_vars = { + "ConnectionStrings__DefaultConnection" = "sql-connection-string" + } + + min_replicas = 0 + max_replicas = 3 + + tags = var.tags +} diff --git a/Iac/bot-api/modules/container-app/main.tf b/Iac/bot-api/modules/container-app/main.tf new file mode 100644 index 0000000..3c9070f --- /dev/null +++ b/Iac/bot-api/modules/container-app/main.tf @@ -0,0 +1,88 @@ +# Reusable Azure Container App module. +# +# Encapsulates the team's standard Container App shape: a system-assigned +# identity, ACR pull via an admin-password secret, external ingress on port +# 8080, and a single container with configurable plain + secret-backed env vars. + +resource "azurerm_container_app" "this" { + name = var.name + resource_group_name = var.resource_group_name + container_app_environment_id = var.container_app_environment_id + revision_mode = "Single" + tags = var.tags + + identity { + type = "SystemAssigned" + } + + # ACR pull credential (admin user), stored as a secret. + secret { + name = "acr-password" + value = var.acr_password + } + + # Caller-supplied secrets (e.g. SQL connection strings). + # Iterate the non-sensitive secret names to avoid using a sensitive map + # directly as a for_each argument (Terraform rejects that). + dynamic "secret" { + for_each = nonsensitive(toset(keys(var.secrets))) + content { + name = secret.value + value = var.secrets[secret.value] + } + } + + registry { + server = var.acr_login_server + username = var.acr_username + password_secret_name = "acr-password" + } + + ingress { + external_enabled = true + target_port = var.target_port + + traffic_weight { + percentage = 100 + latest_revision = true + } + } + + template { + min_replicas = var.min_replicas + max_replicas = var.max_replicas + + container { + name = var.container_name + image = var.image + cpu = var.cpu + memory = var.memory + + # Plain environment variables. + dynamic "env" { + for_each = var.env_vars + content { + name = env.key + value = env.value + } + } + + # Environment variables backed by a secret. + dynamic "env" { + for_each = var.secret_env_vars + content { + name = env.key + secret_name = env.value + } + } + } + } + + lifecycle { + ignore_changes = [ + # The CD pipeline deploys new image tags per commit; let it own the + # running image rather than reverting to "latest" on every apply. + template[0].container[0].image, + ] + } +} diff --git a/Iac/bot-api/modules/container-app/outputs.tf b/Iac/bot-api/modules/container-app/outputs.tf new file mode 100644 index 0000000..40d5dfd --- /dev/null +++ b/Iac/bot-api/modules/container-app/outputs.tf @@ -0,0 +1,19 @@ +output "name" { + description = "Name of the Container App." + value = azurerm_container_app.this.name +} + +output "fqdn" { + description = "Ingress FQDN of the Container App." + value = azurerm_container_app.this.ingress[0].fqdn +} + +output "url" { + description = "Public HTTPS URL of the Container App." + value = "https://${azurerm_container_app.this.ingress[0].fqdn}" +} + +output "identity_principal_id" { + description = "Principal ID of the system-assigned managed identity." + value = azurerm_container_app.this.identity[0].principal_id +} diff --git a/Iac/bot-api/modules/container-app/variables.tf b/Iac/bot-api/modules/container-app/variables.tf new file mode 100644 index 0000000..7753654 --- /dev/null +++ b/Iac/bot-api/modules/container-app/variables.tf @@ -0,0 +1,95 @@ +variable "name" { + description = "Name of the Container App." + type = string +} + +variable "resource_group_name" { + description = "Resource group the Container App lives in." + type = string +} + +variable "container_app_environment_id" { + description = "ID of the Container App Environment to deploy into." + type = string +} + +variable "acr_login_server" { + description = "Login server of the ACR images are pulled from (e.g. deliverybotcr.azurecr.io)." + type = string +} + +variable "acr_username" { + description = "ACR admin username used to authenticate image pulls." + type = string +} + +variable "acr_password" { + description = "ACR admin password. Stored as a Container App secret named 'acr-password'." + type = string + sensitive = true +} + +variable "container_name" { + description = "Name of the container inside the app." + type = string +} + +variable "image" { + description = "Initial image reference. The image tag is owned by the CD pipeline after creation (see lifecycle.ignore_changes)." + type = string +} + +variable "target_port" { + description = "Container port that ingress routes to." + type = number + default = 8080 +} + +variable "cpu" { + description = "vCPU allocated to the container." + type = number + default = 0.5 +} + +variable "memory" { + description = "Memory allocated to the container." + type = string + default = "1Gi" +} + +variable "min_replicas" { + description = "Minimum number of replicas (0 allows scale-to-zero)." + type = number + default = 0 +} + +variable "max_replicas" { + description = "Maximum number of replicas." + type = number + default = 3 +} + +variable "secrets" { + description = "Map of Container App secret name => secret value. Reference these from secret_env_vars." + type = map(string) + default = {} + sensitive = true +} + +variable "env_vars" { + description = "Map of plain environment variable name => value." + type = map(string) + default = {} +} + +variable "secret_env_vars" { + description = "Map of environment variable name => secret name (the secret must exist in `secrets`)." + type = map(string) + default = {} +} + +variable "tags" { + description = "Tags applied to the Container App." + type = map(string) + default = {} +} diff --git a/Iac/bot-api/outputs.tf b/Iac/bot-api/outputs.tf new file mode 100644 index 0000000..aea5db2 --- /dev/null +++ b/Iac/bot-api/outputs.tf @@ -0,0 +1,14 @@ +output "container_app_name" { + description = "Name of the provisioned Bot API Container App." + value = module.bot_api_app.name +} + +output "bot_api_url" { + description = "Public HTTPS URL of the Bot API." + value = module.bot_api_app.url +} + +output "managed_identity_principal_id" { + description = "Principal ID of the app's system-assigned identity — grant this db_owner on BotNetApiDb." + value = module.bot_api_app.identity_principal_id +} diff --git a/Iac/bot-api/variables.tf b/Iac/bot-api/variables.tf new file mode 100644 index 0000000..c8e853c --- /dev/null +++ b/Iac/bot-api/variables.tf @@ -0,0 +1,52 @@ +variable "resource_group_name" { + description = "Resource group that hosts the team's DeliveryBot resources." + type = string + default = "ewu-deliverybotsystem-rg" +} + +variable "container_app_environment_name" { + description = "Existing shared Container App Environment (managed by shared-infra)." + type = string + default = "managedEnvironment-ewudeliverybots-aa2f" +} + +variable "acr_name" { + description = "Existing shared Azure Container Registry (managed by shared-infra)." + type = string + default = "DeliverybotCR" +} + +variable "sql_server_name" { + description = "Existing shared SQL server (managed by shared-infra)." + type = string + default = "deliverybotsystem-sql" +} + +variable "container_app_name" { + description = "Name of the Bot API Container App." + type = string + default = "ewu-deliverybotsystem-api" +} + +variable "image_name" { + description = "Repository name of the Bot API image in ACR (tag is managed by the CD pipeline)." + type = string + default = "botnetapi" +} + +variable "sql_connection_string" { + description = "SQL connection string for BotNetApiDb. Uses Managed Identity auth — passed in from the CD pipeline, never committed." + type = string + sensitive = true + default = "Server=tcp:deliverybotsystem-sql.database.windows.net,1433;Initial Catalog=BotNetApiDb;Authentication=Active Directory Managed Identity;" +} + +variable "tags" { + description = "Common tags applied to Bot API resources." + type = map(string) + default = { + project = "DeliveryBot" + component = "bot-api" + owner = "wmiller17" + } +} diff --git a/Iac/frontend/main.tf b/Iac/frontend/main.tf new file mode 100644 index 0000000..b052cb9 --- /dev/null +++ b/Iac/frontend/main.tf @@ -0,0 +1,14 @@ +# Customer Frontend infrastructure. +# +# Reuses the team's shared resource group and App Service Plan. +# This stack only owns the customer-facing Web App (WA-DeliveryBot-dev). + +module "frontend_webapp" { + source = "./modules/webapp" + + resource_group_name = var.resource_group_name + app_service_plan_name = var.app_service_plan_name + app_service_name = var.app_service_name + node_version = var.node_version + tags = var.tags +} diff --git a/Iac/frontend/modules/webapp/main.tf b/Iac/frontend/modules/webapp/main.tf new file mode 100644 index 0000000..69c4bdc --- /dev/null +++ b/Iac/frontend/modules/webapp/main.tf @@ -0,0 +1,60 @@ +# Reusable module: a Linux App Service that hosts a static SPA via pm2. +# +# Reuses an existing resource group and App Service Plan (passed by name) so +# the team isn't billed for a duplicate plan. The only managed resource is the +# App Service itself. + +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } +} + +data "azurerm_resource_group" "rg" { + name = var.resource_group_name +} + +data "azurerm_service_plan" "plan" { + name = var.app_service_plan_name + resource_group_name = data.azurerm_resource_group.rg.name +} + +resource "azurerm_linux_web_app" "frontend" { + name = var.app_service_name + resource_group_name = data.azurerm_resource_group.rg.name + location = data.azurerm_service_plan.plan.location + service_plan_id = data.azurerm_service_plan.plan.id + https_only = true + + identity { + type = "SystemAssigned" + } + + site_config { + always_on = false + app_command_line = "pm2 serve /home/site/wwwroot --no-daemon --spa" + + application_stack { + node_version = var.node_version + } + + # Allow the GitHub Actions workflow to push builds. + scm_use_main_ip_restriction = true + } + + app_settings = { + "WEBSITE_NODE_DEFAULT_VERSION" = "~22" + } + + tags = var.tags + + lifecycle { + ignore_changes = [ + # Deployments overwrite the build artifact; don't fight the workflow. + app_settings["WEBSITE_RUN_FROM_PACKAGE"], + ] + } +} diff --git a/Iac/frontend/modules/webapp/outputs.tf b/Iac/frontend/modules/webapp/outputs.tf new file mode 100644 index 0000000..505f37b --- /dev/null +++ b/Iac/frontend/modules/webapp/outputs.tf @@ -0,0 +1,14 @@ +output "app_service_name" { + description = "Name of the provisioned App Service." + value = azurerm_linux_web_app.frontend.name +} + +output "default_hostname" { + description = "Default hostname of the App Service." + value = azurerm_linux_web_app.frontend.default_hostname +} + +output "app_url" { + description = "HTTPS URL of the App Service." + value = "https://${azurerm_linux_web_app.frontend.default_hostname}" +} diff --git a/Iac/frontend/modules/webapp/variables.tf b/Iac/frontend/modules/webapp/variables.tf new file mode 100644 index 0000000..44a25ec --- /dev/null +++ b/Iac/frontend/modules/webapp/variables.tf @@ -0,0 +1,26 @@ +variable "resource_group_name" { + description = "Resource group that hosts the team's DeliveryBot resources." + type = string +} + +variable "app_service_plan_name" { + description = "Existing App Service Plan to reuse." + type = string +} + +variable "app_service_name" { + description = "Globally-unique name for the App Service." + type = string +} + +variable "node_version" { + description = "Node runtime version used by the SPA host (pm2 serve)." + type = string + default = "22-lts" +} + +variable "tags" { + description = "Tags applied to the App Service." + type = map(string) + default = {} +} diff --git a/Iac/frontend/outputs.tf b/Iac/frontend/outputs.tf new file mode 100644 index 0000000..525255c --- /dev/null +++ b/Iac/frontend/outputs.tf @@ -0,0 +1,14 @@ +output "app_service_name" { + description = "Name of the provisioned App Service." + value = module.frontend_webapp.app_service_name +} + +output "default_hostname" { + description = "Default hostname of the Customer Frontend Web App." + value = module.frontend_webapp.default_hostname +} + +output "app_url" { + description = "HTTPS URL of the Customer Frontend Web App." + value = module.frontend_webapp.app_url +} diff --git a/Iac/frontend/variables.tf b/Iac/frontend/variables.tf new file mode 100644 index 0000000..dfc03d4 --- /dev/null +++ b/Iac/frontend/variables.tf @@ -0,0 +1,32 @@ +variable "resource_group_name" { + description = "Resource group that hosts the team's DeliveryBot resources." + type = string + default = "ewu-deliverybotsystem-rg" +} + +variable "app_service_plan_name" { + description = "Existing App Service Plan to reuse (shared with the Admin site)." + type = string + default = "ASP-RGDeliveryBotdev-8b82" +} + +variable "app_service_name" { + description = "Globally-unique name for the Customer Frontend App Service." + type = string + default = "WA-DeliveryBot-dev" +} + +variable "node_version" { + description = "Node runtime version used by the SPA host (pm2 serve)." + type = string + default = "22-lts" +} + +variable "tags" { + description = "Common tags applied to frontend resources." + type = map(string) + default = { + project = "DeliveryBot" + component = "frontend" + } +} diff --git a/Iac/imports.tf b/Iac/imports.tf new file mode 100644 index 0000000..30a8096 --- /dev/null +++ b/Iac/imports.tf @@ -0,0 +1,96 @@ +# --------------------------------------------------------------------------- +# One-time import of all pre-existing Azure resources into Terraform state. +# +# Import blocks MUST live in the root module — Terraform does not allow them +# inside child modules. All addresses below are fully-qualified from root. +# +# SAFE TO DELETE after the first successful apply that shows these resources +# as "already imported" (no changes planned for them). +# --------------------------------------------------------------------------- + +locals { + import_sub = "a06983f7-7384-4a09-a092-b13a3896be85" + import_rg = "ewu-deliverybotsystem-rg" +} + +# ── Shared Infrastructure ────────────────────────────────────────────────────── + +import { + to = module.shared_infra.azurerm_container_registry.acr + id = "/subscriptions/${local.import_sub}/resourceGroups/${local.import_rg}/providers/Microsoft.ContainerRegistry/registries/DeliverybotCR" +} + +import { + to = module.shared_infra.azurerm_log_analytics_workspace.logs + id = "/subscriptions/${local.import_sub}/resourceGroups/${local.import_rg}/providers/Microsoft.OperationalInsights/workspaces/workspaceewudeliverybotsystemrg8609" +} + +import { + to = module.shared_infra.azurerm_container_app_environment.env + id = "/subscriptions/${local.import_sub}/resourceGroups/${local.import_rg}/providers/Microsoft.App/managedEnvironments/managedEnvironment-ewudeliverybots-aa2f" +} + +import { + to = module.shared_infra.azurerm_eventhub_namespace.simulator + id = "/subscriptions/${local.import_sub}/resourceGroups/${local.import_rg}/providers/Microsoft.EventHub/namespaces/DeliverybotSimulator-EVHNS" +} + +import { + to = module.shared_infra.azurerm_eventhub.robot_input + id = "/subscriptions/${local.import_sub}/resourceGroups/${local.import_rg}/providers/Microsoft.EventHub/namespaces/DeliverybotSimulator-EVHNS/eventhubs/robot-input" +} + +import { + to = module.shared_infra.azurerm_eventhub.robot_output + id = "/subscriptions/${local.import_sub}/resourceGroups/${local.import_rg}/providers/Microsoft.EventHub/namespaces/DeliverybotSimulator-EVHNS/eventhubs/robot-output" +} + +import { + to = module.shared_infra.azurerm_mssql_server.sql + id = "/subscriptions/${local.import_sub}/resourceGroups/${local.import_rg}/providers/Microsoft.Sql/servers/deliverybotsystem-sql" +} + +import { + to = module.shared_infra.azurerm_mssql_firewall_rule.allow_azure_services + id = "/subscriptions/${local.import_sub}/resourceGroups/${local.import_rg}/providers/Microsoft.Sql/servers/deliverybotsystem-sql/firewallRules/AllowAzureServices" +} + +# ── Bot API ──────────────────────────────────────────────────────────────────── + +import { + to = module.bot_api.module.bot_api_app.azurerm_container_app.this + id = "/subscriptions/${local.import_sub}/resourceGroups/${local.import_rg}/providers/Microsoft.App/containerApps/ewu-deliverybotsystem-api" +} + +import { + to = module.bot_api.azurerm_mssql_database.botnetapi_db + id = "/subscriptions/${local.import_sub}/resourceGroups/${local.import_rg}/providers/Microsoft.Sql/servers/deliverybotsystem-sql/databases/BotNetApiDb" +} + +# ── Order Service ────────────────────────────────────────────────────────────── + +import { + to = module.order_service.module.order_service_app.azurerm_container_app.this + id = "/subscriptions/${local.import_sub}/resourceGroups/${local.import_rg}/providers/Microsoft.App/containerApps/deliverybot-order-service" +} + +# ── Customer Frontend ────────────────────────────────────────────────────────── + +import { + to = module.frontend.module.frontend_webapp.azurerm_linux_web_app.frontend + id = "/subscriptions/${local.import_sub}/resourceGroups/${local.import_rg}/providers/Microsoft.Web/sites/WA-DeliveryBot-dev" +} + +# ── Admin Web App ────────────────────────────────────────────────────────────── + +import { + to = module.admin_webapp.module.admin_webapp.azurerm_linux_web_app.admin + id = "/subscriptions/${local.import_sub}/resourceGroups/${local.import_rg}/providers/Microsoft.Web/sites/WA-DeliveryBot-Admin-dev" +} + +# ── Robot Simulator ──────────────────────────────────────────────────────────── + +import { + to = module.simulator.azurerm_container_app.simulator + id = "/subscriptions/${local.import_sub}/resourceGroups/${local.import_rg}/providers/Microsoft.App/containerApps/deliverybot-robot-simulator" +} diff --git a/Iac/main.tf b/Iac/main.tf new file mode 100644 index 0000000..0e19083 --- /dev/null +++ b/Iac/main.tf @@ -0,0 +1,105 @@ +# Universal DeliveryBot infrastructure — root composition file. +# +# This is the single top-level configuration that wires together all +# per-service modules. Each subdirectory of Iac/ is a Terraform module that +# owns the resources for one service; this file injects the shared variables +# into each one and composes them into a single apply. +# +# Dependency ordering: +# shared-infra → no dependencies on other modules +# bot-api → depends on shared SQL server (data source inside module) +# order-service → depends on shared CAE + ACR (data sources inside module) +# simulator → depends on shared CAE + ACR + Event Hub NS +# admin-webapp → no Azure dependencies on other modules (App Service Plan +# is looked up by name via data source) +# frontend → same pattern as admin-webapp +# +# Terraform resolves the apply order automatically from data source / output +# references. No explicit depends_on is needed here. + +# ── Shared infrastructure ────────────────────────────────────────────────────── +# Owns: ACR, Log Analytics workspace, Container App Environment, +# Event Hub namespace + hubs, SQL server + firewall rule. + +module "shared_infra" { + source = "./shared-infra" + + resource_group_name = var.resource_group_name + location = var.location + sql_location = var.sql_location + sql_ad_admin_login = var.sql_ad_admin_login + sql_ad_admin_object_id = var.sql_ad_admin_object_id + tenant_id = var.tenant_id +} + +# ── Admin Web App ────────────────────────────────────────────────────────────── +# Owns: the WA-DeliveryBot-Admin-dev App Service. + +module "admin_webapp" { + source = "./admin-webapp" + + resource_group_name = var.resource_group_name + app_service_plan_name = var.app_service_plan_name + app_service_name = var.admin_app_service_name + node_version = var.node_version + botnet_api_url = var.botnet_api_url + simulator_api_url = var.simulator_api_url +} + +# ── Order Service ────────────────────────────────────────────────────────────── +# Owns: the deliverybot-order-service Container App. + +module "order_service" { + source = "./order-service" + + resource_group_name = var.resource_group_name + container_app_environment_name = var.container_app_environment_name + acr_name = var.acr_name + container_app_name = var.order_service_container_app_name + sql_connection_string = var.order_service_sql_connection_string + eventhub_connection_string = var.eventhub_connection_string + botnet_api_url = var.botnet_api_url +} + +# ── Bot API ──────────────────────────────────────────────────────────────────── +# Owns: the ewu-deliverybotsystem-api Container App and its SQL database. + +module "bot_api" { + source = "./bot-api" + + resource_group_name = var.resource_group_name + container_app_environment_name = var.container_app_environment_name + acr_name = var.acr_name + sql_server_name = var.bot_api_sql_server_name + container_app_name = var.bot_api_container_app_name + sql_connection_string = var.bot_api_sql_connection_string +} + +# ── Customer Frontend ────────────────────────────────────────────────────────── +# Owns: the WA-DeliveryBot-dev App Service. + +module "frontend" { + source = "./frontend" + + resource_group_name = var.resource_group_name + app_service_plan_name = var.app_service_plan_name + app_service_name = var.customer_frontend_app_service_name + node_version = var.node_version +} + +# ── Robot Simulator ──────────────────────────────────────────────────────────── +# Owns: the deliverybot-robot-simulator Container App. +# Note: simulator/variables.tf uses container_app_env_name (not +# container_app_environment_name) for historical reasons; mapped here. + +module "simulator" { + source = "./simulator" + + resource_group_name = var.resource_group_name + location = var.location + container_app_env_name = var.container_app_environment_name + acr_name = var.acr_name + event_hub_namespace_name = var.eventhub_namespace_name + eventhub_connection_string = var.eventhub_connection_string + container_app_name = var.simulator_container_app_name +} diff --git a/Iac/order-service/imports.tf b/Iac/order-service/imports.tf deleted file mode 100644 index 0b15cb4..0000000 --- a/Iac/order-service/imports.tf +++ /dev/null @@ -1,14 +0,0 @@ -# One-time adoption of the pre-existing Container App into Terraform state. -# -# The deliverybot-order-service app was originally created by hand, so the -# first `terraform apply` must IMPORT it instead of trying to create a -# duplicate (azurerm refuses to create over an existing resource). This import -# block lets CI's service principal do that automatically on the first apply — -# no manual out-of-band `terraform import` needed. -# -# SAFE TO DELETE after the first successful apply has run in CI (the resource -# will already be in remote state; the block then becomes a no-op). -import { - to = module.order_service_app.azurerm_container_app.this - id = "${data.azurerm_resource_group.rg.id}/providers/Microsoft.App/containerApps/${var.container_app_name}" -} diff --git a/Iac/order-service/providers.tf b/Iac/order-service/providers.tf deleted file mode 100644 index 81fe52e..0000000 --- a/Iac/order-service/providers.tf +++ /dev/null @@ -1,35 +0,0 @@ -# Provider + state backend for the Order Service Container App. -# -# Auth: provided by the GitHub Actions workflow via `azure/login@v2` (OIDC) -# and the ARM_USE_OIDC / ARM_USE_AZUREAD env vars — no secrets stored here. -# State: lives in the team's pre-existing storage account `dbstfstate01` -# under a unique key so it doesn't collide with the other features' state. - -terraform { - required_version = ">= 1.6.0" - - required_providers { - azurerm = { - source = "hashicorp/azurerm" - version = "~> 4.0" - } - } - - backend "azurerm" { - resource_group_name = "ewu-deliverybotsystem-rg" - storage_account_name = "dbstfstate01" - container_name = "tfstate" - key = "order-service.tfstate" - use_oidc = true - use_azuread_auth = true - } -} - -provider "azurerm" { - features {} - use_oidc = true - - # The CI service principal is scoped to the resource group and can't register - # subscription-level resource providers. They're already registered, so skip. - resource_provider_registrations = "none" -} diff --git a/Iac/outputs.tf b/Iac/outputs.tf new file mode 100644 index 0000000..36dba01 --- /dev/null +++ b/Iac/outputs.tf @@ -0,0 +1,58 @@ +# Aggregated outputs from all service modules. + +# ── Shared infrastructure ────────────────────────────────────────────────────── + +output "acr_login_server" { + description = "ACR login server hostname." + value = module.shared_infra.acr_login_server +} + +output "container_app_environment_id" { + description = "Resource ID of the shared Container App Environment." + value = module.shared_infra.container_app_environment_id +} + +output "sql_server_fqdn" { + description = "FQDN of the shared SQL server." + value = module.shared_infra.sql_server_fqdn +} + +# ── Admin Web App ────────────────────────────────────────────────────────────── + +output "admin_webapp_url" { + description = "HTTPS URL of the Admin Web App." + value = module.admin_webapp.app_url +} + +# ── Order Service ────────────────────────────────────────────────────────────── + +output "order_service_url" { + description = "HTTPS URL of the Order Service Container App." + value = module.order_service.order_service_url +} + +# ── Bot API ──────────────────────────────────────────────────────────────────── + +output "bot_api_url" { + description = "HTTPS URL of the Bot API Container App." + value = module.bot_api.bot_api_url +} + +# ── Customer Frontend ────────────────────────────────────────────────────────── + +output "customer_frontend_url" { + description = "HTTPS URL of the Customer Frontend App Service." + value = module.frontend.app_url +} + +# ── Robot Simulator ──────────────────────────────────────────────────────────── + +output "simulator_url" { + description = "HTTPS URL of the Robot Simulator Container App." + value = module.simulator.container_app_url +} + +output "simulator_health_url" { + description = "Health check endpoint for the Robot Simulator." + value = module.simulator.health_url +} diff --git a/Iac/providers.tf b/Iac/providers.tf new file mode 100644 index 0000000..fe491ed --- /dev/null +++ b/Iac/providers.tf @@ -0,0 +1,28 @@ +# Universal root provider. +# +# All service modules (admin-webapp, order-service, bot-api, frontend, +# shared-infra, simulator) are called from Iac/main.tf and share this single +# azurerm provider. No provider block is needed inside the modules. +# +# Auth: GitHub Actions OIDC federated identity — no client secrets stored. +# Backend: see backend.tf. + +terraform { + required_version = ">= 1.6.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } +} + +provider "azurerm" { + features {} + use_oidc = true + + # The CI service principal has scoped roles but no subscription-level + # resource-provider registration rights. All providers are already registered. + resource_provider_registrations = "none" +} diff --git a/Iac/shared-infra/main.tf b/Iac/shared-infra/main.tf new file mode 100644 index 0000000..3d5c325 --- /dev/null +++ b/Iac/shared-infra/main.tf @@ -0,0 +1,83 @@ +data "azurerm_resource_group" "rg" { + name = var.resource_group_name +} + +# ── Azure Container Registry ──────────────────────────────────────────────── + +resource "azurerm_container_registry" "acr" { + name = "DeliverybotCR" + resource_group_name = data.azurerm_resource_group.rg.name + location = var.location + sku = "Standard" + admin_enabled = true +} + +# ── Log Analytics Workspace ───────────────────────────────────────────────── + +resource "azurerm_log_analytics_workspace" "logs" { + name = "workspaceewudeliverybotsystemrg8609" + resource_group_name = data.azurerm_resource_group.rg.name + location = var.location + sku = "PerGB2018" + retention_in_days = 30 +} + +# ── Container Apps Managed Environment ───────────────────────────────────── + +resource "azurerm_container_app_environment" "env" { + name = "managedEnvironment-ewudeliverybots-aa2f" + resource_group_name = data.azurerm_resource_group.rg.name + location = var.location + log_analytics_workspace_id = azurerm_log_analytics_workspace.logs.id +} + +# ── Event Hub Namespace ───────────────────────────────────────────────────── + +resource "azurerm_eventhub_namespace" "simulator" { + name = "DeliverybotSimulator-EVHNS" + resource_group_name = data.azurerm_resource_group.rg.name + location = var.location + sku = "Standard" + capacity = 1 +} + +resource "azurerm_eventhub" "robot_input" { + name = "robot-input" + namespace_id = azurerm_eventhub_namespace.simulator.id + partition_count = 2 + message_retention = 1 +} + +resource "azurerm_eventhub" "robot_output" { + name = "robot-output" + namespace_id = azurerm_eventhub_namespace.simulator.id + partition_count = 2 + message_retention = 1 +} + +# ── SQL Server ────────────────────────────────────────────────────────────── +# +# Azure AD-only authentication — no SQL login password. +# Each service stack owns its own database on this server. + +resource "azurerm_mssql_server" "sql" { + name = "deliverybotsystem-sql" + resource_group_name = data.azurerm_resource_group.rg.name + location = var.sql_location + version = "12.0" + + 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 (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" +} diff --git a/Iac/shared-infra/outputs.tf b/Iac/shared-infra/outputs.tf new file mode 100644 index 0000000..202c290 --- /dev/null +++ b/Iac/shared-infra/outputs.tf @@ -0,0 +1,55 @@ +output "acr_login_server" { + description = "ACR login server hostname (e.g. deliverybotcr.azurecr.io)." + value = azurerm_container_registry.acr.login_server +} + +output "acr_admin_username" { + description = "ACR admin username." + value = azurerm_container_registry.acr.admin_username +} + +output "acr_admin_password" { + description = "ACR admin password." + value = azurerm_container_registry.acr.admin_password + sensitive = true +} + +output "container_app_environment_id" { + description = "Resource ID of the Container Apps managed environment." + value = azurerm_container_app_environment.env.id +} + +output "container_app_environment_name" { + description = "Name of the Container Apps managed environment." + value = azurerm_container_app_environment.env.name +} + +output "sql_server_id" { + description = "Resource ID of the shared SQL server." + value = azurerm_mssql_server.sql.id +} + +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_name" { + description = "Name of the Event Hub namespace." + value = azurerm_eventhub_namespace.simulator.name +} + +output "eventhub_namespace_fqdn" { + description = "AMQP endpoint of the Event Hub namespace." + value = "${azurerm_eventhub_namespace.simulator.name}.servicebus.windows.net" +} + +output "robot_input_hub_name" { + description = "Name of the robot-input event hub." + value = azurerm_eventhub.robot_input.name +} + +output "robot_output_hub_name" { + description = "Name of the robot-output event hub." + value = azurerm_eventhub.robot_output.name +} diff --git a/Iac/shared-infra/variables.tf b/Iac/shared-infra/variables.tf new file mode 100644 index 0000000..8fac6fd --- /dev/null +++ b/Iac/shared-infra/variables.tf @@ -0,0 +1,35 @@ +variable "resource_group_name" { + description = "Name of the shared resource group." + type = string + default = "ewu-deliverybotsystem-rg" +} + +variable "location" { + description = "Primary Azure region for shared resources." + type = string + default = "westus2" +} + +variable "sql_location" { + description = "Azure region for the SQL server (kept in southeastasia for cost/availability)." + type = string + default = "southeastasia" +} + +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" +} + +variable "tenant_id" { + description = "Azure Active Directory tenant ID." + type = string + default = "37321907-14a5-4390-987d-ec0c66c655cd" +} diff --git a/Iac/simulator/main.tf b/Iac/simulator/main.tf index e0f5522..6114098 100644 --- a/Iac/simulator/main.tf +++ b/Iac/simulator/main.tf @@ -25,28 +25,9 @@ data "azurerm_eventhub_namespace" "evhns" { # --------------------------------------------------------------------------- # Robot Simulator Container App # -# Ownership boundary (Balanced approach): -# OWNED by this module: -# - Container image reference -# - Ingress: external, port 8080 -# - Revision mode: single (stateful in-memory simulator; one replica only) -# - Scale: min 1, max 1 -# - System-assigned managed identity -# - ACR registry reference -# - Non-sensitive env vars: ASPNETCORE_ENVIRONMENT, ASPNETCORE_URLS -# -# NOT owned by this module (managed outside IaC): -# - EventTransport__Mode -# - EventTransport__ConnectionString -# - EventTransport__InputEventHubName -# - EventTransport__OutputEventHubName -# - EventTransport__ConsumerGroup -# - EventTransport__EnableInputConsumer -# - Any Container App secrets -# -# Reason: these env vars and secrets are already live on the Container App -# and must not be overwritten during early IaC rollout. They will be brought -# under IaC management when the project-wide IaC module is established. +# The simulator holds bot state in memory; only a single replica is safe. +# All environment variables and secrets are managed here — the CD pipeline +# only updates the running image tag. # --------------------------------------------------------------------------- resource "azurerm_container_app" "simulator" { @@ -59,36 +40,110 @@ resource "azurerm_container_app" "simulator" { type = "SystemAssigned" } + secret { + name = "eventhub-connection-string" + value = var.eventhub_connection_string + } + registry { server = data.azurerm_container_registry.acr.login_server } template { + min_replicas = 1 + max_replicas = 1 + container { name = var.container_app_name image = "${data.azurerm_container_registry.acr.login_server}/${var.image_name}:${var.image_tag}" cpu = 0.5 memory = "1Gi" - # Non-sensitive runtime environment variables. - # Event Hub transport settings are intentionally excluded — see ownership - # boundary comment above. env { name = "ASPNETCORE_ENVIRONMENT" - value = "Production" + value = "Development" } env { name = "ASPNETCORE_URLS" value = "http://+:8080" } - } - # Scale is fixed at exactly one replica. - # The simulator holds bot state in memory; multiple replicas would produce - # independent, conflicting bot fleets with no shared state. - min_replicas = 1 - max_replicas = 1 + env { + name = "EventTransport__Mode" + value = var.event_transport_mode + } + + env { + name = "EventTransport__ConnectionString" + secret_name = "eventhub-connection-string" + } + + env { + name = "EventTransport__InputEventHubName" + value = var.event_transport_input_hub + } + + env { + name = "EventTransport__OutputEventHubName" + value = var.event_transport_output_hub + } + + env { + name = "EventTransport__ConsumerGroup" + value = var.event_transport_consumer_group + } + + env { + name = "EventTransport__EnableInputConsumer" + value = "true" + } + + env { + name = "Simulator__InitialBotCount" + value = tostring(var.simulator_initial_bot_count) + } + + env { + name = "Simulator__BotIdPrefix" + value = var.simulator_bot_id_prefix + } + + env { + name = "Simulator__DefaultBotModel" + value = var.simulator_default_bot_model + } + + env { + name = "Simulator__DefaultLatitude" + value = var.simulator_default_latitude + } + + env { + name = "Simulator__DefaultLongitude" + value = var.simulator_default_longitude + } + + env { + name = "Simulation__TickIntervalSeconds" + value = tostring(var.simulation_tick_interval_seconds) + } + + env { + name = "Simulation__TelemetryIntervalSeconds" + value = tostring(var.simulation_telemetry_interval_seconds) + } + + env { + name = "Simulation__DeliverySpeedMetersPerSecond" + value = tostring(var.simulation_delivery_speed_mps) + } + + env { + name = "Simulation__DestinationArrivalThresholdMeters" + value = tostring(var.simulation_arrival_threshold_meters) + } + } } ingress { @@ -102,13 +157,11 @@ resource "azurerm_container_app" "simulator" { } lifecycle { - # Prevent Terraform from overwriting env vars or secrets that are managed - # outside this module (e.g., Event Hub connection settings set via portal - # or CLI). Remove this ignore block once those settings are brought under - # IaC management. ignore_changes = [ - template[0].container[0].env, - secret, + # The CD pipeline deploys new image tags per commit; let it own the + # running image rather than reverting to var.image_tag on every apply. + template[0].container[0].image, ] } } + diff --git a/Iac/simulator/providers.tf b/Iac/simulator/providers.tf deleted file mode 100644 index 0fce778..0000000 --- a/Iac/simulator/providers.tf +++ /dev/null @@ -1,14 +0,0 @@ -terraform { - required_version = ">= 1.5" - - required_providers { - azurerm = { - source = "hashicorp/azurerm" - version = "~> 3.110" - } - } -} - -provider "azurerm" { - features {} -} diff --git a/Iac/simulator/terraform.tfvars b/Iac/simulator/terraform.tfvars index f22929d..745b96d 100644 --- a/Iac/simulator/terraform.tfvars +++ b/Iac/simulator/terraform.tfvars @@ -1,8 +1,12 @@ resource_group_name = "ewu-deliverybotsystem-rg" -location = "westus" +location = "westus2" container_app_env_name = "managedEnvironment-ewudeliverybots-aa2f" acr_name = "DeliverybotCR" event_hub_namespace_name = "DeliverybotSimulator-EVHNS" container_app_name = "deliverybot-robot-simulator" image_name = "deliverybot-robot-simulator" image_tag = "latest" + +# eventhub_connection_string is sensitive — supply via: +# TF_VAR_eventhub_connection_string environment variable (CI) +# or a local secrets.auto.tfvars file (never commit) diff --git a/Iac/simulator/variables.tf b/Iac/simulator/variables.tf index 2d5e859..78e2297 100644 --- a/Iac/simulator/variables.tf +++ b/Iac/simulator/variables.tf @@ -23,6 +23,12 @@ variable "event_hub_namespace_name" { type = string } +variable "eventhub_connection_string" { + description = "Event Hub namespace connection string. Passed in via TF_VAR_eventhub_connection_string; never committed." + type = string + sensitive = true +} + variable "container_app_name" { description = "Name of the simulator Container App." type = string @@ -40,3 +46,83 @@ variable "image_tag" { type = string default = "latest" } + +# ── Simulator configuration ───────────────────────────────────────────────── + +variable "event_transport_mode" { + description = "Transport mode for the simulator (AzureEventHub or InMemory)." + type = string + default = "AzureEventHub" +} + +variable "event_transport_input_hub" { + description = "Name of the robot-input event hub." + type = string + default = "robot-input" +} + +variable "event_transport_output_hub" { + description = "Name of the robot-output event hub." + type = string + default = "robot-output" +} + +variable "event_transport_consumer_group" { + description = "Event Hub consumer group used by the simulator." + type = string + default = "$Default" +} + +variable "simulator_initial_bot_count" { + description = "Number of bots spawned on simulator startup." + type = number + default = 3 +} + +variable "simulator_bot_id_prefix" { + description = "Prefix applied to generated bot IDs." + type = string + default = "bot" +} + +variable "simulator_default_bot_model" { + description = "Default bot model name." + type = string + default = "DeliveryBot-V1" +} + +variable "simulator_default_latitude" { + description = "Default starting latitude for bots." + type = string + default = "47.65837359646208" +} + +variable "simulator_default_longitude" { + description = "Default starting longitude for bots." + type = string + default = "-117.40215401730164" +} + +variable "simulation_tick_interval_seconds" { + description = "Simulation tick interval in seconds." + type = number + default = 1 +} + +variable "simulation_telemetry_interval_seconds" { + description = "How often (seconds) bots emit telemetry events." + type = number + default = 5 +} + +variable "simulation_delivery_speed_mps" { + description = "Bot travel speed in metres per second." + type = number + default = 8 +} + +variable "simulation_arrival_threshold_meters" { + description = "Distance in metres at which a bot is considered to have arrived." + type = number + default = 5 +} diff --git a/Iac/variables.tf b/Iac/variables.tf new file mode 100644 index 0000000..69da6f9 --- /dev/null +++ b/Iac/variables.tf @@ -0,0 +1,158 @@ +# Root-level variable declarations. +# +# These are the "injection points" this file talks about: the root main.tf +# reads these values and passes them into each service module. Variables +# that are only used by one module use a descriptive prefix (e.g. +# admin_app_service_name) to avoid collisions; variables shared across +# multiple modules keep a simple name (e.g. resource_group_name). + +# ── Shared infrastructure ────────────────────────────────────────────────────── + +variable "resource_group_name" { + description = "Resource group shared by all DeliveryBot resources." + type = string + default = "ewu-deliverybotsystem-rg" +} + +variable "location" { + description = "Primary Azure region (Container Apps, Event Hubs, etc.)." + type = string + default = "westus2" +} + +variable "acr_name" { + description = "Name of the shared Azure Container Registry." + type = string + default = "DeliverybotCR" +} + +variable "container_app_environment_name" { + description = "Name of the shared Container Apps managed environment." + type = string + default = "managedEnvironment-ewudeliverybots-aa2f" +} + +variable "eventhub_namespace_name" { + description = "Name of the shared Event Hub namespace." + type = string + default = "DeliverybotSimulator-EVHNS" +} + +# ── Shared-infra specific ────────────────────────────────────────────────────── + +variable "sql_location" { + description = "Azure region for the SQL server (kept in southeastasia for cost/availability)." + type = string + default = "southeastasia" +} + +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" +} + +variable "tenant_id" { + description = "Azure Active Directory tenant ID." + type = string + default = "37321907-14a5-4390-987d-ec0c66c655cd" +} + +# ── Shared App Service settings (admin-webapp + customer frontend) ───────────── + +variable "app_service_plan_name" { + description = "Existing App Service Plan shared by admin-webapp and customer frontend." + type = string + default = "ASP-RGDeliveryBotdev-8b82" +} + +variable "node_version" { + description = "Node runtime version used by pm2 in both web apps." + type = string + default = "22-lts" +} + +# ── Shared API URLs (admin-webapp + order-service) ───────────────────────────── + +variable "botnet_api_url" { + description = "Public HTTPS URL of the BotNet API Container App." + type = string + default = "https://ewu-deliverybotsystem-api.mangocoast-332176b0.westus2.azurecontainerapps.io" +} + +variable "simulator_api_url" { + description = "Public HTTPS URL of the Robot Simulator Container App." + type = string + default = "https://deliverybot-robot-simulator.mangocoast-332176b0.westus2.azurecontainerapps.io" +} + +# ── Admin Web App ────────────────────────────────────────────────────────────── + +variable "admin_app_service_name" { + description = "Name of the Admin Web App App Service." + type = string + default = "WA-DeliveryBot-Admin-dev" +} + +# ── Order Service ────────────────────────────────────────────────────────────── + +variable "order_service_container_app_name" { + description = "Name of the Order Service Container App." + type = string + default = "deliverybot-order-service" +} + +variable "order_service_sql_connection_string" { + description = "SQL connection string for OrderServiceDb. Supplied via TF_VAR_order_service_sql_connection_string in CI — never committed." + type = string + sensitive = true +} + +variable "eventhub_connection_string" { + description = "Event Hub namespace connection string. Used by Order Service and Robot Simulator. Supplied via TF_VAR_eventhub_connection_string in CI — never committed." + type = string + sensitive = true +} + +# ── Bot API ──────────────────────────────────────────────────────────────────── + +variable "bot_api_container_app_name" { + description = "Name of the BotNet API Container App." + type = string + default = "ewu-deliverybotsystem-api" +} + +variable "bot_api_sql_server_name" { + description = "Name of the shared SQL server used by the Bot API." + type = string + default = "deliverybotsystem-sql" +} + +variable "bot_api_sql_connection_string" { + description = "SQL connection string for BotNetApiDb. Uses Managed Identity auth — no password in the string." + type = string + sensitive = true + default = "Server=tcp:deliverybotsystem-sql.database.windows.net,1433;Initial Catalog=BotNetApiDb;Authentication=Active Directory Managed Identity;" +} + +# ── Customer Frontend ────────────────────────────────────────────────────────── + +variable "customer_frontend_app_service_name" { + description = "Name of the Customer Frontend App Service." + type = string + default = "WA-DeliveryBot-dev" +} + +# ── Robot Simulator ──────────────────────────────────────────────────────────── + +variable "simulator_container_app_name" { + description = "Name of the Robot Simulator Container App." + type = string + default = "deliverybot-robot-simulator" +}