diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 110f710..8b36e73 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -23,3 +23,25 @@ updates: labels: - "pip" - "dependencies" + + # Maintain dependencies for pip + - package-ecosystem: "pip" + directory: "/code/backend" + schedule: + interval: "weekly" + day: "sunday" + time: "10:00" + labels: + - "pip" + - "dependencies" + + # Maintain dependencies for Terraform + - package-ecosystem: "terraform" + directory: "/code/infra" + schedule: + interval: "weekly" + day: "sunday" + time: "10:00" + labels: + - "terraform" + - "dependencies" diff --git a/.github/workflows/_terraformDestroyTemplate.yml b/.github/workflows/_terraformDestroyTemplate.yml new file mode 100644 index 0000000..ebeae53 --- /dev/null +++ b/.github/workflows/_terraformDestroyTemplate.yml @@ -0,0 +1,96 @@ +name: Terraform Destroy Template + +on: + workflow_call: + inputs: + environment: + required: true + type: string + default: "dev" + description: "Specifies the environment of the deployment." + config: + required: true + type: string + description: "Specifies the configuration folder for the deployment." + terraform_version: + required: true + type: string + description: "Specifies the terraform version." + node_version: + required: true + type: number + description: "Specifies the node version." + working_directory: + required: true + type: string + description: "Specifies the working directory." + tenant_id: + required: true + type: string + description: "Specifies the tenant id of the deployment." + subscription_id: + required: true + type: string + description: "Specifies the subscription id of the deployment." + secrets: + CLIENT_ID: + required: true + description: "Specifies the client id." + +permissions: + id-token: write + contents: read + +jobs: + deployment: + name: Terraform Destroy + runs-on: [self-hosted] + continue-on-error: false + environment: ${{ inputs.environment }} + if: github.event_name == 'push' || github.event_name == 'release' + concurrency: + group: terraform-${{ inputs.config }}-${{ inputs.environment }} + cancel-in-progress: false + + env: + ARM_TENANT_ID: ${{ inputs.tenant_id }} + ARM_SUBSCRIPTION_ID: ${{ inputs.subscription_id }} + ARM_CLIENT_ID: ${{ secrets.CLIENT_ID }} + ARM_USE_OIDC: true + + steps: + # Setup Node + - name: Setup Node + id: node_setup + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ inputs.node_version }} + + # Setup Terraform + - name: Setup Terraform + id: terraform_setup + uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + terraform_version: ${{ inputs.terraform_version }} + terraform_wrapper: true + + # Check Out Repository + - name: Check Out Repository + id: checkout_repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + # Terraform Init + - name: Terraform Init + working-directory: ${{ inputs.working_directory }} + run: | + terraform init -backend-config=../../config/${CONFIG}/azurerm.tfbackend + env: + CONFIG: ${{ inputs.config }} + + # Terraform Destroy + - name: Terraform Destroy + working-directory: ${{ inputs.working_directory }} + run: | + terraform apply -var-file="../../config/${CONFIG}/vars.tfvars" -auto-approve -input=false -destroy + env: + CONFIG: ${{ inputs.config }} diff --git a/.github/workflows/_terraformEnvironmentTemplate.yml b/.github/workflows/_terraformEnvironmentTemplate.yml new file mode 100644 index 0000000..6d6ae41 --- /dev/null +++ b/.github/workflows/_terraformEnvironmentTemplate.yml @@ -0,0 +1,238 @@ +name: Terraform Template + +on: + workflow_call: + inputs: + environment: + required: true + type: string + description: "Specifies the environment of the deployment." + config: + required: true + type: string + description: "Specifies the configuration folder for the deployment." + terraform_version: + required: true + type: string + description: "Specifies the terraform version." + node_version: + required: true + type: number + description: "Specifies the node version." + working_directory: + required: true + type: string + description: "Specifies the working directory." + tenant_id: + required: true + type: string + description: "Specifies the tenant id of the deployment." + subscription_id: + required: true + type: string + description: "Specifies the subscription id of the deployment." + secrets: + CLIENT_ID: + required: true + description: "Specifies the client id." + +permissions: + id-token: write + contents: read + pull-requests: write + +jobs: + lint: + name: Terraform Lint + runs-on: [ubuntu-latest] + continue-on-error: false + + steps: + # Setup Terraform + - name: Setup Terraform + id: terraform_setup + uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + terraform_version: ${{ inputs.terraform_version }} + terraform_wrapper: true + + # Check Out Repository + - name: Check Out Repository + id: checkout_repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + # Terraform Format + - name: Terraform Format + id: terraform_format + working-directory: ${{ inputs.working_directory }} + run: | + terraform fmt -check -recursive + + # Add Pull Request Comment + - name: Add Pull Request Comment + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + id: pr_comment + if: github.event_name == 'pull_request' + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const output = `#### Terraform Lint Results + * Terraform Version 📎\`${{ inputs.terraform_version }}\` + * Working Directory 📂\`${{ inputs.working_directory }}\` + * Terraform Format and Style 🖌\`${{ steps.terraform_format.outcome }}\``; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: output + }) + + plan: + name: Terraform Plan + runs-on: [self-hosted] + continue-on-error: false + environment: ${{ inputs.environment }} + needs: [lint] + concurrency: + group: terraform-${{ inputs.config }}-${{ inputs.environment }} + cancel-in-progress: false + + env: + ARM_TENANT_ID: ${{ inputs.tenant_id }} + ARM_SUBSCRIPTION_ID: ${{ inputs.subscription_id }} + ARM_CLIENT_ID: ${{ secrets.CLIENT_ID }} + ARM_USE_OIDC: true + + steps: + # Setup Node + - name: Setup Node + id: node_setup + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ inputs.node_version }} + + # Setup Terraform + - name: Setup Terraform + id: terraform_setup + uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + terraform_version: ${{ inputs.terraform_version }} + terraform_wrapper: true + + # Check Out Repository + - name: Check Out Repository + id: checkout_repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + # Terraform Init + - name: Terraform Init + id: terraform_init + working-directory: ${{ inputs.working_directory }} + run: | + terraform init -backend-config=../../config/${CONFIG}/azurerm.tfbackend + env: + CONFIG: ${{ inputs.config }} + + # Terraform Validate + - name: Terraform Validate + id: terraform_validate + working-directory: ${{ inputs.working_directory }} + run: | + terraform validate + + # Terraform Plan + - name: Terraform Plan + id: terraform_plan + working-directory: ${{ inputs.working_directory }} + run: | + terraform plan -var-file="../../config/${CONFIG}/vars.tfvars" -input=false + env: + CONFIG: ${{ inputs.config }} + + # Add Pull Request Comment + - name: Add Pull Request Comment + id: pr_comment + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + if: github.event_name == 'pull_request' + continue-on-error: true + env: + PLAN: "terraform\n${{ steps.terraform_plan.outputs.stdout }}" + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const output = `#### Terraform Validation & Plan Results + * Terraform Version 📎\`${{ inputs.terraform_version }}\` + * Working Directory 📂\`${{ inputs.working_directory }}\` + * Terraform Initialization ⚙️\`${{ steps.terraform_init.outcome }}\` + * Terraform Validation 🤖\`${{ steps.terraform_validate.outcome }}\` + * Terraform Plan 📖\`${{ steps.terraform_plan.outcome }}\` + +
Show Plan + + \`\`\`\n + ${process.env.PLAN} + \`\`\` + +
`; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: output + }) + + apply: + name: Terraform Apply + runs-on: [self-hosted] + continue-on-error: false + environment: ${{ inputs.environment }} + # if: github.event_name == 'push' || github.event_name == 'release' + needs: [plan] + concurrency: + group: terraform-${{ inputs.config }}-${{ inputs.environment }} + cancel-in-progress: false + + env: + ARM_TENANT_ID: ${{ inputs.tenant_id }} + ARM_SUBSCRIPTION_ID: ${{ inputs.subscription_id }} + ARM_CLIENT_ID: ${{ secrets.CLIENT_ID }} + ARM_USE_OIDC: true + + steps: + # Setup Node + - name: Setup Node + id: node_setup + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ inputs.node_version }} + + # Setup Terraform + - name: Setup Terraform + id: terraform_setup + uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + terraform_version: ${{ inputs.terraform_version }} + terraform_wrapper: true + + # Check Out Repository + - name: Check Out Repository + id: checkout_repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + # Terraform Init + - name: Terraform Init + working-directory: ${{ inputs.working_directory }} + run: | + terraform init -backend-config=../../config/${CONFIG}/azurerm.tfbackend + env: + CONFIG: ${{ inputs.config }} + + # Terraform Apply + - name: Terraform Apply + working-directory: ${{ inputs.working_directory }} + run: | + terraform apply -var-file="../../config/${CONFIG}/vars.tfvars" -auto-approve -input=false + env: + CONFIG: ${{ inputs.config }} diff --git a/.github/workflows/_terraformImportTemplate.yml b/.github/workflows/_terraformImportTemplate.yml new file mode 100644 index 0000000..128ed02 --- /dev/null +++ b/.github/workflows/_terraformImportTemplate.yml @@ -0,0 +1,108 @@ +name: Terraform Import Template + +on: + workflow_call: + inputs: + environment: + required: true + type: string + default: "dev" + description: "Specifies the environment of the deployment." + config: + required: true + type: string + description: "Specifies the configuration folder for the deployment." + terraform_version: + required: true + type: string + description: "Specifies the terraform version." + node_version: + required: true + type: number + description: "Specifies the node version." + working_directory: + required: true + type: string + description: "Specifies the working directory." + tenant_id: + required: true + type: string + description: "Specifies the tenant id of the deployment." + subscription_id: + required: true + type: string + description: "Specifies the subscription id of the deployment." + import_target: + required: true + type: string + description: "Specifies the terraform resource address (e.g., azurerm_resource_group.my_rg)." + resource_id: + required: true + type: string + description: "Specifies the resource id to import (e.g., /subscriptions/.../resourceGroups/myResourceGroup)." + secrets: + CLIENT_ID: + required: true + description: "Specifies the client id." + +permissions: + id-token: write + contents: read + pull-requests: write + +jobs: + deployment: + name: Terraform Import + runs-on: [self-hosted] + continue-on-error: false + environment: ${{ inputs.environment }} + # if: github.event_name == 'push' || github.event_name == 'release' + concurrency: + group: terraform-${{ inputs.config }}-${{ inputs.environment }} + cancel-in-progress: false + + env: + ARM_TENANT_ID: ${{ inputs.tenant_id }} + ARM_SUBSCRIPTION_ID: ${{ inputs.subscription_id }} + ARM_CLIENT_ID: ${{ secrets.CLIENT_ID }} + ARM_USE_OIDC: true + + steps: + # Setup Node + - name: Setup Node + id: node_setup + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ inputs.node_version }} + + # Setup Terraform + - name: Setup Terraform + id: terraform_setup + uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + terraform_version: ${{ inputs.terraform_version }} + terraform_wrapper: true + + # Check Out Repository + - name: Check Out Repository + id: checkout_repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + submodules: true + + # Terraform Init + - name: Terraform Init + id: terraform_init + working-directory: ${{ inputs.working_directory }} + run: | + terraform init -backend-config=../../config/${CONFIG}/azurerm.tfbackend + env: + CONFIG: ${{ inputs.config }} + + # Terraform Import + - name: Terraform Import + working-directory: ${{ inputs.working_directory }} + run: | + terraform import -var-file="../../config/${CONFIG}/vars.tfvars" -input=false '${{ inputs.import_target }}' '${{ inputs.resource_id }}' + env: + CONFIG: ${{ inputs.config }} diff --git a/.github/workflows/_terraformStateUnlockTemplate.yml b/.github/workflows/_terraformStateUnlockTemplate.yml new file mode 100644 index 0000000..3a0b174 --- /dev/null +++ b/.github/workflows/_terraformStateUnlockTemplate.yml @@ -0,0 +1,100 @@ +name: Terraform State Unlock Template + +on: + workflow_call: + inputs: + environment: + required: true + type: string + default: "dev" + description: "Specifies the environment of the deployment." + config: + required: true + type: string + description: "Specifies the configuration folder for the deployment." + terraform_version: + required: true + type: string + description: "Specifies the terraform version." + node_version: + required: true + type: number + description: "Specifies the node version." + working_directory: + required: true + type: string + description: "Specifies the working directory." + tenant_id: + required: true + type: string + description: "Specifies the tenant id of the deployment." + subscription_id: + required: true + type: string + description: "Specifies the subscription id of the deployment." + lock_id: + required: true + type: string + description: "Specifies the lock_id of the state to unlock." + secrets: + CLIENT_ID: + required: true + description: "Specifies the client id." + +permissions: + id-token: write + contents: read + pull-requests: write + +jobs: + deployment: + name: Terraform Unlock + runs-on: [self-hosted] + continue-on-error: false + environment: ${{ inputs.environment }} + concurrency: + group: terraform-${{ inputs.config }}-${{ inputs.environment }} + cancel-in-progress: false + + env: + ARM_TENANT_ID: ${{ inputs.tenant_id }} + ARM_SUBSCRIPTION_ID: ${{ inputs.subscription_id }} + ARM_CLIENT_ID: ${{ secrets.CLIENT_ID }} + ARM_USE_OIDC: true + + steps: + # Setup Node + - name: Setup Node + id: node_setup + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ inputs.node_version }} + + # Setup Terraform + - name: Setup Terraform + id: terraform_setup + uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + terraform_version: ${{ inputs.terraform_version }} + terraform_wrapper: true + + # Check Out Repository + - name: Check Out Repository + id: checkout_repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + submodules: true + + # Terraform Init + - name: Terraform Init + working-directory: ${{ inputs.working_directory }} + run: | + terraform init -backend-config=../../config/${CONFIG}/azurerm.tfbackend + env: + CONFIG: ${{ inputs.config }} + + # Terraform State Unlock + - name: Unlock State Terraform + working-directory: ${{ inputs.working_directory }} + run: | + terraform force-unlock -force ${{ inputs.lock_id }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b2c95e5..32ce1ff 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,12 +13,12 @@ jobs: runs-on: ubuntu-latest steps: - # Setup Python 3.11 - - name: Setup Python 3.11 + # Setup Python + - name: Setup Python id: python_setup uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: - python-version: "3.11" + python-version: "3.13" # Setup Node - name: Setup Node @@ -32,7 +32,7 @@ jobs: id: terraform_setup uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 with: - terraform_version: "1.12.2" + terraform_version: "1.13.4" terraform_wrapper: true # Checkout repository diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml new file mode 100644 index 0000000..30d104f --- /dev/null +++ b/.github/workflows/terraform.yml @@ -0,0 +1,47 @@ +name: Infrastructure Deployment +on: + push: + branches: + - main + paths: + - "**.tf" + - "code/infra/**" + - ".github/workflows/terraform.yml" + + pull_request: + branches: + - main + paths: + - "**.tf" + - "code/infra/**" + - ".github/workflows/terraform.yml" + +jobs: + terraform_dev: + uses: ./.github/workflows/_terraformEnvironmentTemplate.yml + name: "Dev" + with: + environment: "dev" + config: "dev" + terraform_version: "1.13.4" + node_version: 24 + working_directory: "./code/infra" + tenant_id: "37963dd4-f4e6-40f8-a7d6-24b97919e452" + subscription_id: "1fdab118-1638-419a-8b12-06c9543714a0" + secrets: + CLIENT_ID: ${{ secrets.CLIENT_ID }} + + terraform_dev_destroy: + uses: ./.github/workflows/_terraformDestroyTemplate.yml + name: "Dev - Destroy" + needs: [terraform_dev] + with: + environment: "dev" + config: "dev" + terraform_version: "1.13.4" + node_version: 24 + working_directory: "./code/infra" + tenant_id: "37963dd4-f4e6-40f8-a7d6-24b97919e452" + subscription_id: "1fdab118-1638-419a-8b12-06c9543714a0" + secrets: + CLIENT_ID: ${{ secrets.CLIENT_ID }} diff --git a/.github/workflows/terraformImport.yml b/.github/workflows/terraformImport.yml new file mode 100644 index 0000000..19641d5 --- /dev/null +++ b/.github/workflows/terraformImport.yml @@ -0,0 +1,44 @@ +name: Import Azure Resource into Terraform State + +on: + workflow_dispatch: + inputs: + environment: + description: "Specifies the environment of the deployment." + required: true + type: choice + default: "dev" + options: + - "dev" + config: + description: "Specifies the config of the deployment." + required: true + type: choice + default: "dev" + options: + - "dev" + import_target: + required: true + type: string + description: 'Specifies the terraform resource address (e.g., azurerm_resource_group.my_rg)' + resource_id: + required: true + type: string + description: "Specifies the resource id to import (e.g., /subscriptions/.../resourceGroups/myResourceGroup)" + +jobs: + terraform_import: + uses: ./.github/workflows/_terraformImportTemplate.yml + name: "Import" + with: + environment: ${{ inputs.environment }} + config: ${{ inputs.config }} + terraform_version: "1.13.4" + node_version: 24 + working_directory: "./code/infra" + tenant_id: "37963dd4-f4e6-40f8-a7d6-24b97919e452" + subscription_id: "1fdab118-1638-419a-8b12-06c9543714a0" + import_target: ${{ inputs.import_target }} + resource_id: ${{ inputs.resource_id }} + secrets: + CLIENT_ID: ${{ secrets.CLIENT_ID }} diff --git a/.github/workflows/terraformStateUnlock.yml b/.github/workflows/terraformStateUnlock.yml new file mode 100644 index 0000000..dd1dc09 --- /dev/null +++ b/.github/workflows/terraformStateUnlock.yml @@ -0,0 +1,39 @@ +name: Unlock Terraform State + +on: + workflow_dispatch: + inputs: + environment: + description: "Specifies the environment of the deployment." + required: true + type: choice + default: "dev" + options: + - "dev" + config: + description: "Specifies the config of the deployment." + required: true + type: choice + default: "dev" + options: + - "dev" + lock_id: + description: 'lock_id of the state to unlock' + required: true + type: string + +jobs: + terraform_unlock: + uses: ./.github/workflows/_terraformStateUnlockTemplate.yml + name: "Dev" + with: + environment: ${{ inputs.environment }} + config: ${{ inputs.config }} + terraform_version: "1.13.4" + node_version: 24 + working_directory: "./code/infra" + tenant_id: "37963dd4-f4e6-40f8-a7d6-24b97919e452" + subscription_id: "1fdab118-1638-419a-8b12-06c9543714a0" + lock_id: ${{ inputs.lock_id }} + secrets: + CLIENT_ID: ${{ secrets.CLIENT_ID }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 920b74d..e5353a1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,11 +13,19 @@ repos: - id: pretty-format-json args: ["--indent", "2", "--autofix", "--no-sort-keys"] - repo: https://github.com/PyCQA/isort - rev: 6.0.1 + rev: 7.0.0 hooks: - id: isort args: ["--profile", "black", "--filter-files"] - repo: https://github.com/psf/black - rev: 25.1.0 + rev: 25.9.0 hooks: - id: black + - repo: local + hooks: + - id: terraform-fmt + name: terraform fmt + description: runs terraform fmt + entry: terraform fmt -recursive + language: system + pass_filenames: false diff --git a/code/infra/aiservice.tf b/code/infra/aiservice.tf new file mode 100644 index 0000000..066f643 --- /dev/null +++ b/code/infra/aiservice.tf @@ -0,0 +1,26 @@ +module "ai_service" { + source = "github.com/PerfectThymeTech/terraform-azurerm-modules//modules/aiservice?ref=main" + providers = { + azurerm = azurerm + azapi = azapi + time = time + } + + location = var.location_aoai + location_private_endpoint = var.location + resource_group_name = azurerm_resource_group.resource_group.name + tags = var.tags + cognitive_account_name = "${local.prefix}-ai001" + cognitive_account_kind = "OpenAI" + cognitive_account_sku = "S0" + cognitive_account_firewall_bypass_azure_services = false + cognitive_account_outbound_network_access_restricted = true + cognitive_account_outbound_network_access_allowed_fqdns = [] + cognitive_account_local_auth_enabled = false + cognitive_account_deployments = {} + diagnostics_configurations = local.diagnostics_configurations + subnet_id = azapi_resource.subnet_private_endpoints.id + connectivity_delay_in_seconds = var.connectivity_delay_in_seconds + private_dns_zone_id_cognitive_account = var.private_dns_zone_id_open_ai + customer_managed_key = var.customer_managed_key +} diff --git a/code/infra/applicationinsights.tf b/code/infra/applicationinsights.tf new file mode 100644 index 0000000..9158db1 --- /dev/null +++ b/code/infra/applicationinsights.tf @@ -0,0 +1,14 @@ +module "application_insights" { + source = "github.com/PerfectThymeTech/terraform-azurerm-modules//modules/applicationinsights?ref=main" + providers = { + azurerm = azurerm + } + + location = var.location + resource_group_name = azurerm_resource_group.resource_group.name + tags = var.tags + application_insights_name = "${local.prefix}-appi001" + application_insights_application_type = "web" + application_insights_log_analytics_workspace_id = var.log_analytics_workspace_id + diagnostics_configurations = local.diagnostics_configurations +} diff --git a/code/infra/communicationservice.tf b/code/infra/communicationservice.tf new file mode 100644 index 0000000..254f124 --- /dev/null +++ b/code/infra/communicationservice.tf @@ -0,0 +1,13 @@ +module "communication_service" { + source = "github.com/PerfectThymeTech/terraform-azurerm-modules//modules/communicationservice?ref=main" + providers = { + azurerm = azurerm + } + + location = var.location + resource_group_name = azurerm_resource_group.resource_group.name + tags = var.tags + communication_service_name = "${local.prefix}-acs001" + communication_service_data_location = var.communication_service_data_location + diagnostics_configurations = local.diagnostics_configurations +} diff --git a/code/infra/containerapps.tf b/code/infra/containerapps.tf new file mode 100644 index 0000000..81c444b --- /dev/null +++ b/code/infra/containerapps.tf @@ -0,0 +1,54 @@ +resource "azurerm_container_app_environment" "container_app_environment" { + name = "${local.prefix}-cae001" + location = var.location + resource_group_name = azurerm_resource_group.resource_group.name + tags = var.tags + + dapr_application_insights_connection_string = module.application_insights.application_insights_connection_string + infrastructure_resource_group_name = "${local.prefix}-cae001-rg" + infrastructure_subnet_id = azapi_resource.subnet_container_app.id + internal_load_balancer_enabled = true + logs_destination = "azure-monitor" + mutual_tls_enabled = false + workload_profile { + name = "Consumption" + workload_profile_type = "Consumption" + } + # workload_profile { + # name = "D4" + # workload_profile_type = "D4" + # minimum_count = 1 + # maximum_count = 3 + # } + zone_redundancy_enabled = var.zone_redundancy_enabled +} + +resource "azurerm_monitor_diagnostic_setting" "diagnostic_setting_container_app_environment" { + for_each = { for index, value in local.diagnostics_configurations : + index => { + log_analytics_workspace_id = value.log_analytics_workspace_id, + storage_account_id = value.storage_account_id + } + } + name = "applicationLogs-${each.key}" + target_resource_id = azurerm_container_app_environment.container_app_environment.id + log_analytics_workspace_id = each.value.log_analytics_workspace_id == "" ? null : each.value.log_analytics_workspace_id + storage_account_id = each.value.storage_account_id == "" ? null : each.value.storage_account_id + + dynamic "enabled_log" { + iterator = entry + for_each = data.azurerm_monitor_diagnostic_categories.diagnostic_categories_container_app_environment.log_category_groups + content { + category_group = entry.value + } + } + + dynamic "metric" { + iterator = entry + for_each = data.azurerm_monitor_diagnostic_categories.diagnostic_categories_container_app_environment.metrics + content { + category = entry.value + enabled = true + } + } +} diff --git a/code/infra/data.tf b/code/infra/data.tf new file mode 100644 index 0000000..7513cdf --- /dev/null +++ b/code/infra/data.tf @@ -0,0 +1,27 @@ +data "azurerm_client_config" "current" {} + +data "azurerm_virtual_network" "virtual_network" { + name = local.virtual_network.name + resource_group_name = local.virtual_network.resource_group_name +} + +data "azurerm_network_security_group" "network_security_group" { + name = local.network_security_group.name + resource_group_name = local.network_security_group.resource_group_name +} + +data "azurerm_route_table" "route_table" { + name = local.route_table.name + resource_group_name = local.route_table.resource_group_name +} + +data "azurerm_log_analytics_workspace" "log_analytics_workspace" { + provider = azurerm.log_analytics + + name = local.log_analytics_workspace.name + resource_group_name = local.log_analytics_workspace.resource_group_name +} + +data "azurerm_monitor_diagnostic_categories" "diagnostic_categories_container_app_environment" { + resource_id = azurerm_container_app_environment.container_app_environment.id +} diff --git a/code/infra/keyvault.tf b/code/infra/keyvault.tf new file mode 100644 index 0000000..a46a178 --- /dev/null +++ b/code/infra/keyvault.tf @@ -0,0 +1,18 @@ +module "key_vault" { + source = "github.com/PerfectThymeTech/terraform-azurerm-modules//modules/keyvault?ref=main" + providers = { + azurerm = azurerm + time = time + } + + location = var.location + resource_group_name = azurerm_resource_group.resource_group.name + tags = var.tags + key_vault_name = "${local.prefix}-kv001" + key_vault_sku_name = "standard" + key_vault_soft_delete_retention_days = 7 + diagnostics_configurations = local.diagnostics_configurations + subnet_id = azapi_resource.subnet_private_endpoints.id + connectivity_delay_in_seconds = var.connectivity_delay_in_seconds + private_dns_zone_id_vault = var.private_dns_zone_id_vault +} diff --git a/code/infra/locals.tf b/code/infra/locals.tf new file mode 100644 index 0000000..9a79fdf --- /dev/null +++ b/code/infra/locals.tf @@ -0,0 +1,41 @@ +locals { + # General locals + prefix = "${lower(var.prefix)}-${var.environment}" + github_labels = "aca" + resource_providers_to_register = [ + "Microsoft.Authorization", + "Microsoft.App", + "Microsoft.Insights", + "Microsoft.KeyVault", + "Microsoft.ManagedIdentity", + "Microsoft.Network", + "Microsoft.Resources", + ] + + # Resource locals + virtual_network = { + resource_group_name = split("/", var.vnet_id)[4] + name = split("/", var.vnet_id)[8] + } + network_security_group = { + resource_group_name = split("/", var.nsg_id)[4] + name = split("/", var.nsg_id)[8] + } + route_table = { + resource_group_name = split("/", var.route_table_id)[4] + name = split("/", var.route_table_id)[8] + } + log_analytics_workspace = { + subscription_id = split("/", var.log_analytics_workspace_id)[2] + resource_group_name = split("/", var.log_analytics_workspace_id)[4] + name = split("/", var.log_analytics_workspace_id)[8] + } + + # Logging locals + diagnostics_configurations = [ + { + log_analytics_workspace_id = var.log_analytics_workspace_id + storage_account_id = "" + } + ] +} diff --git a/code/infra/main.tf b/code/infra/main.tf new file mode 100644 index 0000000..3b2e460 --- /dev/null +++ b/code/infra/main.tf @@ -0,0 +1,5 @@ +resource "azurerm_resource_group" "resource_group" { + name = "${local.prefix}-rg" + location = var.location + tags = var.tags +} diff --git a/code/infra/network.tf b/code/infra/network.tf new file mode 100644 index 0000000..806b61e --- /dev/null +++ b/code/infra/network.tf @@ -0,0 +1,58 @@ +resource "azapi_resource" "subnet_container_app" { + type = "Microsoft.Network/virtualNetworks/subnets@2024-01-01" + name = "ConAppEnvironmentSubnet" + parent_id = data.azurerm_virtual_network.virtual_network.id + + body = { + properties = { + addressPrefix = var.subnet_cidr_container_app + delegations = [ + { + name = "ContainerAppDelegation" + properties = { + serviceName = "Microsoft.App/environments" + } + } + ] + ipAllocations = [] + networkSecurityGroup = { + id = data.azurerm_network_security_group.network_security_group.id + } + privateEndpointNetworkPolicies = "Enabled" + privateLinkServiceNetworkPolicies = "Enabled" + routeTable = { + id = data.azurerm_route_table.route_table.id + } + serviceEndpointPolicies = [] + serviceEndpoints = [] + } + } +} + +resource "azapi_resource" "subnet_private_endpoints" { + type = "Microsoft.Network/virtualNetworks/subnets@2024-01-01" + name = "ConAppPrivateEndpointSubnet" + parent_id = data.azurerm_virtual_network.virtual_network.id + + body = { + properties = { + addressPrefix = var.subnet_cidr_private_endpoints + delegations = [] + ipAllocations = [] + networkSecurityGroup = { + id = data.azurerm_network_security_group.network_security_group.id + } + privateEndpointNetworkPolicies = "Enabled" + privateLinkServiceNetworkPolicies = "Enabled" + routeTable = { + id = data.azurerm_route_table.route_table.id + } + serviceEndpointPolicies = [] + serviceEndpoints = [] + } + } + + depends_on = [ + azapi_resource.subnet_container_app + ] +} diff --git a/code/infra/providers.tf b/code/infra/providers.tf new file mode 100644 index 0000000..476903c --- /dev/null +++ b/code/infra/providers.tf @@ -0,0 +1,51 @@ +provider "azurerm" { + disable_correlation_request_id = false + environment = "public" + resource_provider_registrations = "none" + resource_providers_to_register = local.resource_providers_to_register + storage_use_azuread = true + use_oidc = true + + features { + key_vault { + recover_soft_deleted_key_vaults = true + recover_soft_deleted_certificates = true + recover_soft_deleted_keys = true + recover_soft_deleted_secrets = true + } + resource_group { + prevent_deletion_if_contains_resources = false + } + } +} + +provider "azurerm" { + alias = "log_analytics" + disable_correlation_request_id = false + environment = "public" + resource_provider_registrations = "none" + storage_use_azuread = true + subscription_id = local.log_analytics_workspace.subscription_id + use_oidc = true + + features { + key_vault { + recover_soft_deleted_key_vaults = true + recover_soft_deleted_certificates = true + recover_soft_deleted_keys = true + recover_soft_deleted_secrets = true + } + resource_group { + prevent_deletion_if_contains_resources = false + } + } +} + +provider "azapi" { + default_location = var.location + default_tags = var.tags + disable_correlation_request_id = false + environment = "public" + skip_provider_registration = false + use_oidc = true +} diff --git a/code/infra/roleassignments.tf b/code/infra/roleassignments.tf new file mode 100644 index 0000000..9fe8f7b --- /dev/null +++ b/code/infra/roleassignments.tf @@ -0,0 +1,12 @@ +resource "azurerm_role_assignment" "current_role_assignment_key_vault_secrets_officer" { + scope = module.key_vault.key_vault_id + role_definition_name = "Key Vault Secrets Officer" + principal_id = data.azurerm_client_config.current.object_id +} + +# User Assigned Identity +resource "azurerm_role_assignment" "uai_role_assignment_key_vault_secrets_user" { + scope = module.key_vault.key_vault_id + role_definition_name = "Key Vault Secrets User" + principal_id = module.user_assigned_identity.user_assigned_identity_principal_id +} diff --git a/code/infra/terraform.tf b/code/infra/terraform.tf new file mode 100644 index 0000000..e0482b8 --- /dev/null +++ b/code/infra/terraform.tf @@ -0,0 +1,27 @@ +terraform { + required_version = ">=1.6.0, <2.0.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "4.18.0" + } + azapi = { + source = "azure/azapi" + version = "2.2.0" + } + time = { + source = "hashicorp/time" + version = "0.12.1" + } + } + + backend "azurerm" { + environment = "public" + resource_group_name = "" + storage_account_name = "" + container_name = "" + key = "" + use_azuread_auth = true + } +} diff --git a/code/infra/userassignedidentity.tf b/code/infra/userassignedidentity.tf new file mode 100644 index 0000000..f28123a --- /dev/null +++ b/code/infra/userassignedidentity.tf @@ -0,0 +1,12 @@ +module "user_assigned_identity" { + source = "github.com/PerfectThymeTech/terraform-azurerm-modules//modules/userassignedidentity?ref=main" + providers = { + azurerm = azurerm + } + + location = var.location + resource_group_name = azurerm_resource_group.resource_group.name + tags = var.tags + user_assigned_identity_name = "${local.prefix}-uai001" + user_assigned_identity_federated_identity_credentials = {} +} diff --git a/code/infra/variables.tf b/code/infra/variables.tf new file mode 100644 index 0000000..59db072 --- /dev/null +++ b/code/infra/variables.tf @@ -0,0 +1,205 @@ +# General variables +variable "location" { + description = "Specifies the location for all Azure resources." + type = string + sensitive = false +} + +variable "location_aoai" { + description = "Specifies the location for the Azure Open AI resource." + type = string + sensitive = false +} + +variable "environment" { + description = "Specifies the environment of the deployment." + type = string + sensitive = false + default = "dev" + validation { + condition = contains(["int", "dev", "tst", "qa", "uat", "prd"], var.environment) + error_message = "Please use an allowed value: \"int\", \"dev\", \"tst\", \"qa\", \"uat\" or \"prd\"." + } +} + +variable "prefix" { + description = "Specifies the prefix for all resources created in this deployment." + type = string + sensitive = false + validation { + condition = length(var.prefix) >= 2 && length(var.prefix) <= 10 + error_message = "Please specify a prefix with between 2 and 10 characters inclusive." + } +} + +variable "tags" { + description = "Specifies the tags that you want to apply to all resources." + type = map(string) + sensitive = false + default = {} +} + +# Service variables +variable "container_image_reference" { + description = "Specifies the container image reference used in Azure Container Jobs." + type = string + sensitive = true + validation { + condition = length(var.container_image_reference) > 2 + error_message = "Please specify a valid container reference." + } +} + +variable "communication_service_data_location" { + description = "Specifies the data location for the Communication Service." + type = string + sensitive = false + default = "United States" + validation { + condition = contains(["Africa", "Asia Pacific", "Australia", "Brazil", "Canada", "Europe", "France", "Germany", "India", "Japan", "Korea", "Norway", "Switzerland", "UAE", "UK", "usgov", "United States"], var.communication_service_data_location) + error_message = "Please specify a valid communication service data location." + } +} + +# HA/DR variables +variable "zone_redundancy_enabled" { + description = "Specifies whether zone-redundancy should be enabled for all resources." + type = bool + sensitive = false + nullable = false + default = true +} + +# Logging and monitoring variables +variable "log_analytics_workspace_id" { + description = "Specifies the resource ID of the log analytics workspace used for collecting logs." + type = string + sensitive = false + validation { + condition = length(split("/", var.log_analytics_workspace_id)) == 9 + error_message = "Please specify a valid resource ID." + } +} + +# Identity variables +variable "service_principal_name_terraform_plan" { + description = "Specifies the name of the service principal used for the Terraform plan in PRs." + type = string + sensitive = false + default = "" + validation { + condition = var.service_principal_name_terraform_plan == "" || length(var.service_principal_name_terraform_plan) >= 2 + error_message = "Please specify a valid name." + } +} + +# Network variables +variable "vnet_id" { + description = "Specifies the resource ID of the Vnet used for the Data Landing Zone." + type = string + sensitive = false + validation { + condition = length(split("/", var.vnet_id)) == 9 + error_message = "Please specify a valid resource ID." + } +} + +variable "nsg_id" { + description = "Specifies the resource ID of the default network security group for the Azure Function." + type = string + sensitive = false + validation { + condition = length(split("/", var.nsg_id)) == 9 + error_message = "Please specify a valid resource ID." + } +} + +variable "route_table_id" { + description = "Specifies the resource ID of the default route table for the Azure Function." + type = string + sensitive = false + validation { + condition = length(split("/", var.route_table_id)) == 9 + error_message = "Please specify a valid resource ID." + } +} + +variable "subnet_cidr_container_app" { + description = "Specifies the subnet cidr range for the container app subnet." + type = string + sensitive = false + validation { + condition = length(split("/", var.subnet_cidr_container_app)) == 2 + error_message = "Please specify a valid subnet cidr range." + } +} + +variable "subnet_cidr_private_endpoints" { + description = "Specifies the subnet cidr range for private endpoints." + type = string + sensitive = false + validation { + condition = length(split("/", var.subnet_cidr_private_endpoints)) == 2 + error_message = "Please specify a valid subnet cidr range." + } +} + +variable "connectivity_delay_in_seconds" { + description = "Specifies the delay in seconds after the private endpoint deployment (required for the DNS automation via Policies)." + type = number + sensitive = false + nullable = false + default = 120 + validation { + condition = var.connectivity_delay_in_seconds >= 0 + error_message = "Please specify a valid non-negative number." + } +} + +# DNS variables +variable "private_dns_zone_id_vault" { + description = "Specifies the resource ID of the private DNS zone for Azure Key Vault. Not required if DNS A-records get created via Azure Policy." + type = string + sensitive = false + default = "" + validation { + condition = var.private_dns_zone_id_vault == "" || (length(split("/", var.private_dns_zone_id_vault)) == 9 && endswith(var.private_dns_zone_id_vault, "privatelink.vaultcore.azure.net")) + error_message = "Please specify a valid resource ID for the private DNS Zone." + } +} + +variable "private_dns_zone_id_open_ai" { + description = "Specifies the resource ID of the private DNS zone for Azure Open AI. Not required if DNS A-records get created via Azure Policy." + type = string + sensitive = false + default = "" + validation { + condition = var.private_dns_zone_id_open_ai == "" || (length(split("/", var.private_dns_zone_id_open_ai)) == 9 && (endswith(var.private_dns_zone_id_open_ai, "privatelink.openai.azure.com"))) + error_message = "Please specify a valid resource ID for the private DNS Zone." + } +} + +# Customer-managed key variables +variable "customer_managed_key" { + description = "Specifies the customer managed key configurations." + type = object({ + key_vault_id = string, + key_vault_key_id = string, + key_vault_key_versionless_id = string, + user_assigned_identity_id = string, + user_assigned_identity_client_id = string, + }) + sensitive = false + nullable = true + default = null + validation { + condition = alltrue([ + var.customer_managed_key == null || length(split("/", try(var.customer_managed_key.key_vault_id, ""))) == 9, + var.customer_managed_key == null || startswith(try(var.customer_managed_key.key_vault_key_id, ""), "https://"), + var.customer_managed_key == null || startswith(try(var.customer_managed_key.key_vault_key_versionless_id, ""), "https://"), + var.customer_managed_key == null || length(split("/", try(var.customer_managed_key.user_assigned_identity_id, ""))) == 9, + var.customer_managed_key == null || length(try(var.customer_managed_key.user_assigned_identity_client_id, "")) >= 2, + ]) + error_message = "Please specify a valid resource ID." + } +} diff --git a/config/dev/azurerm.tfbackend b/config/dev/azurerm.tfbackend new file mode 100644 index 0000000..eb59dbc --- /dev/null +++ b/config/dev/azurerm.tfbackend @@ -0,0 +1,7 @@ +environment = "public" +subscription_id = "e82c5267-9dc4-4f45-ac13-abdd5e130d27" +resource_group_name = "rg-terraform" +storage_account_name = "terraformststg001" +container_name = "realtime-agent" +key = "terraform.tfstate" +use_azuread_auth = true diff --git a/config/dev/vars.tfvars b/config/dev/vars.tfvars new file mode 100644 index 0000000..529110f --- /dev/null +++ b/config/dev/vars.tfvars @@ -0,0 +1,35 @@ +# General variables +location = "northeurope" +location_aoai = "swedencentral" +environment = "dev" +prefix = "voi-aig" +tags = { + "workload" = "voice-agent" +} + +# Service variables +container_image_reference = "todo" +communication_service_data_location = "Europe" + +# HA/DR variables +zone_redundancy_enabled = false + +# Logging and monitoring variables +log_analytics_workspace_id = "/subscriptions/e82c5267-9dc4-4f45-ac13-abdd5e130d27/resourceGroups/ptt-dev-logging-rg/providers/Microsoft.OperationalInsights/workspaces/ptt-dev-log001" + +# Identity variables +service_principal_name_terraform_plan = "" + +# Network variables +vnet_id = "/subscriptions/1fdab118-1638-419a-8b12-06c9543714a0/resourceGroups/ptt-dev-networking-rg/providers/Microsoft.Network/virtualNetworks/spoke-ptt-dev-vnet001" +nsg_id = "/subscriptions/1fdab118-1638-419a-8b12-06c9543714a0/resourceGroups/ptt-dev-networking-rg/providers/Microsoft.Network/networkSecurityGroups/ptt-dev-default-nsg001" +route_table_id = "/subscriptions/1fdab118-1638-419a-8b12-06c9543714a0/resourceGroups/ptt-dev-networking-rg/providers/Microsoft.Network/routeTables/ptt-dev-default-rt001" +subnet_cidr_container_app = "10.3.1.0/26" +subnet_cidr_private_endpoints = "10.3.1.64/26" + +# DNS variables +private_dns_zone_id_open_ai = "/subscriptions/e82c5267-9dc4-4f45-ac13-abdd5e130d27/resourceGroups/ptt-dev-privatedns-rg/providers/Microsoft.Network/privateDnsZones/privatelink.openai.azure.com" +private_dns_zone_id_vault = "/subscriptions/e82c5267-9dc4-4f45-ac13-abdd5e130d27/resourceGroups/ptt-dev-privatedns-rg/providers/Microsoft.Network/privateDnsZones/privatelink.vaultcore.azure.net" + +# Customer-managed key variables +customer_managed_key = null