diff --git a/.azdo/pipelines/README.md b/.azdo/README.md similarity index 50% rename from .azdo/pipelines/README.md rename to .azdo/README.md index 665fab5d..01e4c1cc 100644 --- a/.azdo/pipelines/README.md +++ b/.azdo/README.md @@ -1,24 +1,14 @@ # Azure DevOps Pipeline Configuration -This document is included to help you quickly set up this sample as part of an Azure DevOps pipeline that could be used as a starting point for your DevOps automations. +This document will help you create an Azure DevOps pipeline that uses the Azure Developer CLI to deploy this sample. -## Other considerations -Your devOps process should be customized to automate the build, test, and deployment steps specific to your business needs. -We recommend these following considerations to expand on the `azure-dev.yml` sample. +> This pipeline does not include the QA processes that we recommend, it is included to help you quickstart your learning journey. This pipeline also does not include the Database lifecycle management processes that we recommend. You should review your needs with your team to identify a mature solution for deploying database changes, and versioning your database schema with source control. -- You may want to review `scheduled-azure-dev.yml` to see how to add more steps such as validation testing -- You may want multiple workflows defined in different files for different purposes - - Consider database lifecycle management - - Consider quality testing processes (e.g. integration testing) - -## Setting up Azure DevOps Pipelines -The following content show you how to configure an Azure DevOps pipeline that uses the Azure Developer CLI. - -You will find a default Azure DevOps pipeline file in `./.azdo/pipelines/daily-azure-dev.yml`. It will provision your Azure resources and deploy your code on a daily schedule. +You will find a default Azure DevOps pipeline file in `./.azdo/pipelines/azure-dev.yml`. It will provision your Azure resources and deploy your code on a daily schedule. You are welcome to use the file as-is or modify it to suit your needs. -> First time setup: This pipeline does not ask you to store credentials that can access Azure AD. As such, you will need to run the `createAppRegistrations.sh` script with your account for a first time setup. This process can be added to the pipeline as an idempotent script but will require an Azure AD account to create the App Registrations. +> First time setup: This pipeline does not ask you to store credentials that can access Microsoft Entra ID. As such, you will need to run the `create-app-registrations.ps1` script with your account for a first time setup. This process can be added to the pipeline as an idempotent script but will require an Microsoft Entra ID account to create the App Registrations. ## Getting Started The following steps are required to get started. @@ -43,7 +33,7 @@ The following steps walk-through creating the Azure Pipeline. 1. Start by navigating to the Azure DevOps Pipeline page - ![#Azure DevOps Pipeline Page](../../assets/AzdoSetup/1CreateAPipeline.png) + ![#Azure DevOps Pipeline Page](../assets/images/AzdoSetup/1CreateAPipeline.png) Image of Azure DevOps Pipeline Page @@ -51,26 +41,26 @@ The following steps walk-through creating the Azure Pipeline. 3. Choose **Azure Repos Git** and the appropriate git repository - ![#Azure Pipeline asks where your code is](../../assets/AzdoSetup/2CreateAPipeline.png) + ![#Azure Pipeline asks where your code is](../assets/images/AzdoSetup/2CreateAPipeline.png) Azure Pipeline asks where your code is 4. Choose **Existing Azure Pipelines YAML file** - ![#Azure Pipeline asks to pick a template](../../assets/AzdoSetup/3CreateAPipeline.png) + ![#Azure Pipeline asks to pick a template](../assets/images/AzdoSetup/3CreateAPipeline.png) Azure Pipeline asks to pick a template 5. Select the *daily-azure-dev.yml* file from your repo - ![#Pick the daily-azure-dev.yml file](../../assets/AzdoSetup/4CreateAPipeline.png) + ![#Pick the daily-azure-dev.yml file](../assets/images/AzdoSetup/4CreateAPipeline.png) Pick the daily-azure-dev.yml file 6. On the next screen you must provide 3 pipeline variables - ![#Set Pipeline variables](../../assets/AzdoSetup/5CreateAPipeline.png) + ![#Set Pipeline variables](../assets/images/AzdoSetup/5CreateAPipeline.png) Set Pipeline variables @@ -82,5 +72,5 @@ The following steps walk-through creating the Azure Pipeline. 7. Click the `Run` button to start your first pipeline -> Note: Because the pipeline does not configure your Azure AD resources you must configure the Azure AD App Registrations and place those values into Key Vault and App Configuration Service before the application will run successfully. We provide the `createAppRegistration.sh` script to do this one-time setup. +> Note: Because the pipeline does not configure your Microsoft Entra ID resources you must configure the Microsoft Entra ID App Registrations and place those values into Key Vault and App Configuration Service before the application will run successfully. We provide the `createAppRegistration.sh` script to do this one-time setup. diff --git a/.azdo/pipelines/azure-dev.yml b/.azdo/pipelines/azure-dev.yml index 082fcf8b..baedab93 100644 --- a/.azdo/pipelines/azure-dev.yml +++ b/.azdo/pipelines/azure-dev.yml @@ -7,7 +7,7 @@ trigger: pool: vmImage: ubuntu-latest -container: mcr.microsoft.com/azure-dev-cli-apps:latest +container: mcr.microsoft.com/azure-dev-cli-apps:1.5.0 variables: - name: env_name value: $(AZD_AZURE_ENV_NAME)daily diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 3ac6ed69..73aa583a 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,2 +1,6 @@ ARG VARIANT -FROM mcr.microsoft.com/vscode/devcontainers/dotnet:0-${VARIANT} \ No newline at end of file +FROM mcr.microsoft.com/vscode/devcontainers/dotnet:${VARIANT} + +# Install Az module +RUN pwsh -Command "Install-Module -Name Az -Force -AllowClobber -Scope AllUsers" +RUN pwsh -Command "Install-Module -Name SqlServer -Force -AllowClobber -Scope AllUsers" \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 082524ae..cece3175 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,41 +1,39 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: -// https://github.com/microsoft/vscode-dev-containers/tree/v0.236.0/containers/dotnet { - "name": "reliable-web-app-pattern-dotnet", + "name": "web-app-pattern-dotnet", "build": { "dockerfile": "Dockerfile", "args": { - "VARIANT": "6.0-bullseye" - } + "VARIANT": "8.0-bookworm" + } }, "runArgs": ["--init", "--privileged"], "customizations": { "vscode": { "extensions": [ - "ms-dotnettools.csharp", - "ms-azuretools.vscode-bicep", + "ms-azuretools.azure-dev", "ms-azuretools.vscode-azureappservice", + "ms-azuretools.vscode-azureresourcegroups", + "ms-azuretools.vscode-azurestorage", + "ms-azuretools.vscode-bicep", "ms-azuretools.vscode-docker", - "ms-azuretools.azure-dev", + "ms-dotnettools.csharp", "ms-mssql.mssql", - "github.copilot" + "ms-vscode.azure-account", + "ms-vscode.PowerShell" ] } }, - - "remoteUser": "vscode", "features": { + "ghcr.io/azure/azure-dev/azd:latest": { + "version": "1.5.1" + }, + "ghcr.io/devcontainers/features/azure-cli:1": {}, "ghcr.io/devcontainers/features/common-utils:2": {}, + "ghcr.io/devcontainers/features/github-cli:1": {}, "ghcr.io/devcontainers/features/powershell:1": {}, - "ghcr.io/devcontainers/features/azure-cli:1": {}, - "ghcr.io/devcontainers/features/common-utils:1": {}, - "ghcr.io/devcontainers/features/sshd:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/sshd:1": {} }, - - "waitFor": "postCreateCommand", - "postCreateCommand": "curl -fsSL https://aka.ms/install-azd.sh | bash", - "postStartCommand": "az bicep install; azd config set auth.useAzCliAuth true" -} + // resolves error: dubious ownership of the workspace folder + "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}" +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..c10fec7d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto +*.sh text eol=lf \ No newline at end of file diff --git a/.github/workflows/azure-dev.yml b/.github/workflows/azure-dev.yml index caf868b3..5aa79333 100644 --- a/.github/workflows/azure-dev.yml +++ b/.github/workflows/azure-dev.yml @@ -12,7 +12,7 @@ jobs: build: runs-on: ubuntu-latest container: - image: mcr.microsoft.com/azure-dev-cli-apps:latest + image: mcr.microsoft.com/azure-dev-cli-apps:1.5.0 steps: - name: Checkout uses: actions/checkout@v2 diff --git a/.github/workflows/scheduled-azure-dev.yml b/.github/workflows/scheduled-azure-dev.yml index 19d80b56..c7382882 100644 --- a/.github/workflows/scheduled-azure-dev.yml +++ b/.github/workflows/scheduled-azure-dev.yml @@ -13,21 +13,33 @@ permissions: id-token: write contents: read +concurrency: integration_testing + jobs: build: runs-on: ubuntu-latest container: - image: mcr.microsoft.com/azure-dev-cli-apps:1.3.0 + image: mcr.microsoft.com/azure-dev-cli-apps:1.5.1 env: AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} - AZURE_ENV_NAME: ${{ secrets.AZURE_ENV_NAME }}scheduled - AZURE_LOCATION: ${{ secrets.AZURE_LOCATION2 }} steps: + - name: Install jq tool + uses: dcarbone/install-jq-action@v2 - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Install Az module + run: Install-Module -Name Az -Force -AllowClobber -Scope CurrentUser -Repository PSGallery + shell: pwsh + + - name: Check configuration + if: ${{ env.AZURE_CREDENTIALS == '' }} + run: echo "AZURE_CREDENTIALS are not available." # login to run ado commands such provision, deploy, and down - name: Log in with Azure (Client Credentials) for AZD @@ -44,74 +56,39 @@ jobs: env: AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} - - name: Create AZD environment - # Creating an azd environment so we can set the principal type - # https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/241 - run: azd env new ${{ secrets.AZURE_ENV_NAME }}scheduled --subscription ${{ secrets.AZURE_SUBSCRIPTION_ID }} --location ${{ secrets.AZURE_LOCATION2 }} + # login to run azd hooks and the QA validation script + - name: Log in with Azure CLI + if: ${{ env.AZURE_CREDENTIALS != '' }} + uses: Azure/login@v1.6.1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + enable-AzPSSession: true - - name: Set AZD PRINCIPAL_TYPE - # Adding RBAC permissions via the script enables the sample to work around a permission propagation issue outlined in the issue - # https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/138 - run: azd env set PRINCIPAL_TYPE servicePrincipal + - name: Set the environment + if: ${{ env.AZURE_CREDENTIALS != '' }} + run : azd env new ${{ secrets.AZURE_ENV_NAME }} - - name: Azure Dev Provision - run: azd provision --no-prompt + - name: Set the subscription + if: ${{ env.AZURE_CREDENTIALS != '' }} + run : azd env set AZURE_SUBSCRIPTION_ID ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - name: Set AZD AZURE_RESOURCE_GROUP - # temporary work around for known issue with multiple resource groups - # https://github.com/Azure/azure-dev/issues/690 - run: azd env set AZURE_RESOURCE_GROUP ${{ secrets.AZURE_ENV_NAME }}scheduled-rg + - name: Set the location + if: ${{ env.AZURE_CREDENTIALS != '' }} + run : azd env set AZURE_LOCATION ${{ secrets.AZURE_LOCATION }} - - name: Azure Dev Deploy - run: azd deploy --no-prompt + - name: Set the principal type + if: ${{ env.AZURE_CREDENTIALS != '' }} + run : azd env set AZURE_PRINCIPAL_TYPE ServicePrincipal - # login to run az cli commands such as validateDeployment.sh - - name: Log in with Azure CLI - uses: azure/login@v1 - with: - creds: ${{ secrets.AZURE_CREDENTIALS }} + - name: Azure Deploy + if: ${{ env.AZURE_CREDENTIALS != '' }} + run : azd up - name: QA - Validate Deployment - run: | - chmod +x ./infra/devOpsScripts/validateDeployment.sh - ./infra/devOpsScripts/validateDeployment.sh -g ${{ secrets.AZURE_ENV_NAME }}scheduled-rg - - teardown: - needs: [build] - runs-on: ubuntu-latest - container: - image: mcr.microsoft.com/azure-dev-cli-apps:1.3.0 - env: - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} - AZURE_ENV_NAME: ${{ secrets.AZURE_ENV_NAME }}scheduled - AZURE_LOCATION: ${{ secrets.AZURE_LOCATION2 }} - steps: - - name: Checkout - uses: actions/checkout@v2 - - # login to run ado commands such provision, deploy, and down - - name: Log in with Azure (Client Credentials) for AZD if: ${{ env.AZURE_CREDENTIALS != '' }} - run: | - $info = $Env:AZURE_CREDENTIALS | ConvertFrom-Json -AsHashtable; - Write-Host "::add-mask::$($info.clientSecret)" + run : ./testscripts/call-validate-deployment.sh - azd login ` - --client-id "$($info.clientId)" ` - --client-secret "$($info.clientSecret)" ` - --tenant-id "$($info.tenantId)" - shell: pwsh - env: - AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} - - - name: Create AZD environment - # Creating an azd environment so we can set the principal type - # https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/241 - run: azd env new ${{ secrets.AZURE_ENV_NAME }}scheduled --subscription ${{ secrets.AZURE_SUBSCRIPTION_ID }} --location ${{ secrets.AZURE_LOCATION2 }} - - - name: Azure Dev Down - run: azd down --force --purge --no-prompt + - name: Teardown Azure resources + if: ${{ env.AZURE_CREDENTIALS != '' }} + run : azd down --force --purge --no-prompt diff --git a/.github/workflows/scheduled-azure-teardown.yml b/.github/workflows/scheduled-azure-teardown.yml index f4ad224c..72f87cd0 100644 --- a/.github/workflows/scheduled-azure-teardown.yml +++ b/.github/workflows/scheduled-azure-teardown.yml @@ -5,18 +5,38 @@ on: workflow_dispatch: schedule: - cron: '0 13 1 * *' # Run at 13:00 on the 1st day of the month + +# https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#set-up-azure-login-with-openid-connect-authentication +permissions: + id-token: write + contents: read + +concurrency: integration_testing jobs: build: runs-on: ubuntu-latest container: - image: mcr.microsoft.com/azure-dev-cli-apps:1.3.0 + image: mcr.microsoft.com/azure-dev-cli-apps:1.5.1 + env: + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} steps: + - name: Install jq tool + uses: dcarbone/install-jq-action@v2 - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Install Az module + run: Install-Module -Name Az -Force -AllowClobber -Scope CurrentUser -Repository PSGallery + shell: pwsh # login to run ado commands such provision, deploy, and down - - name: Log in with Azure (Client Credentials) + - name: Log in with Azure (Client Credentials) for AZD if: ${{ env.AZURE_CREDENTIALS != '' }} run: | $info = $Env:AZURE_CREDENTIALS | ConvertFrom-Json -AsHashtable; @@ -30,23 +50,31 @@ jobs: env: AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} - - name: Create AZD environment - # Creating an azd environment so we can set the principal type - # https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/241 - run: azd env new ${{ secrets.AZURE_ENV_NAME }}scheduled --subscription ${{ secrets.AZURE_SUBSCRIPTION_ID }} --location ${{ secrets.AZURE_LOCATION2 }} - - - name: Azure Dev Down + # login to run azd hooks + - name: Log in with Azure CLI + uses: Azure/login@v1.6.1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + enable-AzPSSession: true + + - name: Set the environment + run: azd env new ${{ secrets.AZURE_ENV_NAME }} + + - name: Set the subscription + run : azd env set AZURE_SUBSCRIPTION_ID ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Set the location + run : azd env set AZURE_LOCATION ${{ secrets.AZURE_LOCATION }} + + - name: Set the principal type + run : azd env set AZURE_PRINCIPAL_TYPE ServicePrincipal + + - name: Set AZURE_RESOURCE_GROUP + # Azure resource group is required by the AZD tool for teardown + # calculated AZURE_RESOURCE_GROUP from the templates + run : azd env set AZURE_RESOURCE_GROUP rg-${{ secrets.AZURE_ENV_NAME }}-dev-${{ secrets.AZURE_LOCATION }}-application + + - name: Teardown Azure resources continue-on-error: true run: azd down --force --purge --no-prompt - env: - AZURE_ENV_NAME: ${{ secrets.AZURE_ENV_NAME }}scheduled - AZURE_LOCATION: ${{ secrets.AZURE_LOCATION2 }} - AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - # Resource names are reserved for a period of time after deletion - ensure they're purged to keep integration running - # https://learn.microsoft.com/azure/azure-app-configuration/faq#why-can-t-i-create-an-app-configuration-store-with-the-same-name-as-one-that-i-just-deleted - - name: Purge App Configuration Service - continue-on-error: true - run: | - chmod +x ./infra/deploymentScripts/appConfigSvcPurge.sh - ./infra/deploymentScripts/appConfigSvcPurge.sh -g ${{ secrets.AZURE_ENV_NAME }}daily2-rg diff --git a/.gitignore b/.gitignore index e7406832..16612889 100644 --- a/.gitignore +++ b/.gitignore @@ -1,354 +1,356 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ -.azure -createSqlUser.sql -updateSqlUserPerms.sql -sqlcmd* +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Azd folders +.azure + +**/Azure.LoadTest.Tool/publish/ +**/log*.txt diff --git a/README.md b/README.md index a30d70a1..302bdb02 100644 --- a/README.md +++ b/README.md @@ -2,472 +2,190 @@ > :mega: **Got feedback?** Fill out [this survey](https://aka.ms/eap/rwa/dotnet/survey) to help us shape the future of Enterprise App Patterns and understand whether we're focusing on the business goals and features important to you. [Microsoft Privacy Statement](https://go.microsoft.com/fwlink/?LinkId=521839) -The reference implementation provides a production-grade web application that uses best practices from our guidance and gives developers concrete examples to build their own Reliable Web Application in Azure. It simulates the journey from an on-premises ASP.NET application to a migration to Azure. It shows you what changes to make to maximize the benefits of the cloud in the initial cloud adoption phase. Here's an outline of the contents in this readme: +The reference implementation provides a production-grade web application that uses best practices from our guidance and gives developers concrete examples to build their own Reliable Web Application in Azure. This repository specifically demonstrates a concert ticketing application for the fictional company Relecloud, embodying the reliable web app pattern with a focus on .NET technologies. It guides developers through a simulated migration from an on-premises ASP.NET application to Azure, detailing the architectural changes and enhancements that capitalize on the cloud's strengths during the initial adoption phase. + +This project has [a companion article in the Azure Architecture Center](https://aka.ms/eap/rwa/dotnet/doc) that describes design patterns and best practices and [a six-part video series (YouTube)](https://aka.ms/eap/rwa/dotnet/videos) that details the reliable web app pattern for .NET web app. Here's an outline of the contents in this readme: -- [Azure Architecture Center guidance](#azure-architecture-center-guidance) -- [6 videos on the reliable web app pattern for .NET](#videos) - [Architecture](#architecture) - [Workflow](#workflow) - [Steps to deploy the reference implementation](#steps-to-deploy-the-reference-implementation) - [Additional links](#additional-links) - [Data Collection](#data-collection) -## Azure Architecture Center guidance - -This project has a [companion article in the Azure Architecture Center](https://aka.ms/eap/rwa/dotnet/doc) that describes design patterns and best practices for migrating to the cloud. We suggest you read it as it will give important context to the considerations applied in this implementation. +## Architecture -## Videos +Relecloud aligned to a hub and spoke network topology in the production deployment architecture to centralize common resources. This network topology provided cost savings, enhanced security, and facilitated network integration (platform and hybrid): -This project has a six-part video series that details the reliable web app pattern for .NET web app. For more information, see [Reliable web app pattern videos (YouTube)](https://aka.ms/eap/rwa/dotnet/videos). +![architecture diagram](./assets/icons/reliable-web-app-dotnet.svg) -## Architecture +This diagram describes the production deployment which is described in the [prod-deployment.md](./prod-deployment.md) file. The following steps below are for a [development deployment](./assets/icons/reliable-web-app-dotnet-dev.svg) which is a simplified version. -![architecture diagram](./assets/icons/reliable-web-app-dotnet.png) +- Cost efficiency: The hub acts as a central point for shared resources, promoting cost-effective resource reuse. For instance, Azure Bastion is a shared service in the hub, providing secure and cost-effective remote access without the need for separate deployments for each application. +- Traffic control and security: Network traffic is managed and secured using Network Security Groups and Route tables in each subnet, creating secure boundaries for Azure resources. Private endpoints add an extra layer of security, and a jump box allows for deployment within these boundaries, maintaining local IP access to resources. +- Network integration: The topology supports network integrations for data transfer across applications and hybrid scenarios. While the reference architecture doesn't include ExpressRoute or Azure VPN Gateway, these should be considered for applications requiring hybrid network connections. ## Workflow -- Azure Front Door routes traffic based on availability of the primary region. When the primary region is unavailable it will route traffic to the secondary region. -- When Front Door passes the request to the Web App, it will pass-through the Azure Web Application Firewall. The Azure Web Application Firewall will evaluate the request and protect the web app against common security attacks. -- Once the traffic reaches the web front-end users will be shown the home page. They can view these pages without authenticating. -- Navigating to the Concerts on the web app will send a request to the web front-end that tells it to ask the web api app for details about upcoming concerts. -- Details about the upcoming concerts will be retrieved from the Azure SQL Database by the web api app with a SQL query. The results will be formatted as a JSON response and returned to the web front-end. -- When the web front-end receives results from the API it will use razor template engine to render the HTML page shown to the user that asked for a list of concerts. -- Once a user adds a concert ticket to their shopping cart the front-end web app will start interacting with Azure Cache for Redis. Asking the web app to put a concert ticket into the cart tells the web app to save information about that pending purchase as JSON data in Redis as part of a session object for the current user. Saving the session to an external datastore enables the web app to load balance traffic more evenly and to handle horizontal scaling events without losing the customer's intent to buy a ticket. No inventory management is included in this sample so there are no quantities deducted, or placed on reserve, in the backend of the system. -- As the user checks out the front-end web app will ask the user to authenticate with Azure AD. This scenario is for a call center that places orders on-behalf of customers so the accounts in-use are managed by Relecloud and are not self-managed. -- After authenticating to Azure AD the front-end web app will receive a token from Azure AD that represents the current user. This token is saved as a cookie in the user's browser and is not persisted by the front-end web app. -- As the user proceeds with checkout the web app will collect payment data. Payment data is not sent anywhere for this sample. -- When the payment data is submitted for approval the ticket will be purchased. Logic to handle this is located in the web api project so the web app makes a call to the web api project. -- Prior to calling the API, the front-end web app asks the MSAL library for a token it can use to call the web api app as an authenticated user. -- When the MSAL library, in the front-end web app, has a token it will cache it in Azure Cache for Redis. If it does not have a token it will request one from Azure AD and then save it in Azure Cache for Redis. -- Once the ticket purchase request is sent to the web api app the API will render the ticket image and save it to Azure storage. -- After the ticket purchase is completed successfully the user will be directed to their tickets page where they can see a list of the tickets they have purchased. These tickets will be immediately available because rendering the ticket was part of the purchase request. - - As information flows between services the Azure network handles traffic routing across private endpoints by using Azure Private DNS to lookup the correct IP addresses. This enables the system to block public network traffic and use a single v-net to manage traffic between these systems. This v-net can be connected to others as-needed to allow the app to call other systems in your digital estate or to allow other systems to call the web API so they can access details about ticket purchases. - - As the front-end, and web api, apps process requests they are sending data to Application Insights so that you can monitor information about processing web requests - - When the web app is started for the first time it will load configuration data from App Config Service and Azure Key Vault. This information is saved in the web apps memory and is not accessed afterwards. +This description details the workflow for Relecloud's concert ticketing application. It highlights key components and functionality to help you emulate its design: + +- Global traffic routing: Azure Front Door acts as a global traffic manager, routing users to the primary region for optimal performance and failing over to a secondary region during outages for uninterrupted service. +- Security inspection: Incoming traffic is inspected by Azure Web Application Firewall to protect against web vulnerabilities before reaching the web app. +- Static and dynamic content delivery: Users receive static content, like the home page, immediately upon request. Dynamic content, such as 'Upcoming Concerts', is generated by making API calls to the backend, which fetches data from Azure SQL Database and returns it in a JSON format. +- Session state management: User sessions, including shopping cart data, are managed by Azure Cache for Redis, ensuring persistence and consistency across scale-out events. +- User authentication: Microsoft Entra ID handles user authentication, suitable for environments where accounts are centrally managed, enhancing security and control. +- API interaction and token management: The front-end web app uses the MSAL library to obtain tokens for authenticated API calls, caching them in Azure Cache for Redis to optimize performance and manageability. +- Payment and checkout flow: While this example doesn't process real payments, the web app captures payment information during checkout, demonstrating how a web app can handle sensitive data. +- Purchase and ticket generation: The backend API processes purchase requests and generates tickets that are immediately accessible to users. +- Networking and access control: Azure Private DNS, Network Security Groups, and Azure Firewall tightly control the flow of traffic within the app's network, maintaining security and isolation. +- Monitoring and telemetry: Application Insights provides monitoring and telemetry capabilities, enabling performance tracking and proactive issue resolution. +- Configuration and secrets management: Initial configuration and sensitive information are loaded from Azure App Configuration and Azure Key Vault into the app's memory upon startup, minimizing access to sensitive data thereafter. ## Steps to deploy the reference implementation -This reference implementation provides you with the instructions and templates you need to deploy this solution. This solution uses the Azure Dev CLI to set up Azure services -and deploy the code. - -### Pre-requisites - -1. To run the scripts, Windows users require Powershell 7.2 (LTS) or above. Alternatively, you can use a bash terminal using [Windows Subsystem for Linux](https://learn.microsoft.com/en-us/windows/wsl/install). macOS users can use a bash terminal. - - 1. PowerShell users - [Install PowerShell](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows) - Run the following to verify that you're running the latest PowerShell - - ```ps1 - $PsVersionTable - ``` - -1. [Install Git](https://github.com/git-guides/install-git) - Run the following to verify that git is available - ```ps1 - git version - ``` - -1. [Install the Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli). - Run the following command to verify that you're running version - 2.38.0 or higher. - - ```ps1 - az version - ``` - - After the installation, run the following command to [sign in to Azure interactively](https://learn.microsoft.com/cli/azure/authenticate-azure-cli#sign-in-interactively). +The following detailed deployment steps assume you are using a Dev Container inside Visual Studio Code. - ```ps1 - az login - ``` -1. [Upgrade the Azure CLI Bicep extension](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/install#azure-cli). - Run the following command to verify that you're running version 0.12.40 or higher. +> For your convenience, we use Dev Containers with a fully-featured development environment. If you prefer to use Visual Studio, we recommend installing the necessary [dependencies](./prerequisites.md) and skip to the deployment instructions starting in [Step 3](#3-log-in-to-azure). - ```ps1 - az bicep version - ``` +### 1. Clone the repo -1. [Install the Azure Dev CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd). - Run the following command to verify that the Azure Dev CLI is installed. +> For Windows users, we recommend using Windows Subsystem for Linux (WSL) to [improve Dev Container performance](https://code.visualstudio.com/remote/advancedcontainers/improve-performance). - ```ps1 - azd auth login - ``` - -1. [Install .NET 6 SDK](https://dotnet.microsoft.com/download/dotnet/6.0) - Run the following command to verify that the .NET SDK 6.0 is installed. - ```ps1 - dotnet --version - ``` - -### Get the code - -Please clone the repo to get started. - -``` -git clone https://github.com/Azure/reliable-web-app-pattern-dotnet +```pwsh +wsl ``` -And switch to the folder so that `azd` will recognize the solution. +Clone the repository from GitHub into the WSL 2 filesystem using the following command: -``` +```shell +git clone https://github.com/Azure/reliable-web-app-pattern-dotnet.git cd reliable-web-app-pattern-dotnet ``` -### Deploying to Azure +### 2. Open Dev Container in Visual Studio Code -Relecloud's developers use the `azd` command line experience to deploy the code. This means their local workflow is the same -experience that runs from the GitHub action. You can use these -steps to follow their experience by running the commands from the folder where this guide is stored after cloning this repo. +If required, ensure Docker Desktop is started and enabled for your WSL terminal [more details](https://learn.microsoft.com/windows/wsl/tutorials/wsl-containers#install-docker-desktop). Open the repository folder in Visual Studio Code. You can do this from the command prompt: -Use this command to get started with deployment by creating an -`azd` environment on your workstation. - - - - - - - - - - -
PowerShell - -```ps1 -$myEnvironmentName="relecloudresources" +```shell +code . ``` -```ps1 -azd init -e $myEnvironmentName -``` - -
Bash - -```bash -myEnvironmentName="relecloudresources" -``` +Once Visual Studio Code is launched, you should see a popup allowing you to click on the button **Reopen in Container**. -```bash -azd init -e $myEnvironmentName -``` +![Reopen in Container](assets/images/vscode-reopen-in-container.png) -
+If you don't see the popup, open the Visual Studio Code Command Palette to execute the command. There are three ways to open the command palette: +- For Mac users, use the keyboard shortcut ⇧⌘P +- For Windows and Linux users, use Ctrl+Shift+P +- From the Visual Studio Code top menu, navigate to View -> Command Palette. -#### (Optional Steps) Choose Prod or Non-prod environment +Once the command palette is open, search for `Dev Containers: Rebuild and Reopen in Container`. -The Relecloud team uses the same bicep templates to deploy -their production, and non-prod, environments. To do this -they set `azd` environment parameters that change the behavior -of the next steps. +![WSL Ubuntu](assets/images/vscode-reopen-in-container-command.png) -> If you skip the next two optional steps, and change nothing, -> then the bicep templates will default to non-prod settings. +### 3. Log in to Azure -*Step: 1* +Before deploying, you must be authenticated to Azure and have the appropriate subscription selected. Run the following command to authenticate: -Relecloud devs deploy the production environment by running the -following command to choose the SKUs they want in production. +If you are not using PowerShell 7+, run the following command (you can use [$PSVersionTable.PSVersion](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_powershell_editions) to check your version): -```ps1 -azd env set IS_PROD true +```shell +pwsh ``` -*Step: 2* - -Relecloud devs also use the following command to choose a second -Azure location because the production environment is -multiregional. - -```ps1 -azd env set SECONDARY_AZURE_LOCATION westus3 +```pwsh +Import-Module Az.Resources ``` -> You can find a list of available Azure regions by running -> the following Azure CLI command. -> -> ```ps1 -> az account list-locations --query "[].name" -o tsv -> ``` - -#### Provision the infrastructure - -Relecloud uses the following command to deploy the Azure -services defined in the bicep files by running the provision -command. - -> This step will take several minutes based on the region -> and deployment options you selected. - -```ps1 -azd provision +```pwsh +Connect-AzAccount ``` -When prompted, select the preferred Azure Subscription and the Location: - -![screenshot azd env new](./assets/Guide/Azd-Env-New.png) - -> When the command finishes you have deployed Azure App -> Service, SQL Database, and supporting services to your -> subscription. If you log into the the -> [Azure Portal](http://portal.azure.com) you can find them -> in the resource group named `$myEnvironmentName-rg`. - -#### Create App Registrations -Relecloud devs have automated the process of creating Azure -AD resources that support the authentication features of the -web app. They use the following command to create two new -App Registrations within Azure AD. The command is also -responsible for saving configuration data to Key Vault and -App Configuration so that the web app can read this data. +Set the subscription to the one you want to use (you can use [Get-AzSubscription](https://learn.microsoft.com/powershell/module/az.accounts/get-azsubscription?view=azps-11.3.0) to list available subscriptions): - - - - - - - - - -
PowerShell +```pwsh +$AZURE_SUBSCRIPTION_ID="" +``` -```ps1 -pwsh -c "Set-ExecutionPolicy Bypass Process; .\infra\createAppRegistrations.ps1 -g '$myEnvironmentName-rg'" +```pwsh +Set-AzContext -SubscriptionId $AZURE_SUBSCRIPTION_ID ``` -
Bash +Use the next command to login with the Azure Dev CLI (AZD) tool: -```bash -chmod +x ./infra/createAppRegistrations.sh -./infra/createAppRegistrations.sh -g "$myEnvironmentName-rg" +```pwsh +azd auth login ``` -
-> Known issue: [/bin/bash^M: bad interpreter](known-issues.md#troubleshooting) +### 4. Create a new environment -#### Deploy the code +Next we provide the AZD tool with variables that it uses to create the deployment. The first thing we initialize is the AZD environment with a name. -To finish the deployment process the Relecloud devs run the -folowing `azd` commands to build, package, and deploy the dotnet -code for the front-end and API web apps. +The environment name should be less than 18 characters and must be comprised of lower-case, numeric, and dash characters (for example, `dotnetwebapp`). The environment name is used for resource group naming and specific resource naming. -```ps1 - azd env set AZURE_RESOURCE_GROUP "$myEnvironmentName-rg" -``` +By default, Azure resources are sized for a development deployment. If doing a production deployment, see the [production deployment](./prod-deployment.md) instructions for more detail. -```ps1 - azd deploy +```pwsh +azd env new ``` -When finished the console will display the URI for the web app. You can use this URI to view the deployed solution in a browser. +Select the subscription that will be used for the deployment: -![screenshot of Relecloud app home page](./assets/Guide/WebAppHomePage.png) +```pwsh +azd env set AZURE_SUBSCRIPTION_ID $AZURE_SUBSCRIPTION_ID +``` -> If you face any issues with the deployment, see the [Known issues document](known-issues.md) below for possible workarounds. There could be interim issues while deploying to Azure, and repeating the steps after a few minutes should fix most of them. Azure deployments are incremental by default, and only failed actions will be retired. +Set the `AZURE_LOCATION` (Run `(Get-AzLocation).Location` to see a list of locations): -#### Clean up Azure Resources +```pwsh +azd env set AZURE_LOCATION +``` -1. Unprovision the Azure Resources -2. Clean up App Registrations -3. Delete the Deployment +### 5. Create the Azure resources and deploy the code -##### 1. Unprovision the Azure Resources -To tear down an enviroment, and clean up the Azure resource group, use the folloing command: +Run the following command to create the Azure resources and deploy the code (about 15-minutes to complete): -```ps1 -azd down --force --purge --no-prompt +```pwsh +azd up ``` -> You can also use the Azure Portal to delete the "relecloudresources" resource groups. This approach will not purge the Key Vault or App Configuration services and they will remain in your subscription for 7 days in a deleted state that does not charge your subscription. This feature enables you to recover the data if the configuration was accidentally deleted. You can purge these in the _Manage deleted stores_ section of each service in the portal. +### 6. Open and use the application - ![screenshot of Purging App Configurations](./assets/Guide/AppConfig-Purge.png) +Use the URL displayed in the console output to launch the web application that you have deployed: -##### 2. Clean up App Registrations -You will also need to delete the two Azure AD app registrations that were created. You can find them in Azure AD by searching for their environment name. - - **Delete App Registrations** +![screenshot of web app home page](assets/images/WebAppHomePage.png) - ![screenshot of Azure AD App Registrations](./assets/Guide/AD-AppRegistrations.png) - - You will also need to purge the App Configuration Service instance that was deployed. +You can learn more about the web app by reading the [Pattern Simulations](demo.md) documentation. +### 7. Tear down the deployment -##### 3. Delete the Deployment +Run the following command to tear down the deployment: -Your Azure subscription will retain your deployment request as a stateful object. -If you would like to change the Azure region for this deployment you will need to -delete the deployment by running the following command. - -``` -az deployment delete --name $myEnvironmentName +```pwsh +azd down --purge --force ``` -> You can list all deployments with the following command -> `az deployment sub list --query "[].name" -o tsv` - -### Local Development - -Relecloud developers use Visual Studio to develop locally and they co-share -an Azure SQL database for local dev. The team chooses this workflow to -help them practice early integration of changes as modifying the -database and other shared resources can impact multiple workstreams. - -To connect to the shared database the dev team uses connection strings -from Key Vault and App Configuration Service. Devs use the following -script to retrieve data and store it as -[User Secrets](https://learn.microsoft.com/aspnet/core/security/app-secrets?view=aspnetcore-6.0&tabs=windows) -on their workstation. - -Using the `secrets.json` file helps the team keep their credentials -secure. The file is stored outside of the source control directory so -the data is never accidentally checked-in. And the devs don't share -credentials over email or other ways that could compromise their -security. - -Managing secrets from Key Vault and App Configuration ensures that only -authorized team members can access the data and also centralizes the -administration of these secrets so they can be easily changed. - -New team members should setup their environment by following these steps. - -1. Open the Visual Studio solution `./src/Relecloud.sln` -1. Setup the **Relecloud.Web** project User Secrets - 1. Right-click on the **Relecloud.Web** project - 1. From the context menu choose **Manage User Secrets** - 1. From a command prompt run the bash command - - - - - - - - - - -
PowerShell - - ```ps1 - pwsh -c "Set-ExecutionPolicy Bypass Process; .\infra\localDevScripts\getSecretsForLocalDev.ps1 -g '$myEnvironmentName-rg' -Web" - ``` - -
Bash - - ```bash - chmod +x ./infra/localDevScripts/getSecretsForLocalDev.sh - ./infra/localDevScripts/getSecretsForLocalDev.sh -g "$myEnvironmentName-rg" --web - ``` - -
- - 1. Copy the output into the `secrets.json` file for the **Relecloud.Web** - project. - -1. Setup the **Relecloud.Web.Api** project User Secrets - 1. Right-click on the **Relecloud.Web.Api** project - 1. From the context menu choose **Manage User Secrets** - 1. From a command prompt run the bash command - - - - - - - - - - -
PowerShell - - ```ps1 - pwsh -c "Set-ExecutionPolicy Bypass Process; .\infra\localDevScripts\getSecretsForLocalDev.ps1 -g '$myEnvironmentName-rg' -Api" - ``` - -
Bash - - ```bash - ./infra/localDevScripts/getSecretsForLocalDev.sh -g "$myEnvironmentName-rg" --api - ``` - -
- - 1. Copy the output into the `secrets.json` file for the - **Relecloud.Web.Api** project. - -1. Right-click the **Relecloud** solution and pick **Set Startup Projects...** -1. Choose **Multiple startup projects** -1. Change the dropdowns for *Relecloud.Web* and *Relecloud.Web.Api* to the action of **Start**. -1. Click **Ok** to close the popup -1. Add your IP address to the SQL Database firewall as an allowed connection by using the following script - - - - - - - - - - -
PowerShell - - ```ps1 - pwsh -c "Set-ExecutionPolicy Bypass Process; .\infra\localDevScripts\addLocalIPToSqlFirewall.ps1 -g '$myEnvironmentName-rg'" - ``` - -
Bash - - ```bash - chmod +x ./infra/localDevScripts/addLocalIPToSqlFirewall.sh - ./infra/localDevScripts/addLocalIPToSqlFirewall.sh -g "$myEnvironmentName-rg" - ``` - -
- -1. When connecting to Azure SQL database you'll connect with your Azure AD account. -Run the following command to give your Azure AD account permission to access the database. - - - - - - - - - - -
PowerShell - - ```ps1 - pwsh -c "Set-ExecutionPolicy Bypass Process; .\infra\localDevScripts\makeSqlUserAccount.ps1 -g '$myEnvironmentName-rg'" - ``` - -
Bash - - ```bash - chmod +x ./infra/localDevScripts/makeSqlUserAccount.sh - ./infra/localDevScripts/makeSqlUserAccount.sh -g "$myEnvironmentName-rg" - ``` - -
- -1. Press F5 to start debugging the website - -> These steps grant access to SQL server in the primary resource group. -> You can use the same commands if you want to test with the secondary resource -> group by changing the ResourceGroup parameter "-g" to "$myEnvironmentName-secondary-rg" - ## Additional links - [Known issues](known-issues.md) -- [Developer patterns](simulate-patterns.md) +- [Troubleshooting](troubleshooting.md) +- [Pattern Simulations](demo.md) +- [Developer Experience](developer-experience.md) +- [Calculating SLA](./assets/sla-calculation.md) +- [Find additional resources](additional-resources.md) - [Report security concerns](SECURITY.md) - [Find Support](SUPPORT.md) - [Contributing](CONTRIBUTING.md) +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. + ## Data Collection The software may collect information about you and your use of the software and send it to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may turn off the telemetry as described in the repository. There are also some features in the software that may enable you and Microsoft to collect data from users of your applications. If you use these features, you must comply with applicable law, including providing appropriate notices to users of your applications together with a copy of Microsoft's privacy statement. Our privacy statement is located at https://go.microsoft.com/fwlink/?LinkId=521839. You can learn more about data collection and use in the help documentation and our privacy statement. Your use of the software operates as your consent to these practices. @@ -476,4 +194,4 @@ The software may collect information about you and your use of the software and Telemetry collection is on by default. -To opt out, run the following command `azd env set ENABLE_TELEMETRY` to `false` in your environment. +To opt out, run the following command `azd env set ENABLE_TELEMETRY` to `false` in your AZD environment. diff --git a/additional-resources.md b/additional-resources.md new file mode 100644 index 00000000..35f3a7fa --- /dev/null +++ b/additional-resources.md @@ -0,0 +1,41 @@ +# Starting your modernization journey + +In this guide we provided the content to build a web app based on other resources such as the Azure Architecture Center. In this section we'll highlight those source materials that you can use to learn more about Azure and modernization. + +## Additional sources for Azure Best Practices + +Use the following resources to learn more about Microsoft's best practices and recommendations for building solutions on Azure. + +For further guidance on how to build Azure solutions that align with Microsoft's best practices and recommendations + +- [Cloud Adoption Framework](https://docs.microsoft.com/en-us/azure/cloud-adoption-framework/overview) - Helps an organization prepare and execute their strategy to build solutions on Azure. +- [Azure Architecture Center fundamentals](https://docs.microsoft.com/en-us/azure/architecture/guide/) - Provides a library of content that presents a structured approach for designing applications on Azure that are scalable, secure, resilient, and highly available. +- [Well Architected Framework](https://docs.microsoft.com/en-us/azure/architecture/framework/) - Describes the best practices and design principles that should be applied when designing Azure solutions that align with Microsoft's recommended best practices. +- [Azure Architectures](https://docs.microsoft.com/en-us/azure/architecture/browse/) - Provides architecture diagrams and technology descriptions for reference architectures, real world examples of cloud architectures, and solution ideas for common workloads on Azure. + +## Additional sources for Azure Migration + +The following tools and resources can help you with migrating on-prem resources to Azure. + +- [Azure Migrate](https://docs.microsoft.com/en-us/azure/migrate/migrate-services-overview) - Azure Migrate provides a simplified migration, modernization, and optimization service for Azure that handles assessment, migration of web apps, SQL server, and Virtual Machines. +- [Azure Database Migration Guides](https://docs.microsoft.com/en-us/data-migration/) - Provides resources for different database types, and different tools designed for your migration scenario. +- [Azure App Service Landing Zone Accelerator](https://docs.microsoft.com/en-us/azure/cloud-adoption-framework/scenarios/app-platform/app-services/landing-zone-accelerator) - Deployment architecture guidance for hardening and scaling Azure App Service deployments. + +## Additional sources for upgrading .NET Framework apps + +This solution includes a dotnet web app capable of running on Linux that was deployed to an App Service running Windows. +The Azure App Service windows platform enables customers to move .NET Framework web apps to Azure without upgrading to newer framework versions. +For customers wanting Linux App Service plans, or new features and performance improvements added to the latest versions of dotnet, we recommend the following resources. + +- [Overview of porting from .NET Framework to .NET](https://docs.microsoft.com/en-us/dotnet/core/porting/) - A starting point for finding additional guidance based on your specific type of .NET app. +- [Overview of the .NET Upgrade Assistant](https://docs.microsoft.com/en-us/dotnet/core/porting/upgrade-assistant-overview) - A console tool that can help automate many of the tasks associated with upgrading .NET framework projects. +- [Migrating from ASP.NET to ASP.NET Core in Visual Studio](https://devblogs.microsoft.com/dotnet/introducing-project-migrations-visual-studio-extension/) - The ASP.NET Core team is developing a Visual Studio extension that can assist with incremental migrations of web apps. + +## References + +- [Well-Architected Framework](https://docs.microsoft.com/azure/architecture/framework/) +- [12 Factor Application](https://12factor.net/) +- [Retry Pattern](https://docs.microsoft.com/azure/architecture/patterns/retry) + +## Next Step +- [Report security concerns](SECURITY.md) \ No newline at end of file diff --git a/assets/Guide/Simulating_AppInsightsRequestWithSqlServer.png b/assets/Guide/Simulating_AppInsightsRequestWithSqlServer.png deleted file mode 100644 index 67310852..00000000 Binary files a/assets/Guide/Simulating_AppInsightsRequestWithSqlServer.png and /dev/null differ diff --git a/assets/Guide/Simulating_AppInsightsRequestWithoutSql.png b/assets/Guide/Simulating_AppInsightsRequestWithoutSql.png deleted file mode 100644 index 6d758abe..00000000 Binary files a/assets/Guide/Simulating_AppInsightsRequestWithoutSql.png and /dev/null differ diff --git a/assets/Guide/Simulating_RedisConsoleListKeys.png b/assets/Guide/Simulating_RedisConsoleListKeys.png deleted file mode 100644 index 23a63b05..00000000 Binary files a/assets/Guide/Simulating_RedisConsoleListKeys.png and /dev/null differ diff --git a/assets/Guide/Simulating_RedisConsoleShowUpcomingConcerts.png b/assets/Guide/Simulating_RedisConsoleShowUpcomingConcerts.png deleted file mode 100644 index ee7d30ab..00000000 Binary files a/assets/Guide/Simulating_RedisConsoleShowUpcomingConcerts.png and /dev/null differ diff --git a/assets/devcontainers/devcontainers1.png b/assets/devcontainers/devcontainers1.png deleted file mode 100755 index 4267338f..00000000 Binary files a/assets/devcontainers/devcontainers1.png and /dev/null differ diff --git a/assets/devcontainers/devcontainers2.png b/assets/devcontainers/devcontainers2.png deleted file mode 100755 index 575a6ccf..00000000 Binary files a/assets/devcontainers/devcontainers2.png and /dev/null differ diff --git a/assets/devcontainers/devcontainers3.png b/assets/devcontainers/devcontainers3.png deleted file mode 100755 index a02a086b..00000000 Binary files a/assets/devcontainers/devcontainers3.png and /dev/null differ diff --git a/assets/devcontainers/devcontainers4.png b/assets/devcontainers/devcontainers4.png deleted file mode 100755 index 48d3f5e2..00000000 Binary files a/assets/devcontainers/devcontainers4.png and /dev/null differ diff --git a/assets/devcontainers/devcontainers5.png b/assets/devcontainers/devcontainers5.png deleted file mode 100755 index 8bf92a04..00000000 Binary files a/assets/devcontainers/devcontainers5.png and /dev/null differ diff --git a/assets/devcontainers/devcontainers6.png b/assets/devcontainers/devcontainers6.png deleted file mode 100755 index 5f68569d..00000000 Binary files a/assets/devcontainers/devcontainers6.png and /dev/null differ diff --git a/assets/diagrams/reliable-web-app-dotnet-dev.vsdx b/assets/diagrams/reliable-web-app-dotnet-dev.vsdx new file mode 100644 index 00000000..7c6dcb61 Binary files /dev/null and b/assets/diagrams/reliable-web-app-dotnet-dev.vsdx differ diff --git a/assets/diagrams/reliable-web-app-dotnet-vnet.vsdx b/assets/diagrams/reliable-web-app-dotnet-vnet.vsdx new file mode 100644 index 00000000..1a477b82 Binary files /dev/null and b/assets/diagrams/reliable-web-app-dotnet-vnet.vsdx differ diff --git a/assets/diagrams/reliable-web-app-dotnet.vsdx b/assets/diagrams/reliable-web-app-dotnet.vsdx new file mode 100644 index 00000000..c8b4d998 Binary files /dev/null and b/assets/diagrams/reliable-web-app-dotnet.vsdx differ diff --git a/assets/icons/dotnetbot.png b/assets/icons/dotnetbot.png deleted file mode 100644 index 5341c4e9..00000000 Binary files a/assets/icons/dotnetbot.png and /dev/null differ diff --git a/assets/icons/microsoft.png b/assets/icons/microsoft.png deleted file mode 100644 index ecd175ef..00000000 Binary files a/assets/icons/microsoft.png and /dev/null differ diff --git a/assets/icons/reliable-web-app-dotnet-dev.svg b/assets/icons/reliable-web-app-dotnet-dev.svg new file mode 100644 index 00000000..1e8c4ca4 --- /dev/null +++ b/assets/icons/reliable-web-app-dotnet-dev.svg @@ -0,0 +1,1642 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page-1 + + + + logo.1993 + + + + + Azure.1002 + + Sheet.40 + + Sheet.41 + + + + Sheet.42 + + + + Sheet.43 + + Sheet.44 + + + + Sheet.45 + + + + + Sheet.46 + + Sheet.47 + + + + Sheet.48 + + + + + Sheet.49 + + + + + + Sheet.50 + Microsoft Azure + + + + MicrosoftAzure + + + Front Doors + Azure Front Door with WAF + + Sheet.52 + + + + + + + Sheet.53 + + + + + + + Sheet.54 + + + + + + + + + Azure Front Door with WAF + + + Azure Active Directory.1174 + Microsoft Entra ID + + Sheet.56 + + + + + + + Sheet.57 + + + + + + + Sheet.58 + + + + + + + Sheet.59 + + + + + + + Sheet.60 + + + + + + + Sheet.61 + + + + + + + Sheet.62 + + + + + + + + + MicrosoftEntra ID + + + DNS Zones.1182 + Azure DNS + + + + + Sheet.64 + + Sheet.65 + + + + + + + Sheet.66 + + + + + + + Sheet.67 + + + + + + + + + + Azure DNS + + + Browser.1192 + Browser + + Sheet.69 + + + + + + + Sheet.70 + + + + + + + Sheet.71 + + + + + + + + + Browser + + + Dynamic connector.1196 + + + + Dynamic connector.1197 + + + + Dynamic connector.1198 + + + + Cache Redis + Azure Cache For Redis + + Sheet.76 + + + + + + + Sheet.77 + + + + + + + Sheet.78 + + + + + + + Sheet.79 + + + + + + + Sheet.80 + + + + + + + Sheet.81 + + + + + + + Sheet.82 + + + + + + + Sheet.83 + + + + + + + Sheet.84 + + + + + + + Sheet.85 + + + + + + + Sheet.86 + + + + + + + + Sheet.87 + + + + + + + + + Azure Cache For Redis + + + Monitor.2148 + + Sheet.89 + + + + + + + Sheet.90 + + + + + + + Sheet.91 + + + + + + + Sheet.92 + + + + + + + Sheet.93 + + + + + + + Sheet.94 + + + + + + + Sheet.95 + + + + + + + + Application Insights + Application Insights + + Sheet.97 + + + + + + + Sheet.98 + + + + + + + Sheet.99 + + + + + + + Sheet.100 + + + + + + + + + Application Insights + + + Sheet.102 + + Sheet.103 + + + + Sheet.104 + + + + Sheet.105 + + + + Sheet.106 + + + + Sheet.107 + + + + Sheet.108 + + + + Sheet.109 + + + + Sheet.110 + + e189a61f-631a-4122-bb2b-079e860 + + + + fb7033ee-4650-4886-80c7-eddf625 + + + + b166b8c2-b9ee-4c3d-bf7a-77bd5ce + + + + baf437fe-3483-4a25-94ba-1e84a64 + + + + + + Sheet.115 + + Sheet.116 + + + + Sheet.117 + + + + Sheet.118 + + + + Sheet.119 + + + + Sheet.120 + + + + Sheet.121 + + + + Sheet.122 + + + + Sheet.123 + + + + Sheet.124 + + + + + Sheet.127 + Log Analytics Workspace + + + + Log AnalyticsWorkspace + + Sheet.128 + Application Insights + + + + Application Insights + + Sheet.129 + Azure Monitor + + + + Azure Monitor + + Dynamic connector.2353 + + + + + + + Dynamic connector.2354 + + + + + + + Dynamic connector.2355 + + + + + + + Azure Active Directory.2341 + Microsoft Entra ID + + Sheet.135 + + + + + + + Sheet.136 + + + + + + + Sheet.137 + + + + + + + Sheet.138 + + + + + + + Sheet.139 + + + + + + + Sheet.140 + + + + + + + Sheet.141 + + + + + + + + + MicrosoftEntra ID + + + Dynamic connector.2349 + + + + + + + SQL Database + SQL Database + + Sheet.175 + + + + + + + Sheet.176 + + + + + + + Sheet.177 + + + + + + + Sheet.178 + + + + + + + Sheet.179 + + + + + + + + + SQL Database + + + Sheet.193 + + + Sheet.194 + + Sheet.195 + + + + Sheet.196 + + + + Sheet.197 + + + + Sheet.198 + + + + Sheet.199 + + + + Sheet.200 + + + + Sheet.201 + + + + Sheet.202 + + + + Sheet.203 + + + + Sheet.204 + + + + + Sheet.205 + Public Internet + + + + Public Internet + + Sheet.216 + Blocked + + + + Blocked + + + + + App Configuration + App Configuration + + Sheet.220 + + + + ba7b3762-4752-4c4a-8a7e-af4c5e3 + + + + bff6fc37-370d-4b52-9427-43a5a53 + + + + Sheet.223 + + + + + + App Configuration + + + Key Vaults.1000 + Key Vault + + + + + Sheet.237 + + Sheet.238 + + + + + + + Sheet.239 + + + + + + + Sheet.240 + + + + + + + Sheet.241 + + + + + + + Sheet.242 + + + + + + + Sheet.243 + + + + + + + + + + Key Vault + + + Dynamic connector.1069 + + + + + + + Sheet.259 + + Sheet.260 + App Service Plan + + + + App Service Plan + + Sheet.261 + + + + App Service Plans + + Sheet.263 + + + + + + + Sheet.264 + + + + + + + Sheet.265 + + + + + + + Sheet.266 + + + + + + + Sheet.267 + + + + + + + Sheet.268 + + + + + + + + App Services.97 + Front end App Service + + + + + Sheet.270 + + Sheet.271 + + + + + + + Sheet.272 + + + + + + + Sheet.273 + + + + + + + Sheet.274 + + + + + + + Sheet.275 + + + + + + + Sheet.276 + + + + + + + Sheet.277 + + + + + + + Sheet.278 + + + + + + + Sheet.279 + + + + + + + Sheet.280 + + + + + + + Sheet.281 + + + + + + + + + + Front endApp Service + + + Sheet.292 + + Firewalls.2192 + + Sheet.294 + + + + + + + Sheet.295 + + + + + + + Sheet.296 + + + + + + + Sheet.297 + + + + + + + Sheet.298 + + + + + + + Sheet.299 + + + + + + + Sheet.300 + + + + + + + Sheet.301 + + + + + + + Sheet.302 + + + + + + + + Sheet.303 + App Service Access Restrictions + + + + App Service Access Restrictions + + + + Arrow (Straight).2202 + + + + + + + Dynamic connector.1000 + + + + Dynamic connector.1001 + + + + + + + Dynamic connector.1002 + + + + + + + Dynamic connector.1003 + + + + + + + Sheet.1004 + + Sheet.1005 + + Sheet.1006 + + + + Sheet.1007 + + + + Sheet.1008 + + + + + Sheet.1009 + + + + Sheet.1010 + + + + Sheet.1011 + + + + Sheet.1012 + + + + Sheet.1013 + + + + ef0d1b54-a1e7-4cb9-a4e5-8a8518e + + + + Sheet.1015 + + + + Sheet.1016 + + + + Sheet.1017 + + + + Sheet.1018 + + + + Sheet.1019 + + + + Sheet.1020 + + + + Sheet.1021 + + + + b9f25eb4-4c88-45c2-bc4d-f992757 + + + + eb9200a7-4693-4427-bdae-b33ce90 + + + + + Sheet.1024 + Azure Storage + + + + Azure Storage + + + Dynamic connector.1025 + + + + + + + diff --git a/assets/icons/reliable-web-app-dotnet.png b/assets/icons/reliable-web-app-dotnet.png deleted file mode 100644 index 24372266..00000000 Binary files a/assets/icons/reliable-web-app-dotnet.png and /dev/null differ diff --git a/assets/icons/reliable-web-app-dotnet.svg b/assets/icons/reliable-web-app-dotnet.svg new file mode 100644 index 00000000..50225354 --- /dev/null +++ b/assets/icons/reliable-web-app-dotnet.svg @@ -0,0 +1,2069 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + rwa 1.1 + + + + + + Sheet.2403 + + + + Rectangle.2285 + + + + + + + Sheet.1605 + + + + Sheet.1604 + + + + Sheet.1472 + + + + Key Vaults.1162 + Key Vault + + + + + Sheet.1116 + + Sheet.1117 + + + + + + + Sheet.1118 + + + + + + + Sheet.1119 + + + + + + + Sheet.1120 + + + + + + + Sheet.1121 + + + + + + + Sheet.1122 + + + + + + + + + + Key Vault + + + Azure Integration.1173 + + Sheet.1127 + + + + ba7b3762-4752-4c4a-8a7e-af4c5e3 + + + + bff6fc37-370d-4b52-9427-43a5a53 + + + + Sheet.1130 + + + + + SQL Database.1128 + SQL Database + + Sheet.1385 + + + + + + + Sheet.1386 + + + + + + + Sheet.1387 + + + + + + + Sheet.1388 + + + + + + + Sheet.1389 + + + + + + + + + SQL Database + + + Cache Redis.1180 + Azure Cache for Redis + + Sheet.1391 + + + + + + + Sheet.1392 + + + + + + + Sheet.1393 + + + + + + + Sheet.1394 + + + + + + + Sheet.1395 + + + + + + + Sheet.1396 + + + + + + + Sheet.1397 + + + + + + + Sheet.1398 + + + + + + + Sheet.1399 + + + + + + + Sheet.1400 + + + + + + + Sheet.1401 + + + + + + + + Sheet.1402 + + + + + + + + + Azure Cache for Redis + + + Sheet.1483 + + + + DNS Zones.1518 + Private DNS Zones + + + + + Sheet.1519 + + Sheet.1520 + + + + + + + Sheet.1521 + + + + + + + Sheet.1522 + + + + + + + + + + Private DNS Zones + + + Sheet.1575 + + Sheet.1576 + + + + Sheet.1577 + + + + Sheet.1578 + + + + Sheet.1579 + + + + Sheet.1580 + + + + Sheet.1581 + + + + Sheet.1582 + + + + Sheet.1583 + + + + Sheet.1584 + + + + Sheet.1585 + + + + Sheet.1586 + + + + Sheet.1587 + + + + + Sheet.1588 + + Sheet.1589 + + + + Sheet.1590 + + + + Sheet.1591 + + + + Sheet.1592 + + + + Sheet.1593 + + + + + Sheet.1594 + Azure Bastion subnet + + + + Azure Bastion subnet + + Sheet.1595 + Azure Firewall subnet + + + + Azure Firewallsubnet + + Sheet.1548 + Hub virtual network + + + + Hub virtual network + + Sheet.1596 + + Sheet.1597 + + + + Sheet.1598 + + + + Sheet.1599 + + + + Sheet.1600 + + + + Sheet.1601 + + + + Sheet.1602 + + + + Sheet.1603 + + + + + Sheet.1606 + + + + Sheet.1607 + Spoke virtual network 1 + + + + Spoke virtual network 1 + + Sheet.1608 + + Sheet.1609 + + + + Sheet.1610 + + + + Sheet.1611 + + + + Sheet.1612 + + + + Sheet.1613 + + + + Sheet.1614 + + + + Sheet.1615 + + + + + Sheet.1618 + + + + Sheet.1619 + Frontend app integration subnet + + + + Frontend app integration subnet + + Sheet.1620 + + + + Sheet.1621 + Backend app integration subnet + + + + Backend app integration subnet + + Icon-web-41.1811 + + ee75dd06-1aca-4f76-9d11-d05a284 + + + + Sheet.1624 + + + + Sheet.1625 + + + + Sheet.1626 + + + + Sheet.1627 + + + + Sheet.1628 + + + + Sheet.1629 + + + + Sheet.1630 + + + + Sheet.1631 + + + + Sheet.1632 + + + + Sheet.1633 + + + + Sheet.1634 + + + + Sheet.1635 + + + + + Sheet.1636 + App Service web app (frontend) + + + + App Service web app(frontend) + + Icon-web-41.1637 + + ee75dd06-1aca-4f76-9d11-d05a284 + + + + Sheet.1639 + + + + Sheet.1640 + + + + Sheet.1641 + + + + Sheet.1642 + + + + Sheet.1643 + + + + Sheet.1644 + + + + Sheet.1645 + + + + Sheet.1646 + + + + Sheet.1647 + + + + Sheet.1648 + + + + Sheet.1649 + + + + Sheet.1650 + + + + + Sheet.1651 + App Service web app (backend) + + + + App Service web app(backend) + + Azure Active Directory.1656 + Entra ID + + Sheet.1657 + + + + + + + Sheet.1658 + + + + + + + Sheet.1659 + + + + + + + Sheet.1660 + + + + + + + Sheet.1661 + + + + + + + Sheet.1662 + + + + + + + Sheet.1663 + + + + + + + + + Entra ID + + + Sheet.1664 + + + + Sheet.1665 + + + + Sheet.1666 + + Icon-networking-64.1581 + + f57e105d-6d2d-4ad7-b8c3-c10684c + + + + Sheet.1669 + + + + Sheet.1670 + + + + Sheet.1671 + + + + Sheet.1672 + + + + + Sheet.1673 + DNS + + + + DNS + + + Sheet.1674 + + + + Sheet.1675 + + Sheet.1676 + + Sheet.1677 + + + + Sheet.1678 + + + + Sheet.1679 + + + + Sheet.1680 + + + + Sheet.1681 + + + + Sheet.1682 + + + + Sheet.1683 + + + + Sheet.1684 + + + + Sheet.1685 + + + + Sheet.1686 + + + + Sheet.1687 + + + + Sheet.1688 + + + + + Sheet.1689 + Web Application Firewall + + + + Web Application Firewall + + + Sheet.1690 + + + + Front Doors.1117 + Front Door + + Sheet.1692 + + + + + + + Sheet.1693 + + + + + + + Sheet.1694 + + + + + + + + + Front Door + + + Sheet.1701 + + + + Sheet.1702 + Frontend app private endpoint subnet + + + + Frontend app private endpoint subnet + + Sheet.1703 + + + + Sheet.1704 + Backend app private endpoint subnet + + + + Backend app private endpoint subnet + + Sheet.1705 + + + + Sheet.1706 + Other private endpoint subnet + + + + Other private endpoint subnet + + Sheet.1725 + + + + Sheet.1726 + DevOps subnet + + + + DevOps subnet + + Sheet.1828 + Primary region + + + + Primary region + + Sheet.1933 + + + + Sheet.1936 + + + + Sheet.2005 + + + + Sheet.2006 + Spoke virtual network 2 + + + + Spoke virtual network 2 + + Sheet.2007 + + Sheet.2008 + + + + Sheet.2009 + + + + Sheet.2010 + + + + Sheet.2011 + + + + Sheet.2012 + + + + Sheet.2013 + + + + Sheet.2014 + + + + + Sheet.2015 + + + + Sheet.2016 + Frontend app integration subnet + + + + Frontend app integration subnet + + Sheet.2017 + + + + Sheet.2018 + Backend app integration subnet + + + + Backend app integration subnet + + Sheet.2033 + App Service web app (frontend) + + + + App Service web app(frontend) + + Sheet.2048 + App Service web app (backend) + + + + App Service web app(backend) + + Sheet.2049 + + + + Sheet.2050 + Frontend app private endpoint subnet + + + + Frontend app private endpoint subnet + + Sheet.2051 + + + + Sheet.2052 + Backend app private endpoint subnet + + + + Backend app private endpoint subnet + + Sheet.2053 + + + + Sheet.2054 + Other private endpoints subnet + + + + Other private endpoints subnet + + Sheet.2073 + + + + Sheet.2074 + DevOps subnet + + + + DevOps subnet + + Sheet.2075 + + + + Sheet.2076 + + + + Sheet.2077 + Secondary Region + + + + Secondary Region + + Sheet.2182 + + + + Sheet.2183 + + + + Sheet.2189 + + Sheet.2190 + + + + + Sheet.2191 + Browser + + + + Browser + + Sheet.2193 + + + + Sheet.2277 + + Icon-manage-307 + + Sheet.2279 + + + + Sheet.2280 + + + + Sheet.2281 + + + + Sheet.2282 + + + + Sheet.2283 + + + + + Sheet.2284 + Log Analytics workspace + + + + Log Analyticsworkspace + + + Sheet.2286 + Azure App Configuration + + + + Azure App Configuration + + Sheet.2289 + Private endpoint connected services + + + + Private endpoint connected services + + Sheet.2324 + + Sheet.2231 + Azure Storage + + + + Azure Storage + + Icon-storage-86.2232 + + Sheet.2233 + + + + Sheet.2234 + + + + Sheet.2235 + + + + Sheet.2236 + + + + Sheet.2237 + + + + + + Rectangle.2325 + + + + + + + Azure Integration.2326 + + Sheet.2327 + + + + ba7b3762-4752-4c4a-8a7e-af4c5e3 + + + + bff6fc37-370d-4b52-9427-43a5a53 + + + + Sheet.2330 + + + + + SQL Database.2331 + SQL Database + + Sheet.2332 + + + + + + + Sheet.2333 + + + + + + + Sheet.2334 + + + + + + + Sheet.2335 + + + + + + + Sheet.2336 + + + + + + + + + SQL Database + + + Cache Redis.2337 + Azure Cache for Redis + + Sheet.2338 + + + + + + + Sheet.2339 + + + + + + + Sheet.2340 + + + + + + + Sheet.2341 + + + + + + + Sheet.2342 + + + + + + + Sheet.2343 + + + + + + + Sheet.2344 + + + + + + + Sheet.2345 + + + + + + + Sheet.2346 + + + + + + + Sheet.2347 + + + + + + + Sheet.2348 + + + + + + + + Sheet.2349 + + + + + + + + + Azure Cache for Redis + + + Sheet.2350 + Azure App Configuration + + + + Azure App Configuration + + Sheet.2351 + Private endpoint connected services + + + + Private endpoint connected services + + Sheet.2352 + + Sheet.2353 + Azure Storage + + + + Azure Storage + + Icon-storage-86.2232 + + Sheet.2355 + + + + Sheet.2356 + + + + Sheet.2357 + + + + Sheet.2358 + + + + Sheet.2359 + + + + + + Sheet.2360 + + + + Sheet.2361 + + + + Sheet.2363 + + + + Icon-web-41.2364 + + ee75dd06-1aca-4f76-9d11-d05a284 + + + + Sheet.2366 + + + + Sheet.2367 + + + + Sheet.2368 + + + + Sheet.2369 + + + + Sheet.2370 + + + + Sheet.2371 + + + + Sheet.2372 + + + + Sheet.2373 + + + + Sheet.2374 + + + + Sheet.2375 + + + + Sheet.2376 + + + + Sheet.2377 + + + + + Icon-web-41.2378 + + ee75dd06-1aca-4f76-9d11-d05a284 + + + + Sheet.2380 + + + + Sheet.2381 + + + + Sheet.2382 + + + + Sheet.2383 + + + + Sheet.2384 + + + + Sheet.2385 + + + + Sheet.2386 + + + + Sheet.2387 + + + + Sheet.2388 + + + + Sheet.2389 + + + + Sheet.2390 + + + + Sheet.2391 + + + + + Sheet.2392 + peered + + + + peered + + Sheet.2393 + peered + + + + peered + + Sheet.2394 + + + + Sheet.2395 + + + + Sheet.2400 + + Sheet.2398 + + + + Sheet.2399 + Key Vault private endpoint subnet + + + + Key Vault private endpoint subnet + + + Sheet.2401 + + + + Sheet.2402 + + Icon-manage-310 + + Sheet.2199 + + + + Sheet.2200 + + + + Sheet.2201 + + + + Sheet.2202 + + + + Sheet.2203 + + + + + Sheet.2287 + Application Insights + + + + Application Insights + + + diff --git a/assets/icons/reliable-web-app-vnet.svg b/assets/icons/reliable-web-app-vnet.svg new file mode 100644 index 00000000..c53eaccb --- /dev/null +++ b/assets/icons/reliable-web-app-vnet.svg @@ -0,0 +1,3645 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page-1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Plain + + + + + + + + + + + + + + + + + + + + + + + + + Sheet.1071 + + Sheet.1072 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sheet.1073 + + Sheet.1074 + + + + + + + + + + + + + + + + + + + + + + + + Sheet.438 + Firewall Subnet + + + + Firewall Subnet + + Sheet.439 + + Sheet.440 + + + + Firewalls.1037 + + Sheet.442 + + + + + + + Sheet.443 + + + + + + + Sheet.444 + + + + + + + Sheet.445 + + + + + + + Sheet.446 + + + + + + + Sheet.447 + + + + + + + Sheet.448 + + + + + + + Sheet.449 + + + + + + + Sheet.450 + + + + + + + + Sheet.451 + Azure Firewall + + + + Azure Firewall + + + Sheet.452 + App Service Plan + + + + App Service Plan + + Sheet.453 + Private Link Subnet + + + + Private Link Subnet + + Sheet.462 + + + + Sheet.463 + Bastion Subnet + + + + Bastion Subnet + + Sheet.464 + + + + + + + + + + + + + + + + Bastion.1231 + + Sheet.466 + + Sheet.467 + + + + + + Sheet.468 + Azure Bastion + + + + Azure Bastion + + Sheet.469 + + + + Sheet.470 + + Hexagon.1060 + + + + + + + Virtual Networks.1052 + + Sheet.473 + + + + + + + Sheet.474 + + + + + + + Sheet.475 + + + + + + + Sheet.476 + + + + + + + Sheet.477 + + + + + + + Sheet.478 + + + + + + + Sheet.479 + + + + + + + + + Sheet.480 + + + + Sheet.481 + SPOKE PRIMARY + + + + SPOKE PRIMARY + + Sheet.482 + HUB + + + + HUB + + Tops or bottoms.1860 + Peering + + + + + + + + + + + Peering + + Front Doors + Azure Front Door with WAF + + Sheet.498 + + + + + + + Sheet.499 + + + + + + + Sheet.500 + + + + + + + + + Azure Front Door with WAF + + + Azure Active Directory.1174 + Microsoft Entra ID + + Sheet.502 + + + + + + + Sheet.503 + + + + + + + Sheet.504 + + + + + + + Sheet.505 + + + + + + + Sheet.506 + + + + + + + Sheet.507 + + + + + + + Sheet.508 + + + + + + + + + MicrosoftEntra ID + + + DNS Zones.1182 + Azure DNS + + + + + Sheet.510 + + Sheet.511 + + + + + + + Sheet.512 + + + + + + + Sheet.513 + + + + + + + + + + Azure DNS + + + Browser.1192 + Browser + + Sheet.515 + + + + + + + Sheet.516 + + + + + + + Sheet.517 + + + + + + + + + Browser + + + Dynamic connector.1196 + + + + Dynamic connector.1197 + + + + Dynamic connector.1198 + + + + Cache Redis + Azure Cache For Redis + + Sheet.522 + + + + + + + Sheet.523 + + + + + + + Sheet.524 + + + + + + + Sheet.525 + + + + + + + Sheet.526 + + + + + + + Sheet.527 + + + + + + + Sheet.528 + + + + + + + Sheet.529 + + + + + + + Sheet.530 + + + + + + + Sheet.531 + + + + + + + Sheet.532 + + + + + + + + Sheet.533 + + + + + + + + + Azure Cache For Redis + + + Sheet.534 + + + + App Service Plans + + Sheet.536 + + + + + + + Sheet.537 + + + + + + + Sheet.538 + + + + + + + Sheet.539 + + + + + + + Sheet.540 + + + + + + + Sheet.541 + + + + + + + + App Services.97 + Front end App Service + + + + + Sheet.543 + + Sheet.544 + + + + + + + Sheet.545 + + + + + + + Sheet.546 + + + + + + + Sheet.547 + + + + + + + Sheet.548 + + + + + + + Sheet.549 + + + + + + + Sheet.550 + + + + + + + Sheet.551 + + + + + + + Sheet.552 + + + + + + + Sheet.553 + + + + + + + Sheet.554 + + + + + + + + + + Front endApp Service + + + Dynamic connector.2142 + + + + + + + Monitor.2148 + + Sheet.570 + + + + + + + Sheet.571 + + + + + + + Sheet.572 + + + + + + + Sheet.573 + + + + + + + Sheet.574 + + + + + + + Sheet.575 + + + + + + + Sheet.576 + + + + + + + + Application Insights + Application Insights + + Sheet.580 + + + + + + + Sheet.581 + + + + + + + Sheet.582 + + + + + + + Sheet.583 + + + + + + + + + Application Insights + + + Dynamic connector.2178 + + + + + + + Arrow (Straight).2202 + + + + + + + Sheet.597 + + Sheet.598 + + + + Sheet.599 + + + + Sheet.600 + + + + Sheet.601 + + + + Sheet.602 + + + + Sheet.603 + + + + Sheet.604 + + + + Sheet.605 + + e189a61f-631a-4122-bb2b-079e860 + + + + fb7033ee-4650-4886-80c7-eddf625 + + + + b166b8c2-b9ee-4c3d-bf7a-77bd5ce + + + + baf437fe-3483-4a25-94ba-1e84a64 + + + + + + Sheet.640 + + Sheet.641 + + + + Sheet.642 + + + + Sheet.643 + + + + Sheet.644 + + + + Sheet.645 + + + + Sheet.646 + + + + Sheet.647 + + + + Sheet.648 + + + + Sheet.649 + + + + + Dynamic connector.2323 + + + + + + + Dynamic connector.2324 + + + + + + + Sheet.664 + Frontend Ingress Subnet + + + + Frontend Ingress Subnet + + Sheet.665 + + + + Sheet.666 + Private Endpoint + + + + Private Endpoint + + Private Link.1283 + Private Endpoint + + Sheet.668 + + + + + + + Sheet.669 + + + + + + + Sheet.670 + + + + + + + Sheet.671 + + + + + + + Sheet.672 + + + + + + + Sheet.673 + + + + + + + Sheet.674 + + + + + + + Sheet.675 + + + + + + + + + Private Endpoint + + + Sheet.676 + + Sheet.677 + Frontend App Service Integration Subnet “Route All” enabled + + + + Frontend App Service Integration SubnetRoute Allenabled + + Icon-networking-82.2156 + + Sheet.679 + + + + Sheet.680 + + + + Sheet.681 + + + + Sheet.682 + + + + + + Sheet.690 + Log Analytics Workspace + + + + Log AnalyticsWorkspace + + Sheet.691 + Application Insights + + + + Application Insights + + Sheet.692 + Azure Monitor + + + + Azure Monitor + + Dynamic connector.2353 + + + + + + + Dynamic connector.2354 + + + + + + + Dynamic connector.2355 + + + + + + + Sheet.729 + Default route (0.0.0.0/0) + + + + Default route(0.0.0.0/0) + + Azure Active Directory.2341 + Microsoft Entra ID + + Sheet.731 + + + + + + + Sheet.732 + + + + + + + Sheet.733 + + + + + + + Sheet.734 + + + + + + + Sheet.735 + + + + + + + Sheet.736 + + + + + + + Sheet.737 + + + + + + + + + MicrosoftEntra ID + + + Dynamic connector.2349 + + + + + + + Sheet.739 + + Sheet.740 + + Sheet.741 + + Sheet.742 + + + + Sheet.743 + + + + Sheet.744 + + + + + Sheet.745 + + + + + + Sheet.747 + + Arrow (Straight).1850 + + + + + + + Sheet.749 + Private Endpoint + + + + Private Endpoint + + Private Link.2169 + Private Endpoint + + Sheet.751 + + + + + + + Sheet.752 + + + + + + + Sheet.753 + + + + + + + Sheet.754 + + + + + + + Sheet.755 + + + + + + + Sheet.756 + + + + + + + Sheet.757 + + + + + + + Sheet.758 + + + + + + + + + Private Endpoint + + + + Sheet.759 + + Arrow (Straight).1850 + + + + + + + Sheet.761 + Private Endpoint + + + + Private Endpoint + + Private Link.2169 + Private Endpoint + + Sheet.763 + + + + + + + Sheet.764 + + + + + + + Sheet.765 + + + + + + + Sheet.766 + + + + + + + Sheet.767 + + + + + + + Sheet.768 + + + + + + + Sheet.769 + + + + + + + Sheet.770 + + + + + + + + + Private Endpoint + + + + Sheet.771 + + Arrow (Straight).1850 + + + + + + + Sheet.773 + Private Endpoint + + + + Private Endpoint + + Private Link.2169 + Private Endpoint + + Sheet.775 + + + + + + + Sheet.776 + + + + + + + Sheet.777 + + + + + + + Sheet.778 + + + + + + + Sheet.779 + + + + + + + Sheet.780 + + + + + + + Sheet.781 + + + + + + + Sheet.782 + + + + + + + + + Private Endpoint + + + + SQL Database + SQL Database + + Sheet.784 + + + + + + + Sheet.785 + + + + + + + Sheet.786 + + + + + + + Sheet.787 + + + + + + + Sheet.788 + + + + + + + + + SQL Database + + + Sheet.805 + + Sheet.806 + DevOps Subnet + + + + DevOps Subnet + + Sheet.807 + + + + + Virtual Machine + + Sheet.809 + + + + + + + Sheet.810 + + + + + + + Sheet.811 + + + + + + + Sheet.812 + + + + + + + Sheet.813 + + + + + + + Sheet.814 + + + + + + + Sheet.815 + + + + + + + + Azure DevOps + Jump box + + Sheet.817 + + + + + + + + + Jump box + + + Sheet.829 + + + Sheet.830 + + Sheet.831 + + + + Sheet.832 + + + + Sheet.833 + + + + Sheet.834 + + + + Sheet.835 + + + + Sheet.836 + + + + Sheet.837 + + + + Sheet.838 + + + + Sheet.839 + + + + Sheet.840 + + + + + Sheet.841 + Public Internet + + + + Public Internet + + Sheet.842 + + Hexagon.1060 + + + + + + + Virtual Networks.1052 + + Sheet.845 + + + + + + + Sheet.846 + + + + + + + Sheet.847 + + + + + + + Sheet.848 + + + + + + + Sheet.849 + + + + + + + Sheet.850 + + + + + + + Sheet.851 + + + + + + + + + Sheet.852 + Blocked + + + + Blocked + + Sheet.863 + Azure Private DNS Zones + + + + Azure Private DNS Zones + + Sheet.864 + App Service Plan + + + + App Service Plan + + Sheet.865 + + + + App Service Plans.866 + + Sheet.867 + + + + + + + Sheet.868 + + + + + + + Sheet.869 + + + + + + + Sheet.870 + + + + + + + Sheet.871 + + + + + + + Sheet.872 + + + + + + + + App Services.873 + Backend end App Service + + + + + Sheet.874 + + Sheet.875 + + + + + + + Sheet.876 + + + + + + + Sheet.877 + + + + + + + Sheet.878 + + + + + + + Sheet.879 + + + + + + + Sheet.880 + + + + + + + Sheet.881 + + + + + + + Sheet.882 + + + + + + + Sheet.883 + + + + + + + Sheet.884 + + + + + + + Sheet.885 + + + + + + + + + + Backend endApp Service + + + Sheet.577 + UDR + + + + UDR + + Sheet.923 + Backend Ingress Subnet + + + + Backend Ingress Subnet + + Sheet.924 + + + + Sheet.925 + Private Endpoint + + + + Private Endpoint + + Private Link.926 + Private Endpoint + + Sheet.927 + + + + + + + Sheet.928 + + + + + + + Sheet.929 + + + + + + + Sheet.930 + + + + + + + Sheet.931 + + + + + + + Sheet.932 + + + + + + + Sheet.933 + + + + + + + Sheet.934 + + + + + + + + + Private Endpoint + + + Sheet.935 + + Sheet.936 + Backend App Service Integration Subnet “Route All” enabled + + + + Backend App Service Integration SubnetRoute Allenabled + + Icon-networking-82.2156 + + Sheet.938 + + + + Sheet.939 + + + + Sheet.940 + + + + Sheet.941 + + + + + + Sheet.942 + Default route (0.0.0.0/0) + + + + Default route(0.0.0.0/0) + + Sheet.943 + UDR + + + + UDR + + Dynamic connector.944 + + + + + + + Dynamic connector + + + + + + + Dynamic connector.947 + + + + + + + Sheet.948 + + + Sheet.960 + Public Internet + + + + Public Internet + + Sheet.961 + Blocked + + + + Blocked + + Arrow (Straight).962 + + + + + + + Sheet.949 + + Sheet.950 + + + + Sheet.951 + + + + Sheet.952 + + + + Sheet.953 + + + + Sheet.954 + + + + Sheet.955 + + + + Sheet.956 + + + + Sheet.957 + + + + Sheet.958 + + + + Sheet.959 + + + + + + + + App Configuration + App Configuration + + Sheet.964 + + + + ba7b3762-4752-4c4a-8a7e-af4c5e3 + + + + bff6fc37-370d-4b52-9427-43a5a53 + + + + Sheet.967 + + + + + + App Configuration + + + Sheet.968 + + Arrow (Straight).1850 + + + + + + + Sheet.970 + Private Endpoint + + + + Private Endpoint + + Private Link.2169 + Private Endpoint + + Sheet.972 + + + + + + + Sheet.973 + + + + + + + Sheet.974 + + + + + + + Sheet.975 + + + + + + + Sheet.976 + + + + + + + Sheet.977 + + + + + + + Sheet.978 + + + + + + + Sheet.979 + + + + + + + + + Private Endpoint + + + + Sheet.992 + + Sheet.621 + + Sheet.622 + + + + Sheet.623 + + + + Sheet.624 + + + + + Sheet.625 + + + + Sheet.626 + + + + Sheet.627 + + + + Sheet.628 + + + + Sheet.629 + + + + ef0d1b54-a1e7-4cb9-a4e5-8a8518e + + + + Sheet.631 + + + + Sheet.632 + + + + Sheet.633 + + + + Sheet.634 + + + + Sheet.635 + + + + Sheet.636 + + + + Sheet.637 + + + + b9f25eb4-4c88-45c2-bc4d-f992757 + + + + eb9200a7-4693-4427-bdae-b33ce90 + + + + + Sheet.696 + Azure Storage + + + + Azure Storage + + + Key Vaults.1000 + Shared Key Vault In HUB RG + + + + + Sheet.1001 + + Sheet.1002 + + + + + + + Sheet.1003 + + + + + + + Sheet.1004 + + + + + + + Sheet.1005 + + + + + + + Sheet.1006 + + + + + + + Sheet.1007 + + + + + + + + + + Shared Key VaultIn HUB RG + + + Sheet.1008 + + + + Sheet.1009 + SPOKE Secondary + + + + SPOKESecondary + + Sheet.1010 + + Hexagon.1060 + + + + + + + Virtual Networks.1052 + + Sheet.1013 + + + + + + + Sheet.1014 + + + + + + + Sheet.1015 + + + + + + + Sheet.1016 + + + + + + + Sheet.1017 + + + + + + + Sheet.1018 + + + + + + + Sheet.1019 + + + + + + + + + Rectangle.1020 + Repeat of SPOKE PRIMARY + + + + + + + Repeat ofSPOKE PRIMARY + + Sheet.1034 + + + + Private Link.1035 + FrontDoor Private Endpoint + + Sheet.1036 + + + + + + + Sheet.1037 + + + + + + + Sheet.1038 + + + + + + + Sheet.1039 + + + + + + + Sheet.1040 + + + + + + + Sheet.1041 + + + + + + + Sheet.1042 + + + + + + + Sheet.1043 + + + + + + + + + FrontDoor Private Endpoint + + + Sheet.1044 + + + + Private Link.1045 + FrontDoor Private Endpoint + + Sheet.1046 + + + + + + + Sheet.1047 + + + + + + + Sheet.1048 + + + + + + + Sheet.1049 + + + + + + + Sheet.1050 + + + + + + + Sheet.1051 + + + + + + + Sheet.1052 + + + + + + + Sheet.1053 + + + + + + + + + FrontDoor Private Endpoint + + + Sheet.1054 + + Firewalls.2192 + + Sheet.586 + + + + + + + Sheet.587 + + + + + + + Sheet.588 + + + + + + + Sheet.589 + + + + + + + Sheet.590 + + + + + + + Sheet.591 + + + + + + + Sheet.592 + + + + + + + Sheet.593 + + + + + + + Sheet.594 + + + + + + + + Sheet.596 + App Service Access Restrictions + + + + App Service Access Restrictions + + + Sheet.1055 + + Firewalls.2192 + + Sheet.1057 + + + + + + + Sheet.1058 + + + + + + + Sheet.1059 + + + + + + + Sheet.1060 + + + + + + + Sheet.1061 + + + + + + + Sheet.1062 + + + + + + + Sheet.1063 + + + + + + + Sheet.1064 + + + + + + + Sheet.1065 + + + + + + + + Sheet.1066 + App Service Access Restrictions + + + + App Service Access Restrictions + + + Dynamic connector.1067 + + + + + + + Tops or bottoms.1068 + Peering + + + + + + + + + + + Peering + + Dynamic connector.1069 + + + + + + + diff --git a/assets/Guide/Azd-Env-New.png b/assets/images/Azd-Env-New.png similarity index 100% rename from assets/Guide/Azd-Env-New.png rename to assets/images/Azd-Env-New.png diff --git a/assets/AzdoSetup/1CreateAPipeline.png b/assets/images/AzdoSetup/1CreateAPipeline.png similarity index 100% rename from assets/AzdoSetup/1CreateAPipeline.png rename to assets/images/AzdoSetup/1CreateAPipeline.png diff --git a/assets/AzdoSetup/2CreateAPipeline.png b/assets/images/AzdoSetup/2CreateAPipeline.png similarity index 100% rename from assets/AzdoSetup/2CreateAPipeline.png rename to assets/images/AzdoSetup/2CreateAPipeline.png diff --git a/assets/AzdoSetup/3CreateAPipeline.png b/assets/images/AzdoSetup/3CreateAPipeline.png similarity index 100% rename from assets/AzdoSetup/3CreateAPipeline.png rename to assets/images/AzdoSetup/3CreateAPipeline.png diff --git a/assets/AzdoSetup/4CreateAPipeline.png b/assets/images/AzdoSetup/4CreateAPipeline.png similarity index 100% rename from assets/AzdoSetup/4CreateAPipeline.png rename to assets/images/AzdoSetup/4CreateAPipeline.png diff --git a/assets/AzdoSetup/5CreateAPipeline.png b/assets/images/AzdoSetup/5CreateAPipeline.png similarity index 100% rename from assets/AzdoSetup/5CreateAPipeline.png rename to assets/images/AzdoSetup/5CreateAPipeline.png diff --git a/assets/Guide/AD-AppRegistrations.png b/assets/images/Guide/AD-AppRegistrations.png similarity index 100% rename from assets/Guide/AD-AppRegistrations.png rename to assets/images/Guide/AD-AppRegistrations.png diff --git a/assets/Guide/AppConfig-Purge.png b/assets/images/Guide/AppConfig-Purge.png similarity index 100% rename from assets/Guide/AppConfig-Purge.png rename to assets/images/Guide/AppConfig-Purge.png diff --git a/assets/Guide/AsyncRequestReplyPattern.png b/assets/images/Guide/AsyncRequestReplyPattern.png similarity index 100% rename from assets/Guide/AsyncRequestReplyPattern.png rename to assets/images/Guide/AsyncRequestReplyPattern.png diff --git a/assets/images/Guide/Azd-Env-New.png b/assets/images/Guide/Azd-Env-New.png new file mode 100644 index 00000000..9d703231 Binary files /dev/null and b/assets/images/Guide/Azd-Env-New.png differ diff --git a/assets/Guide/AzureMonitorCustomEvents.png b/assets/images/Guide/AzureMonitorCustomEvents.png similarity index 100% rename from assets/Guide/AzureMonitorCustomEvents.png rename to assets/images/Guide/AzureMonitorCustomEvents.png diff --git a/assets/Guide/AzureMonitorLiveMetrics.png b/assets/images/Guide/AzureMonitorLiveMetrics.png similarity index 100% rename from assets/Guide/AzureMonitorLiveMetrics.png rename to assets/images/Guide/AzureMonitorLiveMetrics.png diff --git a/assets/Guide/AzureMonitorLogAnalyticsQueries.png b/assets/images/Guide/AzureMonitorLogAnalyticsQueries.png similarity index 100% rename from assets/Guide/AzureMonitorLogAnalyticsQueries.png rename to assets/images/Guide/AzureMonitorLogAnalyticsQueries.png diff --git a/assets/Guide/Intro-video.jpg b/assets/images/Guide/Intro-video.jpg similarity index 100% rename from assets/Guide/Intro-video.jpg rename to assets/images/Guide/Intro-video.jpg diff --git a/assets/Guide/Intro-video.png b/assets/images/Guide/Intro-video.png similarity index 100% rename from assets/Guide/Intro-video.png rename to assets/images/Guide/Intro-video.png diff --git a/assets/Guide/KeyVault-Purge.png b/assets/images/Guide/KeyVault-Purge.png similarity index 100% rename from assets/Guide/KeyVault-Purge.png rename to assets/images/Guide/KeyVault-Purge.png diff --git a/assets/Guide/ReliableWebAppArchitectureDiagram.png b/assets/images/Guide/ReliableWebAppArchitectureDiagram.png similarity index 100% rename from assets/Guide/ReliableWebAppArchitectureDiagram.png rename to assets/images/Guide/ReliableWebAppArchitectureDiagram.png diff --git a/assets/images/Guide/Simulating_AppInsightsRequestWithSqlServer.png b/assets/images/Guide/Simulating_AppInsightsRequestWithSqlServer.png new file mode 100644 index 00000000..d4bada72 Binary files /dev/null and b/assets/images/Guide/Simulating_AppInsightsRequestWithSqlServer.png differ diff --git a/assets/images/Guide/Simulating_AppInsightsRequestWithoutSql.png b/assets/images/Guide/Simulating_AppInsightsRequestWithoutSql.png new file mode 100644 index 00000000..975997e4 Binary files /dev/null and b/assets/images/Guide/Simulating_AppInsightsRequestWithoutSql.png differ diff --git a/assets/Guide/Simulating_AppInsightsTopRequests.png b/assets/images/Guide/Simulating_AppInsightsTopRequests.png similarity index 100% rename from assets/Guide/Simulating_AppInsightsTopRequests.png rename to assets/images/Guide/Simulating_AppInsightsTopRequests.png diff --git a/assets/Guide/Simulating_AppServiceRestart.png b/assets/images/Guide/Simulating_AppServiceRestart.png similarity index 100% rename from assets/Guide/Simulating_AppServiceRestart.png rename to assets/images/Guide/Simulating_AppServiceRestart.png diff --git a/assets/Guide/Simulating_CheckoutPage.png b/assets/images/Guide/Simulating_CheckoutPage.png similarity index 100% rename from assets/Guide/Simulating_CheckoutPage.png rename to assets/images/Guide/Simulating_CheckoutPage.png diff --git a/assets/Guide/Simulating_CircuitBreakerPart1.png b/assets/images/Guide/Simulating_CircuitBreakerPart1.png similarity index 100% rename from assets/Guide/Simulating_CircuitBreakerPart1.png rename to assets/images/Guide/Simulating_CircuitBreakerPart1.png diff --git a/assets/Guide/Simulating_CircuitBreakerPart2.png b/assets/images/Guide/Simulating_CircuitBreakerPart2.png similarity index 100% rename from assets/Guide/Simulating_CircuitBreakerPart2.png rename to assets/images/Guide/Simulating_CircuitBreakerPart2.png diff --git a/assets/Guide/Simulating_ConfigExplorer.png b/assets/images/Guide/Simulating_ConfigExplorer.png similarity index 100% rename from assets/Guide/Simulating_ConfigExplorer.png rename to assets/images/Guide/Simulating_ConfigExplorer.png diff --git a/assets/images/Guide/Simulating_RedisConsole.png b/assets/images/Guide/Simulating_RedisConsole.png new file mode 100644 index 00000000..96f28524 Binary files /dev/null and b/assets/images/Guide/Simulating_RedisConsole.png differ diff --git a/assets/images/Guide/Simulating_RedisConsoleListKeys.png b/assets/images/Guide/Simulating_RedisConsoleListKeys.png new file mode 100644 index 00000000..62630f71 Binary files /dev/null and b/assets/images/Guide/Simulating_RedisConsoleListKeys.png differ diff --git a/assets/images/Guide/Simulating_RedisConsoleShowUpcomingConcerts.png b/assets/images/Guide/Simulating_RedisConsoleShowUpcomingConcerts.png new file mode 100644 index 00000000..119de4aa Binary files /dev/null and b/assets/images/Guide/Simulating_RedisConsoleShowUpcomingConcerts.png differ diff --git a/assets/Guide/Simulating_RetryPattern.png b/assets/images/Guide/Simulating_RetryPattern.png similarity index 100% rename from assets/Guide/Simulating_RetryPattern.png rename to assets/images/Guide/Simulating_RetryPattern.png diff --git a/assets/Guide/Simulating_UpcomingConcertsPage.png b/assets/images/Guide/Simulating_UpcomingConcertsPage.png similarity index 100% rename from assets/Guide/Simulating_UpcomingConcertsPage.png rename to assets/images/Guide/Simulating_UpcomingConcertsPage.png diff --git a/assets/Guide/WebAppHomePage.png b/assets/images/Guide/WebAppHomePage.png similarity index 100% rename from assets/Guide/WebAppHomePage.png rename to assets/images/Guide/WebAppHomePage.png diff --git a/assets/Guide/WebAppTicketsPage.png b/assets/images/Guide/WebAppTicketsPage.png similarity index 100% rename from assets/Guide/WebAppTicketsPage.png rename to assets/images/Guide/WebAppTicketsPage.png diff --git a/assets/images/WebAppHomePage.png b/assets/images/WebAppHomePage.png new file mode 100644 index 00000000..669a86c1 Binary files /dev/null and b/assets/images/WebAppHomePage.png differ diff --git a/assets/images/configure-multiple-startup-projects.png b/assets/images/configure-multiple-startup-projects.png new file mode 100644 index 00000000..cc658633 Binary files /dev/null and b/assets/images/configure-multiple-startup-projects.png differ diff --git a/assets/images/vscode-reopen-in-container-command.png b/assets/images/vscode-reopen-in-container-command.png new file mode 100644 index 00000000..c7210fc1 Binary files /dev/null and b/assets/images/vscode-reopen-in-container-command.png differ diff --git a/assets/images/vscode-reopen-in-container.png b/assets/images/vscode-reopen-in-container.png new file mode 100644 index 00000000..7ed464e2 Binary files /dev/null and b/assets/images/vscode-reopen-in-container.png differ diff --git a/assets/sla-calculation.md b/assets/sla-calculation.md new file mode 100644 index 00000000..6deb3c08 --- /dev/null +++ b/assets/sla-calculation.md @@ -0,0 +1,75 @@ +# Calculating Solution Service Level Agreement + +The requirement for the web application is that the combined service level agreement for all components in the hot path is greater than 99.9%. The components in the hot path comprise of any service that is used in fulfilling a web request from a user. + +## Development + +With a development environment, network isolation is not used. The following services are considered: + +| Service | Azure SLA | +|:------------------|----------:| +| Azure Front Door | 99.990% | +| Entra ID | 99.990% | +| Azure App Service | 99.950% | +| Redis Cache | 99.900% | +| Azure SQL | 99.995% | +| Azure Storage | 99.900% | +| Key Vault | 99.990% | +| App Configuration | 99.900% | +| **Combined SLA** | **99.616%** | + +## Production - Single Region + +When operating in production, network isolation is used. We do not consider the availability of the hub resources or VNET peering. + +| Service | Azure SLA | +|:------------------|----------:| +| Azure Front Door | 99.990% | +| Entra ID | 99.990% | +| Private DNS Zone | 100.00% | +| AFD Private Link | 99.990% | +| Azure App Service | 99.950% | +| - Private Link | 99.990% | +| Redis Cache | 99.900% | +| - Private Link | 99.990% | +| Azure SQL | 99.995% | +| - Private Link | 99.990% | +| Azure Storage | 99.900% | +| - Private Link | 99.990% | +| Key Vault | 99.990% | +| - Private Link | 99.990% | +| App Configuration | 99.900% | +| - Private Link | 99.990% | +| **Combined SLA** | **99.546%** | + +## Production - Two Regions + +Since the single region SLA is less than the requested 99.9% availability, we have to deploy to two regions. Azure Front Door, Entra ID, and Private DNS Zones are shared resources. However, the rest of the services can be doubled up for more reliability. + +| Service | Azure SLA | +|:------------------|----------:| +| **Shared Services** || +| Azure Front Door | 99.990% | +| Entra ID | 99.990% | +| Private DNS Zone | 100.00% | +| **Regional Services** || +| AFD Private Link | 99.990% | +| Azure App Service | 99.950% | +| - Private Link | 99.990% | +| Redis Cache | 99.900% | +| - Private Link | 99.990% | +| Azure SQL | 99.995% | +| - Private Link | 99.990% | +| Azure Storage | 99.900% | +| - Private Link | 99.990% | +| Key Vault | 99.990% | +| - Private Link | 99.990% | +| App Configuration | 99.900% | +| - Private Link | 99.990% | +| **Shared Services** | **99.980%** | +| **Regional Services** | **99.546%** | +| **Combined SLA** | **99.9779%** | + +Using dual regions will help us achieve the requested service level agreement. + +For more information on how to calculate effective SLO, please refer to [the Well Architected Framework](https://learn.microsoft.com/azure/well-architected/reliability/metrics). diff --git a/assets/sla-calculation.xlsx b/assets/sla-calculation.xlsx new file mode 100644 index 00000000..de76e2df Binary files /dev/null and b/assets/sla-calculation.xlsx differ diff --git a/azure.yaml b/azure.yaml index 6f9a8990..7dda01dd 100644 --- a/azure.yaml +++ b/azure.yaml @@ -2,13 +2,51 @@ name: reliable-csharp-web metadata: - template: reliable-csharp-web@0.0.1-beta + template: reliable-csharp-web@1.1.0 +hooks: + preprovision: + posix: + interactive: true + shell: sh + run: ./infra/scripts/preprovision/validate-params.sh && ./infra/scripts/preprovision/whats-my-ip.sh + windows: + interactive: true + shell: pwsh + run: ./infra/scripts/preprovision/validate-params.ps1 && ./infra/scripts/preprovision/whats-my-ip.ps1 + postprovision: + posix: + interactive: true + run: ./infra/scripts/postprovision/call-create-app-registrations.sh + windows: + interactive: true + run: ./infra/scripts/postprovision/call-create-app-registrations.ps1 + predeploy: + posix: + interactive: true + run: ./infra/scripts/predeploy/call-set-app-configuration.sh + windows: + interactive: true + run: ./infra/scripts/predeploy/call-set-app-configuration.ps1 + postdeploy: + posix: + interactive: true + run: ./infra/scripts/postdeploy/show-webapp-uri.sh + windows: + interactive: true + run: ./infra/scripts/postdeploy/show-webapp-uri.ps1 + predown: + posix: + interactive: true + run: ./infra/scripts/predown/call-cleanup.sh + windows: + interactive: true + run: ./infra/scripts/predown/call-cleanup.ps1 services: - web: - project: src/Relecloud.Web + web-callcenter-service: + project: src/Relecloud.Web.CallCenter.Api language: csharp host: appservice - api: - project: src/Relecloud.Web.Api + web-callcenter-frontend: + project: src/Relecloud.Web.CallCenter language: csharp - host: appservice \ No newline at end of file + host: appservice diff --git a/simulate-patterns.md b/demo.md similarity index 53% rename from simulate-patterns.md rename to demo.md index 65270e6d..3a08a764 100644 --- a/simulate-patterns.md +++ b/demo.md @@ -1,10 +1,10 @@ -# Simulating the design patterns +# Pattern Simulations You can test and configure the three code-level design patterns with this implementation: retry, circuit-breaker, and cache-aside. The following paragraphs detail steps to test the three code-level design patterns. ## Retry pattern -We built an app configuration setting that lets you simulate and test a transient failure from the Web API. The setting is called `Api:App:RetryDemo`. We've included this configuration in the deployable code. The `Api:App:RetryDemo` setting throws a 503 error when the end user sends an HTTP request to the web app API. `Api:App:RetryDemo` has an editable setting that determines the intervals between 503 errors. A value of 2 generates a 503 error for every other request. +We built an app configuration setting that lets you simulate and test a transient failure from the Web API. The setting is called `Api:App:RetryDemo`. We've included this configuration in the deployable code. The `Api:App:RetryDemo` setting throws a 503 error when the end user sends an HTTP request to the web app API. `Api:App:RetryDemo` is an editable setting that determines how many back-to-back exceptions should be thrown. A value of 1 generates one error after returning one successful response. This is disabled by default. Removing the setting, or changing the value to 0 will disable the feature. Follow these steps to set up this test: @@ -18,33 +18,33 @@ Follow these steps to set up this test: |Name|Value| |-----|-----| |*Key*|Api:App:RetryDemo| - |*Value*|2| + |*Value*|1| 1. Restart the API web app App Service - Go to the API web app App Service - Navigate to the "Overview" blade - Click the "Restart" button at the top of the page. - > It will take a few minutes for the App Service to restart. When it restarts, the application will use the `Api:App:RetryDemo` configuration. You need to restart the App Service any time you update a configuration value. + > It will take a few minutes for the App Service to restart. When it restarts, the application will use the `Api:App:RetryDemo` configuration. You need to restart the App Service any time you update a configuration value unless you're using the [sentinal key](https://learn.microsoft.com/azure/azure-app-configuration/enable-dynamic-configuration-aspnet-core) approach. -We recommend collecting telemetry for this test. We've configured Application Insights to collect telemetry. When the value of `Api:App:RetryDemo` is 2, the first request to the application API generates a 503 error. But the retry pattern sends a second request that is successful and generates a 200 response. We recommend using the Application Insights Live Metrics features to view the HTTP responses in near real-time. +We recommend observing telemetry for this test. We've configured Application Insights to collect telemetry. When the value of `Api:App:RetryDemo` is 1, the first request to the application API generates a 503 error. But the retry pattern sends a second request that is successful and generates a 200 response. We recommend using the Application Insights Live Metrics features to view the HTTP responses in near real-time. -> App Insights can up to a minute to aggregate the data it receives, and failed requests might not appear right away in the Failures view. +> App Insights can take up to a minute to aggregate the data it receives, and failed requests might not appear right away in the Failures view. -To see the Retry Pattern in action you can click throughout the Relecloud website and should not see any impact to the user's ability to purchase a concert ticket. However, in App Insights you should see the 503 error happens for 50% of the requests sent to the Web API. +To see the Retry pattern in action you can click throughout the Relecloud website and should not see any impact to the user's ability to purchase a concert ticket. However, in App Insights you should see the 503 error happens for 50% of the requests sent to the Web API. For more information, see: - [Application Insights Live Metrics](/azure/azure-monitor/app/live-stream) - [Visual Studio and Application Insights live telemetry](/azure/azure-monitor/app/visual-studio) -> We recommend you cleanup by deleting the `Api:App:RetryDemo` setting. +> We recommend you cleanup by deleting the `Api:App:RetryDemo` setting. And restart both web apps to resume from a known state. -### Circuit Breaker Pattern +## Circuit Breaker pattern -We built an app configuration setting that lets you simulate and test a failure from the Web API. The setting is called `Api:App:RetryDemo`. We've included this configuration in the deployable code. The `Api:App:RetryDemo` setting throws a 503 error when the end user sends an HTTP request to the web app API. `Api:App:RetryDemo` has an editable setting that determines the intervals between 503 errors. A value of 1 has no intervals and generates a 503 error for every request. +We built an app configuration setting that lets you simulate and test a failure from the Web API. The setting is called `Api:App:RetryDemo`. We've included this configuration in the deployable code. The `Api:App:RetryDemo` setting throws a 503 error when the end user sends an HTTP request to the web app API. `Api:App:RetryDemo` has an editable setting that determines the number of back-to-back errors between a successful request. A value of 5 generates five errors after returning one successful response. This is disabled by default. Removing the setting, or changing the value to 0 will disable the feature. -Following these steps to set up this test: +Follow these steps to set up this test: 1. Create a new key-value in App Configuration. - Go to App Configuration in the Azure Portal @@ -56,44 +56,63 @@ Following these steps to set up this test: |Name|Value| |-----|-----| |*Key*|Api:App:RetryDemo| - |*Value*|1| + |*Value*|5| 1. Restart the API web app App Service - Go to the API web app App Service - Navigate to the "Overview" blade - Click the "Restart" button at the top of the page. - > It will take a few minutes for the App Service to restart. When it restarts, the application will use the `Api:App:RetryDemo` configuration. You need to restart the App Service any time you update a configuration value. + > It will take a few minutes for the App Service to restart. When it restarts, the application will use the `Api:App:RetryDemo` configuration. You need to restart the App Service any time you update a configuration value unless you're using the [sentinal key](https://learn.microsoft.com/azure/azure-app-configuration/enable-dynamic-configuration-aspnet-core) approach. -To see these recommendations in action you can click on the "Upcoming Concerts" page in the Relecloud web app. Since the Web API is returning an error for every request you will see that the front-end applied the Retry Pattern up to three times to request the data for this page. If you reload the "Upcoming Concernts" page you can see that the Circuit Breaker has detected these three errors and that the circuit is now open. When the circuit is open there are no new requests sent to the Web API web app for 30 seconds. This presents a fail-fast behavior to our users and also reduces the number of requests sent to the unhealthy Web API web app so it has more time to recover. +To see these recommendations in action you can click on the "Upcoming Concerts" page in the Relecloud web app. Since the Web API is returning an error for every request you will see that the front-end applied the Retry pattern up to three times to request the data for this page. If you reload the "Upcoming Concernts" page you can see that the Circuit Breaker has detected these errors and that the circuit is now open. When the circuit is open there are no new requests sent to the Web API web app for 30 seconds. This presents a fail-fast behavior to our users and also reduces the number of requests sent to the unhealthy Web API web app so it has more time to recover. -> Note that App Insights can up to a minute to aggregate the data it receives, and failed requests might not appear right away in the Failures view. +> Note that App Insights can take up to a minute to aggregate the data it receives, and failed requests might not appear right away in the Failures view. For more information, see: - [Application Insights Live Metrics](/azure/azure-monitor/app/live-stream) - [Visual Studio and Application Insights live telemetry](/azure/azure-monitor/app/visual-studio) -> We recommend you cleanup by deleting the `Api:App:RetryDemo` setting. +> We recommend you cleanup by deleting the `Api:App:RetryDemo` setting. And restart both web apps to resume from a known state. -### Cache-Aside Pattern +## Cache-Aside pattern -The cache-aside pattern enables us to limit read queries to SQL server. It also provides a layer of redundancy that can keep parts of our application running in the event of issue with Azure SQL Database. +The Cache-Aside pattern enables us to reduce read queries to SQL server. It also provides a layer of redundancy that can keep parts of our application running in the event of issue with Azure SQL Database. -For more information, see [cache-aside pattern](https://learn.microsoft.com/azure/architecture/patterns/cache-aside). +For more information, see [Cache-Aside pattern](https://learn.microsoft.com/azure/architecture/patterns/cache-aside). We can observe this behavior in App Insights by testing two different pages. First, visit the "Upcoming Concerts" page and refresh the page a couple of times. The first time the page is loaded the web API app will send a request to SQL server, but the following requests will go to Azure Cache for Redis. -![image of App Insights shows connection to SQL server to retrieve data](./assets/Guide/Simulating_AppInsightsRequestWithSqlServer.png) - -In this screenshot above we see a connection was made to SQL server and that this request took 742ms. +![image of App Insights shows connection to SQL server to retrieve data](./assets/images/Guide/Simulating_AppInsightsRequestWithSqlServer.png) -![image of App Insights shows request returns data without SQL](./assets/Guide/Simulating_AppInsightsRequestWithoutSql.png) +In this screenshot above we see a connection was made to SQL server and that retrieving the data took 131.1 ms. -In the next request we see that the API call was only 55ms because it didn't have to connect to SQL Server and instead used the data from Azure Cache for Redis. +![image of App Insights shows request returns data without SQL](./assets/images/Guide/Simulating_AppInsightsRequestWithoutSql.png) -![image of Azure Cache for Redis Console lists all keys](./assets/Guide/Simulating_RedisConsoleListKeys.png) +In the next request we see that the total duration of the API call was only 10.4 ms because it didn't have to connect to SQL Server and instead used the data from Azure Cache for Redis. Using the (PREVIEW) Redis Console we can see this data stored in Redis. -![image of Azure Cache for Redis Console shows data for upcoming concerts](./assets/Guide/Simulating_RedisConsoleShowUpcomingConcerts.png) +Open the Redis Console by navigating to the Azure Cache for Redis resource in the Azure Portal and clicking the "Console" link above the overview details for this resource. + +![image of Azure Cache for Redis Console](./assets/images/Guide/Simulating_RedisConsole.png) + + +Run the following command to see all cached keys: + +``` +SCAN 0 COUNT 1000 MATCH * +``` + +![image of Azure Cache for Redis Console lists all keys](./assets/images/Guide/Simulating_RedisConsoleListKeys.png) + +Run the next command to see the concert data cached from the database: + +``` +HGETALL UpcomingConcerts +``` + +![image of Azure Cache for Redis Console shows data for upcoming concerts](./assets/images/Guide/Simulating_RedisConsoleShowUpcomingConcerts.png) + +> You can use the command `DEL UpcomingConcerts` to delete this data from Redis and see the cache rebuild. \ No newline at end of file diff --git a/dev-containers.md b/dev-containers.md deleted file mode 100644 index 13cf7a55..00000000 --- a/dev-containers.md +++ /dev/null @@ -1,28 +0,0 @@ -# Overview -The Visual Studio Code Dev Containers extension lets you use a Docker container as a full-featured development environment. It delivers all tooling required to deploy the application enivrionment and deploy the application code. For more information on Dev Containers, please refer to this [tutorial](https://code.visualstudio.com/docs/devcontainers/tutorial) - -# Pre-requisites -* Docker - * If using Windows Subsystem for Linux, docker needs to be inside WSL - * If using Windows, Docker for Desktop needs to be installed -* Visual Studio Code - * Visual Code for the Web can not be used. There is an issue deploying Azure AD resources when using DevContainers. -* [Dev Container Extenstion](vscode:extension/ms-vscode-remote.remote-containers) - -> **_NOTE:_** Access to a Github Codespace fullfills all the pre-requisites - -# Dev Container Setup -1. git clone https://github.com/Azure/reliable-web-app-pattern-dotnet -1. Open the folder reliable-web-app-pattern-dotnet in Visual Studio Code -1. A prompt to open the folder in Dev Containers will appear in the lower right. - * ![screenshot dev container open](./assets/devcontainers/devcontainers1.png) -1. Click `Reopen in Container` -1. The container will start to build - * ![screenshot dev container building](./assets/devcontainers/devcontainers2.png) -1. When the container is built, open a new shell in the upper right hand corner of the integrated terminal - * ![screenshot dev container shell](./assets/devcontainers/devcontainers4.png) -1. Confirm that `azd` and `az bicep` are install correctly - * ![screenshot dev container azd](./assets/devcontainers/devcontainers5.png) - * ![screenshot dev container bicep](./assets/devcontainers/devcontainers6.png) -1. Continue on deploying the environment as before. - * [Steps to deploy the reference implementation](README.md#steps-to-deploy-the-reference-implementation) diff --git a/developer-experience.md b/developer-experience.md new file mode 100644 index 00000000..b6073953 --- /dev/null +++ b/developer-experience.md @@ -0,0 +1,117 @@ +# Developer Experience + +The dev team uses Visual Studio and they integrate directly with Azure resources when building the code. The team chooses this workflow to so they can integration test with Azure before their code reaches the QA team. + +> **NOTE** +> +> This developer experience is only supported for development deployments. Production deployments +> use network isolation and do not allow devs to connect from their workstation. + +Most configurations in the project are stored in Azure App Configuration with secrets saved into Azure Key Vault. To connect to these resources from a developer workstation you need to complete the following steps. + +1. Add your identity to the Azure SQL resource +1. Set up front-end web app configuration +1. Set up back-end web app configuration + +To support this workflow the following steps will store data in [User Secrets](https://learn.microsoft.com/aspnet/core/security/app-secrets?view=aspnetcore-6.0&tabs=windows) because the code is configured so that these values override configurations and secrets saved in Azure. + +> Note that `secrets.json` file is stored relative to the tooling that supports them. Use the Windows Terminal to execute the following commands instead of the Dev Container if you want to use Visual Studio to launch the project. Read [How the Secret Manager tool works](https://learn.microsoft.com/aspnet/core/security/app-secrets?view=aspnetcore-8.0&tabs=linux#how-the-secret-manager-tool-works) to learn more. + +## Authenticate with Azure + +1. If you are not using PowerShell 7+, run the following command (you can use [$PSVersionTable.PSVersion](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_powershell_editions) to check your version): + + ```sh + pwsh + ``` + +1. Connect to Azure + + ```pwsh + Import-Module Az.Resources + ``` + + ```pwsh + Connect-AzAccount + ``` + +1. Set the subscription to the one you want to use (you can use [Get-AzSubscription](https://learn.microsoft.com/powershell/module/az.accounts/get-azsubscription?view=azps-11.3.0) to list available subscriptions): + + ```pwsh + $AZURE_SUBSCRIPTION_ID="" + ``` + + ```pwsh + Set-AzContext -SubscriptionId $AZURE_SUBSCRIPTION_ID + ``` + +## 1. Add your identity to the Azure SQL resource + +1. Run the following script to automate the process in docs [Configure and manage Microsoft Entra authentication with Azure SQL](https://learn.microsoft.com/en-us/azure/azure-sql/database/authentication-aad-configure?view=azuresql&tabs=azure-powershell) + + ```pwsh + ./infra/scripts/devexperience/call-make-sql-account.ps1 + ``` + +## 2. Set up front-end web app configuration + +1. Get the Azure App Configuration URI + ```pwsh + $appConfigurationUri = ((azd env get-values --output json | ConvertFrom-Json).APP_CONFIG_SERVICE_URI) + ``` + +1. Switch to the front-end web app directory + ```pwsh + cd src/Relecloud.Web.CallCenter + ``` +1. Clear any existing user secrets + ```pwsh + dotnet user-secrets clear + ``` +1. Set the Relecloud API base URI + ```pwsh + dotnet user-secrets set "App:RelecloudApi:BaseUri" "https://localhost:7242" + ``` + +1. Set the Azure App Configuration URI + ```pwsh + dotnet user-secrets set "App:AppConfig:Uri" $appConfigurationUri + ``` + +1. Switch back to the root of the repository + ```pwsh + cd ../.. + ``` + +## 3. Set up back-end web app configuration + + ```pwsh + cd src/Relecloud.Web.CallCenter.Api + ``` + + ```pwsh + dotnet user-secrets clear + ``` + + ```pwsh + dotnet user-secrets set "App:AppConfig:Uri" $appConfigurationUri + ``` + +## 4. Launch the project with Visual Studio + +1. Open the project in Visual Studio +1. Configure the solution to start both the front-end and back-end web apps + 1. Right-click the **Relecloud** solution and pick **Set Startup Projects...** + 1. Choose **Multiple startup projects** + 1. Change the dropdowns for *Relecloud.Web.CallCenter* and *Relecloud.Web.CallCenter.Api* to the action of **Start**. + 1. Click **Ok** to close the popup + + ![screenshot of Visual Studio solution startup configuration](assets/images/configure-multiple-startup-projects.png) + +1. Run the project (F5) +1. Open the browser and navigate to `https://localhost:7227/` + + ![screenshot of web app home page](assets/images/WebAppHomePage.png) + +## Next steps +You can learn more about the web app by reading the [Pattern Simulations](demo.md) documentation. \ No newline at end of file diff --git a/infra/appConfigSvcKeyValue.bicep b/infra/appConfigSvcKeyValue.bicep deleted file mode 100644 index ee7b2485..00000000 --- a/infra/appConfigSvcKeyValue.bicep +++ /dev/null @@ -1,16 +0,0 @@ -@description('Name of the App Configuration Service where the App Service loads configuration') -param appConfigurationServiceName string - -@description('A host name for the Azure Front Door that protects the front end web app') -param frontDoorUri string - -resource appConfigurationService 'Microsoft.AppConfiguration/configurationStores@2022-05-01' existing = { - name: appConfigurationServiceName - - resource frontDoorRedirectUri 'keyValues@2022-05-01' = { - name: 'App:FrontDoorUri' - properties: { - value: frontDoorUri - } - } -} diff --git a/infra/appSvcAutoScaleSettings.bicep b/infra/appSvcAutoScaleSettings.bicep deleted file mode 100644 index 67b7d37e..00000000 --- a/infra/appSvcAutoScaleSettings.bicep +++ /dev/null @@ -1,77 +0,0 @@ -@minLength(1) -@description('Specifies the name of an existing app service plan that will receive scale rules') -param appServicePlanName string - -@description('Enables the template to choose different SKU by environment') -param isProd bool - -@description('The Azure location where this solution is deployed') -param location string = resourceGroup().location - -@description('An object collection that contains annotations to describe the deployed azure resources to improve operational visibility') -param tags object - -var scaleOutThreshold = 85 -var scaleInThreshold = 60 - -resource appServicePlan 'Microsoft.Web/serverfarms@2021-03-01' existing = { - name: appServicePlanName -} - -resource apiAppScaleRule 'Microsoft.Insights/autoscalesettings@2014-04-01' = if (isProd) { - name: '${appServicePlanName}-autoscale' - location: location - tags: tags - properties: { - targetResourceUri: appServicePlan.id - enabled: true - profiles: [ - { - name: 'Auto created scale condition' - capacity: { - minimum: string(1) - maximum: string(10) - default: string(1) - } - rules: [ - { - metricTrigger: { - metricResourceUri: appServicePlan.id - metricName: 'CpuPercentage' - timeGrain: 'PT5M' - statistic: 'Average' - timeWindow: 'PT10M' - timeAggregation: 'Average' - operator: 'GreaterThan' - threshold: scaleOutThreshold - } - scaleAction: { - direction: 'Increase' - type: 'ChangeCount' - value: string(1) - cooldown: 'PT10M' - } - } - { - metricTrigger: { - metricResourceUri: appServicePlan.id - metricName: 'CpuPercentage' - timeGrain: 'PT5M' - statistic: 'Average' - timeWindow: 'PT10M' - timeAggregation: 'Average' - operator: 'LessThan' - threshold: scaleInThreshold - } - scaleAction: { - direction: 'Decrease' - type: 'ChangeCount' - value: string(1) - cooldown: 'PT10M' - } - } - ] - } - ] - } -} diff --git a/infra/applicationinsights.bicep b/infra/applicationinsights.bicep deleted file mode 100644 index 3a78fa8d..00000000 --- a/infra/applicationinsights.bicep +++ /dev/null @@ -1,1255 +0,0 @@ -@minLength(1) -@description('A generated identifier used to create unique resources') -param resourceToken string - -@minLength(1) -@description('Primary location for all resources. Should specify an Azure region. e.g. `eastus2` ') -param location string - -@description('An object collection that contains annotations to describe the deployed azure resources to improve operational visibility') -param tags object - -@minLength(1) -@description('An Id for a log analytics workspace that contains events to show in a dashboard') -param workspaceId string - -resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { - name: 'web-${resourceToken}-appi' - location: location - kind: 'web' - tags: tags - properties: { - Application_Type: 'web' - WorkspaceResourceId: workspaceId - } -} - -// 2020-09-01-preview because that is the latest valid version -resource applicationInsightsDashboard 'Microsoft.Portal/dashboards@2020-09-01-preview' = { - name: 'web-${resourceToken}-appid' - location: location - tags: tags - properties: { - lenses: [ - { - order: 0 - parts: [ - { - position: { - x: 0 - y: 0 - colSpan: 2 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'id' - value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - { - name: 'Version' - value: '1.0' - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/AspNetOverviewPinnedPart' - asset: { - idInputName: 'id' - type: 'ApplicationInsights' - } - defaultMenuItemId: 'overview' - } - } - { - position: { - x: 2 - y: 0 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ComponentId' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'Version' - value: '1.0' - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/ProactiveDetectionAsyncPart' - asset: { - idInputName: 'ComponentId' - type: 'ApplicationInsights' - } - defaultMenuItemId: 'ProactiveDetection' - } - } - { - position: { - x: 3 - y: 0 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ComponentId' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'ResourceId' - value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/QuickPulseButtonSmallPart' - asset: { - idInputName: 'ComponentId' - type: 'ApplicationInsights' - } - } - } - { - position: { - x: 4 - y: 0 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ComponentId' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'TimeContext' - value: { - durationMs: 86400000 - endTime: null - createdTime: '2018-05-04T01:20:33.345Z' - isInitialTime: true - grain: 1 - useDashboardTimeRange: false - } - } - { - name: 'Version' - value: '1.0' - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/AvailabilityNavButtonPart' - asset: { - idInputName: 'ComponentId' - type: 'ApplicationInsights' - } - } - } - { - position: { - x: 5 - y: 0 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ComponentId' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'TimeContext' - value: { - durationMs: 86400000 - endTime: null - createdTime: '2018-05-08T18:47:35.237Z' - isInitialTime: true - grain: 1 - useDashboardTimeRange: false - } - } - { - name: 'ConfigurationId' - value: '78ce933e-e864-4b05-a27b-71fd55a6afad' - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/AppMapButtonPart' - asset: { - idInputName: 'ComponentId' - type: 'ApplicationInsights' - } - } - } - { - position: { - x: 0 - y: 1 - colSpan: 3 - rowSpan: 1 - } - metadata: { - inputs: [] - type: 'Extension/HubsExtension/PartType/MarkdownPart' - settings: { - content: { - settings: { - content: '# Usage' - title: '' - subtitle: '' - } - } - } - } - } - { - position: { - x: 3 - y: 1 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ComponentId' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'TimeContext' - value: { - durationMs: 86400000 - endTime: null - createdTime: '2018-05-04T01:22:35.782Z' - isInitialTime: true - grain: 1 - useDashboardTimeRange: false - } - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/UsageUsersOverviewPart' - asset: { - idInputName: 'ComponentId' - type: 'ApplicationInsights' - } - } - } - { - position: { - x: 4 - y: 1 - colSpan: 3 - rowSpan: 1 - } - metadata: { - inputs: [] - type: 'Extension/HubsExtension/PartType/MarkdownPart' - settings: { - content: { - settings: { - content: '# Reliability' - title: '' - subtitle: '' - } - } - } - } - } - { - position: { - x: 7 - y: 1 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ResourceId' - value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - { - name: 'DataModel' - value: { - version: '1.0.0' - timeContext: { - durationMs: 86400000 - createdTime: '2018-05-04T23:42:40.072Z' - isInitialTime: false - grain: 1 - useDashboardTimeRange: false - } - } - isOptional: true - } - { - name: 'ConfigurationId' - value: '8a02f7bf-ac0f-40e1-afe9-f0e72cfee77f' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/CuratedBladeFailuresPinnedPart' - isAdapter: true - asset: { - idInputName: 'ResourceId' - type: 'ApplicationInsights' - } - defaultMenuItemId: 'failures' - } - } - { - position: { - x: 8 - y: 1 - colSpan: 3 - rowSpan: 1 - } - metadata: { - inputs: [] - type: 'Extension/HubsExtension/PartType/MarkdownPart' - settings: { - content: { - settings: { - content: '# Responsiveness\r\n' - title: '' - subtitle: '' - } - } - } - } - } - { - position: { - x: 11 - y: 1 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ResourceId' - value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - { - name: 'DataModel' - value: { - version: '1.0.0' - timeContext: { - durationMs: 86400000 - createdTime: '2018-05-04T23:43:37.804Z' - isInitialTime: false - grain: 1 - useDashboardTimeRange: false - } - } - isOptional: true - } - { - name: 'ConfigurationId' - value: '2a8ede4f-2bee-4b9c-aed9-2db0e8a01865' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/CuratedBladePerformancePinnedPart' - isAdapter: true - asset: { - idInputName: 'ResourceId' - type: 'ApplicationInsights' - } - defaultMenuItemId: 'performance' - } - } - { - position: { - x: 12 - y: 1 - colSpan: 3 - rowSpan: 1 - } - metadata: { - inputs: [] - type: 'Extension/HubsExtension/PartType/MarkdownPart' - settings: { - content: { - settings: { - content: '# Browser' - title: '' - subtitle: '' - } - } - } - } - } - { - position: { - x: 15 - y: 1 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ComponentId' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'MetricsExplorerJsonDefinitionId' - value: 'BrowserPerformanceTimelineMetrics' - } - { - name: 'TimeContext' - value: { - durationMs: 86400000 - createdTime: '2018-05-08T12:16:27.534Z' - isInitialTime: false - grain: 1 - useDashboardTimeRange: false - } - } - { - name: 'CurrentFilter' - value: { - eventTypes: [ - 4 - 1 - 3 - 5 - 2 - 6 - 13 - ] - typeFacets: {} - isPermissive: false - } - } - { - name: 'id' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'Version' - value: '1.0' - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/MetricsExplorerBladePinnedPart' - asset: { - idInputName: 'ComponentId' - type: 'ApplicationInsights' - } - defaultMenuItemId: 'browser' - } - } - { - position: { - x: 0 - y: 2 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'sessions/count' - aggregationType: 5 - namespace: 'microsoft.insights/components/kusto' - metricVisualization: { - displayName: 'Sessions' - color: '#47BDF5' - } - } - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'users/count' - aggregationType: 5 - namespace: 'microsoft.insights/components/kusto' - metricVisualization: { - displayName: 'Users' - color: '#7E58FF' - } - } - ] - title: 'Unique sessions and users' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - openBladeOnClick: { - openBlade: true - destinationBlade: { - extensionName: 'HubsExtension' - bladeName: 'ResourceMenuBlade' - parameters: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - menuid: 'segmentationUsers' - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 4 - y: 2 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'requests/failed' - aggregationType: 7 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Failed requests' - color: '#EC008C' - } - } - ] - title: 'Failed requests' - visualization: { - chartType: 3 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - openBladeOnClick: { - openBlade: true - destinationBlade: { - extensionName: 'HubsExtension' - bladeName: 'ResourceMenuBlade' - parameters: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - menuid: 'failures' - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 8 - y: 2 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'requests/duration' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Server response time' - color: '#00BCF2' - } - } - ] - title: 'Server response time' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - openBladeOnClick: { - openBlade: true - destinationBlade: { - extensionName: 'HubsExtension' - bladeName: 'ResourceMenuBlade' - parameters: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - menuid: 'performance' - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 12 - y: 2 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'browserTimings/networkDuration' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Page load network connect time' - color: '#7E58FF' - } - } - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'browserTimings/processingDuration' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Client processing time' - color: '#44F1C8' - } - } - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'browserTimings/sendDuration' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Send request time' - color: '#EB9371' - } - } - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'browserTimings/receiveDuration' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Receiving response time' - color: '#0672F1' - } - } - ] - title: 'Average page load time breakdown' - visualization: { - chartType: 3 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 0 - y: 5 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'availabilityResults/availabilityPercentage' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Availability' - color: '#47BDF5' - } - } - ] - title: 'Average availability' - visualization: { - chartType: 3 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - openBladeOnClick: { - openBlade: true - destinationBlade: { - extensionName: 'HubsExtension' - bladeName: 'ResourceMenuBlade' - parameters: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - menuid: 'availability' - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 4 - y: 5 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'exceptions/server' - aggregationType: 7 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Server exceptions' - color: '#47BDF5' - } - } - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'dependencies/failed' - aggregationType: 7 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Dependency failures' - color: '#7E58FF' - } - } - ] - title: 'Server exceptions and Dependency failures' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 8 - y: 5 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'performanceCounters/processorCpuPercentage' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Processor time' - color: '#47BDF5' - } - } - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'performanceCounters/processCpuPercentage' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Process CPU' - color: '#7E58FF' - } - } - ] - title: 'Average processor and process CPU utilization' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 12 - y: 5 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'exceptions/browser' - aggregationType: 7 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Browser exceptions' - color: '#47BDF5' - } - } - ] - title: 'Browser exceptions' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 0 - y: 8 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'availabilityResults/count' - aggregationType: 7 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Availability test results count' - color: '#47BDF5' - } - } - ] - title: 'Availability test results count' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 4 - y: 8 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'performanceCounters/processIOBytesPerSecond' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Process IO rate' - color: '#47BDF5' - } - } - ] - title: 'Average process I/O rate' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 8 - y: 8 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'performanceCounters/memoryAvailableBytes' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Available memory' - color: '#47BDF5' - } - } - ] - title: 'Average available memory' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - ] - } - ] - } -} - -output APPLICATIONINSIGHTS_CONNECTION_STRING string = applicationInsights.properties.ConnectionString -output APPLICATIONINSIGHTS_INSTRUMENTATION_KEY string = applicationInsights.properties.InstrumentationKey diff --git a/infra/azureAdSettings.bicep b/infra/azureAdSettings.bicep deleted file mode 100644 index 80299038..00000000 --- a/infra/azureAdSettings.bicep +++ /dev/null @@ -1,89 +0,0 @@ -@minLength(1) -@description('The name of the Key Vault that will store AAD secrets for the web app') -param keyVaultName string - -@minLength(1) -@description('The name of the Azure App Configuration Service that will store AAD secrets for the web app') -param appConfigurationServiceName string - -@description('A scope used by the front-end public web app to get authorized access to the public web api. Looks similar to api://33333333-bbbb-4444-cccc-555555555555/relecloud.api') -param azureAdApiScopeFrontEnd string - -@description('A unique identifier of the API web app') -param azureAdClientIdForBackEnd string - -@description('A unique identifier of the front-end web app') -param azureAdClientIdForFrontEnd string - -@secure() -@description('A secret generated by Azure AD so that the web app can establish trust with Azure AD') -param azureAdClientSecretForFrontEnd string - -@description('A unique identifier of the Azure AD tenant') -param azureAdTenantId string - -// the semi-colon is not a valid character for a kv key name so we use alternate dotnet syntax of -- to specify this nested config setting -var clientSecretName = 'AzureAd--ClientSecret' - -resource kv 'Microsoft.KeyVault/vaults@2021-11-01-preview' existing = { - name: keyVaultName - - resource kvFrontEndAzureAdClientSecret 'secrets@2021-11-01-preview' = { - name: clientSecretName - properties: { - value: azureAdClientSecretForFrontEnd - } - } -} - -resource appConfigSvc 'Microsoft.AppConfiguration/configurationStores@2022-05-01' existing = { - name: appConfigurationServiceName - - //begin front-end web app settings - resource appConfigSvcFrontEndAzureAdApiScope 'keyValues@2022-05-01' = { - name: 'App:RelecloudApi:AttendeeScope' - properties: { - value: azureAdApiScopeFrontEnd - } - } - - resource appConfigSvcAzureAdClientId 'keyValues@2022-05-01' = { - name: 'AzureAd:ClientId' - properties: { - value: azureAdClientIdForFrontEnd - } - } - - resource appConfigSvcFrontEndAzureAdClientSecret 'keyValues@2022-05-01' = { - name: 'AzureAd:ClientSecret' - properties: { - value: string({ - uri: '${kv.properties.vaultUri}secrets/${clientSecretName}' - }) - contentType: 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8' - } - } - - resource appConfigSvcAzureAdTenantId 'keyValues@2022-05-01' = { - name: 'AzureAd:TenantId' - properties: { - value: azureAdTenantId - } - } - - //begin web API app settings - - resource appConfigSvcAzureAdClientIdForBackEnd 'keyValues@2022-05-01' = { - name: 'Api:AzureAd:ClientId' - properties: { - value: azureAdClientIdForBackEnd - } - } - - resource appConfigSvcApiAzureAdTenantId 'keyValues@2022-05-01' = { - name: 'Api:AzureAd:TenantId' - properties: { - value: azureAdTenantId - } - } -} diff --git a/infra/azureFrontDoor.bicep b/infra/azureFrontDoor.bicep deleted file mode 100644 index 7d14b6a1..00000000 --- a/infra/azureFrontDoor.bicep +++ /dev/null @@ -1,189 +0,0 @@ -// this file is included for the sample to make it easy to get started -// for customer scenarios we recommend reusing your Azure Front Door -// as it supports multiple origins, and endpoints for different needs - -// avoids resource token naming since front door is a global balancer -var globalResourceToken = uniqueString(resourceGroup().id) -var frontDoorEndpointName = 'afd-${globalResourceToken}' - -@minLength(1) -@description('ResourceId for a log analytics workspace that will collect diagnostic info for Key Vault and Front Door') -param logAnalyticsWorkspaceIdForDiagnostics string - -@description('An object collection that contains annotations to describe the deployed azure resources to improve operational visibility') -param tags object - -@minLength(1) -@description('The hostname of the backend. Must be an IP address or FQDN.') -param primaryBackendAddress string - -@description('The hostname of the backend. Must be an IP address or FQDN.') -param secondaryBackendAddress string - -var frontDoorProfileName = 'afd-${globalResourceToken}' -var frontDoorOriginGroupName = 'MyOriginGroup' -var frontDoorOriginName = 'MyAppServiceOrigin' -var frontDoorRouteName = 'MyRoute' - -resource frontDoorProfile 'Microsoft.Cdn/profiles@2021-06-01' = { - name: frontDoorProfileName - tags: tags - location: 'global' - sku: { - name: 'Premium_AzureFrontDoor' - } -} - -resource logAnalyticsWorkspaceDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { - scope: frontDoorProfile - name: 'diagnosticSettings' - properties: { - workspaceId: logAnalyticsWorkspaceIdForDiagnostics - logs: [ - { - category: 'FrontDoorWebApplicationFirewallLog' - enabled: true - } - ] - } -} - -resource frontDoorEndpoint 'Microsoft.Cdn/profiles/afdEndpoints@2021-06-01' = { - name: frontDoorEndpointName - parent: frontDoorProfile - location: 'global' - properties: { - enabledState: 'Enabled' - } -} - -resource frontDoorOriginGroup 'Microsoft.Cdn/profiles/originGroups@2021-06-01' = { - name: frontDoorOriginGroupName - parent: frontDoorProfile - properties: { - loadBalancingSettings: { - sampleSize: 4 - successfulSamplesRequired: 3 - } - healthProbeSettings: { - probePath: '/healthz' - probeRequestType: 'HEAD' - probeProtocol: 'Https' - probeIntervalInSeconds: 100 - } - } -} - -resource frontDoorPrimaryOrigin 'Microsoft.Cdn/profiles/originGroups/origins@2021-06-01' = { - name: '${frontDoorOriginName}1' - parent: frontDoorOriginGroup - properties: { - hostName: primaryBackendAddress - httpPort: 80 - httpsPort: 443 - originHostHeader: primaryBackendAddress - priority: 1 - weight: 1000 - } -} - -resource frontDoorSecondaryOrigin 'Microsoft.Cdn/profiles/originGroups/origins@2021-06-01' = if (secondaryBackendAddress != 'none') { - name: '${frontDoorOriginName}2' - parent: frontDoorOriginGroup - properties: { - hostName: secondaryBackendAddress - httpPort: 80 - httpsPort: 443 - originHostHeader: secondaryBackendAddress - priority: 2 - weight: 1000 - } -} - -resource frontDoorRoute 'Microsoft.Cdn/profiles/afdEndpoints/routes@2021-06-01' = { - name: frontDoorRouteName - parent: frontDoorEndpoint - dependsOn: [ - // These explicit dependencies are required to ensure that the origin group is not empty when the route is created. - frontDoorPrimaryOrigin - frontDoorSecondaryOrigin - ] - properties: { - originGroup: { - id: frontDoorOriginGroup.id - } - supportedProtocols: [ - 'Http' - 'Https' - ] - patternsToMatch: [ - '/*' - ] - forwardingProtocol: 'HttpsOnly' - linkToDefaultDomain: 'Enabled' - httpsRedirect: 'Enabled' - } -} - -resource frontdoorWebApplicationFirewallPolicy 'Microsoft.Network/frontdoorwebapplicationfirewallpolicies@2020-11-01' = { - name: 'wafpolicy${globalResourceToken}' - location: 'Global' - sku: { - name: 'Premium_AzureFrontDoor' - } - properties: { - policySettings: { - enabledState: 'Enabled' - mode: 'Prevention' - requestBodyCheck: 'Enabled' - } - customRules: { - rules: [] - } - managedRules: { - managedRuleSets: [ - { - ruleSetType: 'Microsoft_DefaultRuleSet' - ruleSetVersion: '2.0' - ruleSetAction: 'Block' - ruleGroupOverrides: [] - exclusions: [] - } - { - ruleSetType: 'Microsoft_BotManagerRuleSet' - ruleSetVersion: '1.0' - ruleSetAction: 'Block' - ruleGroupOverrides: [] - exclusions: [] - } - ] - } - } -} - -resource profiles_manualryckozesqpn24_name_manualwafpolicy_cfc67469 'Microsoft.Cdn/profiles/securitypolicies@2021-06-01' = { - parent: frontDoorProfile - name: 'wafpolicy-${globalResourceToken}' - properties: { - parameters: { - wafPolicy: { - id: frontdoorWebApplicationFirewallPolicy.id - } - associations: [ - { - domains: [ - { - id: frontDoorEndpoint.id - } - ] - patternsToMatch: [ - '/*' - ] - } - ] - type: 'WebApplicationFirewall' - } - } -} - -output HOST_NAME string = frontDoorEndpoint.properties.hostName diff --git a/infra/azureKeyVaultDiagnostics.bicep b/infra/azureKeyVaultDiagnostics.bicep deleted file mode 100644 index 2cd42e74..00000000 --- a/infra/azureKeyVaultDiagnostics.bicep +++ /dev/null @@ -1,31 +0,0 @@ -@minLength(1) -@description('ResourceId for a log analytics workspace that will collect diagnostic info for Key Vault and Front Door') -param logAnalyticsWorkspaceIdForDiagnostics string - -@minLength(1) -@description('Name of a key vault that shuold be monitored') -param keyVaultName string - -resource existingKeyVault 'Microsoft.KeyVault/vaults@2021-11-01-preview' existing = { - name: keyVaultName -} - -resource keyVaultDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { - scope: existingKeyVault - name: 'default' - properties: { - workspaceId: logAnalyticsWorkspaceIdForDiagnostics - logs: [ - { - category: 'AuditEvent' - enabled: true - } - ] - metrics: [ - { - category: 'AllMetrics' - enabled: true - } - ] - } -} diff --git a/infra/azureRedisCache.bicep b/infra/azureRedisCache.bicep deleted file mode 100644 index 58d1eccd..00000000 --- a/infra/azureRedisCache.bicep +++ /dev/null @@ -1,139 +0,0 @@ -@description('The id for the user-assigned managed identity that runs deploymentScripts') -param devOpsManagedIdentityId string - -@description('Enables the template to choose different SKU by environment') -param isProd bool - -@minLength(1) -@description('The name of the Key Vault that will store AAD secrets for the web app') -param keyVaultName string - -@description('The Azure location where this solution is deployed') -param location string - -@description('A generated identifier used to create unique resources') -param resourceToken string - -@description('Name for private endpoint') -param privateEndpointNameForRedis string - -@description('Name of subnet for private endpoint') -param privateEndpointSubnetName string - -@description('Name of vnet for private endpoint') -param privateEndpointVnetName string - -@description('An object collection that contains annotations to describe the deployed azure resources to improve operational visibility') -param tags object - -@description('Ensures that the idempotent scripts are executed each time the deployment is executed') -param uniqueScriptId string = newGuid() - -var redisCacheSkuName = isProd ? 'Standard' : 'Basic' -var redisCacheFamilyName = isProd ? 'C' : 'C' -var redisCacheCapacity = isProd ? 1 : 0 - -resource redisCache 'Microsoft.Cache/Redis@2022-05-01' = { - name: '${resourceToken}-rediscache' - location: location - tags: tags - properties: { - redisVersion: '6.0' - sku: { - name: redisCacheSkuName - family: redisCacheFamilyName - capacity: redisCacheCapacity - } - enableNonSslPort: false - publicNetworkAccess: 'Disabled' - redisConfiguration: { - 'maxmemory-reserved': '30' - 'maxfragmentationmemory-reserved': '30' - 'maxmemory-delta': '30' - } - } -} - -resource existingKeyVault 'Microsoft.KeyVault/vaults@2021-11-01-preview' existing = { - name: keyVaultName - scope: resourceGroup() - - resource kvSecretRedis 'secrets@2021-11-01-preview' = { - name: 'App--RedisCache--ConnectionString' - tags: tags - properties: { - value: '${redisCache.name}.redis.cache.windows.net:6380,password=${redisCache.listKeys().primaryKey},ssl=True,abortConnect=False' - } - } -} - -resource vnet 'Microsoft.Network/virtualNetworks@2020-07-01' existing = { - name: privateEndpointVnetName -} - -resource privateEndpointForRedis 'Microsoft.Network/privateEndpoints@2020-07-01' = { - name: privateEndpointNameForRedis - location: location - tags: tags - properties: { - subnet: { - id: resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, privateEndpointSubnetName) - } - privateLinkServiceConnections: [ - { - name: redisCache.name - properties: { - privateLinkServiceId: redisCache.id - groupIds: [ - 'redisCache' - ] - } - } - ] - } - dependsOn: [ - vnet - ] -} - -resource privateDnsZoneNameForRedis 'Microsoft.Network/privateDnsZones@2020-06-01' = { - name: 'privatelink.redis.cache.windows.net' - location: 'global' - tags: tags -} - -resource privateDnsZoneNameForRedis_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = { - parent: privateDnsZoneNameForRedis - name: '${privateDnsZoneNameForRedis.name}-link' - location: 'global' - tags: tags - properties: { - registrationEnabled: false - virtualNetwork: { - id: vnet.id - } - } -} - -resource makeRedisAccessibleForDevs 'Microsoft.Resources/deploymentScripts@2020-10-01' = if (!isProd) { - name: 'makeRedisAccessibleForDevs' - location: location - tags: tags - kind:'AzureCLI' - identity:{ - type: 'UserAssigned' - userAssignedIdentities: { - '${devOpsManagedIdentityId}': {} - } - } - properties: { - forceUpdateTag: uniqueScriptId - azCliVersion: '2.37.0' - retentionInterval: 'P1D' - scriptContent: loadTextContent('./deploymentScripts/azureRedisCachePublicDevAccess.sh') - arguments:' --subscription ${subscription().subscriptionId} --resource-group ${resourceGroup().name} --name ${redisCache.name}' - } -} - -output keyVaultRedisConnStrName string = existingKeyVault::kvSecretRedis.name -output privateDnsZoneId string = privateDnsZoneNameForRedis.id diff --git a/infra/azureSqlDatabase.bicep b/infra/azureSqlDatabase.bicep deleted file mode 100644 index 8bb86ddd..00000000 --- a/infra/azureSqlDatabase.bicep +++ /dev/null @@ -1,144 +0,0 @@ -@minLength(1) -@description('The id for the user-assigned managed identity that runs deploymentScripts') -param devOpsManagedIdentityId string - -@description('Expecting the user-assigned managed identity that represents the API web app. Will become the SQL db admin') -param managedIdentity object - -@minLength(1) -@description('A generated identifier used to create unique resources') -param resourceToken string - -@description('Enables the template to choose different SKU by environment') -param isProd bool - -@minLength(1) -@description('The name of an admin account that can be used to add Managed Identities to Azure SQL') -param sqlAdministratorLogin string - -@secure() -@minLength(1) -// note - this password should not be saved. the apps, and devs, connect with Managed Identity or Azure AD -@description('The password for an admin account that can be used to add Managed Identities to Azure SQL') -param sqlAdministratorPassword string - -@description('Ensures that the idempotent scripts are executed each time the deployment is executed') -param uniqueScriptId string = newGuid() - -@minLength(1) -@description('Primary location for all resources. Should specify an Azure region. e.g. `eastus2` ') -param location string - -@description('An object collection that contains annotations to describe the deployed azure resources to improve operational visibility') -param tags object - -var sqlServerName = '${resourceToken}-sql-server' - -resource allowSqlAdminScript 'Microsoft.Resources/deploymentScripts@2020-10-01' = { - name: 'allowSqlAdminScript' - location: location - tags: tags - kind: 'AzurePowerShell' - identity:{ - type: 'UserAssigned' - userAssignedIdentities: { - '${devOpsManagedIdentityId}': {} - } - } - properties: { - forceUpdateTag: uniqueScriptId - azPowerShellVersion: '7.4' - retentionInterval: 'P1D' - cleanupPreference: 'OnSuccess' - arguments: '-SqlServerName \'${sqlServerName}\' -ResourceGroupName \'${resourceGroup().name}\'' - scriptContent: loadTextContent('./deploymentScripts/enableSqlAdminForServer.ps1') - } -} - -resource sqlServer 'Microsoft.Sql/servers@2021-02-01-preview' = { - name: sqlServerName - location: location - tags: tags - properties: { - administratorLogin: sqlAdministratorLogin - administratorLoginPassword: sqlAdministratorPassword - administrators: { - login: managedIdentity.name - principalType: 'User' - sid: managedIdentity.properties.principalId - tenantId: managedIdentity.properties.tenantId - } - version: '12.0' - } - dependsOn:[ - allowSqlAdminScript - ] -} - -var sqlCatalogName = '${resourceToken}-sql-database' -var skuTierName = isProd ? 'Premium' : 'Standard' -var dtuCapacity = isProd ? 125 : 10 -var requestedBackupStorageRedundancy = isProd ? 'Geo' : 'Local' -var readScale = isProd ? 'Enabled' : 'Disabled' - - -resource sqlDatabase 'Microsoft.Sql/servers/databases@2021-11-01-preview' = { - name: '${sqlServer.name}/${sqlCatalogName}' - location: location - tags: union(tags, { - displayName: sqlCatalogName - }) - sku: { - name: skuTierName - tier: skuTierName - capacity: dtuCapacity - } - properties: { - requestedBackupStorageRedundancy: requestedBackupStorageRedundancy - readScale: readScale - } -} - -// To allow applications hosted inside Azure to connect to your SQL server, Azure connections must be enabled. -// To enable Azure connections, there must be a firewall rule with starting and ending IP addresses set to 0.0.0.0. -// This recommended rule is only applicable to Azure SQL Database. -// Ref: https://learn.microsoft.com/azure/azure-sql/database/firewall-configure?view=azuresql#connections-from-inside-azure -resource allowAllWindowsAzureIps 'Microsoft.Sql/servers/firewallRules@2021-11-01-preview' = { - name: 'AllowAllWindowsAzureIps' - parent: sqlServer - properties: { - endIpAddress: '0.0.0.0' - startIpAddress: '0.0.0.0' - } -} - -resource createSqlUserScript 'Microsoft.Resources/deploymentScripts@2020-10-01' = { - name: 'createSqlUserScript' - location: location - tags: tags - kind: 'AzurePowerShell' - identity:{ - type: 'UserAssigned' - userAssignedIdentities: { - '${devOpsManagedIdentityId}': {} - } - } - properties: { - forceUpdateTag: uniqueScriptId - azPowerShellVersion: '7.4' - retentionInterval: 'P1D' - cleanupPreference: 'OnSuccess' - arguments: '-ServerName \'${sqlServer.name}\' -ResourceGroupName \'${resourceGroup().name}\' -ServerUri \'${sqlServer.properties.fullyQualifiedDomainName}\' -CatalogName \'${sqlCatalogName}\' -ApplicationId \'${managedIdentity.properties.principalId}\' -ManagedIdentityName \'${managedIdentity.name}\' -SqlAdminLogin \'${sqlAdministratorLogin}\' -SqlAdminPwd \'${sqlAdministratorPassword}\' -IsProd ${isProd ? '1' : '0'}' - scriptContent: loadTextContent('./deploymentScripts/createSqlAcctForManagedIdentity.ps1') - } - dependsOn:[ - sqlDatabase - ] -} - -output sqlServerFqdn string = sqlServer.properties.fullyQualifiedDomainName -output sqlCatalogName string = sqlCatalogName - -output sqlServerName string = sqlServer.name -output sqlServerId string = sqlServer.id -output sqlDatabaseName string = sqlDatabase.name diff --git a/infra/azureStorage.bicep b/infra/azureStorage.bicep deleted file mode 100644 index 12289e5d..00000000 --- a/infra/azureStorage.bicep +++ /dev/null @@ -1,78 +0,0 @@ -@description('Enables the template to choose different SKU by environment') -param isProd bool - -@minLength(1) -@description('Primary location for all resources. Should specify an Azure region. e.g. `eastus2` ') -param location string - -@minLength(1) -@description('A generated identifier used to create unique resources') -param resourceToken string - -@description('An object collection that contains annotations to describe the deployed azure resources to improve operational visibility') -param tags object - -@description('Role assignments to add when resource is created') -param roleAssignmentsList array - -@description('Id of subnet for private endpoint') -param privateLinkSubnetId string - -@description('Id of Azure Private DNS for private endpoint') -param privateDnsZoneId string - - -var storageSku = isProd ? 'Standard_ZRS' : 'Standard_LRS' - -resource storageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' = { - name: '${resourceToken}storage' //storage account name cannot contain character '-' - tags: tags - location: location - sku: { - name: storageSku - } - kind: 'StorageV2' - properties: { - publicNetworkAccess: 'Disabled' - } -} - -resource blobServices 'Microsoft.Storage/storageAccounts/blobServices@2022-09-01' = { - parent: storageAccount - name:'default' -} - -resource container 'Microsoft.Storage/storageAccounts/blobServices/containers@2022-09-01' = { - parent: blobServices - name: 'tickets' -} - -resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for roleAssignment in roleAssignmentsList: { - name: guid(roleAssignment.principalId, roleAssignment.roleDefinitionId, resourceGroup().id) - scope: container - properties: { - description: roleAssignment.description - principalId: roleAssignment.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleAssignment.roleDefinitionId) - principalType: roleAssignment.principalType - } -}] - -module privateLink 'connectByPrivateLink.bicep'={ - name: '${storageAccount.name}-privateLink' - params: { - location: location - name: 'privateEndpointForTickeStore' - tags: tags - serviceResourceId: storageAccount.id - subnetResourceId: privateLinkSubnetId - serviceGroupIds: ['blob'] - privateDnsZoneId: privateDnsZoneId - } -} - - -output storageAccountResourceId string = storageAccount.id -output storageAccocuntBlobURL string = storageAccount.properties.primaryEndpoints.blob -output containerId string = container.id -output containerName string = container.name diff --git a/infra/bicepconfig.json b/infra/bicepconfig.json new file mode 100644 index 00000000..1b46d8c9 --- /dev/null +++ b/infra/bicepconfig.json @@ -0,0 +1,6 @@ +{ + "experimentalFeaturesEnabled": { + "sourceMapping": true, + "userDefinedTypes": true + } +} \ No newline at end of file diff --git a/infra/connectByPrivateLink.bicep b/infra/connectByPrivateLink.bicep deleted file mode 100644 index 1d962409..00000000 --- a/infra/connectByPrivateLink.bicep +++ /dev/null @@ -1,62 +0,0 @@ -@minLength(1) -@description('Name of the private endpoint that will be created for this connection') -param name string - -@minLength(1) -@description('Primary location for all resources. Should specify an Azure region. e.g. `eastus2` ') -param location string - -@minLength(1) -@description('The resourceId of an existing Azure subnet that will be used to create a private endpoint connection') -param subnetResourceId string - -@minLength(1) -@description('The resourceId of an existing Azure private DNS that will provide the routing for this private endpoint') -param privateDnsZoneId string - -@minLength(1) -@description('The resourceId of an existing Azure resource that will be accessed by the private endpoint connection') -param serviceResourceId string - -@description('The type of Azure resource that will be networked as a private endpoint such as `configurationStores` or `vault`') -param serviceGroupIds array - -@description('An object collection that contains annotations to describe the deployed azure resources to improve operational visibility') -param tags object - -resource privateEndpoint 'Microsoft.Network/privateEndpoints@2020-07-01' = { - name: name - location: location - tags: tags - properties: { - subnet: { - id: subnetResourceId - } - privateLinkServiceConnections: [ - { - name: name - properties: { - privateLinkServiceId: serviceResourceId - groupIds: serviceGroupIds - } - } - ] - } -} - -resource privateDnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2020-07-01' = { - name: privateEndpoint.name - parent: privateEndpoint - - properties: { - privateDnsZoneConfigs: [ - { - name: name - properties: { - privateDnsZoneId: privateDnsZoneId - } - } - ] - } -} - diff --git a/infra/core/compute/postDeploymentScript/post-deployment.sh b/infra/core/compute/postDeploymentScript/post-deployment.sh new file mode 100644 index 00000000..40216d2e --- /dev/null +++ b/infra/core/compute/postDeploymentScript/post-deployment.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +# install AZD +curl -fsSL https://aka.ms/install-azd.sh | sudo bash + +# add Microsoft package feed for the dotnet install +# Get Ubuntu version +declare repo_version=$(if command -v lsb_release &> /dev/null; then lsb_release -r -s; else grep -oP '(?<=^VERSION_ID=).+' /etc/os-release | tr -d '"'; fi) + +# Download Microsoft signing key and repository +wget https://packages.microsoft.com/config/ubuntu/$repo_version/packages-microsoft-prod.deb -O packages-microsoft-prod.deb + +# Install Microsoft signing key and repository +sudo dpkg -i packages-microsoft-prod.deb + +# Clean up +rm packages-microsoft-prod.deb + +# Update packages +sudo apt-get -y update + +# install jq +sudo apt-get install -y jq + +# install dotnet +sudo apt-get install -y dotnet-sdk-8.0 + +# install Azure CLI +curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash + +# make directory for SCP +mkdir /home/azureadmin/web-app-pattern + +sudo chown -R azureadmin:azureadmin /home/azureadmin/web-app-pattern + +# install pwsh core + +# Download the PowerShell package file +wget https://github.com/PowerShell/PowerShell/releases/download/v7.4.1/powershell_7.4.1-1.deb_amd64.deb + +################################### +# Install the PowerShell package +sudo dpkg -i powershell_7.4.1-1.deb_amd64.deb + +# Resolve missing dependencies and finish the install (if necessary) +sudo apt-get install -f + +# Delete the downloaded package file +rm powershell_7.4.1-1.deb_amd64.deb + +# install Az module +sudo pwsh -Command "Install-Module -Name Az -Repository PSGallery -Scope AllUsers -Force" \ No newline at end of file diff --git a/infra/core/compute/ubuntu-jumpbox.bicep b/infra/core/compute/ubuntu-jumpbox.bicep new file mode 100644 index 00000000..f9e43a9b --- /dev/null +++ b/infra/core/compute/ubuntu-jumpbox.bicep @@ -0,0 +1,275 @@ +targetScope = 'resourceGroup' + +/* +** Ubuntu VM Jumpbox +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Creates an Ubuntu VM with appropriate capabilities to act as a +** jumpbox +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The name of the primary resource') +param name string + +@minLength(3) +@maxLength(15) +@description('The name of the linux PC. By default, this will be automatically constructed by the resource name.') +param computerLinuxName string? + +@description('Username for the Virtual Machine.') +param adminUsername string + +@description('Type of authentication to use on the Virtual Machine. SSH key is recommended.') +@allowed([ + 'sshPublicKey' + 'password' +]) +param authenticationType string = 'password' + +@description('SSH Key or password for the Virtual Machine. SSH key is recommended.') +@secure() +param adminPasswordOrKey string + +@description('The Ubuntu version for the VM. This will pick a fully patched image of this given Ubuntu version.') +@allowed([ + 'Ubuntu-1804' + 'Ubuntu-2004' + 'Ubuntu-2204' +]) +param ubuntuOSVersion string = 'Ubuntu-2204' + +@description('Location for all resources.') +param location string = resourceGroup().location + +@description('The size of the VM') +param vmSize string = 'Standard_B2ms' + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ + +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +@description('The subnet ID to use for the resource.') +param subnetId string + +/* +** Settings +*/ + +// ======================================================================== +// VARIABLES +// ======================================================================== + +// based on the images allowed we enable TrustedLaunch by default to opt-in to security features by default. +// this can be swapped to 'Standard' if the user wants to opt-out of TrustedLaunch +// Trusted launch guards against boot kits, rootkits, and kernel-level malware. +// Learn more at https://learn.microsoft.com/en-us/azure/virtual-machines/trusted-launch +var securityType = 'TrustedLaunch' +var validComputerName = replace(replace(name, '-', ''), '_', '') +var computerName = !empty(computerLinuxName) ? computerLinuxName : length(validComputerName) > 15 ? substring(validComputerName, 0, 15) : validComputerName + +var configScriptRepoUrl = 'https://raw.githubusercontent.com/KSchlobohm/reliable-web-app-vm-postconfiguration/main' + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +var imageReference = { + 'Ubuntu-1804': { + publisher: 'Canonical' + offer: 'UbuntuServer' + sku: '18_04-lts-gen2' + version: 'latest' + } + 'Ubuntu-2004': { + publisher: 'Canonical' + offer: '0001-com-ubuntu-server-focal' + sku: '20_04-lts-gen2' + version: 'latest' + } + 'Ubuntu-2204': { + publisher: 'Canonical' + offer: '0001-com-ubuntu-server-jammy' + sku: '22_04-lts-gen2' + version: 'latest' + } +} +var osDiskType = 'Standard_LRS' +var linuxConfiguration = (authenticationType == 'password') ? {} : { + disablePasswordAuthentication: true + ssh: { + publicKeys: [ + { + path: '/home/${adminUsername}/.ssh/authorized_keys' + keyData: adminPasswordOrKey + } + ] + } +} +var securityProfileJson = { + uefiSettings: { + secureBootEnabled: true + vTpmEnabled: true + } + securityType: securityType +} +var extensionName = 'GuestAttestation' +var extensionPublisher = 'Microsoft.Azure.Security.LinuxAttestation' +var extensionVersion = '1.0' +var maaTenantName = 'GuestAttestation' +var maaEndpoint = substring('emptystring', 0, 0) + + +resource networkInterface 'Microsoft.Network/networkInterfaces@2022-11-01' = { + name: 'nic-${name}' + location: location + tags: tags + properties: { + enableAcceleratedNetworking: false + enableIPForwarding: false + ipConfigurations: [ + { + name: 'ipconfig1' + properties: { + primary: true + privateIPAllocationMethod: 'Dynamic' + subnet: { + id: subnetId + } + } + } + ] + } +} + + +resource virtualMachine 'Microsoft.Compute/virtualMachines@2021-11-01' = { + name: name + location: location + properties: { + hardwareProfile: { + vmSize: vmSize + } + storageProfile: { + osDisk: { + createOption: 'FromImage' + managedDisk: { + storageAccountType: osDiskType + } + } + imageReference: imageReference[ubuntuOSVersion] + } + networkProfile: { + networkInterfaces: [ + { + id: networkInterface.id + } + ] + } + osProfile: { + computerName: computerName + adminUsername: adminUsername + adminPassword: adminPasswordOrKey + linuxConfiguration: ((authenticationType == 'password') ? null : linuxConfiguration) + } + securityProfile: ((securityType == 'TrustedLaunch') ? securityProfileJson : null) + } + tags: tags +} + +resource vmExtension 'Microsoft.Compute/virtualMachines/extensions@2022-03-01' = if ((securityType == 'TrustedLaunch') && ((securityProfileJson.uefiSettings.secureBootEnabled == true) && (securityProfileJson.uefiSettings.vTpmEnabled == true))) { + parent: virtualMachine + name: extensionName + location: location + properties: { + publisher: extensionPublisher + type: extensionName + typeHandlerVersion: extensionVersion + autoUpgradeMinorVersion: true + enableAutomaticUpgrade: true + settings: { + AttestationConfig: { + MaaSettings: { + maaEndpoint: maaEndpoint + maaTenantName: maaTenantName + } + } + } + } +} + +resource postDeploymentScript 'Microsoft.Compute/virtualMachines/extensions@2023-03-01' = { + name: 'postDeploymentScript' + location: location + parent: virtualMachine + properties: { + publisher: 'Microsoft.Azure.Extensions' + type: 'CustomScript' + typeHandlerVersion: '2.1' + autoUpgradeMinorVersion: true + settings: { + skipDos2Unix:false + } + protectedSettings: { + commandToExecute: 'chmod +x post-deployment.sh && bash post-deployment.sh' + fileUris: [ + '${configScriptRepoUrl}/post-deployment.sh' + ] + } + } +} + +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (diagnosticSettings != null && !empty(logAnalyticsWorkspaceId)) { + name: '${name}-diagnostics' + scope: virtualMachine + properties: { + workspaceId: logAnalyticsWorkspaceId + logs: [] + metrics: [ + { + category: 'AllMetrics' + enabled: diagnosticSettings!.enableMetrics + } + ] + } +} + +output id string = virtualMachine.id +output name string = virtualMachine.name + +output computer_name string = computerName! diff --git a/infra/core/compute/windows-buildagent.bicep b/infra/core/compute/windows-buildagent.bicep new file mode 100644 index 00000000..ddf15a95 --- /dev/null +++ b/infra/core/compute/windows-buildagent.bicep @@ -0,0 +1,277 @@ +targetScope = 'resourceGroup' + +/* +** Windows 11 Build Agent +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Creates a Windows 11 VM with appropriate capabilities to act as a +** Build Agent with either Azure DevOps or GitHub Actions. +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// From: infra/types/BuildAgentSettings.bicep +@description('Describes the required settings for a Azure DevOps Pipeline runner') +type AzureDevopsSettings = { + @description('The URL of the Azure DevOps organization to use for this agent') + organizationUrl: string + + @description('The Personal Access Token (PAT) to use for the Azure DevOps agent') + token: string +} + +@description('Describes the required settings for a GitHub Actions runner') +type GithubActionsSettings = { + @description('The URL of the GitHub repository to use for this agent') + repositoryUrl: string + + @description('The Personal Access Token (PAT) to use for the GitHub Actions runner') + token: string +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@description('The ID of a user-assigned managed identity to use as the identity for this resource. Use a blank string for a system-assigned identity.') +param managedIdentityId string = '' + +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +@description('The subnet ID to use for the resource.') +param subnetId string + +/* +** Settings +*/ +@secure() +@minLength(8) +@description('The password for the administrator account on the build agent.') +param administratorPassword string + +@minLength(8) +@description('The username for the administrator account on the build agent.') +param administratorUsername string + +@description('If provided, the Azure DevOps settings to use for the build agent.') +param azureDevopsSettings AzureDevopsSettings? + +@description('If provided, the GitHub Actions settings to use for the build agent.') +param githubActionsSettings GithubActionsSettings? + +@minLength(3) +@maxLength(15) +@description('The name of the windows PC. By default, this will be automatically constructed by the resource name.') +param computerWindowsName string? + +@description('If true, join the computer to the Microsoft Entra ID domain.') +param joinToMicrosoftEntraId bool = true + +@description('The SKU for the virtual machine.') +param sku string = 'Standard_B2ms' + +@description('If true, install the Azure CLI, SSMS, and git on the machine.') +param installTools bool = true + +// ======================================================================== +// VARIABLES +// ======================================================================== + +var identity = !empty(managedIdentityId) ? { + type: 'UserAssigned' + userAssignedIdentities: { + '${managedIdentityId}': {} + } +} : { + type: 'SystemAssigned' +} + +var validComputerName = replace(replace(name, '-', ''), '_', '') +var computerName = !empty(computerWindowsName) ? computerWindowsName : length(validComputerName) > 15 ? substring(validComputerName, 0, 15) : validComputerName + +var installToolsOption = installTools ? ' -install_clis -install_ssms' : '' + +var azureDevopsOption = azureDevopsSettings != null ? ' -ado_organization "${azureDevopsSettings!.organizationUrl}" -ado_token "${azureDevopsSettings!.token}"' : '' +var githubActionsOption = githubActionsSettings != null ? ' -github_repository "${githubActionsSettings!.repositoryUrl}" -github_token "${githubActionsSettings!.token}"' : '' +var doInstall = azureDevopsSettings != null || githubActionsSettings != null + + +// This is the URL to the App Service Landing Zone Accelerator GitHub repository. +// See: https://github.com/Azure/appservice-landing-zone-accelerator +var landingZoneAcceleratorUrl = 'https://raw.githubusercontent.com/Azure/appservice-landing-zone-accelerator/main/scenarios/shared/scripts/win-devops-vm-extensions' + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource networkInterface 'Microsoft.Network/networkInterfaces@2022-11-01' = if (doInstall) { + name: 'nic-${name}' + location: location + tags: tags + properties: { + enableAcceleratedNetworking: false + enableIPForwarding: false + ipConfigurations: [ + { + name: 'ipconfig1' + properties: { + primary: true + privateIPAllocationMethod: 'Dynamic' + subnet: { + id: subnetId + } + } + } + ] + } +} + +resource virtualMachine 'Microsoft.Compute/virtualMachines@2023-03-01' = if (doInstall) { + name: name + location: location + tags: tags + identity: identity + properties: { + diagnosticsProfile: { + bootDiagnostics: { + enabled: true + } + } + hardwareProfile: { + vmSize: sku + } + networkProfile: { + networkInterfaces: [ + { + id: networkInterface.id + properties: { + deleteOption: 'Delete' + } + } + ] + } + osProfile: { + adminPassword: administratorPassword + adminUsername: administratorUsername + computerName: computerName + windowsConfiguration: { + provisionVMAgent: true + enableAutomaticUpdates: true + patchSettings: { + patchMode: 'AutomaticByOS' + assessmentMode: 'ImageDefault' + enableHotpatching: false + } + enableVMAgentPlatformUpdates: true + } + } + storageProfile: { + imageReference: { + publisher: 'MicrosoftWindowsDesktop' + offer: 'Windows-11' + sku: 'win11-22h2-pro' + version: 'latest' + } + osDisk: { + osType: 'Windows' + createOption: 'FromImage' + managedDisk: { + storageAccountType: 'Standard_LRS' + } + } + } + } +} + +resource aadLoginExtension 'Microsoft.Compute/virtualMachines/extensions@2023-03-01' = if (doInstall && joinToMicrosoftEntraId) { + name: 'AADLoginForWindows' + location: location + parent: virtualMachine + properties: { + publisher: 'Microsoft.Azure.ActiveDirectory' + type: 'AADLoginForWindows' + typeHandlerVersion: '1.0' + autoUpgradeMinorVersion: true + } +} + +resource postDeploymentScript 'Microsoft.Compute/virtualMachines/extensions@2023-03-01' = if (doInstall) { + name: 'postDeploymentScript' + location: location + parent: virtualMachine + properties: { + publisher: 'Microsoft.Compute' + type: 'CustomScriptExtension' + typeHandlerVersion: '1.10' + autoUpgradeMinorVersion: true + settings: { + fileUris: [ + '${landingZoneAcceleratorUrl}/post-deployment.ps1' + ] + } + protectedSettings: { + commandToExecute: 'powershell.exe -ExecutionPolicy Unrestricted -File post-deployment.ps1${installToolsOption}${azureDevopsOption}${githubActionsOption}' + } + } +} + +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (doInstall && diagnosticSettings != null && !empty(logAnalyticsWorkspaceId)) { + name: '${name}-diagnostics' + scope: virtualMachine + properties: { + workspaceId: logAnalyticsWorkspaceId + logs: [] + metrics: [ + { + category: 'AllMetrics' + enabled: diagnosticSettings!.enableMetrics + } + ] + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = virtualMachine.id +output name string = virtualMachine.name + +output computer_name string = computerName! diff --git a/infra/core/compute/windows-jumpbox.bicep b/infra/core/compute/windows-jumpbox.bicep new file mode 100644 index 00000000..7fa7f88f --- /dev/null +++ b/infra/core/compute/windows-jumpbox.bicep @@ -0,0 +1,247 @@ +targetScope = 'resourceGroup' + +/* +** Windows 11 Jumpbox +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Creates a Windows 11 VM with appropriate capabilities to act as a +** jumpbox. This includes the Windows CLI, git, and SSMS. +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@description('The ID of a user-assigned managed identity to use as the identity for this resource. Use a blank string for a system-assigned identity.') +param managedIdentityId string = '' + +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +@description('The subnet ID to use for the resource.') +param subnetId string + +/* +** Settings +*/ +@secure() +@minLength(8) +@description('The password for the administrator account on the jump box.') +param administratorPassword string + +@minLength(8) +@description('The username for the administrator account on the jump box.') +param administratorUsername string + +@minLength(3) +@maxLength(15) +@description('The name of the windows PC. By default, this will be automatically constructed by the resource name.') +param computerWindowsName string? + +@description('If true, join the computer to the Microsoft Entra ID domain.') +param joinToMicrosoftEntraId bool = true + +@description('The SKU for the virtual machine.') +param sku string = 'Standard_B2ms' + +@description('If true, install the Azure CLI, SSMS, and git on the machine.') +param installTools bool = true + +// ======================================================================== +// VARIABLES +// ======================================================================== + +var identity = !empty(managedIdentityId) ? { + type: 'UserAssigned' + userAssignedIdentities: { + '${managedIdentityId}': {} + } +} : { + type: 'SystemAssigned' +} + +var validComputerName = replace(replace(name, '-', ''), '_', '') +var computerName = !empty(computerWindowsName) ? computerWindowsName : length(validComputerName) > 15 ? substring(validComputerName, 0, 15) : validComputerName + +var installToolsOption = installTools ? '-install_clis' : '' + +// This is the URL to the App Service Landing Zone Accelerator GitHub repository. +// See: https://github.com/Azure/appservice-landing-zone-accelerator +var landingZoneAcceleratorUrl = 'https://raw.githubusercontent.com/Azure/appservice-landing-zone-accelerator/main/scenarios/shared/scripts/win-devops-vm-extensions' + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource networkInterface 'Microsoft.Network/networkInterfaces@2022-11-01' = { + name: 'nic-${name}' + location: location + tags: tags + properties: { + enableAcceleratedNetworking: false + enableIPForwarding: false + ipConfigurations: [ + { + name: 'ipconfig1' + properties: { + primary: true + privateIPAllocationMethod: 'Dynamic' + subnet: { + id: subnetId + } + } + } + ] + } +} + +resource virtualMachine 'Microsoft.Compute/virtualMachines@2023-03-01' = { + name: name + location: location + tags: tags + identity: identity + properties: { + diagnosticsProfile: { + bootDiagnostics: { + enabled: true + } + } + hardwareProfile: { + vmSize: sku + } + networkProfile: { + networkInterfaces: [ + { + id: networkInterface.id + properties: { + deleteOption: 'Delete' + } + } + ] + } + osProfile: { + adminPassword: administratorPassword + adminUsername: administratorUsername + computerName: computerName + windowsConfiguration: { + provisionVMAgent: true + enableAutomaticUpdates: true + patchSettings: { + patchMode: 'AutomaticByOS' + assessmentMode: 'ImageDefault' + enableHotpatching: false + } + enableVMAgentPlatformUpdates: true + } + } + storageProfile: { + imageReference: { + publisher: 'MicrosoftWindowsDesktop' + offer: 'Windows-11' + sku: 'win11-23h2-pro' + version: 'latest' + } + osDisk: { + osType: 'Windows' + createOption: 'FromImage' + managedDisk: { + storageAccountType: 'Standard_LRS' + } + } + } + } +} + +resource aadLoginExtension 'Microsoft.Compute/virtualMachines/extensions@2023-03-01' = if (joinToMicrosoftEntraId) { + name: 'AADLoginForWindows' + location: location + parent: virtualMachine + properties: { + publisher: 'Microsoft.Azure.ActiveDirectory' + type: 'AADLoginForWindows' + typeHandlerVersion: '1.0' + autoUpgradeMinorVersion: true + } +} + +resource postDeploymentScript 'Microsoft.Compute/virtualMachines/extensions@2023-03-01' = { + name: 'postDeploymentScript' + location: location + parent: virtualMachine + properties: { + publisher: 'Microsoft.Compute' + type: 'CustomScriptExtension' + typeHandlerVersion: '1.10' + autoUpgradeMinorVersion: true + settings: { + fileUris: [ + '${landingZoneAcceleratorUrl}/post-deployment.ps1' + ] + } + protectedSettings: { + commandToExecute: 'powershell.exe -ExecutionPolicy Unrestricted -File post-deployment.ps1 ${installToolsOption}' + } + } +} + +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (diagnosticSettings != null && !empty(logAnalyticsWorkspaceId)) { + name: '${name}-diagnostics' + scope: virtualMachine + properties: { + workspaceId: logAnalyticsWorkspaceId + logs: [] + metrics: [ + { + category: 'AllMetrics' + enabled: diagnosticSettings!.enableMetrics + } + ] + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = virtualMachine.id +output name string = virtualMachine.name + +output computer_name string = computerName! diff --git a/infra/core/config/app-configuration.bicep b/infra/core/config/app-configuration.bicep new file mode 100644 index 00000000..d77e2f8c --- /dev/null +++ b/infra/core/config/app-configuration.bicep @@ -0,0 +1,197 @@ +targetScope = 'resourceGroup' + +/* +** App Configuration Store +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Creates an Azure App Configuration Store resource, including permission grants and diagnostics. +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/ApplicationIdentity.bicep +@description('Type describing an application identity.') +type ApplicationIdentity = { + @description('The ID of the identity') + principalId: string + + @description('The type of identity - either ServicePrincipal or User') + principalType: 'ServicePrincipal' | 'User' +} + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// From: infra/types/PrivateEndpointSettings.bicep +@description('Type describing the private endpoint settings.') +type PrivateEndpointSettings = { + @description('The name of the resource group to hold the Private DNS Zone. By default, this uses the same resource group as the resource.') + dnsResourceGroupName: string + + @description('The name of the private endpoint resource. By default, this uses a prefix of \'pe-\' followed by the name of the resource.') + name: string + + @description('The name of the resource group to hold the private endpoint. By default, this uses the same resource group as the resource.') + resourceGroupName: string + + @description('The ID of the subnet to link the private endpoint to.') + subnetId: string +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +/* +** Settings +*/ +@description('Whether or not public endpoint access is allowed for this server') +param enablePublicNetworkAccess bool = true + +@description('If set, the private endpoint settings for this resource') +param privateEndpointSettings PrivateEndpointSettings? + +@description('The list of application identities to be granted owner access to the application resources.') +param ownerIdentities ApplicationIdentity[] = [] + +@description('The list of application identities to be granted reader access to the application resources.') +param readerIdentities ApplicationIdentity[] = [] + +@description('Specifies the SKU of the app configuration store.') +param skuName string = 'standard' + +// ======================================================================== +// VARIABLES +// ======================================================================== + +/* https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles */ + +// Allows full access to App Configuration data. +var appConfigurationDataOwnerRoleId = '5ae67dd6-50cb-40e7-96ff-dc2bfa4b606b' + +// Allows read access to App Configuration data. +var appConfigurationDataReaderRoleId = '516239f1-63e1-4d78-a4de-a74fb236a071' + +var logCategories = [ + 'Audit' + 'HttpRequest' +] + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource grantDataOwnerAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [ for id in ownerIdentities: if (!empty(id.principalId)) { + name: guid(appConfigurationDataOwnerRoleId, id.principalId, appConfigStore.id, resourceGroup().name) + scope: appConfigStore + properties: { + principalType: id.principalType + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', appConfigurationDataOwnerRoleId) + principalId: id.principalId + } +}] + +resource grantDataReaderAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [ for id in readerIdentities: if (!empty(id.principalId)) { + name: guid(appConfigurationDataReaderRoleId, id.principalId, appConfigStore.id, resourceGroup().name) + scope: appConfigStore + properties: { + principalType: id.principalType + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', appConfigurationDataReaderRoleId) + principalId: id.principalId + } +}] + +resource appConfigStore 'Microsoft.AppConfiguration/configurationStores@2023-03-01' = { + name: name + location: location + properties: { + // when publicNetworkAccess is Disabled - must pair with build agent to set config values + publicNetworkAccess: enablePublicNetworkAccess ? 'Enabled' : 'Disabled' + } + sku: { + name: skuName + } +} + +module privateEndpoint '../network/private-endpoint.bicep' = if (privateEndpointSettings != null) { + name: '${name}-private-endpoint' + scope: resourceGroup(privateEndpointSettings != null ? privateEndpointSettings!.resourceGroupName : resourceGroup().name) + params: { + name: privateEndpointSettings != null ? privateEndpointSettings!.name : 'pep-${name}' + location: location + tags: tags + dnsRsourceGroupName: privateEndpointSettings == null ? resourceGroup().name : privateEndpointSettings!.dnsResourceGroupName + + // Dependencies + linkServiceId: appConfigStore.id + linkServiceName: appConfigStore.name + subnetId: privateEndpointSettings != null ? privateEndpointSettings!.subnetId : '' + + // Settings + dnsZoneName: 'privatelink.azconfig.io' + groupIds: [ 'configurationStores' ] + } +} + +resource appConfigDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (diagnosticSettings != null && !empty(logAnalyticsWorkspaceId)) { + name: '${name}-diagnostics' + scope: appConfigStore + properties: { + workspaceId: logAnalyticsWorkspaceId + logs: map(logCategories, (category) => { + category: category + enabled: diagnosticSettings!.enableLogs + }) + metrics: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } +} + + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = appConfigStore.id +output name string = appConfigStore.name +output app_config_uri string = appConfigStore.properties.endpoint diff --git a/infra/core/cost-management/budget.bicep b/infra/core/cost-management/budget.bicep new file mode 100644 index 00000000..2590dbe3 --- /dev/null +++ b/infra/core/cost-management/budget.bicep @@ -0,0 +1,96 @@ +targetScope = 'resourceGroup' + +/* +** Budget +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Provides a recurring budget for the resource group. You must specify +** the amount minimally. +*/ + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The name of the primary resource') +param name string + +@description('The total amount of cost or usage to track with the budget; this is in the currency of the billing account.') +param amount int = 1000 + +@description('The time covered by a budget. Tracking of the amount will be reset based on the time grain.') +@allowed([ 'Monthly', 'Quarterly', 'Annually' ]) +param timeGrain string = 'Monthly' + +@description('The start date must be first of the month in YYYY-MM-DD format. Future start date should not be more than three months. Past start date should be selected within the timegrain preiod.') +param startDate string = utcNow('yyyy-MM') + +@description('The end date for the budget in YYYY-MM-DD format. If not provided, we default this to 10 years from the start date.') +param endDate string = dateTimeAdd(utcNow(), 'P10Y', 'yyyy-MM') + +@description('Threshold value associated with a notification. Notification is sent when the cost exceeded the threshold. It is always percent and has to be between 0.01 and 1000.') +param firstThreshold int = 75 + +@description('Threshold value associated with a notification. Notification is sent when the cost exceeded the threshold. It is always percent and has to be between 0.01 and 1000.') +param secondThreshold int = 95 + +@description('The list of email addresses to send the budget notification to when the threshold is exceeded.') +param contactEmails string[] + +@description('The set of values for the resource group filter.') +param resourceGroups string[] + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource budget 'Microsoft.Consumption/budgets@2021-10-01' = { + name: name + properties: { + timePeriod: { + startDate: '${startDate}-01' + endDate: '${endDate}-01' + } + timeGrain: timeGrain + amount: amount + category: 'Cost' + notifications: { + NotificationForExceededBudget1: { + enabled: true + operator: 'GreaterThan' + threshold: firstThreshold + contactEmails: contactEmails + } + NotificationForExceededBudget2: { + enabled: true + operator: 'GreaterThan' + threshold: secondThreshold + contactEmails: contactEmails + } + NotificationForExceededBudget3: { + enabled: true + operator: 'GreaterThan' + threshold: 100 + contactEmails: contactEmails + } + } + filter: { + dimensions: { + name: 'ResourceGroupName' + operator: 'In' + values: resourceGroups + } + } + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = budget.id +output name string = budget.name + diff --git a/infra/core/database/azure-cache-for-redis.bicep b/infra/core/database/azure-cache-for-redis.bicep new file mode 100644 index 00000000..3be97689 --- /dev/null +++ b/infra/core/database/azure-cache-for-redis.bicep @@ -0,0 +1,165 @@ +targetScope = 'resourceGroup' + +/* +** Azure Cache for Redis +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Creates an Azure Cache for Redis resource, including permission grants and diagnostics. +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// From: infra/types/PrivateEndpointSettings.bicep +@description('Type describing the private endpoint settings.') +type PrivateEndpointSettings = { + @description('The name of the resource group to hold the Private DNS Zone. By default, this uses the same resource group as the resource.') + dnsResourceGroupName: string + + @description('The name of the private endpoint resource.') + name: string + + @description('The name of the resource group to hold the private endpoint.') + resourceGroupName: string + + @description('The ID of the subnet to link the private endpoint to.') + subnetId: string +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +/* +** Settings +*/ + +@description('Specify a boolean value that indicates whether to allow access via non-SSL ports.') +param enableNonSslPort bool = false + +@description('Specify the pricing tier of the new Azure Redis Cache.') +@allowed([ + 'Basic' + 'Standard' + 'Premium' +]) +param redisCacheSku string = 'Standard' + +@description('Specify the family for the sku. C = Basic/Standard, P = Premium.') +@allowed([ + 'C' + 'P' +]) +param redisCacheFamily string = 'C' + +@description('Specify the size of the new Azure Redis Cache instance. Valid values: for C (Basic/Standard) family (0, 1, 2, 3, 4, 5, 6), for P (Premium) family (1, 2, 3, 4)') +@allowed([ + 0 + 1 + 2 + 3 + 4 + 5 + 6 +]) +param redisCacheCapacity int = 1 + +@description('If set, the private endpoint settings for this resource') +param privateEndpointSettings PrivateEndpointSettings? + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource cache 'Microsoft.Cache/redis@2023-04-01' = { + name: name + location: location + tags: tags + properties: { + enableNonSslPort: enableNonSslPort + minimumTlsVersion: '1.2' + sku: { + capacity: redisCacheCapacity + family: redisCacheFamily + name: redisCacheSku + } + } +} + +module privateEndpoint '../network/private-endpoint.bicep' = if (privateEndpointSettings != null) { + name: '${name}-private-endpoint' + scope: resourceGroup(privateEndpointSettings != null ? privateEndpointSettings!.resourceGroupName : resourceGroup().name) + params: { + name: privateEndpointSettings != null ? privateEndpointSettings!.name : 'pep-${name}' + location: location + tags: tags + dnsRsourceGroupName: privateEndpointSettings == null ? resourceGroup().name : privateEndpointSettings!.dnsResourceGroupName + + // Dependencies + linkServiceId: cache.id + linkServiceName: cache.name + subnetId: privateEndpointSettings != null ? privateEndpointSettings!.subnetId : '' + + // Settings + dnsZoneName: 'privatelink.redis.cache.windows.net' + groupIds: [ 'redisCache' ] + } +} + +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (diagnosticSettings != null && !empty(logAnalyticsWorkspaceId)) { + name: '${name}-diagnostics' + scope: cache + properties: { + workspaceId: logAnalyticsWorkspaceId + logs: map([ 'ConnectedClientList' ], (category) => { + category: category + enabled: diagnosticSettings!.enableLogs + }) + metrics: [ + { + category: 'AllMetrics' + enabled: diagnosticSettings!.enableMetrics + } + ] + } +} + +output name string = cache.name diff --git a/infra/core/database/create-sql-user-and-role.bicep b/infra/core/database/create-sql-user-and-role.bicep new file mode 100644 index 00000000..d51c7286 --- /dev/null +++ b/infra/core/database/create-sql-user-and-role.bicep @@ -0,0 +1,76 @@ +targetScope = 'resourceGroup' + +/* +** Create a User and Role on the SQL Database +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +*/ + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The Azure region for the resource.') +param location string + +@description('The tags to associate with this resource.') +param tags object = {} + +@description('The comma-separated list of database roles to assign to the user.') +param databaseRoles string = 'db_datareader' + +@description('The ID of the managed identity to be used to run the script.') +param managedIdentityId string + +@description('The principal (or object) ID of the user to create.') +param principalId string + +@description('The name of the user to create.') +param principalName string = '' + +@allowed([ 'ServicePrincipal', 'User' ]) +@description('The type of identity referenced by \'principalId\'.') +param principalType string = 'ServicePrincipal' + +@description('The name of the SQL Database resource.') +param sqlDatabaseName string + +@description('The name of the SQL Server resource.') +param sqlServerName string + +@description('Do not set - unique script ID to force the script to run.') +param uniqueScriptId string = newGuid() + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource createSqlUserAndRole 'Microsoft.Resources/deploymentScripts@2020-10-01' = { + name: 'createSqlUserAndRole-${principalId}' + location: location + tags: tags + kind: 'AzurePowerShell' + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${managedIdentityId}': {} + } + } + properties: { + forceUpdateTag: uniqueScriptId + azPowerShellVersion: '7.4' + retentionInterval: 'PT1H' + cleanupPreference: 'OnExpiration' + arguments: join([ + '-SqlServerName \'${sqlServerName}\'' + '-SqlDatabaseName \'${sqlDatabaseName}\'' + '-ObjectId \'${principalId}\'' + !empty(principalName) ? '-DisplayName \'${principalName}\'' : '' + principalType == 'ServicePrincipal' ? '-IsServicePrincipal' : '' + '-DatabaseRoles ${databaseRoles}' + ], ' ') + scriptContent: loadTextContent('./scripts/create-sql-user-and-role.ps1') + } +} diff --git a/infra/core/database/scripts/create-sql-user-and-role.ps1 b/infra/core/database/scripts/create-sql-user-and-role.ps1 new file mode 100644 index 00000000..fb30f4c6 --- /dev/null +++ b/infra/core/database/scripts/create-sql-user-and-role.ps1 @@ -0,0 +1,108 @@ +#Requires -Version 7.0 + +<# +.SYNOPSIS + Creates a SQL user and assigns the user account to one or more roles. + +.DESCRIPTION + During an application deployment, the managed identity (and potentially the developer identity) + must be added to the SQL database as a user and assigned to one or more roles. This script + does exactly that using the owner managed identity. + +.PARAMETER SqlServerName + The name of the SQL Server resource +.PARAMETER SqlDatabaseName + The name of the SQL Database resource +.PARAMETER ObjectId + The Object (Principal) ID of the user to be added. +.PARAMETER DisplayName + The display name of the user to be added. This is optional. If not provided, the Get-AzADUser cmdlet + will be used to retrieve the display name. +.PARAMETER IsServicePrincipal + True if the ObjectId refers to a service principal rather than a user. +.PARAMETER DatabaseRoles + The comma-separated list of database roles that need to be assigned to the user. +#> + +Param( + [string] $SqlServerName, + [string] $SqlDatabaseName, + [string] $ObjectId, + [string] $DisplayName, + [switch] $IsServicePrincipal = $false, + [string[]] $DatabaseRoles = @('db_datareader','db_datawriter') +) + +function Resolve-Module($moduleName) { + # If module is imported; say that and do nothing + if (Get-Module | Where-Object { $_.Name -eq $moduleName }) { + Write-Debug "Module $moduleName is already imported" + } elseif (Get-Module -ListAvailable | Where-Object { $_.Name -eq $moduleName }) { + Import-Module $moduleName + } elseif (Find-Module -Name $moduleName | Where-Object { $_.Name -eq $moduleName }) { + Install-Module $moduleName -Force -Scope CurrentUser + Import-Module $moduleName + } else { + Write-Error "Module $moduleName not found" + Write-Host "###vso[task.complete result=Failed;]Failed" + [Environment]::exit(1) + } +} + +function ConvertTo-Sid($applicationId) { + [System.Guid]$guid = [System.Guid]::Parse($applicationId) + foreach ($byte in $guid.ToByteArray()) { + $byteGuid += [System.String]::Format("{0:X2}", $byte) + } + return "0x" + $byteGuid +} + +### +### MAIN SCRIPT +### +Resolve-Module -moduleName SqlServer + +# Get the SID for the ObjectId we are using +$Sid = ConvertTo-Sid -applicationId $ObjectId + +# Construct the SQL to create the user. +$sqlList = [System.Collections.ArrayList]@() + +$UserCreationOpt = if ($IsServicePrincipal) { "WITH sid = $($Sid), type = E" } else { "FROM EXTERNAL PROVIDER" } +$CreateUserSql = @" +IF NOT EXISTS ( + SELECT * FROM sys.database_principals WHERE name = N'$($DisplayName)' +) +CREATE USER [$($DisplayName)] $($UserCreationOpt); + +"@ +$sqlList.Add($CreateUserSql) | Out-Null + +foreach ($role in $DatabaseRoles) { + $GrantRoleSql = @" +IF NOT EXISTS ( + SELECT * FROM sys.database_principals p + JOIN sys.database_role_members $($role)_role ON $($role)_role.member_principal_id = p.principal_id + JOIN sys.database_principals role_names ON role_names.principal_id = $($role)_role.role_principal_id AND role_names.[name] = '$($role)' + WHERE p.[name]=N'$($DisplayName)' + ) +ALTER ROLE $($role) ADD MEMBER [$($DisplayName)]; + +"@ + $sqlList.Add($GrantRoleSql) | Out-Null +} + +# Execute the SQL Command on Azure SQL. +foreach ($sqlcmd in $sqlList) { + try { + $sqlcmd | Write-Output + $token = (Get-AzAccessToken -ResourceUrl https://database.windows.net/).Token + Invoke-SqlCmd -ServerInstance "$SqlServerName.database.windows.net" -Database $SqlDatabaseName -AccessToken $token -Query $sqlcmd -ErrorAction 'Stop' -StatisticsVariable 'stats' + $stats | ConvertTo-Json -Depth 10 | Write-Output + } catch { + Write-Error $_.Exception.Message + Write-Host "###vso[task.complete result=Failed;]Failed" + [Environment]::exit(1) + } +} + diff --git a/infra/core/database/sql-database.bicep b/infra/core/database/sql-database.bicep new file mode 100644 index 00000000..2e38eda8 --- /dev/null +++ b/infra/core/database/sql-database.bicep @@ -0,0 +1,181 @@ +targetScope = 'resourceGroup' + +/* +** SQL Database on an existing SQL Server +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// From: infra/types/PrivateEndpointSettings.bicep +@description('Type describing the private endpoint settings.') +type PrivateEndpointSettings = { + @description('The name of the resource group to hold the Private DNS Zone. By default, this uses the same resource group as the resource.') + dnsResourceGroupName: string + + @description('The name of the private endpoint resource. By default, this uses a prefix of \'pe-\' followed by the name of the resource.') + name: string + + @description('The name of the resource group to hold the private endpoint. By default, this uses the same resource group as the resource.') + resourceGroupName: string + + @description('The ID of the subnet to link the private endpoint to.') + subnetId: string +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +@description('The SQL Server resource name.') +param sqlServerName string + +/* +** Settings +*/ +@description('The number of DTUs to allocate to the database.') +param dtuCapacity int + +@description('If set, the private endpoint settings for this resource') +param privateEndpointSettings PrivateEndpointSettings? + +@allowed([ 'Basic', 'Standard', 'Premium' ]) +@description('The service tier to use for the database.') +param sku string = 'Basic' + +@description('If true, enable availability zone redundancy.') +param zoneRedundant bool = false + +// ======================================================================== +// VARIABLES +// ======================================================================== + +var logCategories = [ + 'SQLSecurityAuditEvents' + 'DevOpsOperationsAudit' + 'AutomaticTuning' + 'Blocks' + 'DatabaseWaitStatistics' + 'Deadlocks' + 'Errors' + 'QueryStoreRuntimeStatistics' + 'QueryStoreWaitStatistics' + 'SQLInsights' + 'Timeouts' +] + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource sqlServer 'Microsoft.Sql/servers@2021-11-01' existing = { + name: sqlServerName +} + +resource sqlDatabase 'Microsoft.Sql/servers/databases@2021-11-01' = { + name: name + parent: sqlServer + location: location + tags: union(tags, { displayName: name }) + sku: { + name: sku + tier: sku + capacity: sku == 'Basic' ? 5 : dtuCapacity + } + properties: { + requestedBackupStorageRedundancy: zoneRedundant ? 'Zone' : 'Local' + readScale: sku == 'Premium' ? 'Enabled' : 'Disabled' + collation: 'SQL_Latin1_General_CP1_CI_AS' + zoneRedundant: zoneRedundant + } +} + +module privateEndpoint '../network/private-endpoint.bicep' = if (privateEndpointSettings != null) { + name: '${name}-sql-private-endpoint' + scope: resourceGroup(privateEndpointSettings != null ? privateEndpointSettings!.resourceGroupName : resourceGroup().name) + params: { + name: privateEndpointSettings != null ? privateEndpointSettings!.name : 'pep-${name}' + location: location + tags: tags + dnsRsourceGroupName: privateEndpointSettings == null ? resourceGroup().name : privateEndpointSettings!.dnsResourceGroupName + + + // Dependencies + linkServiceId: sqlServer.id + linkServiceName: '${sqlServer.name}/${sqlDatabase.name}' + subnetId: privateEndpointSettings != null ? privateEndpointSettings!.subnetId : '' + + // Settings + dnsZoneName: 'privatelink${az.environment().suffixes.sqlServerHostname}' + groupIds: [ 'sqlServer' ] + } +} + +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (diagnosticSettings != null && !empty(logAnalyticsWorkspaceId)) { + name: '${name}-diagnostics' + scope: sqlDatabase + properties: { + workspaceId: logAnalyticsWorkspaceId + logs: map(logCategories, (category) => { + category: category + enabled: diagnosticSettings!.enableLogs + }) + metrics: [ + { + category: 'AllMetrics' + enabled: diagnosticSettings!.enableMetrics + } + ] + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = sqlDatabase.id +output name string = sqlDatabase.name +output connection_string string = 'Server=tcp:${sqlServer.properties.fullyQualifiedDomainName},1433;Initial Catalog=${sqlDatabase.name};Authentication=Active Directory Default; Connect Timeout=180' + +output sql_server_id string = sqlServer.id +output sql_server_name string = sqlServer.name +output sql_server_hostname string = sqlServer.properties.fullyQualifiedDomainName diff --git a/infra/core/database/sql-server.bicep b/infra/core/database/sql-server.bicep new file mode 100644 index 00000000..73a7ff5b --- /dev/null +++ b/infra/core/database/sql-server.bicep @@ -0,0 +1,147 @@ +targetScope = 'resourceGroup' + +/* +** This template creates an Azure SQL Server. +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Defines a SQL Server, with a user-assigned managed identity. +** The Server is separated from the database, to allow for multiple +** databases to be created on the same server. +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +type FirewallRules = { + @description('The list of IP address CIDR blocks to allow access from.') + allowedIpAddresses: string[] +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@description('The Name of a user-assigned managed identity to use as the identity for this resource. Use a blank string for a system-assigned identity.') +param managedIdentityName string = '' + +/* +** Settings +*/ +@description('Whether or not public endpoint access is allowed for this server') +param enablePublicNetworkAccess bool = true + +@description('The firewall rules to install on the Key Vault.') +param firewallRules FirewallRules? + +@secure() +@minLength(8) +@description('The password for the administrator account on the SQL Server.') +param sqlAdministratorPassword string = newGuid() + +@minLength(8) +@description('The username for the administrator account on the SQL Server.') +param sqlAdministratorUsername string = 'adminuser' + +// ======================================================================== +// VARIABLES +// ======================================================================== + +var allowedCidrBlocks = firewallRules != null ? map(firewallRules!.allowedIpAddresses, ipaddr => { + name: replace(replace(ipaddr, '.', '_'), '/','_') + startIpAddress: parseCidr(ipaddr).firstUsable + endIpAddress: parseCidr(ipaddr).lastUsable +}) : [] + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = { + name: managedIdentityName +} + +resource sqlServer 'Microsoft.Sql/servers@2021-11-01' = { + name: name + location: location + tags: tags + properties: { + administratorLogin: sqlAdministratorUsername + administratorLoginPassword: sqlAdministratorPassword + administrators: { + azureADOnlyAuthentication: false + login: managedIdentity.name + principalType: 'User' + sid: managedIdentity.properties.principalId + tenantId: managedIdentity.properties.tenantId + } + publicNetworkAccess: enablePublicNetworkAccess || firewallRules != null ? 'Enabled' : 'Disabled' + version: '12.0' + } + + resource allowAzureServices 'firewallRules' = if (enablePublicNetworkAccess) { + name: 'AllowAllWindowsAzureIps' + properties: { + endIpAddress: '0.0.0.0' + startIpAddress: '0.0.0.0' + } + } + + resource allowClientIps 'firewallRules' = [ for entry in allowedCidrBlocks: { + name: 'AllowClientIp-${entry.name}' + properties: { + endIpAddress: entry.endIpAddress + startIpAddress: entry.startIpAddress + } + }] + + resource auditSettings 'auditingSettings' = { + name: 'default' + properties: { + state: diagnosticSettings.enableLogs ? 'Enabled' : 'Disabled' + isAzureMonitorTargetEnabled: true + } + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = sqlServer.id +output name string = sqlServer.name +output hostname string = sqlServer.properties.fullyQualifiedDomainName diff --git a/infra/core/hosting/app-service-plan.bicep b/infra/core/hosting/app-service-plan.bicep new file mode 100644 index 00000000..c5dfc44d --- /dev/null +++ b/infra/core/hosting/app-service-plan.bicep @@ -0,0 +1,208 @@ +targetScope = 'resourceGroup' + +/* +** App Service Plan +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +@description('A type that describes the auto-scale settings via Microsoft.Insights') +type AutoScaleSettings = { + @description('The minimum number of scale units to provision.') + minCapacity: int + + @description('The maximum number of scale units to provision.') + maxCapacity: int + + @description('The CPU percentage at which point to scale in.') + scaleInThreshold: int? + + @description('The CPU percentage at which point to scale out.') + scaleOutThreshold: int? +} + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('If using network isolation, the network isolation settings to use.') +param diagnosticSettings DiagnosticSettings? + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +/* +** Settings +*/ +@description('If set, the auto-scale settings') +param autoScaleSettings AutoScaleSettings? + +@allowed([ 'Windows', 'Linux' ]) +@description('The OS for the application that will be run on this App Service Plan. Default is windows.') +param serverType string = 'Windows' + +@allowed([ 'B1', 'B2', 'B3', 'P0v3', 'P1v3', 'P2v3', 'P3v3', 'S1', 'S2', 'S3' ]) +@description('The SKU to use for the compute platform.') +param sku string = 'B1' + +@description('If true, set this App Service Plan to be availability zone redundant.') +param zoneRedundant bool = false + +// ======================================================================== +// VARIABLES +// ======================================================================== + +// Default auto-scale settings +var defaultScaleInThreshold = 40 +var defaultScaleOutThreshold = 75 + +// https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/patterns-configuration-set#example +var environmentConfigurationMap = { + B1: { name: 'B1', tier: 'Basic', size: 'B1', family: 'B' } + B2: { name: 'B2', tier: 'Basic', size: 'B2', family: 'B' } + B3: { name: 'B3', tier: 'Basic', size: 'B3', family: 'B' } + P0v3: { name: 'P0v3', tier: 'PremiumV3', size: 'P0v3', family: 'Pv3' } + P1v3: { name: 'P1v3', tier: 'PremiumV3', size: 'P1v3', family: 'Pv3' } + P2v3: { name: 'P2v3', tier: 'PremiumV3', size: 'P2v3', family: 'Pv3' } + P3v3: { name: 'P3v3', tier: 'PremiumV3', size: 'P3v3', family: 'Pv3' } + S1: { name: 'S1', tier: 'Standard', size: 'S1', family: 'S' } + S2: { name: 'S2', tier: 'Standard', size: 'S2', family: 'S' } + S3: { name: 'S3', tier: 'Standard', size: 'S3', family: 'S' } +} + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource appServicePlan 'Microsoft.Web/serverfarms@2022-09-01' = { + name: name + location: location + tags: tags + sku: { + name: environmentConfigurationMap[sku].name + tier: environmentConfigurationMap[sku].tier + size: environmentConfigurationMap[sku].size + family: environmentConfigurationMap[sku].family + capacity: (environmentConfigurationMap[sku].tier == 'PremiumV3' && zoneRedundant) ? 3 : 1 + } + kind: serverType == 'Windows' ? '' : 'linux' + properties: { + perSiteScaling: true + reserved: serverType == 'Linux' + zoneRedundant: zoneRedundant + } +} + +resource autoScaleRule 'Microsoft.Insights/autoscalesettings@2022-10-01' = if (autoScaleSettings != null) { + name: '${name}-autoscale' + location: location + tags: tags + properties: { + targetResourceUri: appServicePlan.id + enabled: true + profiles: [ + { + name: 'Auto created scale condition' + capacity: { + minimum: string(zoneRedundant ? 3 : autoScaleSettings!.minCapacity) + maximum: string(autoScaleSettings!.maxCapacity) + default: string(zoneRedundant ? 3 : autoScaleSettings!.minCapacity) + } + rules: [ + { + metricTrigger: { + metricResourceUri: appServicePlan.id + metricName: 'CpuPercentage' + timeGrain: 'PT5M' + statistic: 'Average' + timeWindow: 'PT10M' + timeAggregation: 'Average' + operator: 'GreaterThan' + threshold: autoScaleSettings.?scaleOutThreshold ?? defaultScaleOutThreshold + } + scaleAction: { + direction: 'Increase' + type: 'ChangeCount' + value: string(1) + cooldown: 'PT10M' + } + } + { + metricTrigger: { + metricResourceUri: appServicePlan.id + metricName: 'CpuPercentage' + timeGrain: 'PT5M' + statistic: 'Average' + timeWindow: 'PT10M' + timeAggregation: 'Average' + operator: 'LessThan' + threshold: autoScaleSettings.?scaleInThreshold ?? defaultScaleInThreshold + } + scaleAction: { + direction: 'Decrease' + type: 'ChangeCount' + value: string(1) + cooldown: 'PT10M' + } + } + ] + } + ] + } +} + +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (diagnosticSettings != null && !empty(logAnalyticsWorkspaceId)) { + name: '${name}-diagnostics' + scope: appServicePlan + properties: { + workspaceId: logAnalyticsWorkspaceId + logs: [] + metrics: [ + { + category: 'AllMetrics' + enabled: diagnosticSettings!.enableMetrics + } + ] + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = appServicePlan.id +output name string = appServicePlan.name diff --git a/infra/core/hosting/app-service.bicep b/infra/core/hosting/app-service.bicep new file mode 100644 index 00000000..51266968 --- /dev/null +++ b/infra/core/hosting/app-service.bicep @@ -0,0 +1,225 @@ +targetScope = 'resourceGroup' + +/* +** An App Service running on a pre-existing App Service Plan +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// From: infra/types/PrivateEndpointSettings.bicep +@description('Type describing the private endpoint settings.') +type PrivateEndpointSettings = { + @description('The name of the resource group to hold the Private DNS Zone. By default, this uses the same resource group as the resource.') + dnsResourceGroupName: string + + @description('The name of the private endpoint resource. By default, this uses a prefix of \'pe-\' followed by the name of the resource.') + name: string + + @description('The name of the resource group to hold the private endpoint. By default, this uses the same resource group as the resource.') + resourceGroupName: string + + @description('The ID of the subnet to link the private endpoint to.') + subnetId: string +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@description('The name of the App Service Plan to use for compute resources.') +param appServicePlanName string + +@description('The ID of a user-assigned managed identity to use as the identity for this resource. Use a blank string for a system-assigned identity.') +param managedIdentityId string = '' + +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +@description('If using VNET integration, the ID of the subnet to route all outbound traffic through.') +param outboundSubnetId string = '' + +/* +** Settings +*/ +@description('The list of App Settings for this App Service.') +param appSettings object + +@description('If true, enable public network access for this resource.') +param enablePublicNetworkAccess bool = true + +@description('The list of IP security restrictions to configure.') +param ipSecurityRestrictions object[] = [] + +@description('If set, the private endpoint settings for this resource') +param privateEndpointSettings PrivateEndpointSettings? + +@description('The service prefix to use.') +param servicePrefix string + +// ======================================================================== +// VARIABLES +// ======================================================================== + +var identity = !empty(managedIdentityId) ? { + type: 'UserAssigned' + userAssignedIdentities: { + '${managedIdentityId}': {} + } +} : { + type: 'SystemAssigned' +} + +var logCategories = [ + 'AppServiceAppLogs' + 'AppServiceConsoleLogs' + 'AppServiceHTTPLogs' + 'AppServicePlatformLogs' +] + +var defaultAppServiceProperties = { + clientAffinityEnabled: false + httpsOnly: true + publicNetworkAccess: enablePublicNetworkAccess ? 'Enabled' : 'Disabled' + serverFarmId: resourceId('Microsoft.Web/serverfarms', appServicePlanName) + siteConfig: { + alwaysOn: true + detailedErrorLoggingEnabled: diagnosticSettings.enableLogs + httpLoggingEnabled: diagnosticSettings.enableLogs + requestTracingEnabled: diagnosticSettings.enableLogs + ftpsState: 'Disabled' + ipSecurityRestrictions: ipSecurityRestrictions + minTlsVersion: '1.2' + } +} + +var networkIsolationAppServiceProperties = !empty(outboundSubnetId) ? { + virtualNetworkSubnetId: outboundSubnetId + vnetRouteAllEnabled: true +} : {} + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource appService 'Microsoft.Web/sites@2022-09-01' = { + name: name + location: location + tags: union(tags, { 'azd-service-name': servicePrefix }) + kind: 'web' + identity: identity + properties: union(defaultAppServiceProperties, networkIsolationAppServiceProperties) + + resource configAppSettings 'config' = { + name: 'appsettings' + properties: appSettings + } + + resource configLogs 'config' = { + name: 'logs' + properties: { + applicationLogs: { + fileSystem: { level: 'Verbose' } + } + detailedErrorMessages: { + enabled: true + } + failedRequestsTracing: { + enabled: true + } + httpLogs: { + fileSystem: { + enabled: true + retentionInDays: 2 + retentionInMb: 100 + } + } + } + dependsOn: [ + configAppSettings + ] + } +} + +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (diagnosticSettings != null && !empty(logAnalyticsWorkspaceId)) { + name: '${name}-diagnostics' + scope: appService + properties: { + workspaceId: logAnalyticsWorkspaceId + logs: map(logCategories, (category) => { + category: category + enabled: diagnosticSettings!.enableLogs + }) + metrics: [ + { + category: 'AllMetrics' + enabled: diagnosticSettings!.enableMetrics + } + ] + } +} + +module privateEndpoint '../network/private-endpoint.bicep' = if (privateEndpointSettings != null) { + name: '${name}-private-endpoint' + scope: resourceGroup(privateEndpointSettings != null ? privateEndpointSettings!.resourceGroupName : resourceGroup().name) + params: { + name: privateEndpointSettings != null ? privateEndpointSettings!.name : 'pep-${name}' + location: location + tags: tags + dnsRsourceGroupName: privateEndpointSettings == null ? resourceGroup().name : privateEndpointSettings!.dnsResourceGroupName + + // Dependencies + linkServiceId: appService.id + linkServiceName: appService.name + subnetId: privateEndpointSettings != null ? privateEndpointSettings!.subnetId : '' + + // Settings + dnsZoneName: 'privatelink.azurewebsites.net' + groupIds: [ 'sites' ] + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = appService.id +output name string = appService.name +output hostname string = appService.properties.defaultHostName +output uri string = 'https://${appService.properties.defaultHostName}' diff --git a/infra/core/identity/managed-identity.bicep b/infra/core/identity/managed-identity.bicep new file mode 100644 index 00000000..731a20f1 --- /dev/null +++ b/infra/core/identity/managed-identity.bicep @@ -0,0 +1,40 @@ +targetScope = 'resourceGroup' + +/* +** User-Assigned Managed Identity +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +*/ + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: name + location: location + tags: tags +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = managedIdentity.id +output name string = managedIdentity.name +output principal_id string = managedIdentity.properties.principalId diff --git a/infra/core/identity/resource-group-role-assignment.bicep b/infra/core/identity/resource-group-role-assignment.bicep new file mode 100644 index 00000000..9a9a667b --- /dev/null +++ b/infra/core/identity/resource-group-role-assignment.bicep @@ -0,0 +1,49 @@ +targetScope = 'resourceGroup' + +/* +** Assigns a role to a managed identity +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +*/ + + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The name of a managed identity.') +param identityName string + +@description('Azure role id for assignment') +param roleId string + +@description('A description of the purpose for the role assignment') +param roleDescription string + + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = { + name: identityName +} + +resource devOpsIdentityRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { + name: guid(roleId, identityName, resourceGroup().id) + scope: resourceGroup() + properties: { + principalType: 'ServicePrincipal' + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleId) + principalId: managedIdentity.properties.principalId + description: roleDescription + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output identity_name string = identityName diff --git a/infra/core/monitor/application-insights.bicep b/infra/core/monitor/application-insights.bicep new file mode 100644 index 00000000..da470968 --- /dev/null +++ b/infra/core/monitor/application-insights.bicep @@ -0,0 +1,60 @@ +targetScope = 'resourceGroup' + +/* +** Application Insights +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Creates an Application Insights resource linked to the provided Log +** Analytics Workspace. +*/ + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +/* +** Settings +*/ +@allowed([ 'web', 'ios', 'other', 'store', 'java', 'phone' ]) +@description('The kind of application being monitored.') +param kind string = 'web' + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { + name: name + location: location + tags: tags + kind: kind + properties: { + Application_Type: kind == 'web' ? 'web' : 'other' + WorkspaceResourceId: logAnalyticsWorkspaceId + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = applicationInsights.id +output name string = applicationInsights.name diff --git a/infra/core/monitor/log-analytics-workspace.bicep b/infra/core/monitor/log-analytics-workspace.bicep new file mode 100644 index 00000000..934b7836 --- /dev/null +++ b/infra/core/monitor/log-analytics-workspace.bicep @@ -0,0 +1,66 @@ +targetScope = 'resourceGroup' + +/* +** Log Analytics Workspace +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +*/ + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@allowed([ 'PerGB2018', 'PerNode', 'Premium', 'Standalone', 'Standard' ]) +@description('The name of the pricing SKU to use.') +param sku string = 'PerGB2018' + +@minValue(0) +@description('The workspace daily quota for ingestion. Use 0 for unlimited.') +param dailyQuotaInGB int = 0 + +// ======================================================================== +// VARIABLES +// ======================================================================== + +var skuProperties = { + sku: { + name: sku + } +} +var quotaProperties = dailyQuotaInGB > 0 ? { dailyQuotaGb: dailyQuotaInGB } : {} + +var retentionProperties = { + retentionInDays: 30 +} + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' = { + name: name + location: location + tags: tags + properties: union(skuProperties, quotaProperties, retentionProperties) +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = logAnalyticsWorkspace.id +output name string = logAnalyticsWorkspace.name diff --git a/infra/core/network/bastion-host.bicep b/infra/core/network/bastion-host.bicep new file mode 100644 index 00000000..0d06abef --- /dev/null +++ b/infra/core/network/bastion-host.bicep @@ -0,0 +1,155 @@ +targetScope = 'resourceGroup' + +/* +** Bastion Host +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Creates a bastion host and diagnostics. +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +@description('The ID of the subnet to link the bastion host to.') +param subnetId string + +/* +** Settings +*/ +@description('The name of the public IP address resource to create. If not specified, a name will be generated.') +param publicIpAddressName string = '' + +@allowed([ 'Basic', 'Standard' ]) +@description('The pricing SKU to choose.') +param sku string = 'Basic' + +@description('If true, enable availability zone redundancy.') +param zoneRedundant bool = false + +// ======================================================================== +// VARIABLES +// ======================================================================== + +var pipName = !empty(publicIpAddressName) ? publicIpAddressName : 'pip-${name}' + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +module publicIpAddress '../network/public-ip-address.bicep' = { + name: pipName + params: { + location: location + name: pipName + tags: tags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + + // Settings + allocationMethod: 'Static' + diagnosticSettings: diagnosticSettings + domainNameLabel: name + ipAddressType: 'IPv4' + sku: 'Standard' + tier: 'Regional' + zoneRedundant: zoneRedundant + } +} + +resource bastionHost 'Microsoft.Network/bastionHosts@2022-11-01' = { + name: name + location: location + tags: tags + sku: { + name: sku + } + properties: { + enableTunneling: sku == 'Standard' ? true : false + ipConfigurations: [ + { + name: 'ipconfig1' + properties: { + privateIPAllocationMethod: 'Dynamic' + publicIPAddress: { + id: publicIpAddress.outputs.id + } + subnet: { + id: subnetId + } + } + } + ] + } +} + +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (diagnosticSettings != null && !empty(logAnalyticsWorkspaceId)) { + name: '${name}-diagnostics' + scope: bastionHost + properties: { + workspaceId: logAnalyticsWorkspaceId + logs: [ + { + category: 'BastionAuditLogs' + enabled: diagnosticSettings!.enableLogs + } + ] + metrics: [ + { + category: 'AllMetrics' + enabled: diagnosticSettings!.enableMetrics + } + ] + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = bastionHost.id +output name string = bastionHost.name + +output hostname string = publicIpAddress.outputs.hostname diff --git a/infra/core/network/ddos-protection-plan.bicep b/infra/core/network/ddos-protection-plan.bicep new file mode 100644 index 00000000..cd2c05fd --- /dev/null +++ b/infra/core/network/ddos-protection-plan.bicep @@ -0,0 +1,44 @@ +targetScope = 'resourceGroup' + +/* +** DDoS Protection Plan +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Create a DDoS Protection Plan. +*/ + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource ddosProtectionPlan 'Microsoft.Network/ddosProtectionPlans@2022-11-01' = { + location: location + name: name + tags: tags + properties: { + + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = ddosProtectionPlan.id +output name string = ddosProtectionPlan.name diff --git a/infra/core/network/firewall.bicep b/infra/core/network/firewall.bicep new file mode 100644 index 00000000..cd4069ab --- /dev/null +++ b/infra/core/network/firewall.bicep @@ -0,0 +1,192 @@ +targetScope = 'resourceGroup' + +/* +** Azure Firewall Resource +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** A fully stateful firewall as a service that provides both east-west and north-south traffic inspection. +** https://learn.microsoft.com/en-us/azure/firewall/overview +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@description('The resource ID of the Firewall Policy that should be attached to this firewall.') +param firewallPolicyId string = '' + +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +@description('The ID of the subnet to link the firewall to.') +param subnetId string + +/* +** Settings +*/ +@description('The name of the Public IP Address resource to use for outbound connectivity. If not specified, a name will be created.') +param publicIpAddressName string = '' + +@allowed([ 'Standard', 'Premium' ]) +@description('The pricing SKU to configure.') +param sku string = 'Standard' + +@description('The operational mode for threat intelligence. The default is to alert, but not deny traffic.') +param threatIntelMode string = 'Alert' + +@description('If true, the resource should be redundant across all availability zones.') +param zoneRedundant bool = false + +/* +** The firewall rules to install. +*/ +@description('The list of application rule collections to configure') +param applicationRuleCollections object[] = [] + +@description('The list of NAT rule collections to configure.') +param natRuleCollections object[] = [] + +@description('The list of network rule collections to configure.') +param networkRuleCollections object[] = [] + +// ======================================================================== +// VARIABLES +// ======================================================================== + +var pipName = !empty(publicIpAddressName) ? publicIpAddressName : 'pip-${name}' + +var logCategories = [ + 'AZFWApplicationRuleAggregation' + 'AZFWNatRuleAggregation' + 'AZFWNetworkRuleAggregation' + 'AZFWThreatIntel' + 'AZFWApplicationRule' + 'AZFWFlowTrace' + 'AZFWIdpsSignature' + 'AZFWNatRule' + 'AZFWNetworkRule' +] + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +module publicIpAddress '../network/public-ip-address.bicep' = { + name: pipName + params: { + location: location + name: pipName + tags: tags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + + // Settings + allocationMethod: 'Static' + diagnosticSettings: diagnosticSettings + domainNameLabel: name + ipAddressType: 'IPv4' + sku: 'Standard' + tier: 'Regional' + zoneRedundant: zoneRedundant + } +} + +resource azureFirewall 'Microsoft.Network/azureFirewalls@2022-11-01' = { + name: name + location: location + tags: tags + properties: { + firewallPolicy: !empty(firewallPolicyId) ? { + id: firewallPolicyId + } : null + ipConfigurations: [ + { + name: 'ipconfig1' + properties: { + subnet: { + id: subnetId + } + publicIPAddress: { + id: publicIpAddress.outputs.id + } + } + } + ] + sku: { + name: 'AZFW_VNet' + tier: sku + } + applicationRuleCollections: applicationRuleCollections + natRuleCollections: natRuleCollections + networkRuleCollections: networkRuleCollections + threatIntelMode: threatIntelMode + } + zones: zoneRedundant ? [ '1', '2', '3' ] : [] +} + +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (diagnosticSettings != null && !empty(logAnalyticsWorkspaceId)) { + name: '${name}-diagnostics' + scope: azureFirewall + properties: { + workspaceId: logAnalyticsWorkspaceId + logs: map(logCategories, (category) => { + category: category + enabled: diagnosticSettings!.enableLogs + }) + metrics: [ + { + category: 'AllMetrics' + enabled: diagnosticSettings!.enableMetrics + } + ] + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = azureFirewall.id +output name string = azureFirewall.name + +output hostname string = publicIpAddress.outputs.hostname +output internal_ip_address string = azureFirewall.properties.ipConfigurations[0].properties.privateIPAddress diff --git a/infra/core/network/network-security-group.bicep b/infra/core/network/network-security-group.bicep new file mode 100644 index 00000000..b037626f --- /dev/null +++ b/infra/core/network/network-security-group.bicep @@ -0,0 +1,94 @@ +targetScope = 'resourceGroup' + +/* +** Network Security Group +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Creates a floating Network Security Group that can be attached to a +** subnet or network interface. +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +/* +** Settings +*/ +@description('The list of security rules to attach to this network security group.') +param securityRules object[] + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource networkSecurityGroup 'Microsoft.Network/networkSecurityGroups@2022-11-01' = { + name: name + location: location + tags: tags + properties: { + securityRules: securityRules + } +} + +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (diagnosticSettings != null && !empty(logAnalyticsWorkspaceId)) { + name: '${name}-diagnostics' + scope: networkSecurityGroup + properties: { + workspaceId: logAnalyticsWorkspaceId + logs: map([ 'NetworkSecurityGroupEvent', 'NetworkSecurityGroupRuleCounter' ], (category) => { + category: category + enabled: diagnosticSettings!.enableLogs + }) + metrics: [] + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = networkSecurityGroup.id +output name string = networkSecurityGroup.name + diff --git a/infra/core/network/peer-virtual-network.bicep b/infra/core/network/peer-virtual-network.bicep new file mode 100644 index 00000000..a0b75869 --- /dev/null +++ b/infra/core/network/peer-virtual-network.bicep @@ -0,0 +1,47 @@ +targetScope = 'resourceGroup' + +/* +** Peer two virtual networks together. +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +*/ + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The name of the primary resource') +param name string + +/* +** Dependencies +*/ +@description('The name of the local virtual network.') +param virtualNetworkName string = '' + +@description('The ID of the remote virtual network.') +param remoteVirtualNetworkId string = '' + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource virtualNetwork 'Microsoft.Network/virtualNetworks@2022-11-01' existing = { + name: virtualNetworkName + + resource peer 'virtualNetworkPeerings' = { + name: name + properties: { + allowVirtualNetworkAccess: true + allowGatewayTransit: false + allowForwardedTraffic: false + useRemoteGateways: false + remoteVirtualNetwork: { + id: remoteVirtualNetworkId + } + } + } +} + diff --git a/infra/core/network/private-dns-zone-link.bicep b/infra/core/network/private-dns-zone-link.bicep new file mode 100644 index 00000000..ecde2f86 --- /dev/null +++ b/infra/core/network/private-dns-zone-link.bicep @@ -0,0 +1,52 @@ +targetScope = 'resourceGroup' + +/* +** Private DNS Zone +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Adds a vnet for DNS zone link to a private DNS zone. +*/ + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The name of the primary resource') +param name string + +/* +** Dependencies +*/ +@description('Array of custom objects describing vNet links of the DNS zone. Each object should contain vnetName, vnetId, registrationEnabled') +param virtualNetworkLinks array = [] + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource privateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = { + name: name +} + +resource privateDnsZoneLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = [ for vnet in virtualNetworkLinks: { + parent: privateDnsZone + name: '${vnet.vnetName}-link' + location: 'global' + properties: { + registrationEnabled: vnet.registrationEnabled + virtualNetwork: { + id: vnet.vnetId + } + } +}] + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = privateDnsZone.id +output name string = privateDnsZone.name + diff --git a/infra/core/network/private-dns-zone.bicep b/infra/core/network/private-dns-zone.bicep new file mode 100644 index 00000000..deeac603 --- /dev/null +++ b/infra/core/network/private-dns-zone.bicep @@ -0,0 +1,58 @@ +targetScope = 'resourceGroup' + +/* +** Private DNS Zone +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Creates a private DNS zone (mostly used for private endpoints) and links +** it to the specified virtual network. +*/ + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@description('Array of custom objects describing vNet links of the DNS zone. Each object should contain vnetName, vnetId, registrationEnabled') +param virtualNetworkLinks array = [] + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource privateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = { + name: name + location: 'global' + tags: tags +} + +resource privateDnsZoneLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = [ for vnet in virtualNetworkLinks: { + parent: privateDnsZone + name: '${vnet.vnetName}-link' + location: 'global' + properties: { + registrationEnabled: vnet.registrationEnabled + virtualNetwork: { + id: vnet.vnetId + } + } +}] + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = privateDnsZone.id +output name string = privateDnsZone.name + diff --git a/infra/core/network/private-endpoint.bicep b/infra/core/network/private-endpoint.bicep new file mode 100644 index 00000000..696c96f7 --- /dev/null +++ b/infra/core/network/private-endpoint.bicep @@ -0,0 +1,95 @@ +targetScope = 'resourceGroup' + +/* +** Private Endpoint +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Creates a private endpoint for a resource. +*/ + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@description('The ID of the linked service') +param linkServiceId string + +@description('The name of the linked service') +param linkServiceName string + +@description('The ID of the subnet to host the private endpoint') +param subnetId string + +/* +** Settings +*/ + +@description('The resourceGroup where the Private DNS zone is located') +param dnsRsourceGroupName string + +@description('The DNS zone name that will be used for registering the private link.') +param dnsZoneName string + +@description('The list of group IDs to redirect through the private endpoint.') +param groupIds string[] + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource privateEndpoint 'Microsoft.Network/privateEndpoints@2022-11-01' = { + name: name + location: location + tags: tags + properties: { + subnet: { + id: subnetId + } + privateLinkServiceConnections: [ + { + name: linkServiceName + properties: { + privateLinkServiceId: linkServiceId + groupIds: groupIds + } + } + ] + } +} + +resource dnsGroupName 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2022-11-01' = { + name: 'mydnsgroupname' + parent: privateEndpoint + properties: { + privateDnsZoneConfigs: [ + { + name: 'config1' + properties: { + privateDnsZoneId: dnsRsourceGroupName == '' ? resourceId('Microsoft.Network/privateDnsZones', dnsZoneName) : resourceId(dnsRsourceGroupName, 'Microsoft.Network/privateDnsZones', dnsZoneName) + } + } + ] + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = privateEndpoint.id +output name string = privateEndpoint.name diff --git a/infra/core/network/public-ip-address.bicep b/infra/core/network/public-ip-address.bicep new file mode 100644 index 00000000..1617c763 --- /dev/null +++ b/infra/core/network/public-ip-address.bicep @@ -0,0 +1,129 @@ +targetScope = 'resourceGroup' + +/* +** Public IP Address +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Creates a public IP address and diagnostics resource. +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +/* +** Settings +*/ +@allowed([ 'Dynamic', 'Static' ]) +@description('The public IP address allocation method. The default is dynamic allocation.') +param allocationMethod string = 'Dynamic' + +@description('The DNS label for the resource. This will become a domain name of domainlabel.region.cloudapp.azure.com') +param domainNameLabel string + +@allowed([ 'IPv4', 'IPv6']) +@description('The type of public IP address to generate') +param ipAddressType string = 'IPv4' + +@allowed([ 'Basic', 'Standard' ]) +param sku string = 'Basic' + +@allowed([ 'Regional', 'Global' ]) +param tier string = 'Regional' + +@description('True if you want the resource to be zone redundant') +param zoneRedundant bool = false + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource publicIpAddress 'Microsoft.Network/publicIPAddresses@2022-11-01' = { + location: location + name: name + tags: tags + properties: { + ddosSettings: { + protectionMode: 'VirtualNetworkInherited' + } + dnsSettings: { + domainNameLabel: domainNameLabel + } + publicIPAddressVersion: ipAddressType + publicIPAllocationMethod: allocationMethod + idleTimeoutInMinutes: 4 + } + sku: { + name: sku + tier: tier + } + zones: zoneRedundant ? [ '1', '2', '3' ] : [] +} + +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (diagnosticSettings != null && !empty(logAnalyticsWorkspaceId)) { + name: '${name}-diagnostics' + scope: publicIpAddress + properties: { + workspaceId: logAnalyticsWorkspaceId + logs: map( [ 'DDoSMitigationFlowLogs', 'DDoSMitigationReports', 'DDoSProtectionNotifications' ], (category) => { + category: category + enabled: diagnosticSettings!.enableLogs + }) + metrics: [ + { + category: 'AllMetrics' + enabled: diagnosticSettings!.enableMetrics + } + ] + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = publicIpAddress.id +output name string = publicIpAddress.name + +output hostname string = publicIpAddress.properties.dnsSettings.fqdn diff --git a/infra/core/network/route-table.bicep b/infra/core/network/route-table.bicep new file mode 100644 index 00000000..9a75f716 --- /dev/null +++ b/infra/core/network/route-table.bicep @@ -0,0 +1,52 @@ +targetScope = 'resourceGroup' + +/* +** Route Table +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +*/ + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Settings +*/ +@description('Optional. Switch to disable BGP route propagation.') +param disableBgpRoutePropagation bool = false + +@description('The list of routes to install in the route table') +param routes object[] + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource routeTable 'Microsoft.Network/routeTables@2022-11-01' = { + name: name + location: location + tags: tags + properties: { + routes: routes + disableBgpRoutePropagation: disableBgpRoutePropagation + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = routeTable.id +output name string = routeTable.name diff --git a/infra/core/network/virtual-network.bicep b/infra/core/network/virtual-network.bicep new file mode 100644 index 00000000..6740f472 --- /dev/null +++ b/infra/core/network/virtual-network.bicep @@ -0,0 +1,114 @@ +targetScope = 'resourceGroup' + +/* +** Virtual Network Resource +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Creates a new virtual network, plus diagnostics for the resource. +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@description('If a DDoS protection plan is in use, the ID of the plan to associate with this virtual network.') +param ddosProtectionPlanId string = '' + +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +/* +** Settings +*/ +@description('The CIDR block to use for the address prefix of this virtual network.') +param addressPrefix string + +@description('The set of subnets to use for this resource') +param subnets object[] + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource virtualNetwork 'Microsoft.Network/virtualNetworks@2022-11-01' = { + location: location + name: name + tags: tags + properties: { + addressSpace: { + addressPrefixes: [ addressPrefix ] + } + ddosProtectionPlan: !empty(ddosProtectionPlanId) ? { + id: ddosProtectionPlanId + } : null + enableDdosProtection: !empty(ddosProtectionPlanId) + subnets: subnets + } +} + +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (diagnosticSettings != null && !empty(logAnalyticsWorkspaceId)) { + name: '${name}-diagnostics' + scope: virtualNetwork + properties: { + workspaceId: logAnalyticsWorkspaceId + logs: [ + { + category: 'VMProtectionAlerts' + enabled: diagnosticSettings!.enableLogs + } + ] + metrics: [ + { + category: 'AllMetrics' + enabled: diagnosticSettings!.enableMetrics + } + ] + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = virtualNetwork.id +output name string = virtualNetwork.name + +output subnets object = toObject(virtualNetwork.properties.subnets, subnet => subnet.name) diff --git a/infra/core/security/front-door-route-approval.bicep b/infra/core/security/front-door-route-approval.bicep new file mode 100644 index 00000000..e25e9e9b --- /dev/null +++ b/infra/core/security/front-door-route-approval.bicep @@ -0,0 +1,54 @@ +/* +** Azure Front Door Route Approval +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +*/ + +// ===================================================================================================================== +// PARAMETERS +// ===================================================================================================================== + +@description('The Azure region used to host the deployment script') +param location string + +@description('The owner managed identity used to auto-approve the private endpoint') +param managedIdentityName string + +@description('Force the deployment script to run') +param utcValue string = utcNow() + +// ===================================================================================================================== +// AZURE RESOURCES +// ===================================================================================================================== + +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = { + name: managedIdentityName +} + +resource approval 'Microsoft.Resources/deploymentScripts@2020-10-01' = { + name: 'auto-approve-private-endpoint' + location: location + kind: 'AzureCLI' + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${managedIdentity.id}': {} + } + } + properties: { + forceUpdateTag: utcValue + azCliVersion: '2.47.0' + timeout: 'PT30M' + environmentVariables: [ + { + name: 'ResourceGroupName' + value: resourceGroup().name + } + ] + scriptContent: loadTextContent('./scripts/front-door-route-approval.sh') + cleanupPreference: 'OnExpiration' + retentionInterval: 'PT1H' + } +} diff --git a/infra/core/security/front-door-route.bicep b/infra/core/security/front-door-route.bicep new file mode 100644 index 00000000..1424e6a3 --- /dev/null +++ b/infra/core/security/front-door-route.bicep @@ -0,0 +1,139 @@ +/* +** Azure Front Door Route +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +*/ + +// ===================================================================================================================== +// USER-DEFINED TYPES +// ===================================================================================================================== + +type PrivateLinkSettings = { + @description('The resource ID of the private endpoint resource') + privateEndpointResourceId: string? + + @description('The private link resource type') + linkResourceType: string? + + @description('The Azure region hosting the private link') + location: string? +} + +// ===================================================================================================================== +// PARAMETERS +// ===================================================================================================================== + +@description('The name of the Azure Front Door endpoint to configure.') +param frontDoorEndpointName string + +@description('The name of the Azure Front Door profile to configure.') +param frontDoorProfileName string + +@description('The HTTP method to use for the health probe') +@allowed([ 'HEAD', 'GET' ]) +param healthProbeMethod string = 'HEAD' + +@description('The path portion of the URI for the health probe') +param healthProbePath string = '/' + +@description('The prefix for the name of the resources to create') +param originPrefix string + +@description('The private link settings for the backend service') +param privateLinkSettings PrivateLinkSettings = {} + +@description('The route pattern to route to this backend service') +param routePattern string + +@description('The host name to use for backend service routing') +param serviceAddress string + +@description('A directory path on the origin that AzureFrontDoor can use to retrieve content from, e.g. contoso.cloudapp.net/originpath') +param originPath string + +// ===================================================================================================================== +// CALCULATED VARIABLES +// ===================================================================================================================== + +var isPrivateLinkOrigin = contains(privateLinkSettings, 'privateEndpointResourceId') + +var privateLinkOriginDetails = isPrivateLinkOrigin ? { + privateLink: { + id: privateLinkSettings.privateEndpointResourceId ?? '' + } + groupId: privateLinkSettings.linkResourceType ?? '' + privateLinkLocation: privateLinkSettings.location ?? '' + requestMessage: 'Please approve the private link request' +} : null + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource profile 'Microsoft.Cdn/profiles@2021-06-01' existing = { + name: frontDoorProfileName +} + +resource endpoint 'Microsoft.Cdn/profiles/afdEndpoints@2021-06-01' existing = { + name: frontDoorEndpointName + parent: profile +} + +resource originGroup 'Microsoft.Cdn/profiles/originGroups@2021-06-01' = { + name: '${originPrefix}-origin-group' + parent: profile + properties: { + loadBalancingSettings: { + sampleSize: 4 + successfulSamplesRequired: 3 + } + healthProbeSettings: { + probePath: healthProbePath + probeRequestType: healthProbeMethod + probeProtocol: 'Https' + probeIntervalInSeconds: 120 + } + } +} + +resource origin 'Microsoft.Cdn/profiles/originGroups/origins@2021-06-01' = { + name: '${originPrefix}-origin' + parent: originGroup + properties: { + hostName: serviceAddress + httpPort: 80 + httpsPort: 443 + originHostHeader: serviceAddress + priority: 1 + sharedPrivateLinkResource: isPrivateLinkOrigin ? privateLinkOriginDetails : null + weight: 1000 + } +} + +resource route 'Microsoft.Cdn/profiles/afdEndpoints/routes@2021-06-01' = { + name: '${originPrefix}-route' + parent: endpoint + dependsOn: [ + origin + ] + properties: { + originGroup: { + id: originGroup.id + } + supportedProtocols: [ 'Http', 'Https' ] + patternsToMatch: [ routePattern ] + forwardingProtocol: 'HttpsOnly' + linkToDefaultDomain: 'Enabled' + httpsRedirect: 'Enabled' + originPath: originPath + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output endpoint string = 'https://${endpoint.properties.hostName}${routePattern}' +output uri string = 'https://${endpoint.properties.hostName}' diff --git a/infra/core/security/front-door-with-waf.bicep b/infra/core/security/front-door-with-waf.bicep new file mode 100644 index 00000000..9b6e8407 --- /dev/null +++ b/infra/core/security/front-door-with-waf.bicep @@ -0,0 +1,192 @@ +/* +** Azure Front Door +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Creates an Azure Front Door resource with a Web application Firewall +*/ + +// ===================================================================================================================== +// USER-DEFINED TYPES +// ===================================================================================================================== + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +type WAFRuleSet = { + @description('The name of the rule set') + name: string + + @description('The version of the rule set') + version: string +} + +// ===================================================================================================================== +// PARAMETERS +// ===================================================================================================================== + +@description('The diagnostic settings to use for this resource') +param diagnosticSettings DiagnosticSettings + +@description('The tags to associate with the resource') +param tags object + +/* +** Resource names to create +*/ +@description('The name of the Azure Front Door endpoint to create') +param frontDoorEndpointName string + +@description('The name of the Azure Front Door profile to create') +param frontDoorProfileName string + +@description('The name of the Web Application Firewall to create') +param webApplicationFirewallName string + +/* +** Dependencies +*/ +@description('The Log Analytics Workspace to send diagnostic and audit data to') +param logAnalyticsWorkspaceId string + +/* +** Service settings +*/ +@description('A list of managed rule sets to enable') +param managedRules WAFRuleSet[] + +@allowed([ 'Premium', 'Standard' ]) +@description('The pricing plan to use for the Azure Front Door and Web Application Firewall') +param sku string + +// ===================================================================================================================== +// CALCULATED VARIABLES +// ===================================================================================================================== + +// For a list of all categories that this resource supports, see: https://learn.microsoft.com/azure/azure-monitor/essentials/resource-logs-categories +var logCategories = [ + 'FrontDoorAccessLog' + 'FrontDoorWebApplicationFirewallLog' +] + +// Convert the managed rule sets list into the object form required by the web application firewall +var managedRuleSets = map(managedRules, rule => { + ruleSetType: rule.name + ruleSetVersion: rule.version + ruleSetAction: 'Block' + ruleGroupOverrides: [] + exclusions: [] +}) + +// ===================================================================================================================== +// AZURE RESOURCES +// ===================================================================================================================== + +resource frontDoorProfile 'Microsoft.Cdn/profiles@2023-05-01' = { + name: frontDoorProfileName + location: 'global' + tags: tags + sku: { + name: '${sku}_AzureFrontDoor' + } +} + +resource frontDoorEndpoint 'Microsoft.Cdn/profiles/afdEndpoints@2023-05-01' = { + name: frontDoorEndpointName + parent: frontDoorProfile + location: 'global' + tags: tags + properties: { + enabledState: 'Enabled' + } +} + +resource wafPolicy 'Microsoft.Network/FrontDoorWebApplicationFirewallPolicies@2022-05-01' = { + name: webApplicationFirewallName + location: 'global' + tags: tags + sku: { + name: '${sku}_AzureFrontDoor' + } + properties: { + policySettings: { + enabledState: 'Enabled' + mode: 'Prevention' + requestBodyCheck: 'Enabled' + } + customRules: { + rules: [] + } + managedRules: { + managedRuleSets: sku == 'Premium' ? managedRuleSets : [] + } + } +} + +resource wafPolicyLink 'Microsoft.Cdn/profiles/securityPolicies@2023-05-01' = { + name: '${webApplicationFirewallName}-link' + parent: frontDoorProfile + properties: { + parameters: { + type: 'WebApplicationFirewall' + wafPolicy: { + id: wafPolicy.id + } + associations: [ + { + domains: [ + { id: frontDoorEndpoint.id } + ] + patternsToMatch: [ + '/*' + ] + } + ] + } + } +} + +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (diagnosticSettings != null && !empty(logAnalyticsWorkspaceId)) { + name: '${frontDoorProfileName}-diagnostics' + scope: frontDoorProfile + properties: { + workspaceId: logAnalyticsWorkspaceId + logs: map(logCategories, (category) => { + category: category + enabled: diagnosticSettings!.enableLogs + }) + metrics: [ + { + category: 'AllMetrics' + enabled: diagnosticSettings!.enableMetrics + } + ] + } +} + +// ===================================================================================================================== +// AZURE RESOURCES +// ===================================================================================================================== + +output endpoint_name string = frontDoorEndpoint.name +output profile_name string = frontDoorProfile.name +output waf_name string = wafPolicy.name + +output front_door_id string = frontDoorProfile.properties.frontDoorId +output hostname string = frontDoorEndpoint.properties.hostName +output uri string = 'https://${frontDoorEndpoint.properties.hostName}' diff --git a/infra/core/security/key-vault-secrets.bicep b/infra/core/security/key-vault-secrets.bicep new file mode 100644 index 00000000..f62ea1d5 --- /dev/null +++ b/infra/core/security/key-vault-secrets.bicep @@ -0,0 +1,57 @@ +targetScope = 'resourceGroup' + +/* +** Write secrets to Key Vault +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Writes a set of secrets to the connected Key Vault. +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +@description('The form of each Key Vault Secret to store.') +type KeyVaultSecret = { + @description('The key for the secret') + key: string + + @description('The value of the secret') + value: string +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The name of the Key Vault resource') +param name string + +/* +** Settings +*/ +@description('The list of secrets to store in the Key Vault') +param secrets KeyVaultSecret[] + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource keyVault 'Microsoft.KeyVault/vaults@2023-02-01' existing = { + name: name +} + +resource keyVaultSecretResources 'Microsoft.KeyVault/vaults/secrets@2023-02-01' = [for secret in secrets: { + name: secret.key + parent: keyVault + properties: { + contentType: 'text/plain; charset=utf-8' + value: secret.value + } +}] + +#disable-next-line outputs-should-not-contain-secrets // Doesn't contain a secret, just contains the ID references +output secret_ids array = [for (secret, i) in secrets: keyVaultSecretResources[i].id] diff --git a/infra/core/security/key-vault.bicep b/infra/core/security/key-vault.bicep new file mode 100644 index 00000000..ebf3c9c5 --- /dev/null +++ b/infra/core/security/key-vault.bicep @@ -0,0 +1,206 @@ +targetScope = 'resourceGroup' + +/* +** Key Vault +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Creates a Key Vault resource, including permission grants and diagnostics. +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/ApplicationIdentity.bicep +@description('Type describing an application identity.') +type ApplicationIdentity = { + @description('The ID of the identity') + principalId: string + + @description('The type of identity - either ServicePrincipal or User') + principalType: 'ServicePrincipal' | 'User' +} + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// From: infra/types/PrivateEndpointSettings.bicep +@description('Type describing the private endpoint settings.') +type PrivateEndpointSettings = { + @description('The name of the resource group to hold the Private DNS Zone. By default, this uses the same resource group as the resource.') + dnsResourceGroupName: string + + @description('The name of the private endpoint resource.') + name: string + + @description('The name of the resource group to hold the private endpoint.') + resourceGroupName: string + + @description('The ID of the subnet to link the private endpoint to.') + subnetId: string +} + +type FirewallRules = { + @description('The list of IP address CIDR blocks to allow access from.') + allowedIpAddresses: string[] +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +/* +** Settings +*/ +@description('Whether or not public endpoint access is allowed for this server') +param enablePublicNetworkAccess bool = true + +@description('The firewall rules to install on the Key Vault.') +param firewallRules FirewallRules? + +@description('The list of application identities to be granted owner access to the application resources.') +param ownerIdentities ApplicationIdentity[] = [] + +@description('If set, the private endpoint settings for this resource') +param privateEndpointSettings PrivateEndpointSettings? + +@description('The list of application identities to be granted reader access to the application resources.') +param readerIdentities ApplicationIdentity[] = [] + +// ======================================================================== +// VARIABLES +// ======================================================================== + +@description('Built in \'Key Vault Administrator\' role ID: https://docs.microsoft.com/en-us/azure/role-based-access-control/built-in-roles') +var vaultAdministratorRoleId = '00482a5a-887f-4fb3-b363-3b7fe8e74483' + +@description('Built in \'Key Vault Secrets User\' role ID: https://docs.microsoft.com/en-us/azure/role-based-access-control/built-in-roles') +var vaultSecretsUserRoleId = '4633458b-17de-408a-b874-0445c86b69e6' + +var networkAcls = firewallRules != null ? { + bypass: 'AzureServices' + defaultAction: 'Deny' + ipRules: map(firewallRules!.allowedIpAddresses, (ipAddr) => { value: ipAddr }) +} : { + bypass: 'None' +} + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource keyVault 'Microsoft.KeyVault/vaults@2023-02-01' = { + name: name + location: location + tags: tags + properties: { + enableRbacAuthorization: true + networkAcls: networkAcls + publicNetworkAccess: enablePublicNetworkAccess || firewallRules != null ? 'Enabled' : 'Disabled' + sku: { + family: 'A' + name: 'standard' + } + tenantId: subscription().tenantId + } +} + +resource grantVaultAdminAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [ for id in ownerIdentities: if (!empty(id.principalId)) { + name: guid(vaultAdministratorRoleId, id.principalId, keyVault.id, resourceGroup().name) + scope: keyVault + properties: { + principalType: id.principalType + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', vaultAdministratorRoleId) + principalId: id.principalId + } +}] + +resource grantSecretsUserAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [ for id in readerIdentities: if (!empty(id.principalId)) { + name: guid(vaultSecretsUserRoleId, id.principalId, keyVault.id, resourceGroup().name) + scope: keyVault + properties: { + principalType: id.principalType + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', vaultSecretsUserRoleId) + principalId: id.principalId + } +}] + +module privateEndpoint '../network/private-endpoint.bicep' = if (privateEndpointSettings != null) { + name: '${name}-private-endpoint' + scope: resourceGroup(privateEndpointSettings != null ? privateEndpointSettings!.resourceGroupName : resourceGroup().name) + params: { + name: privateEndpointSettings != null ? privateEndpointSettings!.name : 'pep-${name}' + location: location + tags: tags + dnsRsourceGroupName: privateEndpointSettings == null ? resourceGroup().name : privateEndpointSettings!.dnsResourceGroupName + + // Dependencies + linkServiceId: keyVault.id + linkServiceName: keyVault.name + subnetId: privateEndpointSettings != null ? privateEndpointSettings!.subnetId : '' + + // Settings + dnsZoneName: 'privatelink.vaultcore.azure.net' + groupIds: [ 'vault' ] + } +} + +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (diagnosticSettings != null && !empty(logAnalyticsWorkspaceId)) { + name: '${name}-diagnostics' + scope: keyVault + properties: { + workspaceId: logAnalyticsWorkspaceId + logs: map([ 'AuditEvent', 'AzurePolicyEvaluationDetails' ], (category) => { + category: category + enabled: diagnosticSettings!.enableLogs + }) + metrics: [ + { + category: 'AllMetrics' + enabled: diagnosticSettings!.enableMetrics + } + ] + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output id string = keyVault.id +output name string = keyVault.name +output vaultUri string = keyVault.properties.vaultUri diff --git a/infra/core/security/scripts/front-door-route-approval.sh b/infra/core/security/scripts/front-door-route-approval.sh new file mode 100644 index 00000000..2eb569e9 --- /dev/null +++ b/infra/core/security/scripts/front-door-route-approval.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +# This script approves pending private endpoint connections for Azure Web Apps. +# It retrieves the resource group name from the environment variable $ResourceGroupName. +# It then lists all the web apps in the specified resource group and retrieves their IDs. +# For each web app, it checks for pending private endpoint connections and approves them. +# The approval is done by calling the 'az network private-endpoint-connection approve' command. +# The description for the approval is set to "ApprovedByCli". +# +# Usage: ./front-door-route-approval.sh +# +# Prerequisites: +# - Azure CLI must be installed and logged in. +# - The environment variable $ResourceGroupName must be set to the desired resource group name. +# +# Note: This script requires appropriate permissions to approve private endpoint connections. + +rg_name="$ResourceGroupName" +if [[ -z "$rg_name" ]]; then + echo "Resource group name not set. Please set the environment variable \$ResourceGroupName" + exit 1 +fi + +webapp_ids=$(az webapp list -g $rg_name --query "[].id" | jq -r '.[]') + +# Validate that we found a front-end and back-end web app. +# When deploying multi-region, we expect to find 2 web apps as two resource groups are deployed. +if [[ $(echo "$webapp_ids" | wc -w) -ne 2 ]]; then + echo "Invalid webapp_ids length. Expected 2, but found $(echo "$webapp_ids" | wc -w)" + exit 1 +else + echo "Proceeding to approve private endpoint connections for web apps in resource group: $rg_name" +fi + +for webapp_id in $webapp_ids; do + retry_count=0 + echo "Approving private endpoint connections for web app with ID: !!$webapp_id!!" + + # Retrieve the pending private endpoint connections for the web app. + # The front door pending private endpoint connections will be created asynchronously + # so the retry has been added for this scenario to await the asynchronous operation. + while [[ $retry_count -lt 5 ]]; do + fd_approved_conn_ids=$(az network private-endpoint-connection list --id "$webapp_id" --query "[?properties.provisioningState == 'Succeeded'].id" -o tsv) + # break from loop if we found 2 approved private endpoint connections + # because that means there is nothing to approve + if [[ $(echo "$fd_approved_conn_ids" | wc -w) -eq 2 ]]; then + echo "Found 2 approved private endpoint connections for web app with ID: $webapp_id" + fd_conn_ids="" + break + fi + + fd_conn_ids=$(az network private-endpoint-connection list --id "$webapp_id" --query "[?properties.provisioningState == 'Pending'].id" -o tsv) + # break from loop if we found any pending private endpoint connections + if [[ $(echo "$fd_conn_ids" | wc -w) -gt 0 ]]; then + break + fi + + retry_count=$((retry_count + 1)) + # allows for a maximum of 30 seconds waiting with an incrementally increasing sleep duration + sleep_duration=$((retry_count * 2)) + echo "... retrying in $sleep_duration seconds" + sleep $sleep_duration + done + + # report an error condition; we expect to find 2 approved private endpoint connections or to have something that needs approved + if [[ $retry_count -eq 5 ]]; then + echo "Failed to find pending private endpoint connections for web app with ID: $webapp_id" + exit 1 + fi + + # Approve any pending private endpoint connections. + for fd_conn_id in $fd_conn_ids; do + echo "Approved private endpoint connection with ID: $fd_conn_id" + az network private-endpoint-connection approve --id "$fd_conn_id" --description "ApprovedByCli" + done +done diff --git a/infra/core/storage/storage-account-blob.bicep b/infra/core/storage/storage-account-blob.bicep new file mode 100644 index 00000000..88f71875 --- /dev/null +++ b/infra/core/storage/storage-account-blob.bicep @@ -0,0 +1,96 @@ +targetScope = 'resourceGroup' + +/* +** Azure Storage Account +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +*/ + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The name of the primary resource') +param name string + +@description('A collection of objects with each object describing the container name and access level') +param containers array = [] + +/* +** Dependencies +*/ +@description('The name of the storage account.') +param storageAccountName string + +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +/* +** Settings +*/ + +@description('The blob service properties for blob soft delete.') +param deleteRetentionPolicy object = {} + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource storage 'Microsoft.Storage/storageAccounts@2022-05-01' existing = { + name: storageAccountName +} + +resource blobServices 'Microsoft.Storage/storageAccounts/blobServices@2022-05-01' = if (!empty(containers)) { + parent: storage + name: 'default' + properties: { + deleteRetentionPolicy: deleteRetentionPolicy + } + + resource container 'containers' = [for container in containers: { + name: container.name + properties: { + publicAccess: contains(container, 'publicAccess') ? container.publicAccess : 'None' + } + }] +} + + +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (diagnosticSettings != null && !empty(logAnalyticsWorkspaceId)) { + name: '${name}-diagnostics' + scope: blobServices + properties: { + workspaceId: logAnalyticsWorkspaceId + logs: map([ 'StorageDelete', 'StorageRead', 'StorageWrite' ], (category) => { + category: category + enabled: diagnosticSettings!.enableLogs + }) + metrics: [ + { + category: 'AllMetrics' + enabled: diagnosticSettings!.enableMetrics + } + ] + } +} diff --git a/infra/core/storage/storage-account.bicep b/infra/core/storage/storage-account.bicep new file mode 100644 index 00000000..96683ecb --- /dev/null +++ b/infra/core/storage/storage-account.bicep @@ -0,0 +1,188 @@ +targetScope = 'resourceGroup' + +/* +** Azure Storage Account +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/ApplicationIdentity.bicep +@description('Type describing an application identity.') +type ApplicationIdentity = { + @description('The ID of the identity') + principalId: string + + @description('The type of identity - either ServicePrincipal or User') + principalType: 'ServicePrincipal' | 'User' +} + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// From: infra/types/PrivateEndpointSettings.bicep +@description('Type describing the private endpoint settings.') +type PrivateEndpointSettings = { + @description('The name of the resource group to hold the Private DNS Zone. By default, this uses the same resource group as the resource.') + dnsResourceGroupName: string + + @description('The name of the private endpoint resource.') + name: string + + @description('The name of the resource group to hold the private endpoint.') + resourceGroupName: string + + @description('The ID of the subnet to link the private endpoint to.') + subnetId: string +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The Azure region for the resource.') +param location string + +@description('The name of the primary resource') +param name string + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Settings +*/ + +@description('Required for storage accounts where kind = BlobStorage. The access tier is used for billing.') +@allowed(['Cool', 'Hot', 'Premium' ]) +param accessTier string = 'Hot' + +@description('Allow or disallow public access to all blobs or containers in the storage account. The default interpretation is true for this property.') +param allowBlobPublicAccess bool = true + +@description('Allow or disallow cross AAD tenant object replication. The default interpretation is true for this property.') +param allowCrossTenantReplication bool = true + +@description('Indicates whether the storage account permits requests to be authorized with the account access key via Shared Key. If false, then all requests, including shared access signatures, must be authorized with Microsoft Entra ID. The default value is null, which is equivalent to true.') +param allowSharedKeyAccess bool = true + +@description('The list of application identities to be granted contributor access to the application resources.') +param contributorIdentities ApplicationIdentity[] = [] + +@description('Whether or not public endpoint access is allowed for this server') +param enablePublicNetworkAccess bool = true + +@description('Required. Indicates the type of storage account.') +@allowed(['BlobStorage', 'BlockBlobStorage', 'FileStorage', 'Storage', 'StorageV2' ]) +param kind string = 'StorageV2' + +@description('Set the minimum TLS version to be permitted on requests to storage.') +@allowed(['TLS1_0','TLS1_1','TLS1_2']) +param minimumTlsVersion string = 'TLS1_2' + +@description('The list of application identities to be granted owner access to the application resources.') +param ownerIdentities ApplicationIdentity[] = [] + +@description('If set, the private endpoint settings for this resource') +param privateEndpointSettings PrivateEndpointSettings? + +@description('Required. Gets or sets the SKU name.') +param sku object = { name: 'Standard_LRS' } + +// ======================================================================== +// VARIABLES +// ======================================================================== + +/* https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles */ + +// Provides full access to Azure Storage blob containers and data, including assigning POSIX access control. +var storageBlobDataOwnerRoleId = 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' + +// Read, write, and delete Azure Storage containers and blobs. To learn which actions are required for a given data operation +var storageBlobDataContributorRoleId = 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' + +var defaultToOAuthAuthentication = false +var dnsEndpointType = 'Standard' + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource storage 'Microsoft.Storage/storageAccounts@2022-05-01' = { + name: name + location: location + tags: tags + kind: kind + sku: sku + properties: { + accessTier: accessTier + allowBlobPublicAccess: allowBlobPublicAccess + allowCrossTenantReplication: allowCrossTenantReplication + allowSharedKeyAccess: allowSharedKeyAccess + defaultToOAuthAuthentication: defaultToOAuthAuthentication + dnsEndpointType: dnsEndpointType + minimumTlsVersion: minimumTlsVersion + publicNetworkAccess: enablePublicNetworkAccess ? 'Enabled' : 'Disabled' + } +} + +resource grantOwnerAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [ for id in ownerIdentities: if (!empty(id.principalId)) { + name: guid(storageBlobDataOwnerRoleId, id.principalId, storage.id, resourceGroup().name) + scope: storage + properties: { + principalType: id.principalType + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', storageBlobDataOwnerRoleId) + principalId: id.principalId + } +}] + +resource grantContributorAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [ for id in contributorIdentities: if (!empty(id.principalId)) { + name: guid(storageBlobDataContributorRoleId, id.principalId, storage.id, resourceGroup().name) + scope: storage + properties: { + principalType: id.principalType + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', storageBlobDataContributorRoleId) + principalId: id.principalId + } +}] + +module privateEndpoint '../network/private-endpoint.bicep' = if (privateEndpointSettings != null) { + name: '${name}-private-endpoint' + scope: resourceGroup(privateEndpointSettings != null ? privateEndpointSettings!.resourceGroupName : resourceGroup().name) + params: { + name: privateEndpointSettings != null ? privateEndpointSettings!.name : 'pep-${name}' + location: location + tags: tags + dnsRsourceGroupName: privateEndpointSettings == null ? resourceGroup().name : privateEndpointSettings!.dnsResourceGroupName + + // Dependencies + linkServiceId: storage.id + linkServiceName: storage.name + subnetId: privateEndpointSettings != null ? privateEndpointSettings!.subnetId : '' + + // Settings + dnsZoneName: 'privatelink.blob.${environment().suffixes.storage}' + groupIds: [ 'blob' ] + } +} + +output name string = storage.name +output primaryEndpoints object = storage.properties.primaryEndpoints diff --git a/infra/createAppRegistrations.ps1 b/infra/createAppRegistrations.ps1 deleted file mode 100644 index 21e11068..00000000 --- a/infra/createAppRegistrations.ps1 +++ /dev/null @@ -1,447 +0,0 @@ -#Requires -Version 7.0 - -<# -.SYNOPSIS - Creates two Azure AD app registrations for the reliable-web-app-pattern-dotnet - and saves the configuration data in App Configuration Svc and Key Vault. - - -.DESCRIPTION - The Relecloud web app uses Azure AD to authenticate and authorize the users that can - make concert ticket purchases. To prove that the website is a trusted, and secure, resource - the web app must handshake with Azure AD by providing the configuration settings like the following. - - TenantID identifies which Azure AD instance holds the users that should be authorized - - ClientID identifies which app this code says it represents - - ClientSecret provides a secret known only to Azure AD, and shared with the web app, to - validate that Azure AD can trust this web app - - This script will create the App Registrations that provide these configurations. Once those - are created the configuration data will be saved to Azure App Configuration and the secret - will be saved in Azure Key Vault so that the web app can read these values and provide them - to Azure AD during the authentication process. - - NOTE: This functionality assumes that the web app, app configuration service, and app - service have already been successfully deployed. - -.PARAMETER ResourceGroupName - A required parameter for the name of resource group that contains the environment that was - created by the azd command. The cmdlet will populate the App Config Svc and Key - Vault services in this resource group with Azure AD app registration config data. -#> - -Param( - [Alias("g")] - [Parameter(Mandatory = $true, HelpMessage = "Name of the resource group that was created by azd")] - [String]$ResourceGroupName -) - -$canSetSecondAzureLocation = 1 - -$Debug = $psboundparameters.debug.ispresent - -Write-Debug "Inputs" -Write-Debug "----------------------------------------------" -Write-Debug "resourceGroupName='$resourceGroupName'" -Write-Debug "" - -if ($ResourceGroupName -eq "-rg") { - Write-Error "FATAL ERROR: $ResourceGroupName could not be found in the current subscription" - exit 5 -} -$groupExists = (az group exists -n $ResourceGroupName) -if ($groupExists -eq 'false') { - Write-Error "FATAL ERROR: $ResourceGroupName could not be found in the current subscription" - exit 6 -} -else { - Write-Debug "Found resource group named: $ResourceGroupName" -} - -$keyVaultName = (az keyvault list -g "$ResourceGroupName" --query "[? starts_with(name,'rc-')].name" -o tsv) -$appConfigSvcName = (az appconfig list -g "$ResourceGroupName" --query "[].name" -o tsv) - -# updated az resource selection to filter to first based on https://github.com/Azure/azure-cli/issues/25214 -$frontEndWebAppName = (az resource list -g "$ResourceGroupName" --query "[? tags.\`"azd-service-name\`" == 'web' ].name | [0]" -o tsv) - -$resourceToken = $frontEndWebAppName.substring(4, 13) -$environmentName = $ResourceGroupName.substring(0, $ResourceGroupName.Length - 3) - -$frontDoorProfileName = (az resource list -g $ResourceGroupName --query "[? kind=='frontdoor' ].name | [0]" -o tsv) -$frontEndWebAppUri = (az afd endpoint list -g $ResourceGroupName --profile-name $frontDoorProfileName --query "[].hostName | [0]" -o tsv --only-show-errors) -$frontEndWebAppUri = "https://$frontEndWebAppUri" - -$secondaryResourceGroupName = $ResourceGroupName.Substring(0,$ResourceGroupName.Length-2) + "secondary-rg" -$group2Exists = (az group exists -n $secondaryResourceGroupName) -if ($group2Exists -eq 'false') { - $secondaryResourceGroupName = '' -} - -# updated az resource selection to filter to first based on https://github.com/Azure/azure-cli/issues/25214 -$mySqlServer = (az resource list -g $ResourceGroupName --query "[?type=='Microsoft.Sql/servers'].name | [0]" -o tsv) -$azdEnvironmentData=(azd env get-values) -$isProd=($azdEnvironmentData | select-string 'IS_PROD="true"').Count -gt 0 - -Write-Debug "Derived inputs" -Write-Debug "----------------------------------------------" -Write-Debug "isProd=$isProd" -Write-Debug "keyVaultName=$keyVaultName" -Write-Debug "appConfigSvcName=$appConfigSvcName" -Write-Debug "frontDoorProfileName=$frontDoorProfileName" -Write-Debug "frontEndWebAppUri=$frontEndWebAppUri" -Write-Debug "resourceToken=$resourceToken" -Write-Debug "environmentName=$environmentName" -Write-Debug "secondaryResourceGroupName=$secondaryResourceGroupName" -Write-Debug "" - -if ($keyVaultName.Length -eq 0) { - Write-Error "FATAL ERROR: Could not find Key Vault resource. Confirm the --ResourceGroupName is the one created by the ``azd provision`` command." - exit 7 -} - -Write-Debug "Runtime values" -Write-Debug "----------------------------------------------" -$frontEndWebAppName = "$environmentName-$resourceToken-frontend" -$apiWebAppName = "$environmentName-$resourceToken-api" -$maxNumberOfRetries = 20 - -Write-Debug "frontEndWebAppName='$frontEndWebAppName'" -Write-Debug "apiWebAppName='$apiWebAppName'" -Write-Debug "maxNumberOfRetries=$maxNumberOfRetries" - -$tenantId = (az account show --query "tenantId" -o tsv) -$userObjectId = (az account show --query "id" -o tsv) - -Write-Debug "tenantId='$tenantId'" -Write-Debug "" - -if ($Debug) { - Read-Host -Prompt "Press enter to continue" > $null - Write-Debug "..." -} - -# Resolves permission constraint that prevents the deploymentScript from running this command -# https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/134 - -if ($isProd) { - az sql server update -n $mySqlServer -g $ResourceGroupName --set publicNetworkAccess="Disabled" > $null -} - -$frontEndWebObjectId = (az ad app list --filter "displayName eq '$frontEndWebAppName'" --query "[].id" -o tsv) - -if ($frontEndWebObjectId.Length -eq 0) { - - # this web app doesn't exist and must be creaed - - $frontEndWebAppClientId = (az ad app create ` - --display-name $frontEndWebAppName ` - --sign-in-audience AzureADMyOrg ` - --app-roles '"[{ \"allowedMemberTypes\": [ \"User\" ], \"description\": \"Relecloud Administrator\", \"displayName\": \"Relecloud Administrator\", \"isEnabled\": \"true\", \"value\": \"Administrator\" }]"' ` - --web-redirect-uris $frontEndWebAppUri/signin-oidc https://localhost:7227/signin-oidc ` - --enable-id-token-issuance ` - --query appId --output tsv) - - Write-Host "frontEndWebAppClientId='$frontEndWebAppClientId'" - - if ($frontEndWebAppClientId.Length -eq 0) { - Write-Error "FATAL ERROR: Failed to create front-end app registration" - exit 8 - } - - $isWebAppCreated = 0 - $currentRetryCount = 0 - while ( $isWebAppCreated -eq 0) { - # assumes that we only need to create client secret if the app registration did not exist - $frontEndWebAppClientSecret = (az ad app credential reset --id $frontEndWebAppClientId --query "password" -o tsv --only-show-errors 2> $null) - $isWebAppCreated = $frontEndWebAppClientSecret.Length # treating 0 as $false and positive nums as $true - - $currentRetryCount++ - - if ($currentRetryCount -gt $maxNumberOfRetries) { - Write-Error "FATAL ERROR: Tried to create a client secret too many times" - exit 14 - } - - if ($isWebAppCreated -eq 0) { - Write-Debug "... trying to create clientSecret for front-end attempt #$currentRetryCount" - } - else { - Write-Host "... created clientSecret for front-end" - Write-Host "" - } - - # sleep until the app registration is created - Start-Sleep -Seconds 3 - } - - # prod environments do not allow public network access, this must be changed before we can set values - if ($isProd) { - # open the app config so that the local user can access - az appconfig update --name $appConfigSvcName --resource-group $ResourceGroupName --enable-public-network true > $null - - # open the key vault so that the local user can access - az keyvault update --name $keyVaultName --resource-group $ResourceGroupName --public-network-access Enabled > $null - } - - # save 'AzureAd:ClientSecret' to Key Vault - az keyvault secret set --name 'AzureAd--ClientSecret' --vault-name $keyVaultName --value $frontEndWebAppClientSecret --only-show-errors > $null - Write-Host "Set keyvault value for: 'AzureAd--ClientSecret'" - - # save 'AzureAd:TenantId' to App Config Svc - az appconfig kv set --name $appConfigSvcName --key 'AzureAd:TenantId' --value $tenantId --yes --only-show-errors > $null - Write-Host "Set appconfig value for: 'AzureAd:TenantId'" - - #save 'AzureAd:ClientId' to App Config Svc - az appconfig kv set --name $appConfigSvcName --key 'AzureAd:ClientId' --value $frontEndWebAppClientId --yes --only-show-errors > $null - Write-Host "Set appconfig value for: 'AzureAd:ClientId'" - - # prod environments do not allow public network access - if ($isProd) { - # close the app config so that the local user can access - az appconfig update --name $appConfigSvcName --resource-group $ResourceGroupName --enable-public-network false > $null - - # close the key vault so that the local user can access - az keyvault update --name $keyVaultName --resource-group $ResourceGroupName --public-network-access Disabled > $null - } -} -else { - Write-Host "frontend app registration objectId=$frontEndWebObjectId already exists. Delete the '$frontEndWebAppName' app registration to recreate or reset the settings." - $frontEndWebAppClientId = (az ad app show --id $frontEndWebObjectId --query "appId" -o tsv) - $canSetSecondAzureLocation = 2 -} - -Write-Host "" -Write-Host "Finished app registration for front-end" -Write-Host "" - -$apiObjectId = (az ad app list --filter "displayName eq '$apiWebAppName'" --query "[].id" -o tsv) - - -if ( $apiObjectId.Length -eq 0 ) { - # the api app registration does not exist and must be created - - $apiWebAppClientId = (az ad app create ` - --display-name $apiWebAppName ` - --sign-in-audience AzureADMyOrg ` - --app-roles '[{ \"allowedMemberTypes\": [ \"User\" ], \"description\": \"Relecloud Administrator\", \"displayName\": \"Relecloud Administrator\", \"isEnabled\": \"true\", \"value\": \"Administrator\" }]' ` - --query appId --output tsv) - - Write-Debug "apiWebAppClientId='$apiWebAppClientId'" - - # sleep until the app registration is created correctly - $isApiCreated = 0 - $currentRetryCount = 0 - - while ($isApiCreated -eq 0) { - $apiObjectId = (az ad app show --id $apiWebAppClientId --query id -o tsv 2> $null) - $isApiCreated = $apiObjectId.Length # treating 0 as $false and positive nums as $true - - $currentRetryCount++ - if ($currentRetryCount -gt $maxNumberOfRetries) { - Write-Error 'FATAL ERROR: Tried to create retrieve the apiObjectId too many times' - exit 15 - } - - if ($isApiCreated -eq 0) { - Write-Debug "... trying to retrieve apiObjectId attempt #$currentRetryCount" - } - else { - Write-Debug "... retrieved apiObjectId='$apiObjectId'" - } - - Start-Sleep -Seconds 3 - } - - # Expose an API by defining a scope - # application ID URI will be clientId by default - - $scopeName = 'relecloud.api' - - $isScopeAdded = 0 - $currentRetryCount = 0 - - while ($isScopeAdded -eq 0) { - - az rest ` - --method PATCH ` - --uri "https://graph.microsoft.com/v1.0/applications/$apiObjectId" ` - --headers 'Content-Type=application/json' ` - --body "{ identifierUris:[ 'api://$apiWebAppClientId' ], api: { oauth2PermissionScopes: [ { value: '$scopeName', adminConsentDescription: 'Relecloud API access', adminConsentDisplayName: 'Relecloud API access', id: 'c791b666-cc87-4904-bc9f-c5945e08ba8f', isEnabled: true, type: 'Admin' } ] } }" 2> $null - - $createdScope = (az ad app show --id $apiWebAppClientId --query 'api.oauth2PermissionScopes[0].value' -o tsv 2> $null) - - if ($createdScope -eq $scopeName) { - $isScopeAdded = 1 - Write-Debug "... added scope $scopeName" - } - else { - $currentRetryCount++ - Write-Host "... trying to add scope attempt #$currentRetryCount" - if ($currentRetryCount -gt $maxNumberOfRetries) { - Write-Error 'FATAL ERROR: Tried to set scopes too many times' - exit 16 - } - } - - Start-Sleep -Seconds 3 - } - - Write-Host "... assigned scope to api" - - $permId = '' - $currentRetryCount = 0 - while ($permId.Length -eq 0 ) { - $permId = (az ad app show --id $apiWebAppClientId --query 'api.oauth2PermissionScopes[].id' -o tsv 2> $null) - - if ($permId.Length -eq 0 ) { - $currentRetryCount++ - Write-Debug "... trying to retrieve permId attempt #$currentRetryCount" - - if ($currentRetryCount -gt $maxNumberOfRetries) { - Write-Error 'FATAL ERROR: Tried to retrieve permissionId too many times' - exit 17 - } - } - else { - Write-Debug "... retrieved permId=$permId" - } - - Start-Sleep -Seconds 3 - } - - $preAuthedAppApplicationId = $frontEndWebAppClientId - - # Preauthorize the front-end as a client to suppress scope requests - $authorizedApps = '' - $currentRetryCount = 0 - while ($authorizedApps.Length -eq 0) { - az rest ` - --method PATCH ` - --uri "https://graph.microsoft.com/v1.0/applications/$apiObjectId" ` - --headers 'Content-Type=application/json' ` - --body "{api:{preAuthorizedApplications:[{appId:'$preAuthedAppApplicationId',delegatedPermissionIds:['$permId']}]}}" 2> $null - - $authorizedApps = (az ad app show --id $apiObjectId --query "api.preAuthorizedApplications" -o tsv 2> $null) - - if ($authorizedApps.Length -eq 0) { - $currentRetryCount++ - Write-Debug "... trying to set front-end app as an preAuthorized client attempt #$currentRetryCount" - - if ($currentRetryCount -gt $maxNumberOfRetries) { - Write-Error 'FATAL ERROR: Tried to authorize the front-end app too many times' - exit 18 - } - } - else { - Write-Host "front-end web app is now preAuthorized" - Write-Host "" - } - - Start-Sleep -Seconds 3 - } - - # prod environments do not allow public network access, this must be changed before we can set values - if ($isProd) { - # open the app config so that the local user can access - az appconfig update --name $appConfigSvcName --resource-group $ResourceGroupName --enable-public-network true > $null - } - - # save 'App:RelecloudApi:AttendeeScope' scope for role to App Config Svc - az appconfig kv set --name $appConfigSvcName --key 'App:RelecloudApi:AttendeeScope' --value "api://$apiWebAppClientId/$scopeName" --yes --only-show-errors > $null - Write-Host "Set appconfig value for: 'App:RelecloudApi:AttendeeScope'" - - # save 'Api:AzureAd:ClientId' to App Config Svc - az appconfig kv set --name $appConfigSvcName --key 'Api:AzureAd:ClientId' --value $apiWebAppClientId --yes --only-show-errors > $null - Write-Host "Set appconfig value for: 'Api:AzureAd:ClientId'" - - # save 'Api:AzureAd:TenantId' to App Config Svc - az appconfig kv set --name $appConfigSvcName --key 'Api:AzureAd:TenantId' --value $tenantId --yes --only-show-errors > $null - Write-Host "Set appconfig value for: 'Api:AzureAd:TenantId'" - - # prod environments do not allow public network access - if ($isProd) { - # close the app config so that the local user can access - az appconfig update --name $appConfigSvcName --resource-group $ResourceGroupName --enable-public-network false > $null - } -} -else { - Write-Host "API app registration objectId=$apiObjectId already exists. Delete the '$apiWebAppName' app registration to recreate or reset the settings." - $canSetSecondAzureLocation = 3 -} - -############## Copy the App Configuration and Key Vault settings to second azure location ############## - -if ($secondaryResourceGroupName.Length -gt 0 -and $canSetSecondAzureLocation -eq 1) { - - # assumes there is only one vault deployed to this resource group that will match this filter - $secondaryKeyVaultName = (az keyvault list -g "$secondaryResourceGroupName" --query "[? name.starts_with(@,'rc-') ].name" -o tsv) - - $secondaryAppConfigSvcName = (az appconfig list -g "$secondaryResourceGroupName" --query "[].name" -o tsv) - - Write-Debug "" - Write-Debug "Derived inputs for second azure location" - Write-Debug "----------------------------------------------" - Write-Debug "secondaryKeyVaultName=$secondaryKeyVaultName" - Write-Debug "secondaryAppConfigSvcName=$secondaryAppConfigSvcName" - - if ($secondaryKeyVaultName.Length -eq 0) { - Write-Debug "No secondary vault to configure" - exit 0 - } - - Write-Host "" - Write-Host "Now configuring secondary key vault" - - # prod environments do not allow public network access, this must be changed before we can set values - if ($isProd) { - # open the app config so that the local user can access - az appconfig update --name $secondaryAppConfigSvcName --resource-group $secondaryResourceGroupName --enable-public-network true > $null - - # open the key vault so that the local user can access - az keyvault update --name $secondaryKeyVaultName --resource-group $secondaryResourceGroupName --public-network-access Enabled > $null - } - - # save 'AzureAd:ClientSecret' to Key Vault - az keyvault secret set --name 'AzureAd--ClientSecret' --vault-name $secondaryKeyVaultName --value $frontEndWebAppClientSecret --only-show-errors > $null - Write-Host "... Set keyvault value for: 'AzureAd--ClientSecret'" - - Write-Host "" - Write-Host "Now configuring secondary app config svc" - # save 'AzureAd:TenantId' to App Config Svc - az appconfig kv set --name $secondaryAppConfigSvcName --key 'AzureAd:TenantId' --value $tenantId --yes --only-show-errors > $null - Write-Host "... Set appconfig value for: 'AzureAd:TenantId'" - - #save 'AzureAd:ClientId' to App Config Svc - az appconfig kv set --name $secondaryAppConfigSvcName --key 'AzureAd:ClientId' --value $frontEndWebAppClientId --yes --only-show-errors > $null - Write-Host "... Set appconfig value for: 'AzureAd:ClientId'" - - # save 'App:RelecloudApi:AttendeeScope' scope for role to App Config Svc - az appconfig kv set --name $secondaryAppConfigSvcName --key 'App:RelecloudApi:AttendeeScope' --value "api://$apiWebAppClientId/$scopeName" --yes --only-show-errors > $null - Write-Host "... Set appconfig value for: 'App:RelecloudApi:AttendeeScope'" - - # save 'Api:AzureAd:ClientId' to App Config Svc - az appconfig kv set --name $secondaryAppConfigSvcName --key 'Api:AzureAd:ClientId' --value $apiWebAppClientId --yes --only-show-errors > $null - Write-Host "... Set appconfig value for: 'Api:AzureAd:ClientId'" - - # save 'Api:AzureAd:TenantId' to App Config Svc - az appconfig kv set --name $secondaryAppConfigSvcName --key 'Api:AzureAd:TenantId' --value $tenantId --yes --only-show-errors > $null - Write-Host "... Set appconfig value for: 'Api:AzureAd:TenantId'" - - # prod environments do not allow public network access - if ($isProd) { - # close the app config so that the local user can access - az appconfig update --name $secondaryAppConfigSvcName --resource-group $secondaryResourceGroupName --enable-public-network false > $null - - # close the key vault so that the local user can access - az keyvault update --name $secondaryKeyVaultName --resource-group $secondaryResourceGroupName --public-network-access Disabled > $null - } - -} elseif ($canSetSecondAzureLocation -eq 2) { - Write-Host "" - Write-Host "skipped setup for secondary azure location because frontend app registration objectId=$frontEndWebObjectId already exists." -} elseif ($canSetSecondAzureLocation -eq 3) { - Write-Host "" - Write-Host "skipped setup for secondary location because API app registration objectId=$apiObjectId already exists." -} - -# all done -exit 0 \ No newline at end of file diff --git a/infra/createAppRegistrations.sh b/infra/createAppRegistrations.sh deleted file mode 100755 index c6255cc5..00000000 --- a/infra/createAppRegistrations.sh +++ /dev/null @@ -1,501 +0,0 @@ -#!/bin/bash - -# This script is part of the sample's workflow for configuring App Registrations -# in Azure AD and saving the appropriate values in Key Vault, and Azure App Config Service -# so that the application can authenticate users. Note that an app registration is -# something you'll want to set up once, and reuse for every version of the web app -# that you deploy. You can learn more about app registrations at -# https://learn.microsoft.com/en-us/azure/active-directory/develop/application-model -# -# If you do not have permission to create App Registrations consider -# sharing this script, or something similar, with your administrators to help them -# set up the variables you need to integrate with Azure AD -# -# This code may be repurposed for your scenario as desired -# but is not covered by the guidance in this content. - -POSITIONAL_ARGS=() - -debug='' - -while [[ $# -gt 0 ]]; do - case $1 in - --resource-group|-g) - resourceGroupName="$2" - shift # past argument - shift # past value - ;; - --debug) - debug=1 - shift # past argument - ;; - --help*) - echo "" - echo "" - echo "" - echo "Command" - echo " createAppRegistrations.sh : Will create two app registrations for the reliable-web-app-pattern-dotnet and register settings with App Configuration Svc and Key Vault." - echo "" - echo "Arguments" - echo " --resource-group -g : Name of resource group containing the environment that was created by the azd command." - echo "" - exit 1 - ;; - -*|--*) - echo "Unknown option $1" - exit 1 - ;; - *) - POSITIONAL_ARGS+=("$1") # save positional arg - shift # past argument - ;; - esac -done - -green='\033[0;32m' -yellow='\e[0;33m' -red='\e[1;31m' -clear='\033[0m' - -if [[ ${#resourceGroupName} -eq 0 ]]; then - printf "${red}FATAL ERROR:${clear} Missing required parameter --resource-group" - echo "" - exit 6 -fi - -canSetSecondAzureLocation=1 - -echo "Inputs" -echo "----------------------------------------------" -echo "resourceGroupName='$resourceGroupName'" -echo "" - -# assumes there is only one vault deployed to this resource group that will match this filter -keyVaultName=$(az keyvault list -g "$resourceGroupName" --query "[?name.starts_with(@,'rc-')].name" -o tsv) - -appConfigSvcName=$(az appconfig list -g "$resourceGroupName" --query "[].name" -o tsv) - -appServiceRootUri='azurewebsites.net' # hard coded because app svc does not return the public endpoint -# updated az resource selection to filter to first based on https://github.com/Azure/azure-cli/issues/25214 -frontEndWebAppName=$(az resource list -g "$resourceGroupName" --query "[?tags.\"azd-service-name\"=='web'].name" -o tsv) -frontEndWebAppUri="https://$frontEndWebAppName.$appServiceRootUri" - -# assumes resourceToken is located in app service frontend web app name -# assumes the uniquestring function from the bicep template always returns a string of length 13 -resourceToken=${frontEndWebAppName:4:13} - -# assumes environment name is used to build resourceGroupName -locationOfHyphen=$(echo $resourceGroupName | awk -F "-" '{print length($0)-length($NF)}') -environmentName=${resourceGroupName:0:$locationOfHyphen-1} - - -frontDoorProfileName=$(az resource list -g $resourceGroupName --query "[? kind=='frontdoor' ].name" -o tsv) -echo "frontDoorProfileName=$frontDoorProfileName" -frontEndWebAppHostName=$(az afd endpoint list -g $resourceGroupName --profile-name $frontDoorProfileName --query "[].hostName" -o tsv --only-show-errors) -echo "frontEndWebAppHostName=$frontEndWebAppHostName" -frontEndWebAppUri="https://$frontEndWebAppHostName" - -substring="-rg" -secondaryResourceGroupName=(${resourceGroupName%%$substring*}) -secondaryResourceGroupName+="-secondary-rg" -group2Exists=$(az group exists -n $secondaryResourceGroupName) -if [[ $group2Exists == 'false' ]]; then - secondaryResourceGroupName='' -fi - -# updated az resource selection to filter to first based on https://github.com/Azure/azure-cli/issues/25214 -mySqlServer=$(az resource list -g $resourceGroupName --query "[?type=='Microsoft.Sql/servers'].name" -o tsv) - -azdData=$(azd env get-values) -isProd='' -if [[ $azdData =~ 'IS_PROD="true"' ]]; then - isProd=true -fi - -echo "Derived inputs" -echo "----------------------------------------------" -if [[ $isProd ]]; then - echo "isProd=true" -else - echo "isProd=false" -fi -echo "keyVaultName=$keyVaultName" -echo "appConfigSvcName=$appConfigSvcName" -echo "frontEndWebAppUri=$frontEndWebAppUri" -echo "resourceToken=$resourceToken" -echo "environmentName=$environmentName" -echo "secondaryResourceGroupName=$secondaryResourceGroupName" -echo "" - -if [[ ${#keyVaultName} -eq 0 ]]; then - printf "${red}FATAL ERROR:${clear} Could not find Key Vault resource. Confirm the --resourceGroupName is the one created by the `azd provision` command." - echo "" - exit 7 -fi - -echo "Runtime values" -echo "----------------------------------------------" -frontEndWebAppName="$environmentName-$resourceToken-frontend" -apiWebAppName="$environmentName-$resourceToken-api" -maxNumberOfRetries=20 - -echo "frontEndWebAppName='$frontEndWebAppName'" -echo "apiWebAppName='$apiWebAppName'" -echo "maxNumberOfRetries=$maxNumberOfRetries" - -tenantId=$(az account show --query "tenantId" -o tsv) - -userObjectId=$(az account show --query "id" -o tsv) - -echo "tenantId='$tenantId'" -echo "" - -if [[ $debug ]]; then - read -n 1 -r -s -p "Press any key to continue..." - echo '' - echo "..." -fi - -# prod environments do not allow public network access, this must be changed before we can set values -if [[ $isProd ]]; then - # Resolves permission constraint that prevents the deploymentScript from running this command - # https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/134 - az sql server update -n $mySqlServer -g $resourceGroupName --set publicNetworkAccess="Disabled" > /dev/null -fi - -frontEndWebObjectId=$(az ad app list --filter "displayName eq '$frontEndWebAppName'" --query "[].id" -o tsv) - -if [[ ${#frontEndWebObjectId} -eq 0 ]]; then - - # this web app doesn't exist and must be created - - frontEndWebAppClientId=$(az ad app create \ - --display-name $frontEndWebAppName \ - --sign-in-audience AzureADMyOrg \ - --app-roles '[{ "allowedMemberTypes": [ "User" ], "description": "Relecloud Administrator", "displayName": "Relecloud Administrator", "isEnabled": "true", "value": "Administrator" }]' \ - --web-redirect-uris $frontEndWebAppUri/signin-oidc https://localhost:7227/signin-oidc \ - --enable-id-token-issuance \ - --query appId --output tsv) - - echo "frontEndWebAppClientId='$frontEndWebAppClientId'" - - if [[ ${#frontEndWebAppClientId} -eq 0 ]]; then - printf "${red}FATAL ERROR:${clear} Unknown Azure AD error. Failed to create front-end app registration." - echo "" - - exit 8 - fi - - - isWebAppCreated=0 - currentRetryCount=0 - while [ $isWebAppCreated -eq 0 ] - do - # assumes that we only need to create client secret if the app registration did not exist - frontEndWebAppClientSecret=$(az ad app credential reset --id $frontEndWebAppClientId --query "password" -o tsv --only-show-errors 2> /dev/null) - isWebAppCreated=${#frontEndWebAppClientSecret} - - currentRetryCount=$((currentRetryCount + 1)) - if [[ $currentRetryCount -gt $maxNumberOfRetries ]]; then - echo "FATAL ERROR: Tried to create a client secret too many times" 1>&2 - - printf "${red}FATAL ERROR:${clear} Unknown Azure AD error. Could not create and retrieve a client secret. Tried to create a client secret too many times" - echo "" - - exit 14 - fi - - if [[ $isWebAppCreated -eq 0 ]]; then - echo "... trying to create clientSecret for front-end attempt #$currentRetryCount" - else - echo "... created clientSecret for front-end" - echo "" - fi - - # sleep until the app registration is created - sleep 3 - done - - # prod environments do not allow public network access, this must be changed before we can set values - if [[ $isProd ]]; then - # open the app config so that the local user can access - az appconfig update --name $appConfigSvcName --resource-group $resourceGroupName --enable-public-network true > /dev/null - - # open the key vault so that the local user can access - az keyvault update --name $keyVaultName --resource-group $resourceGroupName --public-network-access Enabled > /dev/null - fi - - # save 'AzureAd:ClientSecret' to Key Vault - az keyvault secret set --name 'AzureAd--ClientSecret' --vault-name $keyVaultName --value $frontEndWebAppClientSecret --only-show-errors > /dev/null - echo "Set keyvault value for: 'AzureAd--ClientSecret'" - - # save 'AzureAd:TenantId' to App Config Svc - az appconfig kv set --name $appConfigSvcName --key 'AzureAd:TenantId' --value $tenantId --yes --only-show-errors > /dev/null - echo "Set appconfig value for: 'AzureAd:TenantId'" - - # save 'AzureAd:ClientId' to App Config Svc - az appconfig kv set --name $appConfigSvcName --key 'AzureAd:ClientId' --value $frontEndWebAppClientId --yes --only-show-errors > /dev/null - echo "Set appconfig value for: 'AzureAd:ClientId'" - - # prod environments do not allow public network access - if [[ $isProd ]]; then - # close the app config so that the local user cannot access - az appconfig update --name $appConfigSvcName --resource-group $resourceGroupName --enable-public-network false > /dev/null - - # close the key vault so that the local user cannot access - az keyvault update --name $keyVaultName --resource-group $resourceGroupName --public-network-access Disabled > /dev/null - fi -else - echo "frontend app registration objectId=$frontEndWebObjectId already exists. Delete the '$frontEndWebAppName' app registration to recreate or reset the settings." - frontEndWebAppClientId=$(az ad app show --id $frontEndWebObjectId --query "appId" -o tsv) - echo "frontEndWebAppClientId='$frontEndWebAppClientId'" - canSetSecondAzureLocation=2 -fi - -echo "" -echo "Finished app registration for front-end" -echo "" - -apiObjectId=$(az ad app list --filter "displayName eq '$apiWebAppName'" --query "[].id" -o tsv) - -if [[ ${#apiObjectId} -eq 0 ]]; then - # the api app registration does not exist and must be created - - apiWebAppClientId=$(az ad app create \ - --display-name $apiWebAppName \ - --sign-in-audience AzureADMyOrg \ - --app-roles '[{ "allowedMemberTypes": [ "User" ], "description": "Relecloud Administrator", "displayName": "Relecloud Administrator", "isEnabled": "true", "value": "Administrator" }]' \ - --query appId --output tsv) - - echo "apiWebAppClientId='$apiWebAppClientId'" - - # sleep until the app registration is created correctly - isApiCreated=0 - currentRetryCount=0 - - while [ $isApiCreated -eq 0 ] - do - apiObjectId=$(az ad app show --id $apiWebAppClientId --query id -o tsv 2> /dev/null) - isApiCreated=${#apiObjectId} - - currentRetryCount=$((currentRetryCount + 1)) - if [[ $currentRetryCount -gt $maxNumberOfRetries ]]; then - printf "${red}FATAL ERROR:${clear} Unknown Azure AD error. Tried to create retrieve the apiObjectId too many times." - echo "" - - exit 15 - fi - - if [[ $isApiCreated -eq 0 ]]; then - echo "... trying to retrieve apiObjectId attempt #$currentRetryCount" - else - echo "... retrieved apiObjectId='$apiObjectId'" - fi - - sleep 3 - done - - # Expose an API by defining a scope - # application ID URI will be clientId by default - - scopeName='relecloud.api' - - isScopeAdded=0 - currentRetryCount=0 - while [ $isScopeAdded -eq 0 ] - do - az rest \ - --method PATCH \ - --uri "https://graph.microsoft.com/v1.0/applications/$apiObjectId" \ - --headers 'Content-Type=application/json' \ - --body "{ identifierUris:[ 'api://$apiWebAppClientId' ], api: { oauth2PermissionScopes: [ { value: '$scopeName', adminConsentDescription: 'Relecloud API access', adminConsentDisplayName: 'Relecloud API access', id: 'c791b666-cc87-4904-bc9f-c5945e08ba8f', isEnabled: true, type: 'Admin' } ] } }" 2> /dev/null - - createdScope=$(az ad app show --id $apiWebAppClientId --query 'api.oauth2PermissionScopes[0].value' -o tsv 2> /dev/null) - - if [[ $createdScope == $scopeName ]]; then - isScopeAdded=1 - echo "... added scope $scopeName" - else - currentRetryCount=$((currentRetryCount + 1)) - echo "... trying to add scope attempt #$currentRetryCount" - if [[ $currentRetryCount -gt $maxNumberOfRetries ]]; then - printf "${red}FATAL ERROR:${clear} Unknown Azure AD error. Tried to set scopes too many times." - echo "" - - exit 16 - fi - fi - - sleep 3 - done - - echo "... assigned scope to api" - - permId='' - currentRetryCount=0 - while [ ${#permId} -eq 0 ] - do - permId=$(az ad app show --id $apiWebAppClientId --query 'api.oauth2PermissionScopes[].id' -o tsv 2> /dev/null) - - if [[ ${#permId} -eq 0 ]]; then - currentRetryCount=$((currentRetryCount + 1)) - echo "... trying to retrieve permId attempt #$currentRetryCount" - - if [[ $currentRetryCount -gt $maxNumberOfRetries ]]; then - printf "${red}FATAL ERROR:${clear} Unknown Azure AD error. Tried to retrieve permissionId too many times" - echo "" - - exit 17 - fi - else - echo "... retrieved permId=$permId" - fi - - sleep 3 - done - - preAuthedAppApplicationId=$frontEndWebAppClientId - - # Preauthorize the front-end as a client to suppress scope requests - authorizedApps='' - currentRetryCount=0 - while [ ${#authorizedApps} -eq 0 ] - do - az rest \ - --method PATCH \ - --uri "https://graph.microsoft.com/v1.0/applications/$apiObjectId" \ - --headers 'Content-Type=application/json' \ - --body "{api:{preAuthorizedApplications:[{appId:'$preAuthedAppApplicationId',delegatedPermissionIds:['$permId']}]}}" 2> /dev/null - - authorizedApps=$(az ad app show --id $apiObjectId --query "api.preAuthorizedApplications[].appId" -o tsv 2> /dev/null) - - if [[ ${#authorizedApps} -eq 0 ]]; then - currentRetryCount=$((currentRetryCount + 1)) - echo "... trying to set front-end app as an preAuthorized client attempt #$currentRetryCount" - - if [[ $currentRetryCount -gt $maxNumberOfRetries ]]; then - printf "${red}FATAL ERROR:${clear} Unknown Azure AD error. Tried to authorize the front-end app too many times" - echo "" - - exit 18 - fi - else - echo "front-end web app is now preAuthorized" - echo "" - fi - - sleep 3 - done - - # prod environments do not allow public network access, this must be changed before we can set values - if [[ $isProd ]]; then - # open the app config so that the local user can access - az appconfig update --name $appConfigSvcName --resource-group $resourceGroupName --enable-public-network true > /dev/null - fi - - # save 'App:RelecloudApi:AttendeeScope' scope for role to App Config Svc - az appconfig kv set --name $appConfigSvcName --key 'App:RelecloudApi:AttendeeScope' --value "api://$apiWebAppClientId/$scopeName" --yes --only-show-errors > /dev/null - echo "Set appconfig value for: 'App:RelecloudApi:AttendeeScope'" - - # save 'Api:AzureAd:ClientId' to App Config Svc - az appconfig kv set --name $appConfigSvcName --key 'Api:AzureAd:ClientId' --value $apiWebAppClientId --yes --only-show-errors > /dev/null - echo "Set appconfig value for: 'Api:AzureAd:ClientId'" - - # save 'Api:AzureAd:TenantId' to App Config Svc - az appconfig kv set --name $appConfigSvcName --key 'Api:AzureAd:TenantId' --value $tenantId --yes --only-show-errors > /dev/null - echo "Set appconfig value for: 'Api:AzureAd:TenantId'" - - # prod environments do not allow public network access - if [[ $isProd ]]; then - # close the app config so that the local user cannot access - az appconfig update --name $appConfigSvcName --resource-group $resourceGroupName --enable-public-network false > /dev/null - fi -else - echo "API app registration objectId=$apiObjectId already exists. Delete the '$apiWebAppName' app registration to recreate or reset the settings." - canSetSecondAzureLocation=3 -fi - -############## Copy the App Configuration and Key Vault settings to second azure location ############## - -if [[ ${#secondaryResourceGroupName} -gt 0 && $canSetSecondAzureLocation -eq 1 ]]; then - - # assumes there is only one vault deployed to this resource group that will match this filter - secondaryKeyVaultName=$(az keyvault list -g "$secondaryResourceGroupName" --query "[?name.starts_with(@,'rc-')].name" -o tsv) - - secondaryAppConfigSvcName=$(az appconfig list -g "$secondaryResourceGroupName" --query "[].name" -o tsv) - - echo "" - echo "Derived inputs for second azure location" - echo "----------------------------------------------" - echo "secondaryKeyVaultName=$secondaryKeyVaultName" - echo "secondaryAppConfigSvcName=$secondaryAppConfigSvcName" - - if [[ ${#secondaryKeyVaultName} -eq 0 ]]; then - echo "" - printf "${green}Finished successfully${clear} after configuring 1 Key Vault and 1 App Configuration Service!" - echo "" - echo "" - exit 0 - fi - - echo "" - echo "Now configuring secondary key vault" - - # prod environments do not allow public network access, this must be changed before we can set values - if [[ $isProd ]]; then - # open the app config so that the local user can access - az appconfig update --name $secondaryAppConfigSvcName --resource-group $secondaryResourceGroupName --enable-public-network true > /dev/null - - # open the key vault so that the local user can access - az keyvault update --name $secondaryKeyVaultName --resource-group $secondaryResourceGroupName --public-network-access Enabled > /dev/null - fi - - # save 'AzureAd:ClientSecret' to Key Vault - az keyvault secret set --name 'AzureAd--ClientSecret' --vault-name $secondaryKeyVaultName --value $frontEndWebAppClientSecret --only-show-errors > /dev/null - echo "... Set keyvault value for: 'AzureAd--ClientSecret'" - - echo "" - echo "Now configuring secondary app config svc" - # save 'AzureAd:TenantId' to App Config Svc - az appconfig kv set --name $secondaryAppConfigSvcName --key 'AzureAd:TenantId' --value $tenantId --yes --only-show-errors > /dev/null - echo "... Set appconfig value for: 'AzureAd:TenantId'" - - #save 'AzureAd:ClientId' to App Config Svc - az appconfig kv set --name $secondaryAppConfigSvcName --key 'AzureAd:ClientId' --value $frontEndWebAppClientId --yes --only-show-errors > /dev/null - echo "... Set appconfig value for: 'AzureAd:ClientId'" - - # save 'App:RelecloudApi:AttendeeScope' scope for role to App Config Svc - az appconfig kv set --name $secondaryAppConfigSvcName --key 'App:RelecloudApi:AttendeeScope' --value "api://$apiWebAppClientId/$scopeName" --yes --only-show-errors > /dev/null - echo "... Set appconfig value for: 'App:RelecloudApi:AttendeeScope'" - - # save 'Api:AzureAd:ClientId' to App Config Svc - az appconfig kv set --name $secondaryAppConfigSvcName --key 'Api:AzureAd:ClientId' --value $apiWebAppClientId --yes --only-show-errors > /dev/null - echo "... Set appconfig value for: 'Api:AzureAd:ClientId'" - - # save 'Api:AzureAd:TenantId' to App Config Svc - az appconfig kv set --name $secondaryAppConfigSvcName --key 'Api:AzureAd:TenantId' --value $tenantId --yes --only-show-errors > /dev/null - echo "... Set appconfig value for: 'Api:AzureAd:TenantId'" - - # prod environments do not allow public network access - if [[ $isProd ]]; then - # close the app config so that the local user cannot access - az appconfig update --name $secondaryAppConfigSvcName --resource-group $secondaryResourceGroupName --enable-public-network false > /dev/null - - # close the key vault so that the local user cannot access - az keyvault update --name $secondaryKeyVaultName --resource-group $secondaryResourceGroupName --public-network-access Disabled > /dev/null - fi - - echo "" - printf "${green}Finished successfully${clear} after configuring 2 Key Vaults and 2 App Configuration Services!" - echo "" - echo "" -elif [[ $canSetSecondAzureLocation -eq 2 ]]; then - echo "" - echo "skipped setup for secondary azure location because frontend app registration objectId=$frontEndWebObjectId already exists." -elif [[ $canSetSecondAzureLocation -eq 3 ]]; then - echo "" - echo "skipped setup for secondary location because API app registration objectId=$apiObjectId already exists." -fi - -# all done -exit 0 diff --git a/infra/deploymentScripts/azureRedisCachePublicDevAccess.sh b/infra/deploymentScripts/azureRedisCachePublicDevAccess.sh deleted file mode 100644 index 76879a66..00000000 --- a/infra/deploymentScripts/azureRedisCachePublicDevAccess.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/bin/bash - -POSITIONAL_ARGS=() - -while [[ $# -gt 0 ]]; do - case $1 in - --name|-n) - redisCacheName="$2" - shift # past argument - shift # past value - ;; - --resource-group|-g) - resourceGroupName="$2" - shift # past argument - shift # past value - ;; - --subscription|-s) - subscriptionId="$2" - shift # past argument - shift # past value - ;; - --help*) - echo "" - echo "" - echo "" - echo "Command" - echo " azureRedisCachePublicDevAccess.sh : is used by devs to make Redis accessible for non-prod dev tasks" - echo "" - echo "Arguments" - echo " --resource-group -g : Name of resource group where this Redis Cache is deployed." - echo " --subscription -s : The subscriptionId where this Redis Cache is deployed." - echo " --name -n : Name of the Redis Cache that should be modified." - echo "" - exit 1 - ;; - -*|--*) - echo "Unknown option $1" - exit 1 - ;; - *) - POSITIONAL_ARGS+=("$1") # save positional arg - shift # past argument - ;; - esac -done - -if [[ ${#resourceGroupName} -eq 0 ]]; then - echo "FATAL ERROR: Missing required parameter --resource-group" 1>&2 - exit 6 -fi - -if [[ ${#subscriptionId} -eq 0 ]]; then - echo "FATAL ERROR: Missing required parameter --subscription" 1>&2 - exit 7 -fi - -if [[ ${#redisCacheName} -eq 0 ]]; then - echo "FATAL ERROR: Missing required parameter --name" 1>&2 - exit 8 -fi - -az rest \ - --method PATCH \ - --uri "https://management.azure.com/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.Cache/Redis/$redisCacheName?api-version=2020-06-01" \ - --headers 'Content-Type=application/json' \ - --body "{ \"properties\": { \"publicNetworkAccess\":\"Enabled\" } }" \ No newline at end of file diff --git a/infra/deploymentScripts/createSqlAcctForManagedIdentity.ps1 b/infra/deploymentScripts/createSqlAcctForManagedIdentity.ps1 deleted file mode 100644 index fb840f6b..00000000 --- a/infra/deploymentScripts/createSqlAcctForManagedIdentity.ps1 +++ /dev/null @@ -1,82 +0,0 @@ -#Requires -Version 7.0 - -<# -.SYNOPSIS - Used to create Sql Account for Managed Identity -.DESCRIPTION - Creates a new Sql Account for the Managed Identity service principal and grants account db_owner role - - Also configures the Sql Database for AAD authentication only - - NOTE: This script is not intended to be run from a local environment. - This script is run by azd during devOps deployment. - For the local environment version of this script, please see makeSqlUserAccount.ps1 - - This script provides a workflow to automatically configure the deployed Azure resources and make it easier to get - started. It is not intended as part of a recommended best practice as we do not recommend deploying Azure SQL - with network configurations that would allow a deployment script such as this to connect. - - We recommend handling this one-time process as part of your SQL data migration process - More details can be found in our docs for Azure SQL server - https://learn.microsoft.com/en-us/azure/app-service/tutorial-connect-msi-sql-database?tabs=windowsclient%2Cef%2Cdotnet -.PARAMETER ServerName - A required parameter for the name of target Azure SQL Server. -.PARAMETER ResourceGroupName - A required parameter for the name of resource group that contains the environment that was - created by the azd command. -.PARAMETER ServerUri - A required parameter for the Uri of target Azure SQL Server. -.PARAMETER CatalogName - A required parameter for the name the Azure SQL Database name used. -.PARAMETER ApplicationId - A required parameter for the Managed Identity's Application ID used to generate its SID - used for creating a user in SQL. -.PARAMETER ManagedIdentityName - A required parameter for the name of Managed Identity that will be used. -.PARAMETER SqlAdminLogin - A required parameter for the SQL Administrator Login used. -.PARAMETER SqlAdminPwd - A required parameter for the SQL Administrator Password used. -.PARAMETER IsProd - A required parameter indicating Production environment is being used. -#> - -Param( - [Parameter(Mandatory = $true)][string]$ServerName, - [Parameter(Mandatory = $true)][string]$ResourceGroupName, - [Parameter(Mandatory = $true)][string]$ServerUri, - [Parameter(Mandatory = $true)][string]$CatalogName, - [Parameter(Mandatory = $true)][string]$ApplicationId, - [Parameter(Mandatory = $true)][string]$ManagedIdentityName, - [Parameter(Mandatory = $true)][string]$SqlAdminLogin, - [Parameter(Mandatory = $true)][string]$SqlAdminPwd, - [Parameter(Mandatory = $true)][bool]$IsProd -) - -# Make Invoke-Sqlcmd available -Install-Module -Name SqlServer -Force -Import-Module -Name SqlServer - -# translate applicationId into SID -[guid]$guid = [System.Guid]::Parse($ApplicationId) - -foreach ($byte in $guid.ToByteArray()) { - $byteGuid += [System.String]::Format("{0:X2}", $byte) -} -$Sid = "0x" + $byteGuid - -# Prepare SQL cmd to CREATE USER -$CreateUserSQL = "IF NOT EXISTS (SELECT * FROM sys.database_principals WHERE name = N'$ManagedIdentityName') create user [$ManagedIdentityName] with sid = $Sid, type = E;" - -# Connect as SQL Admin acct and execute SQL cmd -Invoke-Sqlcmd -ServerInstance $ServerUri -database $CatalogName -Username $SqlAdminLogin -Password $SqlAdminPwd -Query $CreateUserSQL - -# Prepare SQL cmd to grant db_owner role -$GrantDbOwner = "IF NOT EXISTS (SELECT * FROM sys.database_principals p JOIN sys.database_role_members db_owner_role ON db_owner_role.member_principal_id = p.principal_id JOIN sys.database_principals role_names ON role_names.principal_id = db_owner_role.role_principal_id AND role_names.[name] = 'db_owner' WHERE p.[name]=N'$ManagedIdentityName') ALTER ROLE db_owner ADD MEMBER [$ManagedIdentityName];" - -# Connect as SQL Admin acct and execute SQL cmd -Invoke-Sqlcmd -ServerInstance $ServerUri -database $CatalogName -Username $SqlAdminLogin -Password $SqlAdminPwd -Query $GrantDbOwner - -# Restrict access to Azure AD users -Enable-AzSqlServerActiveDirectoryOnlyAuthentication -ServerName $ServerName -ResourceGroupName $ResourceGroupName - diff --git a/infra/deploymentScripts/enableSqlAdminForServer.ps1 b/infra/deploymentScripts/enableSqlAdminForServer.ps1 deleted file mode 100644 index cd6ecf53..00000000 --- a/infra/deploymentScripts/enableSqlAdminForServer.ps1 +++ /dev/null @@ -1,40 +0,0 @@ -#Requires -Version 7.0 - -<# -.SYNOPSIS - Used to enable Sql Authentication only for target Azure SQL Server -.DESCRIPTION - Used to enable Sql Authentication only for target Azure SQL Server - - NOTE: This script is not intended to be run from a local environment. - This script is run by azd during devOps deployment. This script handles rolling back auth changes - that would block the createSqlAcctForManagedIdentity.ps1 scripts from connecting when run as a deploymentScript - https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/224 - - This script provides a workflow to automatically configure the deployed Azure resources and make it easier to get - started. It is not intended as part of a recommended best practice as we do not recommend deploying Azure SQL - with network configurations that would allow a deployment script such as this to connect. - -.PARAMETER SqlServerName - A required parameter for the name of the Azure SQL Server instance. -.PARAMETER ResourceGroupName - A required parameter for the name of resource group that contains the environment that was - created by the azd command. -#> - -Param( - [Parameter(Mandatory = $true)][string]$SqlServerName, - [Parameter(Mandatory = $true)][string]$ResourceGroupName -) - -# check if resource group exists -if (!(Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue)) { - Exit -} - -$DoesSqlServerExist = Get-AzResource -ResourceGroupName $ResourceGroupName -ODataQuery "ResourceType eq 'Microsoft.Sql/servers'" - -if ($DoesSqlServerExist) { - Write-Host "Disabling Ad only admin" - Disable-AzSqlServerActiveDirectoryOnlyAuthentication -ServerName $SqlServerName -ResourceGroupName $ResourceGroupName -} \ No newline at end of file diff --git a/infra/devOpsIdentitySetup.bicep b/infra/devOpsIdentitySetup.bicep deleted file mode 100644 index 85d98fb3..00000000 --- a/infra/devOpsIdentitySetup.bicep +++ /dev/null @@ -1,35 +0,0 @@ - -@minLength(1) -@description('Primary location for all resources. Should specify an Azure region. e.g. `eastus2` ') -param location string - -@minLength(1) -@description('A generated identifier used to create unique resources') -param resourceToken string - -@description('An object collection that contains annotations to describe the deployed azure resources to improve operational visibility') -param tags object - -@description('A user-assigned managed identity that is used to run deploymentScripts on this resource group.') -resource devOpsManagedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2018-11-30' = { - name: 'devops-${resourceToken}-identity' - location: location - tags: tags -} - -@description('Built in \'Contributor\' role ID: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles') -// Allows read access to App Configuration data -var contributorRole = 'b24988ac-6180-42a0-ab88-20f7382dd24c' - -resource devOpsIdentityRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { - name: guid(contributorRole, devOpsManagedIdentity.id) - scope: resourceGroup() - properties: { - principalType: 'ServicePrincipal' - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', contributorRole) - principalId: devOpsManagedIdentity.properties.principalId - description: 'Grant the "Contributor" role to the user-assigned managed identity so it can run deployment scripts.' - } -} - -output devOpsManagedIdentityId string = devOpsManagedIdentity.id diff --git a/infra/devOpsScripts/appConfigSvcPurge.sh b/infra/devOpsScripts/appConfigSvcPurge.sh deleted file mode 100644 index ad3f2c52..00000000 --- a/infra/devOpsScripts/appConfigSvcPurge.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/bash - -# This file is part of our engineering process to build and maintain this file. -# See the README markdown file in .github/workflows for further details -# https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/232 - -POSITIONAL_ARGS=() - -while [[ $# -gt 0 ]]; do - case $1 in - --resource-group|-g) - resourceGroupName="$2" - shift # past argument - shift # past value - ;; - -*|--*) - echo "Unknown option $1" - exit 1 - ;; - *) - POSITIONAL_ARGS+=("$1") # save positional arg - shift # past argument - ;; - esac -done - -echo "Inputs" -echo "----------------------------------------------" -echo "resourceGroupName=$resourceGroupName" -echo "----------------------------------------------" - -deletedAppConfigSvcName=$(az appconfig list-deleted --query "[?configurationStoreId.contains(@,'$resourceGroupName')].name" -o tsv) - -if [[ ${#deletedAppConfigSvcName} -gt 0 ]]; then - az appconfig purge --name $deletedAppConfigSvcName --yes - echo "Purged $deletedAppConfigSvcName" -else - echo "No appconfig to purge" -fi - -deletedKeyVaultSvcName=$(az keyvault list-deleted --query "[? properties.vaultId.contains(@,'$resourceGroupName')] | [0].name" -o tsv) -currentRetryCount=0 - -while [ ${#deletedKeyVaultSvcName} -ne 0 ] -do - if [[ $currentRetryCount -gt 6 ]]; then - echo "FATAL ERROR: Tried to purge key vault too many times" 1>&2 - exit 14 - fi - - if [[ ${#deletedKeyVaultSvcName} -gt 0 ]]; then - echo "Purging $deletedKeyVaultSvcName" - az keyvault purge --name $deletedKeyVaultSvcName - else - echo "Done purging key vaults" - fi - - currentRetryCount=$((currentRetryCount + 1)) - deletedKeyVaultSvcName=$(az keyvault list-deleted --query "[? properties.vaultId.contains(@,'$resourceGroupName')] | [0].name" -o tsv) -done \ No newline at end of file diff --git a/infra/devOpsScripts/validateDeployment.ps1 b/infra/devOpsScripts/validateDeployment.ps1 deleted file mode 100644 index d2fb8940..00000000 --- a/infra/devOpsScripts/validateDeployment.ps1 +++ /dev/null @@ -1,110 +0,0 @@ -#Requires -Version 7.0 - -<# -.SYNOPSIS - Examines the web app that was deployed to identify any known issues and provide recommendations. - - -.DESCRIPTION - Use this command to examine your deployed settings and automatically find recommendations - that can help you troubleshoot issues that you may encounter. - - This script was created after identifying intermittent Azure deployment issues. Many - of which can be resolved by re-running 'azd provision' command. - - NOTE: This script is used by our QA process to ensure the quality of this sample it measures - characteristics of the deployment and will be modified as needed to explore intermittent issues - - This engineering code may be repurposed for your scenario as desired - but is not covered by the guidance in this content. - - This functionality assumes that the web app, app configuration service, and app - service have already been successfully deployed. - -.PARAMETER ResourceGroupName - A required parameter for the name of resource group that contains the environment that was - created by the azd command. The cmdlet will populate the App Config Svc and Key - Vault services in this resource group with Azure AD app registration config data. -#> - -Param( - [Alias("g")] - [Parameter(Mandatory = $true, HelpMessage = "Name of the resource group that was created by azd")] - [String]$ResourceGroupName -) - -if ($ResourceGroupName.Length -eq 0) { - Write-Error 'FATAL ERROR: Missing required parameter --resource-group' - exit 6 -} - -if ($ResourceGroupName -eq '-rg') { - Write-Error 'FATAL ERROR: Required parameter --resource-group was not initialized' - exit 7 -} - -### check if group exists ### - -$groupExists=$(az group exists -n $ResourceGroupName) - -if ($groupExists -eq 'false') { - Write-Error "Missing required resource group. The resource group '$ResourceGroupName' does not exist" - Write-Error "Recommended Action: run the `azd provision` command again to overlay the missing settings" - exit 32 -} else { - Write-Debug "Validated that the resource group does exist" -} - -### end check group exists ### - - -### validate web app settings ### - -# checking for known issue 87 -# https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/87 - -$frontEndWebAppName=$(az resource list -g "$ResourceGroupName" --query "[? tags.\`"azd-service-name\`" == 'web' ].name" -o tsv) - -if ($frontEndWebAppName.Length -eq 0) { - Write-Error "Cannot find the front-end web app" - Write-Error "Recommended Action: run the 'azd provision' command again to overlay the missing settings" - exit 32 -} else { - Write-Debug "Found front-end web app named '$frontEndWebAppName'" -} - -$frontEndAppSvcUri=$(az webapp config appsettings list -n $frontEndWebAppName -g $ResourceGroupName --query "[?name=='App:AppConfig:Uri'].value" -o tsv) - -if ($frontEndAppSvcUri.Length -eq 0) { - Write-Error "Missing required Azure App Service configuration setting front-end web app: App:AppConfig:Uri" - Write-Error "Recommended Action: run the 'azd provision' command again to overlay the missing settings" - exit 33 -} else { - Write-Debug "Validated that the App Service was configured with setting 'App:AppConfig:Uri' equal to '$frontEndAppSvcUri'" -} - -$apiWebAppName=$(az resource list -g "$ResourceGroupName" --query "[? tags.\`"azd-service-name\`" == 'api' ].name" -o tsv) - -if ($apiWebAppName.Length -eq 0 ) { - Write-Error "Cannot find the API web app" - Write-Error "Recommended Action: run the 'azd provision' command again to overlay the missing settings" - exit 34 -} else { - Write-Debug "Found API web app named '$apiWebAppName'" -} - -$apiAppSvcUri=$(az webapp config appsettings list -n $apiWebAppName -g $ResourceGroupName --query "[?name=='Api:AppConfig:Uri'].value" -o tsv) - -if ($apiAppSvcUri.Length -eq 0) { - Write-Error "Missing required Azure App Service configuration setting for api web app: Api:AppConfig:Uri" - Write-Error "Recommended Action: run the 'azd provision' command again to overlay the missing settings" - exit 35 -} else { - Write-Debug "Validated that the App Service was configured with setting 'Api:AppConfig:Uri' equal to '$apiAppSvcUri'" -} - -# end of check for issue 87 - -Write-Host "All settings validated successfully..." -Write-Host "If this script was unable to diagnose your problem then please create a GitHub issue" -exit 0 \ No newline at end of file diff --git a/infra/localDevScripts/addLocalIPToSqlFirewall.ps1 b/infra/localDevScripts/addLocalIPToSqlFirewall.ps1 deleted file mode 100644 index 9a79db1a..00000000 --- a/infra/localDevScripts/addLocalIPToSqlFirewall.ps1 +++ /dev/null @@ -1,61 +0,0 @@ -#Requires -Version 7.0 - -<# -.SYNOPSIS - Used by developers to get access to Azure SQL database -.DESCRIPTION - Makes a web request to a public site to retrieve the user's public IP address - and then adds that IP address to the Azure SQL Database Firewall as an allowed connection. - - NOTE: This functionality assumes that the web app, app configuration service, and app - service have already been successfully deployed. - -.PARAMETER ResourceGroupName - A required parameter for the name of resource group that contains the environment that was - created by the azd command. -#> - -Param( - [Alias("g")] - [Parameter(Mandatory = $true, HelpMessage = "Name of the resource group that was created by azd")] - $ResourceGroupName -) - -if ($ResourceGroupName -eq "-rg") { - Write-Error "FATAL ERROR: $ResourceGroupName could not be found in the current subscription" - exit 5 -} - -$groupExists = (az group exists -n "$ResourceGroupName") -if ($groupExists -eq 'false') { - Write-Error "FATAL ERROR: $ResourceGroupName could not be found in the current subscription" - exit 6 -} -else { - Write-Debug "Found resource group named: $ResourceGroupName" -} - -Write-Debug "`$ResourceGroupName = '$ResourceGroupName'" - -$myIpAddress = (Invoke-WebRequest ipinfo.io/ip) - -Write-Debug "`$myIpAddress = '$myIpAddress'" - -# updated az resource selection to filter to first based on https://github.com/Azure/azure-cli/issues/25214 -$mySqlServer = (az resource list -g $ResourceGroupName --query "[?type=='Microsoft.Sql/servers'].name | [0]" -o tsv) - -Write-Debug "`$mySqlServer = '$mySqlServer'" - -$customRuleName = "devbox_$((Get-Date).ToString("yyyy-mm-dd_HH-MM-ss"))" - -Write-Debug "`$customRuleName = '$customRuleName'" - -# Resolves permission constraint that prevents the deploymentScript from running this command -# https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/134 -az sql server update -n $mySqlServer -g $ResourceGroupName --set publicNetworkAccess="Enabled" > $null - -Write-Debug "Change Rule" - -az sql server firewall-rule create -g $ResourceGroupName -s $mySqlServer -n $customRuleName --start-ip-address $myIpAddress --end-ip-address $myIpAddress - -Write-Host "Successful" -ForegroundColor Green -NoNewline; Write-Host " this client's IP address was added to Azure SQL Firewall" \ No newline at end of file diff --git a/infra/localDevScripts/addLocalIPToSqlFirewall.sh b/infra/localDevScripts/addLocalIPToSqlFirewall.sh deleted file mode 100644 index 2db6aa36..00000000 --- a/infra/localDevScripts/addLocalIPToSqlFirewall.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/bin/bash - -# This script is part of the sample's workflow for giving developers access -# to the resources that were deployed. Note that a better solution, beyond -# the scope of this demo, would be to associate permissions based on -# Azure AD groups so that all team members inherit access from Azure AD. -# https://learn.microsoft.com/en-us/azure/active-directory/roles/groups-concept -# -# This code may be repurposed for your scenario as desired -# but is not covered by the guidance in this content. - -POSITIONAL_ARGS=() - -while [[ $# -gt 0 ]]; do - case $1 in - --resource-group|-g) - resourceGroupName="$2" - shift # past argument - shift # past value - ;; - --help*) - echo "" - echo "" - echo "" - echo "Command" - echo " addLocalIPToSqlFirewall.sh : Makes a web request to a public site to retrieve the user's public IP address and then adds that IP address to the Azure SQL Database Firewall as an allowed connection." - echo "" - echo "Arguments" - echo " --resource-group -g : Name of resource group containing the environment that was created by the azd command." - echo "" - exit 1 - ;; - -*|--*) - echo "Unknown option $1" - exit 1 - ;; - *) - POSITIONAL_ARGS+=("$1") # save positional arg - shift # past argument - ;; - esac -done - -green='\033[0;32m' -yellow='\e[0;33m' -red='\e[1;31m' -clear='\033[0m' - -if [[ ${#resourceGroupName} -eq 0 ]]; then - printf "${red}FATAL ERROR:${clear} Missing required parameter --resource-group" - echo "" - - exit 6 -fi - -myIpAddress=$(wget -q -O - ipinfo.io/ip) -# updated az resource selection to filter to first based on https://github.com/Azure/azure-cli/issues/25214 -mySqlServer=$(az resource list -g $resourceGroupName --query "[?type=='Microsoft.Sql/servers'].name " -o tsv) - -# Resolves permission constraint that prevents the deploymentScript from running this command -# https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/134 -az sql server update -n $mySqlServer -g $resourceGroupName --set publicNetworkAccess="Enabled" > /dev/null - -az sql server firewall-rule create -g $resourceGroupName -s $mySqlServer -n "devbox_$(date +"%Y-%m-%d_%I-%M-%S")" --start-ip-address $myIpAddress --end-ip-address $myIpAddress - -printf "${green}Finished successfully${clear}" -echo "" - -exit 0 \ No newline at end of file diff --git a/infra/localDevScripts/getSecretsForLocalDev.ps1 b/infra/localDevScripts/getSecretsForLocalDev.ps1 deleted file mode 100644 index 777dc22f..00000000 --- a/infra/localDevScripts/getSecretsForLocalDev.ps1 +++ /dev/null @@ -1,138 +0,0 @@ -#Requires -Version 7.0 - -<# -.SYNOPSIS - Will show a json snippet you can save in Visual Studio secrets.json file to run the code locally. -.DESCRIPTION - Supports the local development workflow by retrieving the secrets and configuration necessary - to run the web app sample locally. The secrets and configurations displayed as outputs from this - command should be copied into a secrets.json file to keep secrets out of source control. - - -.PARAMETER ResourceGroupName - Name of resource group containing the environment that was created by the azd command. -.PARAMETER Web - Print the json snippet for the api web app. Defaults to False. -.PARAMETER Api - Print the json snippet for the front-end web app. Defaults to False. -#> - -Param( - [Alias("g")] - [Parameter(Mandatory = $true, HelpMessage = "Name of the resource group that was created by azd")] - [String]$ResourceGroupName, - - [Alias("w")] - [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $false)] - [switch]$Web, - - [Alias("a")] - [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $false)] - [switch]$Api -) - -$web_app = $Web -$api_app = $Api - -$groupExists = (az group exists -n $ResourceGroupName) -if ($groupExists -eq 'false') { - Write-Error "FATAL ERROR: $ResourceGroupName could not be found in the current subscription" - exit 6 -} -else { - Write-Debug "Found resource group named: $ResourceGroupName" -} - -Write-Debug "`$web_app=$web_app" -Write-Debug "`$api_app=$api_app" - -if ( $web_app -eq $false -and $api_app -eq $false ) { - Write-Error 'FATAL ERROR: Missing required flag -Web or -Api' - exit 7 -} - -Write-Debug "" -Write-Debug "Inputs" -Write-Debug "----------------------------------------------" -Write-Debug "ResourceGroupName='$ResourceGroupName'" -Write-Debug "" - -# assumes there is only one vault deployed to this resource group that will match this filter -$keyVaultName = (az keyvault list -g "$ResourceGroupName" --query "[? name.starts_with(@,'rc-') ].name" -o tsv) - -$appConfigSvcName = (az resource list -g $ResourceGroupName --query "[? type== 'Microsoft.AppConfiguration/configurationStores' ].name" -o tsv) - -$appConfigUri = (az appconfig show -n $appConfigSvcName -g $ResourceGroupName --query "endpoint" -o tsv 2> $null) - -Write-Debug "Derived inputs" -Write-Debug "----------------------------------------------" -Write-Debug "keyVaultName=$keyVaultName" -Write-Debug "appConfigSvcName=$appConfigSvcName" - -### -# Step1: Print json snippet for web app -### - -if ($web_app) { - # get 'AzureAd:ClientSecret' from Key Vault - $frontEndAzureAdClientSecret = (az keyvault secret show --vault-name $keyVaultName --name AzureAd--ClientSecret -o tsv --query "value" 2> $null) - - # get 'App:RedisCache:ConnectionString' from Key Vault - $frontEndRedisConnStr = (az keyvault secret show --vault-name $keyVaultName --name App--RedisCache--ConnectionString -o tsv --query "value" 2> $null) - - # get 'App:RelecloudApi:AttendeeScope' from App Configuration Svc - $frontEndAttendeeScope = (az appconfig kv show -n $appConfigSvcName --key App:RelecloudApi:AttendeeScope -o tsv --query value 2> $null) - - # get 'App:RelecloudApi:BaseUri' from App Configuration svc - # frontEndBaseUri=$(az appconfig kv show -n $appConfigSvcName --key App:RelecloudApi:BaseUri -o tsv --query value 2> $null) - $frontEndBaseUri = "https://localhost:7242" - - # get 'AzureAd:ClientId' from App Configuration svc - $frontEndAzureAdClientId = (az appconfig kv show -n $appConfigSvcName --key AzureAd:ClientId -o tsv --query value 2> $null) - - # get 'AzureAd:TenantId' from App Configuration svc - $frontEndAzureAdTenantId = (az appconfig kv show -n $appConfigSvcName --key AzureAd:TenantId -o tsv --query value 2> $null) - - Write-Host "" - Write-Host "{" - Write-Host " `"App:AppConfig:Uri`": `"$appConfigUri`"," - Write-Host " `"App:RedisCache:ConnectionString`": `"$frontEndRedisConnStr`"," - Write-Host " `"App:RelecloudApi:AttendeeScope`": `"$frontEndAttendeeScope`"," - Write-Host " `"App:RelecloudApi:BaseUri`": `"$frontEndBaseUri`"," - Write-Host " `"AzureAd:ClientId`": `"$frontEndAzureAdClientId`"," - Write-Host " `"AzureAd:ClientSecret`": `"$frontEndAzureAdClientSecret`"," - Write-Host " `"AzureAd:TenantId`": `"$frontEndAzureAdTenantId`"" - Write-Host "}" - Write-Host "" - Write-Host "Successful" -ForegroundColor Green -NoNewline; Write-Host " use these values to start debugging locally" -} - -if ($api_app) { - # App:StorageAccount:ConnectionString - $apiAppQueueConnStr = (az keyvault secret show --vault-name $keyVaultName --name App--StorageAccount--ConnectionString -o tsv --query "value" 2> $null) - - # get 'App:RedisCache:ConnectionString' from Key Vault - $apiAppRedisConnStr = (az keyvault secret show --vault-name $keyVaultName --name App--RedisCache--ConnectionString -o tsv --query "value" 2> $null) - - # get 'Api:AzureAd:ClientId' from App Configuration svc - $apiAppAzureAdClientId = (az appconfig kv show -n $appConfigSvcName --key Api:AzureAd:ClientId -o tsv --query value 2> $null) - - # get 'Api:AzureAd:TenantId' from App Configuration svc - $apiAppAzureAdTenantId = (az appconfig kv show -n $appConfigSvcName --key Api:AzureAd:TenantId -o tsv --query value 2> $null) - - # App:SqlDatabase:ConnectionString - $apiAppSqlConnStr = (az appconfig kv show -n $appConfigSvcName --key App:SqlDatabase:ConnectionString -o tsv --query value 2> $null) - - Write-Host "" - Write-Host "{" - Write-Host " `"Api:AppConfig:Uri`": `"$appConfigUri`"," - Write-Host " `"Api:AzureAd:ClientId`": `"$apiAppAzureAdClientId`"," - Write-Host " `"Api:AzureAd:TenantId`": `"$apiAppAzureAdTenantId`"," - Write-Host " `"App:RedisCache:ConnectionString`": `"$apiAppRedisConnStr`"," - Write-Host " `"App:SqlDatabase:ConnectionString`": `"$apiAppSqlConnStr`"," - Write-Host " `"App:StorageAccount:QueueConnectionString`": `"$apiAppQueueConnStr`"" - Write-Host "}" - Write-Host "" - - Write-Host "Successful" -ForegroundColor Green -NoNewline; Write-Host " use these values to start debugging locally" -} diff --git a/infra/localDevScripts/getSecretsForLocalDev.sh b/infra/localDevScripts/getSecretsForLocalDev.sh deleted file mode 100644 index 4147873d..00000000 --- a/infra/localDevScripts/getSecretsForLocalDev.sh +++ /dev/null @@ -1,184 +0,0 @@ -#!/bin/bash - -# This script is part of the sample's workflow for giving developers access -# to the resources that were deployed. Note that a better solution, beyond -# the scope of this demo, would be to associate permissions based on -# Azure AD groups so that all team members inherit access from Azure AD. -# https://learn.microsoft.com/en-us/azure/active-directory/roles/groups-concept -# -# This code may be repurposed for your scenario as desired -# but is not covered by the guidance in this content. - -web_app='' -api_app='' -debug='' - -POSITIONAL_ARGS=() - -while [[ $# -gt 0 ]]; do - case $1 in - --resource-group|-g) - resourceGroupName="$2" - shift # past argument - shift # past value - ;; - --web|-w) - web_app=true - shift # past argument - ;; - --api|-a) - api_app=true - shift # past argument - ;; - --debug) - debug=true - shift # past argument - ;; - --help*) - echo "" - echo "" - echo "" - echo "Command" - echo " getSecretsForLocalDev.sh : Will show a json snippet you can save in Visual Studio secrets.json file to run the code locally." - echo "" - echo "Arguments" - echo " --resource-group -g : Name of resource group containing the environment that was created by the azd command." - echo "" - echo " Must select one or more of the following flags" - echo " --api -a : Print the json snippet for the api web app. Defaults to False." - echo " --web -w : Print the json snippet for the front-end web app. Defaults to False." - echo "" - exit 1 - ;; - -*|--*) - echo "Unknown option $1" - exit 1 - ;; - *) - POSITIONAL_ARGS+=("$1") # save positional arg - shift # past argument - ;; - esac -done - -green='\033[0;32m' -yellow='\e[0;33m' -red='\e[1;31m' -clear='\033[0m' - -if [[ ${#resourceGroupName} -eq 0 ]]; then - printf "${red}FATAL ERROR:${clear} Missing required parameter --resource-group" - echo "" - - exit 6 -fi - -if [[ $web_app == '' && $api_app == '' ]]; then - printf "${red}FATAL ERROR:${clear} Missing required flag --web or --api" - echo "" - - exit 7 -fi - -if [[ $debug ]]; then - echo "" - echo "Inputs" - echo "----------------------------------------------" - echo "resourceGroupName='$resourceGroupName'" - echo "" -fi - -# assumes there is only one vault deployed to this resource group that will match this filter -keyVaultName=$(az keyvault list -g "$resourceGroupName" --query "[?name.starts_with(@,'rc-')].name " -o tsv) - -appConfigSvcName=$(az resource list -g $resourceGroupName --query "[?type=='Microsoft.AppConfiguration/configurationStores'].name " -o tsv) - -appConfigUri=$(az appconfig show -n $appConfigSvcName -g $resourceGroupName --query "endpoint" -o tsv 2> /dev/null) - -if [[ $debug ]]; then - echo "Derived inputs" - echo "----------------------------------------------" - echo "keyVaultName=$keyVaultName" - echo "appConfigSvcName=$appConfigSvcName" - - read -n 1 -r -s -p "Press any key to continue..." - echo '' - echo "..." -fi - -### -# Step1: Print json snippet for web app -### - -if [[ $web_app ]]; then - # get 'AzureAd:ClientSecret' from Key Vault - frontEndAzureAdClientSecret=$(az keyvault secret show --vault-name $keyVaultName --name AzureAd--ClientSecret -o tsv --query "value" 2> /dev/null) - - # get 'App:RedisCache:ConnectionString' from Key Vault - frontEndRedisConnStr=$(az keyvault secret show --vault-name $keyVaultName --name App--RedisCache--ConnectionString -o tsv --query "value" 2> /dev/null) - - # get 'App:RelecloudApi:AttendeeScope' from App Configuration Svc - frontEndAttendeeScope=$(az appconfig kv show -n $appConfigSvcName --key App:RelecloudApi:AttendeeScope -o tsv --query value 2> /dev/null) - - # get 'App:RelecloudApi:BaseUri' from App Configuration svc - # frontEndBaseUri=$(az appconfig kv show -n $appConfigSvcName --key App:RelecloudApi:BaseUri -o tsv --query value 2> /dev/null) - frontEndBaseUri="https://localhost:7242" - - # get 'AzureAd:ClientId' from App Configuration svc - frontEndAzureAdClientId=$(az appconfig kv show -n $appConfigSvcName --key AzureAd:ClientId -o tsv --query value 2> /dev/null) - - # get 'AzureAd:TenantId' from App Configuration svc - frontEndAzureAdTenantId=$(az appconfig kv show -n $appConfigSvcName --key AzureAd:TenantId -o tsv --query value 2> /dev/null) - - echo "" - echo "{" - echo " \"App:AppConfig:Uri\": \"$appConfigUri\"," - echo " \"App:RedisCache:ConnectionString\": \"$frontEndRedisConnStr\"," - echo " \"App:RelecloudApi:AttendeeScope\": \"$frontEndAttendeeScope\"," - echo " \"App:RelecloudApi:BaseUri\": \"$frontEndBaseUri\"," - echo " \"AzureAd:ClientId\": \"$frontEndAzureAdClientId\"," - echo " \"AzureAd:ClientSecret\": \"$frontEndAzureAdClientSecret\"," - echo " \"AzureAd:TenantId\": \"$frontEndAzureAdTenantId\"" - echo "}" - echo "" - - printf "${green}Finished successfully${clear}" - echo "" - - exit 0 -fi - - -if [[ $api_app ]]; then - - # App:StorageAccount:ConnectionString - apiAppQueueConnStr=$(az keyvault secret show --vault-name $keyVaultName --name App--StorageAccount--ConnectionString -o tsv --query "value" 2> /dev/null) - - # get 'App:RedisCache:ConnectionString' from Key Vault - apiAppRedisConnStr=$(az keyvault secret show --vault-name $keyVaultName --name App--RedisCache--ConnectionString -o tsv --query "value" 2> /dev/null) - - # get 'Api:AzureAd:ClientId' from App Configuration svc - apiAppAzureAdClientId=$(az appconfig kv show -n $appConfigSvcName --key Api:AzureAd:ClientId -o tsv --query value 2> /dev/null) - - # get 'Api:AzureAd:TenantId' from App Configuration svc - apiAppAzureAdTenantId=$(az appconfig kv show -n $appConfigSvcName --key Api:AzureAd:TenantId -o tsv --query value 2> /dev/null) - - # App:SqlDatabase:ConnectionString - apiAppSqlConnStr=$(az appconfig kv show -n $appConfigSvcName --key App:SqlDatabase:ConnectionString -o tsv --query value 2> /dev/null) - - echo "" - echo "{" - echo " \"Api:AppConfig:Uri\": \"$appConfigUri\"," - echo " \"Api:AzureAd:ClientId\": \"$apiAppAzureAdClientId\"," - echo " \"Api:AzureAd:TenantId\": \"$apiAppAzureAdTenantId\"," - echo " \"App:RedisCache:ConnectionString\": \"$apiAppRedisConnStr\"," - echo " \"App:SqlDatabase:ConnectionString\": \"$apiAppSqlConnStr\"," - echo " \"App:StorageAccount:QueueConnectionString\": \"$apiAppQueueConnStr\"" - echo "}" - echo "" - - printf "${green}Finished successfully${clear}" - echo "" - - exit 0 -fi diff --git a/infra/localDevScripts/makeSqlUserAccount.ps1 b/infra/localDevScripts/makeSqlUserAccount.ps1 deleted file mode 100644 index a454509f..00000000 --- a/infra/localDevScripts/makeSqlUserAccount.ps1 +++ /dev/null @@ -1,127 +0,0 @@ -#Requires -Version 7.0 - -<# -.SYNOPSIS - Will make the SQL user account required to authenticate with Azure AD to Azure SQL Database. -.DESCRIPTION - Will make the SQL user account required to authenticate with Azure AD to Azure SQL Database. - - -.PARAMETER ResourceGroupName - Name of resource group containing the environment that was created by the azd command. -#> - -Param( - [Alias("g")] - [Parameter(Mandatory = $true, HelpMessage = "Name of the resource group that was created by azd")] - [String]$ResourceGroupName -) - -# this will reset the SQL password because the password is not saved during set up -Write-Host "WARNING: this script will reset the password for the SQL Admin on Azure SQL Server." -Write-Host " Since this scenario uses Managed Identity, and no one accesses the database with this password, there should be no impact" -Write-Host "Use command interrupt if you would like to abort" -Read-Host -Prompt "Press enter if you wish to proceed" > $null -Write-Host "..." - -if (Get-Module -ListAvailable -Name SqlServer) { - Write-Debug "SQL Already Installed" -} -else { - try { - Install-Module -Name SqlServer -AllowClobber -Confirm:$False -Force - } - catch [Exception] { - $_.message - exit - } -} - -Import-Module -Name SqlServer - -if ($ResourceGroupName -eq "-rg") { - Write-Error "FATAL ERROR: $ResourceGroupName could not be found in the current subscription" - exit 5 -} - -$groupExists = (az group exists -n $ResourceGroupName) -if ($groupExists -eq 'false') { - Write-Error "FATAL ERROR: $ResourceGroupName could not be found in the current subscription" - exit 6 -} -else { - Write-Debug "Found resource group named: $ResourceGroupName" -} - -$azureAdUsername = (az ad signed-in-user show --query userPrincipalName -o tsv) -Write-Debug "`$azureAdUsername='$azureAdUsername'" -$objectIdForCurrentUser = (az ad signed-in-user show --query id -o tsv) -Write-Debug "`$objectIdForCurrentUser='$objectIdForCurrentUser'" - -# updated az resource selection to filter to first based on https://github.com/Azure/azure-cli/issues/25214 -$databaseServer = (az resource list -g $ResourceGroupName --query "[?type=='Microsoft.Sql/servers'].name | [0]" -o tsv ) -Write-Debug "`$databaseServer='$databaseServer'" - -$databaseServerFqdn = (az sql server show -n $databaseServer -g $ResourceGroupName --query fullyQualifiedDomainName -o tsv) -Write-Debug "`$databaseServerFqdn='$databaseServerFqdn'" - -# updated az resource selection to filter to first based on https://github.com/Azure/azure-cli/issues/25214 -$databaseName = (az resource list -g $ResourceGroupName --query "[?type=='Microsoft.Sql/servers/databases' && name.ends_with(@, 'database')].tags.displayName | [0]" -o tsv) -Write-Debug "`$databaseName='$databaseName'" - - -# disable Azure AD only admin access -# the current user does not have access to login to SQL so we need to use the SQL Admin account -az sql server ad-only-auth disable -n $databaseServer -g $ResourceGroupName - -# https://learn.microsoft.com/en-us/sql/relational-databases/security/password-policy?view=sql-server-ver16 -$TokenSet = @{ - U = [Char[]]'ABCDEFGHIJKLMNOPQRSTUVWXYZ' - L = [Char[]]'abcdefghijklmnopqrstuvwxyz' - N = [Char[]]'0123456789' - S = [Char[]]'!#$%' -} - -$Upper = Get-Random -Count 5 -InputObject $TokenSet.U -$Lower = Get-Random -Count 5 -InputObject $TokenSet.L -$Number = Get-Random -Count 5 -InputObject $TokenSet.N -$Special = Get-Random -Count 5 -InputObject $TokenSet.S - -$StringSet = $Upper + $Lower + $Number + $Special - -# new random password -$sqlPassword = ((Get-Random -Count 15 -InputObject $StringSet) -join '') -$sqlAdmin = (az sql server show --name $databaseServer -g $ResourceGroupName --query "administratorLogin" -o tsv) - -az sql server update -n $databaseServer -g $ResourceGroupName -p "$sqlPassword" - -# translate applicationId into SID -[guid]$guid = [System.Guid]::Parse($objectIdForCurrentUser) - -foreach ($byte in $guid.ToByteArray()) { - $byteGuid += [System.String]::Format("{0:X2}", $byte) -} -$Sid = "0x" + $byteGuid - -# Prepare SQL cmd to CREATE USER -$createUserSQL = "IF NOT EXISTS (SELECT * FROM sys.database_principals WHERE name = N'$azureAdUsername') create user [$azureAdUsername] with sid = $Sid, type = E;" - -# Connect as SQL Admin acct and execute SQL cmd -Invoke-Sqlcmd -ServerInstance $databaseServerFqdn -database $databaseName -Username $sqlAdmin -Password $sqlPassword -Query $createUserSQL -Write-Debug "Created user" - -Invoke-Sqlcmd -ServerInstance $databaseServerFqdn -database 'master' -Username $sqlAdmin -Password $sqlPassword -Query $createUserSQL -Write-Debug "Created for root db" - -# Prepare SQL cmd to grant db_owner role -$grantDbOwner = "IF NOT EXISTS (SELECT * FROM sys.database_principals p JOIN sys.database_role_members db_owner_role ON db_owner_role.member_principal_id = p.principal_id JOIN sys.database_principals role_names ON role_names.principal_id = db_owner_role.role_principal_id AND role_names.[name] = 'db_owner' WHERE p.[name]=N'$azureAdUsername') ALTER ROLE db_owner ADD MEMBER [$azureAdUsername];" - -# Connect as SQL Admin acct and execute SQL cmd -Invoke-Sqlcmd -ServerInstance $databaseServerFqdn -database $databaseName -Username $sqlAdmin -Password $sqlPassword -Query $grantDbOwner - -Write-Debug "Granted db_owner" - -# enable Azure AD only admin access -az sql server ad-only-auth enable -n $databaseServer -g $ResourceGroupName - -Write-Host "Successful" -ForegroundColor Green -NoNewline; Write-Host " an account for the current user was created in Azure SQL" \ No newline at end of file diff --git a/infra/localDevScripts/makeSqlUserAccount.sh b/infra/localDevScripts/makeSqlUserAccount.sh deleted file mode 100644 index 3b89d576..00000000 --- a/infra/localDevScripts/makeSqlUserAccount.sh +++ /dev/null @@ -1,144 +0,0 @@ -#!/bin/bash - -# This script is part of the sample's workflow for giving developers access -# to the resources that were deployed. Note that a better solution, beyond -# the scope of this demo, would be to associate permissions based on -# Azure AD groups so that all team members inherit access from Azure AD. -# https://learn.microsoft.com/en-us/azure/active-directory/roles/groups-concept -# -# This code may be repurposed for your scenario as desired -# but is not covered by the guidance in this content. - -POSITIONAL_ARGS=() - -while [[ $# -gt 0 ]]; do - case $1 in - --resource-group|-g) - resourceGroupName="$2" - shift # past argument - shift # past value - ;; - --debug) - debug=true - shift # past argument - ;; - --help*) - echo "" - echo "" - echo "" - echo "Command" - echo " makeSqlUserAccount.sh : Will make the SQL user account required to authenticate with Azure AD to Azure SQL Database." - echo "" - echo "Arguments" - echo " --resource-group -g : Name of resource group containing the environment that was created by the azd command." - echo "" - exit 1 - ;; - -*|--*) - echo "Unknown option $1" - exit 1 - ;; - *) - POSITIONAL_ARGS+=("$1") # save positional arg - shift # past argument - ;; - esac -done - -green='\033[0;32m' -yellow='\e[0;33m' -red='\e[1;31m' -clear='\033[0m' - -if [[ ${#resourceGroupName} -eq 0 ]]; then - printf "${red}FATAL ERROR:${clear} Missing required parameter --resource-group" - echo "" - - exit 6 -fi - -# this will reset the SQL password because the password is not saved during set up -printf "${yellow}WARNING:${clear} this script will reset the password for the SQL Admin on Azure SQL Server." -echo "" -echo " Since this scenario uses Managed Identity, and no one accesses the database with this password, there should be no impact" -echo "Use command interrupt if you would like to abort" -read -n 1 -r -s -p "Press any key to continue..." -echo '' -echo "..." - -if ! [ -x "$(command -v ./sqlcmd)" ]; then - echo 'installing sqlcmd' - - wget https://github.com/microsoft/go-sqlcmd/releases/download/v0.9.1/sqlcmd-v0.9.1-linux-x64.tar.bz2 - tar x -f sqlcmd-v0.9.1-linux-x64.tar.bz2 - -else - echo 'found sqlcmd' -fi - -azureAdUsername=$(az ad signed-in-user show --query userPrincipalName -o tsv) - -objectIdForCurrentUser=$(az ad signed-in-user show --query id -o tsv) - -# using json format bypasses issue with tsv format observed in this issue -# https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/202 -databaseServer=$(az resource list -g $resourceGroupName --query "[? type=='Microsoft.Sql/servers'].name" -o tsv) - -databaseServerFqdn=$(az sql server show -n $databaseServer -g $resourceGroupName --query fullyQualifiedDomainName -o tsv) - -# updated az resource selection to filter to first based on https://github.com/Azure/azure-cli/issues/25214 -databaseName=$(az resource list -g $resourceGroupName --query "[?type=='Microsoft.Sql/servers/databases' && name.ends_with(@, 'database')].tags.displayName" -o tsv) - -sqlAdmin=$(az sql server show --name $databaseServer -g $resourceGroupName --query "administratorLogin" -o tsv) - -# new random password -# https://learn.microsoft.com/en-us/sql/relational-databases/security/password-policy?view=sql-server-ver16 -sqlPassword=$(sed "s/[^a-zA-Z0-9\!#\$%*()]//g" <<< $(cat /dev/urandom | tr -dc 'a-zA-Z0-9!@#$%*()-+' | fold -w 32 | head -n 1)) - -echo "connecting to: $databaseServerFqdn" -echo "opening: $databaseName" - -# disable Azure AD only admin access -az sql server ad-only-auth disable -n $databaseServer -g $resourceGroupName - -az sql server update -n $databaseServer -g $resourceGroupName -p $sqlPassword - -cat < createSqlUser.sql -DECLARE @myObjectId varchar(100) = '$objectIdForCurrentUser' -DECLARE @sid binary(16) = CAST(CAST(@myObjectId as uniqueidentifier) as binary(16)) - -DECLARE @sql nvarchar(max) = N'CREATE user [$azureAdUsername] WITH TYPE = E, SID = 0x' + convert(varchar(1000), @sid, 2); - -IF NOT EXISTS (SELECT * FROM sys.database_principals WHERE name = N'$azureAdUsername') - EXEC sys.sp_executesql @sql; - -SCRIPT_END - -export SQLCMDPASSWORD=$sqlPassword -./sqlcmd -S "tcp:$databaseServerFqdn,1433" -U $sqlAdmin -i createSqlUser.sql - -cat < updateSqlUserPerms.sql -DECLARE @myObjectId varchar(100) = '$objectIdForCurrentUser' -DECLARE @sid binary(16) = CAST(CAST(@myObjectId as uniqueidentifier) as binary(16)) - -DECLARE @sql nvarchar(max) = N'CREATE user [$azureAdUsername] WITH TYPE = E, SID = 0x' + convert(varchar(1000), @sid, 2); - -IF NOT EXISTS (SELECT * FROM sys.database_principals WHERE name = N'$azureAdUsername') - EXEC sys.sp_executesql @sql; - -IF NOT EXISTS (SELECT * FROM sys.database_principals p JOIN sys.database_role_members db_owner_role ON db_owner_role.member_principal_id = p.principal_id JOIN sys.database_principals role_names ON role_names.principal_id = db_owner_role.role_principal_id AND role_names.[name] = 'db_owner' WHERE p.[name]=N'$azureAdUsername') - ALTER ROLE db_owner ADD MEMBER [$azureAdUsername]; - -SCRIPT_END - -./sqlcmd -S "tcp:$databaseServerFqdn,1433" -d $databaseName -U $sqlAdmin -i updateSqlUserPerms.sql - -export SQLCMDPASSWORD=clear - -# enable Azure AD only admin access -az sql server ad-only-auth enable -n $databaseServer -g $resourceGroupName - -printf "${green}Finished successfully${clear}" -echo "" - -exit 0 \ No newline at end of file diff --git a/infra/logAnalyticsWorkspaceForDiagnostics.bicep b/infra/logAnalyticsWorkspaceForDiagnostics.bicep deleted file mode 100644 index 594b1648..00000000 --- a/infra/logAnalyticsWorkspaceForDiagnostics.bicep +++ /dev/null @@ -1,24 +0,0 @@ -@minLength(1) -@description('Name for a log analytics workspace that will collect diagnostic info for Key Vault and Front Door') -param logAnalyticsWorkspaceNameForDiagnstics string - -@minLength(1) -@description('Primary location for all resources. Should specify an Azure region. e.g. `eastus2` ') -param location string - -@description('An object collection that contains annotations to describe the deployed azure resources to improve operational visibility') -param tags object - -resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2021-06-01' = { - name: logAnalyticsWorkspaceNameForDiagnstics - location: location - tags: tags - properties: { - retentionInDays: 30 - sku: { - name: 'PerGB2018' - } - } -} - -output LOG_WORKSPACE_ID string = logAnalyticsWorkspace.id diff --git a/infra/main.bicep b/infra/main.bicep index 319b3d8a..a15c7c8b 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -1,224 +1,568 @@ targetScope = 'subscription' -@minLength(1) -@maxLength(64) -@description('Name of the the environment which is used to generate a short unqiue hash used in all resources.') -param name string +// ======================================================================== +// +// Relecloud Scenario of the Reliable Web Application (RWA) +// Infrastructure description +// Copyright (C) 2023 Microsoft, Inc. +// +// ======================================================================== -@minLength(1) -@description('Primary location for all resources. Should specify an Azure region. e.g. `eastus2` ') +/* +** Parameters that are provided by Azure Developer CLI. +** +** If you are running this with bicep, use the main.parameters.json +** and overrides to generate these. +*/ + + +@minLength(3) +@maxLength(18) +@description('The environment name - a unique string that is used to identify THIS deployment.') +param environmentName string + +@minLength(3) +@description('The name of the Azure region that will be used for the deployment.') param location string -@minLength(1) -@description('Id of the user or app to assign application roles') -param principalId string +@minLength(3) +@description('The email address of the owner of the workload.') +param ownerEmail string + +@minLength(3) +@description('The name of the owner of the workload.') +param ownerName string -@description('Will select production ready SKUs when `true`') -param isProd string = 'false' +@description('The ID of the running user or service principal. This will be set as the owner when needed.') +param principalId string = '' -@description('Should specify an Azure region, if not set to none, to trigger multiregional deployment. The second region should be different than the `location` . e.g. `westus3`') -param secondaryAzureLocation string +@allowed([ 'ServicePrincipal', 'User' ]) +@description('The type of the principal specified in \'principalId\'') +param principalType string = 'ServicePrincipal' +/* +** Passwords - specify these! +*/ @secure() -@description('Specifies a password that will be used to secure the Azure SQL Database') -param azureSqlPassword string = '' +@minLength(8) +@description('The password for the SQL administrator account. This will be used for the jump box, SQL server, and anywhere else a password is needed for creating a resource.') +param databasePassword string + +@secure() +@minLength(12) +@description('The password for the jump box administrator account.') +param jumpboxAdministratorPassword string + -// Adding RBAC permissions via the script enables the sample to work around a permission propagation issue outlined in the issue -// https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/138 -@minLength(1) -@description('When the deployment is executed by a user we give the principal RBAC access to key vault') -param principalType string +@minLength(8) +@description('The username for the administrator account. This will be used for the jump box, SQL server, and anywhere else a password is needed for creating a resource.') +param administratorUsername string = 'azureadmin' /* -The following Azure AD parameters enable the code to reuse an existing app registration -https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/160 -These values are created by the createAppRegistration.ps1 script found in deploy-solution.md -These values are not optional when the code runs, but they are optional at deployment time -as you may choose to re-use an existing app registration or choose to create a new one. +** Parameters that make changes to the deployment based on requirements. They mostly have +** "reasonable" defaults such that a developer can just run "azd up" and get a working dev +** system. */ -@description('A scope used by the front-end public web app to get authorized access to the public web api. Looks similar to api://33333333-bbbb-4444-cccc-555555555555/relecloud.api') -param azureAdApiScopeFrontEnd string -@description('A unique identifier of the API web app') -param azureAdClientIdForBackEnd string +// Settings for setting up a build agent for Azure DevOps +@description('The URL of the Azure DevOps organization. If this and the adoToken is provided, then an Azure DevOps build agent will be deployed.') +param adoOrganizationUrl string = '' + +@description('The access token for the Azure DevOps organization. If this and the adoOrganizationUrl is provided, then an Azure DevOps build agent will be deployed.') +param adoToken string = '' + +// Settings for setting up a build agent for GitHub Actions +@description('The URL of the GitHub repository. If this and the githubToken is provided, then a GitHub Actions build agent will be deployed.') +param githubRepositoryUrl string = '' + +@description('The personal access token for the GitHub repository. If this and the githubRepositoryUrl is provided, then a GitHub Actions build agent will be deployed.') +param githubToken string = '' + +// The IP address for the current system. This is used to set up the firewall +// for Key Vault and SQL Server if in development mode. +@description('The IP address of the current system. This is used to set up the firewall for Key Vault and SQL Server if in development mode.') +param clientIpAddress string = '' + +// A differentiator for the environment. This is used in CI/CD testing to ensure +// that each environment is unique. +@description('A differentiator for the environment. Set this to a build number or date to ensure that the resource groups and resources are unique.') +param differentiator string = 'none' + +// Environment type - dev or prod; affects sizing and what else is deployed alongside. +@allowed([ 'dev', 'prod' ]) +@description('The set of pricing SKUs to choose for resources. \'dev\' uses cheaper SKUs by avoiding features that are unnecessary for writing code.') +param environmentType string = 'dev' + +// Deploy Hub Resources; if auto, then +// - environmentType == dev && networkIsolation == true => true +@allowed([ 'auto', 'false', 'true' ]) +@description('Deploy hub resources. Normally, the hub resources are not deployed since the app developer wouldn\'t have access, but we also need to be able to deploy a complete solution') +param deployHubNetwork string = 'auto' + +// Network isolation - determines if the app is deployed in a VNET or not. +// if environmentType == prod => true +// if environmentType == dev => false +@allowed([ 'auto', 'false', 'true' ]) +@description('Deploy the application in network isolation mode. \'auto\' will deploy in isolation only if deploying to production.') +param networkIsolation string = 'auto' + +// Secondary Azure location - provides the name of the 2nd Azure region. Blank by default to represent a single region deployment. +@description('Should specify an Azure region. If not set to empty string then deploy to single region, else trigger multiregional deployment. The second region should be different than the `location`. e.g. `westus3`') +param azureSecondaryLocation string = '' + +// Common App Service Plan - determines if a common app service plan should be deployed. +// auto = yes in dev, no in prod. +@allowed([ 'auto', 'false', 'true' ]) +@description('Should we deploy a common app service plan, used by both the API and WEB app services? \'auto\' will deploy a common app service plan in dev, but separate plans in prod.') +param useCommonAppServicePlan string = 'auto' + +// ======================================================================== +// VARIABLES +// ======================================================================== + +var prefix = '${environmentName}-${environmentType}' + +// Boolean to indicate the various values for the deployment settings +var isMultiLocationDeployment = azureSecondaryLocation == '' ? false : true +var isProduction = environmentType == 'prod' +var isNetworkIsolated = networkIsolation == 'true' || (networkIsolation == 'auto' && isProduction) +var willDeployHubNetwork = isNetworkIsolated && (deployHubNetwork == 'true' || (deployHubNetwork == 'auto' && isProduction)) +var willDeployCommonAppServicePlan = useCommonAppServicePlan == 'true' || (useCommonAppServicePlan == 'auto' && !isProduction) + +// A unique token that is used as a differentiator for all resources. All resources within the +// same deployment will have the same token. +var primaryResourceToken = uniqueString(subscription().id, environmentName, environmentType, location, differentiator) +var secondaryResourceToken = uniqueString(subscription().id, environmentName, environmentType, azureSecondaryLocation, differentiator) + +var defaultDeploymentSettings = { + isMultiLocationDeployment: isMultiLocationDeployment + isProduction: isProduction + isNetworkIsolated: isNetworkIsolated + isPrimaryLocation: true + location: location + name: environmentName + principalId: principalId + principalType: principalType + resourceToken: primaryResourceToken + stage: environmentType + tags: { + 'azd-env-name': environmentName + 'azd-env-type': environmentType + 'azd-owner-email': ownerEmail + 'azd-owner-name': ownerName + ResourceToken: primaryResourceToken + } + workloadTags: { + WorkloadIdentifier: environmentName + WorkloadName: environmentName + Environment: environmentType + OwnerName: ownerEmail + ServiceClass: isProduction ? 'Silver' : 'Dev' + OpsCommitment: 'Workload operations' + } +} -@description('A unique identifier of the front-end web app') -param azureAdClientIdForFrontEnd string +var primaryNamingDeployment = defaultDeploymentSettings +var secondaryNamingDeployment = union(defaultDeploymentSettings, { + isPrimaryLocation: false + location: azureSecondaryLocation + resourceToken: secondaryResourceToken + tags: { + ResourceToken: secondaryResourceToken + } +}) + +var primaryDeployment = { + workloadTags: { + HubGroupName: isNetworkIsolated ? naming.outputs.resourceNames.hubResourceGroup : naming.outputs.resourceNames.resourceGroup + IsPrimaryLocation: 'true' + PrimaryLocation: location + SecondaryLocation: azureSecondaryLocation + } +} -@secure() -@description('A secret generated by Azure AD so that the web app can establish trust with Azure AD') -param azureAdClientSecretForFrontEnd string +var primaryDeploymentSettings = union(defaultDeploymentSettings, primaryDeployment) -@description('A unique identifier of the Azure AD tenant') -param azureAdTenantId string +var secondDeployment = { + location: azureSecondaryLocation + isPrimaryLocation: false + resourceToken: secondaryResourceToken + tags: { + ResourceToken: secondaryResourceToken + } + workloadTags: { + HubGroupName: isNetworkIsolated ? naming.outputs.resourceNames.hubResourceGroup : '' + IsPrimaryLocation: 'false' + PrimaryLocation: location + SecondaryLocation: azureSecondaryLocation + } +} -var isProdBool = isProd == 'true' ? true : false +// a copy of the deploymentSettings that is aware these details describe a second deployment +var secondaryDeploymentSettings = union(defaultDeploymentSettings, secondDeployment) -var tags = { - 'azd-env-name': name +var diagnosticSettings = { + logRetentionInDays: isProduction ? 30 : 3 + metricRetentionInDays: isProduction ? 7 : 3 + enableLogs: true + enableMetrics: true } -var isMultiLocationDeployment = secondaryAzureLocation == '' ? false : true +var installBuildAgent = isNetworkIsolated && ((!empty(adoOrganizationUrl) && !empty(adoToken)) || (!empty(githubRepositoryUrl) && !empty(githubToken))) -var primaryResourceGroupName = '${name}-rg' -var secondaryResourceGroupName = '${name}-secondary-rg' +var spokeAddressPrefixPrimary = '10.0.16.0/20' +var spokeAddressPrefixSecondary = '10.0.32.0/20' -var primaryResourceToken = toLower(uniqueString(subscription().id, primaryResourceGroupName, location)) -var secondaryResourceToken = toLower(uniqueString(subscription().id, secondaryResourceGroupName, secondaryAzureLocation)) +// ======================================================================== +// BICEP MODULES +// ======================================================================== -module logAnalyticsForDiagnostics 'logAnalyticsWorkspaceForDiagnostics.bicep' = { - name: 'logAnalyticsForDiagnostics' - scope: primaryResourceGroup +/* +** Every single resource can have a naming override. Overrides should be placed +** into the 'naming.overrides.jsonc' file. The output of this module drives the +** naming of all resources. +*/ +module naming './modules/naming.bicep' = { + name: '${prefix}-naming' params: { - tags: tags - location: location - logAnalyticsWorkspaceNameForDiagnstics: 'diagnostics-${primaryResourceToken}-log' + deploymentSettings: primaryNamingDeployment + differentiator: differentiator != 'none' ? differentiator : '' + overrides: loadJsonContent('./naming.overrides.jsonc') + primaryLocation: location } } -resource primaryResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { - name: primaryResourceGroupName - location: location - tags: tags +module naming2 './modules/naming.bicep' = { + name: '${prefix}-naming2' + params: { + deploymentSettings: secondaryNamingDeployment + differentiator: differentiator != 'none' ? '${differentiator}2' : '2' + overrides: loadJsonContent('./naming.overrides.jsonc') + primaryLocation: location + } } -module devOpsIdentitySetup './devOpsIdentitySetup.bicep' = { - name: 'devOpsIdentitySetup' - scope: primaryResourceGroup +/* +** Workload resources are organized into one of three resource groups: +** +** hubResourceGroup - contains the hub network resources +** spokeResourceGroup - contains the spoke network resources +** applicationResourceGroup - contains the application resources +** +** Not all of the resource groups are necessarily available - it +** depends on the settings. +*/ +module resourceGroups './modules/resource-groups.bicep' = { + name: '${prefix}-resource-groups' params: { - tags: tags - location: location - resourceToken: primaryResourceToken + deploymentSettings: primaryDeploymentSettings + resourceNames: naming.outputs.resourceNames + + // Settings + deployHubNetwork: willDeployHubNetwork } } -// temporary workaround for multiple resource group bug -// https://github.com/Azure/azure-dev/issues/690 -// `azd down` expects to be able to delete this resource because it was listed by the azure deployment output even when it is not deployed -resource secondaryResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { - name: secondaryResourceGroupName - location: isMultiLocationDeployment ? secondaryAzureLocation : location - tags: tags +module resourceGroups2 './modules/resource-groups.bicep' = if (isMultiLocationDeployment) { + name: '${prefix}-resource-groups2' + params: { + deploymentSettings: secondaryDeploymentSettings + resourceNames: naming2.outputs.resourceNames + + // Settings + deployHubNetwork: willDeployHubNetwork + } } -module primaryResources './resources.bicep' = { - name: 'primary-${primaryResourceToken}' - scope: primaryResourceGroup +/* +** Azure Monitor Resources +** +** Azure Monitor resources (Log Analytics Workspace and Application Insights) are +** homed in the hub network when it's available, and the application resource group +** when it's not available. +*/ +module azureMonitor './modules/azure-monitor.bicep' = { + name: '${prefix}-azure-monitor' params: { - azureSqlPassword: azureSqlPassword - devOpsManagedIdentityId: devOpsIdentitySetup.outputs.devOpsManagedIdentityId - isProd: isProdBool - location: location - principalId: principalId - principalType: principalType - resourceToken: primaryResourceToken - tags: tags - azureAdApiScopeFrontEnd: azureAdApiScopeFrontEnd - azureAdClientIdForBackEnd: azureAdClientIdForBackEnd - azureAdClientIdForFrontEnd: azureAdClientIdForFrontEnd - azureAdClientSecretForFrontEnd: azureAdClientSecretForFrontEnd - azureAdTenantId: azureAdTenantId + deploymentSettings: primaryDeploymentSettings + resourceNames: naming.outputs.resourceNames + resourceGroupName: willDeployHubNetwork ? resourceGroups.outputs.hub_resource_group_name : resourceGroups.outputs.application_resource_group_name } } -module devOpsIdentitySetupSecondary './devOpsIdentitySetup.bicep' = if (isMultiLocationDeployment) { - name: 'devOpsIdentitySetupSecondary' - scope: secondaryResourceGroup +/* +** Create the hub network, if requested. +** +** The hub network consists of the following resources +** +** The hub virtual network with subnets for Bastion Hosts and Firewall +** The bastion host +** The firewall +** A route table that is used within the spoke to reach the firewall +** +** We also set up a budget with cost alerting for the hub network (separate +** from the application budget) +*/ +module hubNetwork './modules/hub-network.bicep' = if (willDeployHubNetwork) { + name: '${prefix}-hub-network' params: { - tags: tags - location: location - resourceToken: secondaryResourceToken + deploymentSettings: primaryDeploymentSettings + diagnosticSettings: diagnosticSettings + resourceNames: naming.outputs.resourceNames + + // Dependencies + logAnalyticsWorkspaceId: azureMonitor.outputs.log_analytics_workspace_id + + // Settings + administratorPassword: jumpboxAdministratorPassword + administratorUsername: administratorUsername + createDevopsSubnet: true + enableBastionHost: true + // DDoS protection is recommended for Production deployments + // however, for this sample we disable this feature because DDoS should be configured to protect multiple subscriptions, deployments, and resources + // learn more at https://learn.microsoft.com/azure/ddos-protection/ddos-protection-overview + enableDDoSProtection: false // primaryDeploymentSettings.isProduction + enableFirewall: true + enableJumpBox: true } + dependsOn: [ + resourceGroups + ] } -module secondaryResources './resources.bicep' = if (isMultiLocationDeployment) { - name: 'secondary-${primaryResourceToken}' - scope: secondaryResourceGroup +/* +** The hub network MAY have created an Azure Monitor workspace. If it did, we don't need +** to do it again. If not, we'll create one in the application resource group +*/ + + +/* +** The spoke network is the network that the application resources are deployed into. +*/ +module spokeNetwork './modules/spoke-network.bicep' = if (isNetworkIsolated) { + name: '${prefix}-spoke-network' + params: { + deploymentSettings: primaryDeploymentSettings + diagnosticSettings: diagnosticSettings + resourceNames: naming.outputs.resourceNames + + // Dependencies + logAnalyticsWorkspaceId: azureMonitor.outputs.log_analytics_workspace_id + firewallInternalIpAddress: willDeployHubNetwork ? hubNetwork.outputs.firewall_ip_address : '' + + // Settings + addressPrefix: spokeAddressPrefixPrimary + } + dependsOn: [ + resourceGroups + ] +} + +module spokeNetwork2 './modules/spoke-network.bicep' = if (isNetworkIsolated && isMultiLocationDeployment) { + name: '${prefix}-spoke-network2' + params: { + deploymentSettings: secondaryDeploymentSettings + diagnosticSettings: diagnosticSettings + resourceNames: naming2.outputs.resourceNames + + // Dependencies + logAnalyticsWorkspaceId: azureMonitor.outputs.log_analytics_workspace_id + firewallInternalIpAddress: willDeployHubNetwork ? hubNetwork.outputs.firewall_ip_address : '' + + // Settings + addressPrefix: spokeAddressPrefixSecondary + } + dependsOn: [ + resourceGroups2 + ] +} + +/* +** Now that the networking resources have been created, we need to peer the networks. This is +** only done if the hub network was created in this deployment. If the hub network was not +** deployed, then a manual peering process needs to be done. +*/ +module peerHubAndPrimarySpokeVirtualNetworks './modules/peer-networks.bicep' = if (willDeployHubNetwork && isNetworkIsolated) { + name: '${prefix}-peer-hub-primary-networks' + params: { + hubNetwork: { + name: willDeployHubNetwork ? hubNetwork.outputs.virtual_network_name : '' + resourceGroupName: naming.outputs.resourceNames.hubResourceGroup + } + spokeNetwork: { + name: isNetworkIsolated ? spokeNetwork.outputs.virtual_network_name : '' + resourceGroupName: naming.outputs.resourceNames.spokeResourceGroup + } + } +} + +/* peer the hub and spoke for secondary region if it was deployed */ +module peerHubAndSecondarySpokeVirtualNetworks './modules/peer-networks.bicep' = if (willDeployHubNetwork && isNetworkIsolated && isMultiLocationDeployment) { + name: '${prefix}-peer-hub-secondary-networks' params: { - azureSqlPassword: azureSqlPassword - // when not deployed, the evaluation of this template must still supply a valid parameter - devOpsManagedIdentityId: isMultiLocationDeployment ? devOpsIdentitySetupSecondary.outputs.devOpsManagedIdentityId : 'none' - isProd: isProdBool - location: secondaryAzureLocation - principalId: principalId - principalType: principalType - resourceToken: secondaryResourceToken - tags: tags - azureAdApiScopeFrontEnd: azureAdApiScopeFrontEnd - azureAdClientIdForBackEnd: azureAdClientIdForBackEnd - azureAdClientIdForFrontEnd: azureAdClientIdForFrontEnd - azureAdClientSecretForFrontEnd: azureAdClientSecretForFrontEnd - azureAdTenantId: azureAdTenantId + hubNetwork: { + name: isMultiLocationDeployment ? hubNetwork.outputs.virtual_network_name : '' + resourceGroupName: naming.outputs.resourceNames.hubResourceGroup + } + spokeNetwork: { + name: isMultiLocationDeployment ? spokeNetwork2.outputs.virtual_network_name : '' + resourceGroupName: isMultiLocationDeployment ? naming2.outputs.resourceNames.spokeResourceGroup : '' + } } } -module azureFrontDoor './azureFrontDoor.bicep' = { - name: 'frontDoor-${primaryResourceToken}' - scope: primaryResourceGroup +/* +** Create the application resources. +*/ + +module frontdoor './modules/shared-frontdoor.bicep' = { + name: '${prefix}-frontdoor' params: { - tags: tags - logAnalyticsWorkspaceIdForDiagnostics: logAnalyticsForDiagnostics.outputs.LOG_WORKSPACE_ID - primaryBackendAddress: primaryResources.outputs.WEB_URI - secondaryBackendAddress: isMultiLocationDeployment ? secondaryResources.outputs.WEB_URI : 'none' + deploymentSettings: primaryDeploymentSettings + diagnosticSettings: diagnosticSettings + resourceNames: naming.outputs.resourceNames + + // Dependencies + logAnalyticsWorkspaceId: azureMonitor.outputs.log_analytics_workspace_id } } -module primaryAppConfigSvcFrontDoorUri 'appConfigSvcKeyValue.bicep' = { - name: 'primaryKeyValue' - scope: primaryResourceGroup - params:{ - appConfigurationServiceName: primaryResources.outputs.APP_CONFIGURATION_SVC_NAME - frontDoorUri: azureFrontDoor.outputs.HOST_NAME +module application './modules/application-resources.bicep' = { + name: '${prefix}-application' + params: { + deploymentSettings: primaryDeploymentSettings + diagnosticSettings: diagnosticSettings + resourceNames: naming.outputs.resourceNames + + // Dependencies + applicationInsightsId: azureMonitor.outputs.application_insights_id + logAnalyticsWorkspaceId: azureMonitor.outputs.log_analytics_workspace_id + dnsResourceGroupName: willDeployHubNetwork ? resourceGroups.outputs.hub_resource_group_name : '' + subnets: isNetworkIsolated ? spokeNetwork.outputs.subnets : {} + frontDoorSettings: frontdoor.outputs.settings + + // Settings + administratorUsername: administratorUsername + databasePassword: databasePassword + clientIpAddress: clientIpAddress + useCommonAppServicePlan: willDeployCommonAppServicePlan } + dependsOn: [ + resourceGroups + spokeNetwork + ] } -module primaryKeyVaultDiagnostics 'azureKeyVaultDiagnostics.bicep' = { - name: 'primaryKeyVaultDiagnostics' - scope: primaryResourceGroup +module application2 './modules/application-resources.bicep' = if (isMultiLocationDeployment) { + name: '${prefix}-application2' params: { - keyVaultName: primaryResources.outputs.KEY_VAULT_NAME - logAnalyticsWorkspaceIdForDiagnostics: logAnalyticsForDiagnostics.outputs.LOG_WORKSPACE_ID + deploymentSettings: secondaryDeploymentSettings + diagnosticSettings: diagnosticSettings + resourceNames: naming2.outputs.resourceNames + + // Dependencies + applicationInsightsId: azureMonitor.outputs.application_insights_id + logAnalyticsWorkspaceId: azureMonitor.outputs.log_analytics_workspace_id + dnsResourceGroupName: willDeployHubNetwork ? resourceGroups.outputs.hub_resource_group_name : '' + subnets: isNetworkIsolated && isMultiLocationDeployment? spokeNetwork2.outputs.subnets : {} + frontDoorSettings: frontdoor.outputs.settings + + // Settings + administratorUsername: administratorUsername + databasePassword: databasePassword + clientIpAddress: clientIpAddress + useCommonAppServicePlan: willDeployCommonAppServicePlan } + dependsOn: [ + resourceGroups2 + spokeNetwork2 + ] } -module secondaryAppConfigSvcFrontDoorUri 'appConfigSvcKeyValue.bicep' = if (isMultiLocationDeployment) { - name: 'secondaryKeyValue' - scope: secondaryResourceGroup - params:{ - appConfigurationServiceName: isMultiLocationDeployment ? secondaryResources.outputs.APP_CONFIGURATION_SVC_NAME : 'none' - frontDoorUri: azureFrontDoor.outputs.HOST_NAME +/* +** Runs for all configurations (NotIsolated, Isolated, and MultiLocation) +*/ +module applicationPostConfiguration './modules/application-post-config.bicep' = { + name: '${prefix}-application-postconfig' + params: { + deploymentSettings: primaryDeploymentSettings + administratorPassword: jumpboxAdministratorPassword + administratorUsername: administratorUsername + databasePassword: databasePassword + keyVaultName: isNetworkIsolated? hubNetwork.outputs.key_vault_name : application.outputs.key_vault_name + kvResourceGroupName: isNetworkIsolated? resourceGroups.outputs.hub_resource_group_name : resourceGroups.outputs.application_resource_group_name + readerIdentities: union(application.outputs.service_managed_identities, defaultDeploymentSettings.isMultiLocationDeployment ? application2.outputs.service_managed_identities : []) + redisCacheNamePrimary: application.outputs.redis_cache_name + redisCacheNameSecondary: isMultiLocationDeployment ? application2.outputs.redis_cache_name : application.outputs.redis_cache_name + resourceNames: naming.outputs.resourceNames + applicationResourceGroupNamePrimary: resourceGroups.outputs.application_resource_group_name + applicationResourceGroupNameSecondary: isMultiLocationDeployment ? resourceGroups2.outputs.application_resource_group_name : '' } } -module secondaryKeyVaultDiagnostics 'azureKeyVaultDiagnostics.bicep' = if (isMultiLocationDeployment) { - name: 'secondaryKeyVaultDiagnostics' - scope: secondaryResourceGroup +/* +** Create a build agent (only if network isolated and the relevant information has been provided) +*/ +module buildAgent './modules/build-agent.bicep' = if (installBuildAgent) { + name: '${prefix}-build-agent' params: { - keyVaultName: isMultiLocationDeployment ? secondaryResources.outputs.KEY_VAULT_NAME : 'none' - logAnalyticsWorkspaceIdForDiagnostics: logAnalyticsForDiagnostics.outputs.LOG_WORKSPACE_ID + deploymentSettings: primaryDeploymentSettings + diagnosticSettings: diagnosticSettings + resourceNames: naming.outputs.resourceNames + + // Dependencies + logAnalyticsWorkspaceId: azureMonitor.outputs.log_analytics_workspace_id + managedIdentityId: application.outputs.owner_managed_identity_id + subnets: isNetworkIsolated ? spokeNetwork.outputs.subnets : {} + + // Settings + administratorPassword: jumpboxAdministratorPassword + administratorUsername: administratorUsername + adoOrganizationUrl: adoOrganizationUrl + adoToken: adoToken + githubRepositoryUrl: githubRepositoryUrl + githubToken: githubToken } } +/* +** Enterprise App Patterns Telemetry +** A non-billable resource deployed to Azure to identify the template +*/ @description('Enable usage and telemetry feedback to Microsoft.') param enableTelemetry bool = true -var telemetryId = '063f9e42-c824-4573-8a47-5f6112612fe2-${location}' -resource telemetrydeployment 'Microsoft.Resources/deployments@2021-04-01' = if (enableTelemetry) { - name: telemetryId - location: location - properties: { - mode: 'Incremental' - template: { - '$schema': 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#' - contentVersion: '1.0.0.0' - resources: {} - } +module telemetry './modules/telemetry.bicep' = if (enableTelemetry) { + name: '${prefix}-telemetry' + params: { + deploymentSettings: primaryDeploymentSettings } } -output WEB_URI string = 'https://${azureFrontDoor.outputs.HOST_NAME}' -output AZURE_LOCATION string = location - -output DEBUG_IS_MULTI_LOCATION_DEPLOYMENT bool = isMultiLocationDeployment -output DEBUG_SECONDARY_AZURE_LOCATION string = secondaryAzureLocation -output DEBUG_IS_PROD bool = isProdBool +// ======================================================================== +// OUTPUTS +// ======================================================================== + +// Hub resources +output BASTION_NAME string = willDeployHubNetwork ? hubNetwork.outputs.bastion_name : '' +output BASTION_RESOURCE_GROUP string = willDeployHubNetwork ? resourceGroups.outputs.hub_resource_group_name : '' +output bastion_hostname string = willDeployHubNetwork ? hubNetwork.outputs.bastion_hostname : '' +output firewall_hostname string = willDeployHubNetwork ? hubNetwork.outputs.firewall_hostname : '' + +// Spoke resources +output build_agent string = installBuildAgent ? buildAgent.outputs.build_agent_hostname : '' +output JUMPBOX_RESOURCE_ID string = isNetworkIsolated ? hubNetwork.outputs.jumpbox_resource_id : '' + +// Application resources +output AZURE_RESOURCE_GROUP string = resourceGroups.outputs.application_resource_group_name +output SECONDARY_RESOURCE_GROUP string = isMultiLocationDeployment ? resourceGroups2.outputs.application_resource_group_name : 'not-deployed' +output service_managed_identities object[] = application.outputs.service_managed_identities +output service_web_endpoints string[] = application.outputs.service_web_endpoints +output AZURE_OPS_VAULT_NAME string = isNetworkIsolated ? hubNetwork.outputs.key_vault_name : application.outputs.key_vault_name + +// Local development values +output AZURE_PRINCIPAL_TYPE string = principalType +output APP_CONFIG_SERVICE_URI string = application.outputs.app_config_uri +output WEB_URI string = application.outputs.web_uri +output SQL_DATABASE_NAME string = application.outputs.sql_database_name +output SQL_SERVER_NAME string = application.outputs.sql_server_name diff --git a/infra/main.parameters.json b/infra/main.parameters.json index 5e92dd62..30422f82 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -2,44 +2,62 @@ "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", "contentVersion": "1.0.0.0", "parameters": { - "name": { - "value": "${AZURE_ENV_NAME}" + "adoOrganizationUrl": { + "value": "${AZURE_DEVOPS_ORG_URL}" }, - "location": { - "value": "${AZURE_LOCATION}" + "adoToken": { + "value": "${AZURE_DEVOPS_TOKEN}" }, - "principalId": { - "value": "${AZURE_PRINCIPAL_ID}" + "clientIpAddress": { + "value": "${AZD_IP_ADDRESS}" }, - "secondaryAzureLocation": { - "value": "${SECONDARY_AZURE_LOCATION}" + "databasePassword": { + "value": "$(secretOrRandomPassword ${AZURE_OPS_VAULT_NAME} Application--SqlAdministratorPassword)" }, - "isProd": { - "value": "${IS_PROD}" + "differentiator": { + "value": "${AZURE_CI_DIFFERENTIATOR=none}" }, - "azureSqlPassword": { - "value": "$(secretOrRandomPassword ${AZURE_KEYVAULT_NAME} sqlAdminPassword)" + "enableTelemetry": { + "value": "${ENABLE_TELEMETRY=true}" }, - "principalType": { - "value": "${PRINCIPAL_TYPE=user}" + "environmentName": { + "value": "${AZURE_ENV_NAME}" }, - "azureAdApiScopeFrontEnd": { - "value": "$(secretOrRandomPassword ${AZURE_OPS_VAULT_NAME} azureAdApiScopeFrontEnd)" + "environmentType": { + "value": "${ENVIRONMENT=dev}" }, - "azureAdClientIdForBackEnd": { - "value": "$(secretOrRandomPassword ${AZURE_OPS_VAULT_NAME} azureAdClientIdForBackEnd)" + "githubRepositoryUrl": { + "value": "${GITHUB_REPO}" }, - "azureAdClientIdForFrontEnd": { - "value": "$(secretOrRandomPassword ${AZURE_OPS_VAULT_NAME} azureAdClientIdForFrontEnd)" + "githubToken": { + "value": "${GITHUB_TOKEN}" }, - "azureAdClientSecretForFrontEnd": { - "value": "$(secretOrRandomPassword ${AZURE_OPS_VAULT_NAME} azureAdClientSecretForFrontEnd)" + "jumpboxAdministratorPassword": { + "value": "$(secretOrRandomPassword ${AZURE_OPS_VAULT_NAME} Jumpbox--AdministratorPassword)" }, - "azureAdTenantId": { - "value": "$(secretOrRandomPassword ${AZURE_OPS_VAULT_NAME} azureAdTenantId)" + "location": { + "value": "${AZURE_LOCATION}" }, - "enableTelemetry": { - "value": "${ENABLE_TELEMETRY=true}" + "principalId": { + "value": "${AZURE_PRINCIPAL_ID}" + }, + "principalType": { + "value": "${AZURE_PRINCIPAL_TYPE=User}" + }, + "deployHubNetwork": { + "value": "${DEPLOY_HUB_NETWORK=auto}" + }, + "networkIsolation": { + "value": "${NETWORK_ISOLATION=auto}" + }, + "ownerEmail": { + "value": "${OWNER_EMAIL=noreply@contoso.com}" + }, + "ownerName": { + "value": "${OWNER_NAME=notset}" + }, + "azureSecondaryLocation": { + "value": "${AZURE_SECONDARY_LOCATION}" } } } \ No newline at end of file diff --git a/infra/modules/application-appservice.bicep b/infra/modules/application-appservice.bicep new file mode 100644 index 00000000..951c7fca --- /dev/null +++ b/infra/modules/application-appservice.bicep @@ -0,0 +1,239 @@ +targetScope = 'resourceGroup' + +/* +** An App Service running on a App Service Plan +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DeploymentSettings.bicep +@description('Type that describes the global deployment settings') +type DeploymentSettings = { + @description('If \'true\', then two regional deployments will be performed.') + isMultiLocationDeployment: bool + + @description('If \'true\', use production SKUs and settings.') + isProduction: bool + + @description('If \'true\', isolate the workload in a virtual network.') + isNetworkIsolated: bool + + @description('If \'false\', then this is a multi-location deployment for the second location.') + isPrimaryLocation: bool + + @description('The Azure region to host resources') + location: string + + @description('The name of the workload.') + name: string + + @description('The ID of the principal that is being used to deploy resources.') + principalId: string + + @description('The type of the \'principalId\' property.') + principalType: 'ServicePrincipal' | 'User' + + @description('The token to use for naming resources. This should be unique to the deployment.') + resourceToken: string + + @description('The development stage for this application') + stage: 'dev' | 'prod' + + @description('The common tags that should be used for all created resources') + tags: object + + @description('The common tags that should be used for all workload resources') + workloadTags: object +} + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// From: infra/types/PrivateEndpointSettings.bicep +@description('Type describing the private endpoint settings.') +type PrivateEndpointSettings = { + @description('The name of the resource group to hold the Private DNS Zone. By default, this uses the same resource group as the resource.') + dnsResourceGroupName: string + + @description('The name of the private endpoint resource. By default, this uses a prefix of \'pe-\' followed by the name of the resource.') + name: string? + + @description('The name of the resource group to hold the private endpoint. By default, this uses the same resource group as the resource.') + resourceGroupName: string? + + @description('The ID of the subnet to link the private endpoint to.') + subnetId: string +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The deployment settings to use for this deployment.') +param deploymentSettings DeploymentSettings + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The tags to associate with this resource.') +param tags object = {} + +/* +** Dependencies +*/ +@description('The name of the App Configuration store to configure for configuration.') +param appConfigurationName string + +@description('The ID of the Application Insights instance to use for logging.') +param applicationInsightsId string + +@description('The name of the App Service Plan to use for compute resources.') +param appServicePlanName string + +@description('The managed identity name to use as the identity of the App Service.') +param managedIdentityName string + +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +/* +** Settings +*/ +@description('The name of the App Service to create.') +param appServiceName string + +@description('If using VNET integration, the ID of the subnet for outbound traffic.') +param outboundSubnetId string = '' + +@description('If using network isolation, the settings for the private endpoint.') +param privateEndpointSettings PrivateEndpointSettings? + +@description('If not blank, restrict the App Service to only allow traffic from the specified front door.') +param restrictToFrontDoor string = '' + +@description('The service prefix to use.') +param servicePrefix string + +@description('If true, use an existing App Service Plan') +param useExistingAppServicePlan bool = false + +// ======================================================================== +// VARIABLES +// ======================================================================== + +// Get the name and resource group for the Application Insights instance. +// var applicationInsightsName = split('/', applicationInsightsId)[8] +// var applicationInsightsRG = split('/', applicationInsightsId)[4] + +var applicationInsights = reference(applicationInsightsId, '2020-02-02') + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + + +resource appConfigurationStore 'Microsoft.AppConfiguration/configurationStores@2023-03-01' existing = { + name: appConfigurationName +} + +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = { + name: managedIdentityName +} + +module appServicePlan '../core/hosting/app-service-plan.bicep' = if (!useExistingAppServicePlan) { + name: '${servicePrefix}-app-service-plan' + params: { + name: appServicePlanName + location: deploymentSettings.location + tags: tags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + + // Settings + autoScaleSettings: deploymentSettings.isProduction ? { maxCapacity: 10, minCapacity: 3 } : null + diagnosticSettings: diagnosticSettings + sku: deploymentSettings.isProduction ? 'P1v3' : 'B1' + zoneRedundant: deploymentSettings.isProduction + } +} + +module appService '../core/hosting/app-service.bicep' = { + name: '${servicePrefix}-app-service' + params: { + name: appServiceName + location: deploymentSettings.location + tags: tags + + // Dependencies + appServicePlanName: useExistingAppServicePlan ? appServicePlanName : appServicePlan.outputs.name + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + managedIdentityId: managedIdentity.id + outboundSubnetId: outboundSubnetId + + // Settings + appSettings: { + SCM_DO_BUILD_DURING_DEPLOYMENT: 'false' + ASPNETCORE_ENVIRONMENT: deploymentSettings.isProduction ? 'Production' : 'Development' + + // Application Insights + ApplicationInsightsAgent_EXTENSION_VERSION: '~2' + XDT_MicrosoftApplicationInsights_Mode: 'recommended' + InstrumentationEngine_EXTENSION_VERSION: '~1' + XDT_MicrosoftApplicationInsights_BaseExtensions: '~1' + APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsights.ConnectionString + APPLICATIONINSIGHTS_INSTRUMENTATIONKEY: applicationInsights.InstrumentationKey + + // Identity for DefaultAzureCredential connections + AZURE_CLIENT_ID: managedIdentity.properties.clientId + + // App Configuration + 'App:AppConfig:Uri': appConfigurationStore.properties.endpoint + } + diagnosticSettings: diagnosticSettings + enablePublicNetworkAccess: !deploymentSettings.isNetworkIsolated + ipSecurityRestrictions: !empty(restrictToFrontDoor) ? [ + { + tag: 'ServiceTag' + ipAddress: 'AzureFrontDoor.Backend' + action: 'Allow' + priority: 100 + headers: { + 'x-azure-fdid': [ restrictToFrontDoor ] + } + name: 'Allow traffic from Front Door' + } + ] : [] + privateEndpointSettings: privateEndpointSettings + servicePrefix: servicePrefix + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output app_service_id string = appService.outputs.id +output app_service_name string = appService.outputs.name +output app_service_hostname string = appService.outputs.hostname +output app_service_uri string = appService.outputs.uri + diff --git a/infra/modules/application-post-config.bicep b/infra/modules/application-post-config.bicep new file mode 100644 index 00000000..cf03e3ce --- /dev/null +++ b/infra/modules/application-post-config.bicep @@ -0,0 +1,251 @@ +targetScope = 'subscription' + +/* +** Application Infrastructure post-configuration +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** The Application consists of a virtual network that has shared resources that +** are generally associated with a hub. This module provides post-configuration +** actions such as creating key-vault secrets to save information from +** modules that depend on the hub. +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DeploymentSettings.bicep +@description('Type that describes the global deployment settings') +type DeploymentSettings = { + @description('If \'true\', then two regional deployments will be performed.') + isMultiLocationDeployment: bool + + @description('If \'true\', use production SKUs and settings.') + isProduction: bool + + @description('If \'true\', isolate the workload in a virtual network.') + isNetworkIsolated: bool + + @description('If \'false\', then this is a multi-location deployment for the second location.') + isPrimaryLocation: bool + + @description('The Azure region to host resources') + location: string + + @description('The name of the workload.') + name: string + + @description('The ID of the principal that is being used to deploy resources.') + principalId: string + + @description('The type of the \'principalId\' property.') + principalType: 'ServicePrincipal' | 'User' + + @description('The token to use for naming resources. This should be unique to the deployment.') + resourceToken: string + + @description('The development stage for this application') + stage: 'dev' | 'prod' + + @description('The common tags that should be used for all created resources') + tags: object + + @description('The common tags that should be used for all workload resources') + workloadTags: object +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The deployment settings to use for this deployment.') +param deploymentSettings DeploymentSettings + +/* +** Passwords - specify these! +*/ +@secure() +@minLength(12) +@description('The password for the administrator account. This will be used for the jump box, SQL server, and anywhere else a password is needed for creating a resource.') +param administratorPassword string = newGuid() + +@minLength(8) +@description('The username for the administrator account on the jump box.') +param administratorUsername string = 'adminuser' + +@secure() +@minLength(8) +@description('The password for the administrator account on the SQL Server.') +param databasePassword string + +@description('The resource names for the resources to be created.') +param resourceNames object + +/* +** Dependencies +*/ +@description('The resource names for the resources to be created.') +param keyVaultName string + +@description('Name of the hub resource group containing the key vault.') +param kvResourceGroupName string + +@description('Name of the primary Azure Cache for Redis.') +param redisCacheNamePrimary string + +@description('Name of the second Azure Cache for Redis.') +param redisCacheNameSecondary string + +@description('Name of the resource group containing Azure Cache for Redis.') +param applicationResourceGroupNamePrimary string + +@description('Name of the resource group containing Azure Cache for Redis.') +param applicationResourceGroupNameSecondary string + +@description('List of user assigned managed identities that will receive Secrets User role to the shared key vault') +param readerIdentities object[] + +// ======================================================================== +// VARIABLES +// ======================================================================== + +var microsoftEntraIdApiClientId = 'Api--MicrosoftEntraId--ClientId' +var microsoftEntraIdApiInstance = 'Api--MicrosoftEntraId--Instance' +var microsoftEntraIdApiScope = 'App--RelecloudApi--AttendeeScope' +var microsoftEntraIdApiTenantId = 'Api--MicrosoftEntraId--TenantId' +var microsoftEntraIdCallbackPath = 'MicrosoftEntraId--CallbackPath' +var microsoftEntraIdClientId = 'MicrosoftEntraId--ClientId' +var microsoftEntraIdClientSecret = 'MicrosoftEntraId--ClientSecret' +var microsoftEntraIdInstance = 'MicrosoftEntraId--Instance' +var microsoftEntraIdSignedOutCallbackPath = 'MicrosoftEntraId--SignedOutCallbackPath' +var microsoftEntraIdTenantId = 'MicrosoftEntraId--TenantId' +var redisCacheSecretNamePrimary = 'App--RedisCache--ConnectionString-Primary' +var redisCacheSecretNameSecondary = 'App--RedisCache--ConnectionString-Secondary' + +var multiRegionalSecrets = deploymentSettings.isMultiLocationDeployment ? [redisCacheSecretNameSecondary] : [] + +var listOfAppConfigSecrets = [ + microsoftEntraIdApiClientId + microsoftEntraIdApiInstance + microsoftEntraIdApiScope + microsoftEntraIdApiTenantId + microsoftEntraIdCallbackPath + microsoftEntraIdClientId + microsoftEntraIdClientSecret + microsoftEntraIdInstance + microsoftEntraIdSignedOutCallbackPath + microsoftEntraIdTenantId +] + +var listOfSecretNames = union(listOfAppConfigSecrets, + [ + redisCacheSecretNamePrimary + ], multiRegionalSecrets) + +// ======================================================================== +// EXISTING RESOURCES +// ======================================================================== + +resource existingKvResourceGroup 'Microsoft.Resources/resourceGroups@2022-09-01' existing = { + name: kvResourceGroupName +} + +resource existingPrimaryRedisCache 'Microsoft.Cache/redis@2023-04-01' existing = { + name: redisCacheNamePrimary + scope: resourceGroup(applicationResourceGroupNamePrimary) +} + +resource existingSecondaryRediscache 'Microsoft.Cache/redis@2023-04-01' existing = if (deploymentSettings.isMultiLocationDeployment) { + name: redisCacheNameSecondary + scope: resourceGroup(deploymentSettings.isMultiLocationDeployment ? applicationResourceGroupNameSecondary : applicationResourceGroupNamePrimary) +} + +resource existingKeyVault 'Microsoft.KeyVault/vaults@2023-02-01' existing = { + name: keyVaultName + scope: existingKvResourceGroup +} + +// ======================================================================== +// AZURE MODULES +// ======================================================================== + +module writeJumpBoxCredentialsToKeyVault '../core/security/key-vault-secrets.bicep' = if (deploymentSettings.isNetworkIsolated) { + name: 'hub-write-jumpbox-credentials-${deploymentSettings.resourceToken}' + scope: existingKvResourceGroup + params: { + name: existingKeyVault.name + secrets: [ + { key: 'Jumpbox--AdministratorPassword', value: administratorPassword } + { key: 'Jumpbox--AdministratorUsername', value: administratorUsername } + { key: 'Jumpbox--ComputerName', value: resourceNames.hubJumpbox } + ] + } +} + +module writeSqlAdminInfoToKeyVault '../core/security/key-vault-secrets.bicep' = { + name: 'write-sql-admin-info-to-keyvault' + scope: existingKvResourceGroup + params: { + name: existingKeyVault.name + secrets: [ + { key: 'Application--SqlAdministratorUsername', value: administratorUsername } + { key: 'Application--SqlAdministratorPassword', value: databasePassword } + ] + } +} + +module writePrimaryRedisSecret '../core/security/key-vault-secrets.bicep' = { + name: 'write-primary-redis-secret-to-keyvault' + scope: existingKvResourceGroup + params: { + name: existingKeyVault.name + secrets: [ + { key: redisCacheSecretNamePrimary, value: '${existingPrimaryRedisCache.name}.redis.cache.windows.net:6380,password=${existingPrimaryRedisCache.listKeys().primaryKey},ssl=True,abortConnect=False' } + ] + } +} + +module writeSecondaryRedisSecret '../core/security/key-vault-secrets.bicep' = if (deploymentSettings.isMultiLocationDeployment) { + name: 'write-secondary-redis-secret-to-keyvault' + scope: existingKvResourceGroup + params: { + name: existingKeyVault.name + secrets: [ + { key: redisCacheSecretNameSecondary, value: '${existingSecondaryRediscache.name}.redis.cache.windows.net:6380,password=${existingSecondaryRediscache.listKeys().primaryKey},ssl=True,abortConnect=False' } + ] + } +} + +// ======================================================================== // +// Microsoft Entra Application Registration placeholders +// ======================================================================== // +module writeAppRegistrationSecrets '../core/security/key-vault-secrets.bicep' = [ for secretName in listOfAppConfigSecrets: { + name: 'write-temp-kv-secret-${secretName}' + scope: existingKvResourceGroup + params: { + name: existingKeyVault.name + secrets: [ + { key: secretName, value: 'placeholder-populated-by-script' } + ] + } +}] + +// ======================================================================== // +// Grant reader permissions for the web apps to access the key vault +// ======================================================================== // + +module grantSecretsUserAccessBySecretName './grant-secret-user.bicep' = [ for secretName in listOfSecretNames: { + scope: existingKvResourceGroup + name: 'grant-kv-access-for-${secretName}' + params: { + keyVaultName: existingKeyVault.name + readerIdentities: readerIdentities + secretName: secretName + } + dependsOn: [ + writeAppRegistrationSecrets + ] +}] diff --git a/infra/modules/application-resources.bicep b/infra/modules/application-resources.bicep new file mode 100644 index 00000000..7837cccf --- /dev/null +++ b/infra/modules/application-resources.bicep @@ -0,0 +1,565 @@ +targetScope = 'subscription' + +/* +** Application Infrastructure +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DeploymentSettings.bicep +@description('Type that describes the global deployment settings') +type DeploymentSettings = { + @description('If \'true\', then two regional deployments will be performed.') + isMultiLocationDeployment: bool + + @description('If \'true\', use production SKUs and settings.') + isProduction: bool + + @description('If \'true\', isolate the workload in a virtual network.') + isNetworkIsolated: bool + + @description('If \'false\', then this is a multi-location deployment for the second location.') + isPrimaryLocation: bool + + @description('The Azure region to host resources') + location: string + + @description('The name of the workload.') + name: string + + @description('The ID of the principal that is being used to deploy resources.') + principalId: string + + @description('The type of the \'principalId\' property.') + principalType: 'ServicePrincipal' | 'User' + + @description('The token to use for naming resources. This should be unique to the deployment.') + resourceToken: string + + @description('The development stage for this application') + stage: 'dev' | 'prod' + + @description('The common tags that should be used for all created resources') + tags: object + + @description('The common tags that should be used for all workload resources') + workloadTags: object +} + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// From: infra/types/FrontDoorSettings.bicep +@description('Type describing the settings for Azure Front Door.') +type FrontDoorSettings = { + @description('The name of the Azure Front Door endpoint') + endpointName: string + + @description('Front Door Id used for traffic restriction') + frontDoorId: string + + @description('The hostname that can be used to access Azure Front Door content.') + hostname: string + + @description('The profile name that is used for configuring Front Door routes.') + profileName: string +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The deployment settings to use for this deployment.') +param deploymentSettings DeploymentSettings + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The resource names for the resources to be created.') +param resourceNames object + +/* +** Dependencies +*/ +@description('The ID of the Application Insights resource to use for App Service logging.') +param applicationInsightsId string = '' + +@description('When deploying a hub, the private endpoints will need this parameter to specify the resource group that holds the Private DNS zones') +param dnsResourceGroupName string = '' + +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +@description('The list of subnets that are used for linking into the virtual network if using network isolation.') +param subnets object = {} + +@description('The settings for a pre-configured Azure Front Door that provides WAF for App Services.') +param frontDoorSettings FrontDoorSettings + +/* +** Settings +*/ +@secure() +@minLength(8) +@description('The password for the administrator account on the SQL Server.') +param databasePassword string + +@minLength(8) +@description('The username for the administrator account on the SQL Server.') +param administratorUsername string + +@description('The IP address of the current system. This is used to set up the firewall for Key Vault and SQL Server if in development mode.') +param clientIpAddress string = '' + +@description('If true, use a common App Service Plan. If false, use a separate App Service Plan per App Service.') +param useCommonAppServicePlan bool + +// ======================================================================== +// VARIABLES +// ======================================================================== + +// The tags to apply to all resources in this workload +var moduleTags = union(deploymentSettings.tags, deploymentSettings.workloadTags) + +// If the sqlResourceGroup != the application resource group, don't create a server. +var createSqlServer = resourceNames.sqlResourceGroup == resourceNames.resourceGroup + +// Budget amounts +// All values are calculated in dollars (rounded to nearest dollar) in the South Central US region. +var budget = { + sqlDatabase: deploymentSettings.isProduction ? 457 : 15 + appServicePlan: (deploymentSettings.isProduction ? 690 : 55) * (useCommonAppServicePlan ? 1 : 2) + virtualNetwork: deploymentSettings.isNetworkIsolated ? 4 : 0 + privateEndpoint: deploymentSettings.isNetworkIsolated ? 9 : 0 + frontDoor: deploymentSettings.isProduction || deploymentSettings.isNetworkIsolated ? 335 : 38 +} +var budgetAmount = reduce(map(items(budget), (obj) => obj.value), 0, (total, amount) => total + amount) + +// describes the Azure Storage container where ticket images will be stored after they are rendered during purchase +var ticketContainerName = 'tickets' + +// Built-in Azure Contributor role +var contributorRole = 'b24988ac-6180-42a0-ab88-20f7382dd24c' + +// ======================================================================== +// EXISTING RESOURCES +// ======================================================================== + +resource resourceGroup 'Microsoft.Resources/resourceGroups@2022-09-01' existing = { + name: resourceNames.resourceGroup +} + +// ======================================================================== +// NEW RESOURCES +// ======================================================================== + +/* +** Identities used by the application. +*/ +module ownerManagedIdentity '../core/identity/managed-identity.bicep' = { + name: 'owner-managed-identity-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.ownerManagedIdentity + location: deploymentSettings.location + tags: moduleTags + } +} + +module appManagedIdentity '../core/identity/managed-identity.bicep' = { + name: 'application-managed-identity-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.appManagedIdentity + location: deploymentSettings.location + tags: moduleTags + } +} + +module ownerManagedIdentityRoleAssignment '../core/identity/resource-group-role-assignment.bicep' = { + name: 'owner-managed-identity-role-assignment-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + identityName: ownerManagedIdentity.outputs.name + roleId: contributorRole + roleDescription: 'Grant the "Contributor" role to the user-assigned managed identity so it can run deployment scripts.' + } +} + +/* +** App Configuration - used for storing configuration data +*/ +module appConfiguration '../core/config/app-configuration.bicep' = { + name: 'application-app-configuration-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.appConfiguration + location: deploymentSettings.location + tags: moduleTags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + + // Settings + diagnosticSettings: diagnosticSettings + enablePublicNetworkAccess: !deploymentSettings.isNetworkIsolated + ownerIdentities: [ + { principalId: deploymentSettings.principalId, principalType: deploymentSettings.principalType } + { principalId: ownerManagedIdentity.outputs.principal_id, principalType: 'ServicePrincipal' } + ] + privateEndpointSettings: deploymentSettings.isNetworkIsolated ? { + dnsResourceGroupName: dnsResourceGroupName + name: resourceNames.appConfigurationPrivateEndpoint + resourceGroupName: resourceNames.spokeResourceGroup + subnetId: subnets[resourceNames.spokePrivateEndpointSubnet].id + + } : null + readerIdentities: [ + { principalId: appManagedIdentity.outputs.principal_id, principalType: 'ServicePrincipal' } + ] + } +} + +/* +** Key Vault - used for storing configuration secrets. +** This vault is deployed with the application when not using Network Isolation. +*/ +module keyVault '../core/security/key-vault.bicep' = if (!deploymentSettings.isNetworkIsolated) { + name: 'application-key-vault-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.keyVault + location: deploymentSettings.location + tags: moduleTags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + + // Settings + diagnosticSettings: diagnosticSettings + enablePublicNetworkAccess: true + ownerIdentities: [ + { principalId: deploymentSettings.principalId, principalType: deploymentSettings.principalType } + { principalId: ownerManagedIdentity.outputs.principal_id, principalType: 'ServicePrincipal' } + ] + privateEndpointSettings: deploymentSettings.isNetworkIsolated ? { + dnsResourceGroupName: dnsResourceGroupName + name: resourceNames.keyVaultPrivateEndpoint + resourceGroupName: resourceNames.spokeResourceGroup + subnetId: subnets[resourceNames.spokePrivateEndpointSubnet].id + } : null + readerIdentities: [ + { principalId: appManagedIdentity.outputs.principal_id, principalType: 'ServicePrincipal' } + ] + } +} + +/* +** SQL Database +*/ +module sqlServer '../core/database/sql-server.bicep' = if (createSqlServer) { + name: 'application-sql-server-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.sqlServer + location: deploymentSettings.location + tags: moduleTags + + // Dependencies + managedIdentityName: ownerManagedIdentity.outputs.name + + // Settings + firewallRules: !deploymentSettings.isProduction && !empty(clientIpAddress) ? { + allowedIpAddresses: [ '${clientIpAddress}/32' ] + } : null + diagnosticSettings: diagnosticSettings + enablePublicNetworkAccess: !deploymentSettings.isNetworkIsolated + sqlAdministratorPassword: databasePassword + sqlAdministratorUsername: administratorUsername + } +} + +module sqlDatabase '../core/database/sql-database.bicep' = { + name: 'application-sql-database-${deploymentSettings.resourceToken}' + scope: az.resourceGroup(resourceNames.sqlResourceGroup) + params: { + name: resourceNames.sqlDatabase + location: deploymentSettings.location + tags: moduleTags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + sqlServerName: createSqlServer ? sqlServer.outputs.name : resourceNames.sqlServer + + // Settings + diagnosticSettings: diagnosticSettings + dtuCapacity: deploymentSettings.isProduction ? 125 : 10 + privateEndpointSettings: deploymentSettings.isNetworkIsolated ? { + dnsResourceGroupName: dnsResourceGroupName + name: resourceNames.sqlDatabasePrivateEndpoint + resourceGroupName: resourceNames.spokeResourceGroup + subnetId: subnets[resourceNames.spokePrivateEndpointSubnet].id + } : null + sku: deploymentSettings.isProduction ? 'Premium' : 'Standard' + zoneRedundant: deploymentSettings.isProduction + } +} + +/* +** App Services +*/ +module commonAppServicePlan '../core/hosting/app-service-plan.bicep' = if (useCommonAppServicePlan) { + name: 'application-app-service-plan-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.commonAppServicePlan + location: deploymentSettings.location + tags: moduleTags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + + // Settings + autoScaleSettings: deploymentSettings.isProduction ? { maxCapacity: 10, minCapacity: 3 } : null + diagnosticSettings: diagnosticSettings + sku: deploymentSettings.isProduction ? 'P1v3' : 'B1' + zoneRedundant: deploymentSettings.isProduction + } +} + +module webService './application-appservice.bicep' = { + name: 'application-webservice-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + deploymentSettings: deploymentSettings + diagnosticSettings: diagnosticSettings + // mapping code projects to web apps by tags matching names from azure.yaml + tags: moduleTags + + // Dependencies + appConfigurationName: appConfiguration.outputs.name + applicationInsightsId: applicationInsightsId + appServicePlanName: useCommonAppServicePlan ? commonAppServicePlan.outputs.name : resourceNames.webAppServicePlan + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + // uses ownerManagedIdentity with code first schema and seeding operations + // separate approach will be researched by 1852428 + managedIdentityName: ownerManagedIdentity.outputs.name + + // Settings + appServiceName: resourceNames.webAppService + outboundSubnetId: deploymentSettings.isNetworkIsolated ? subnets[resourceNames.spokeWebOutboundSubnet].id : '' + privateEndpointSettings: deploymentSettings.isNetworkIsolated ? { + dnsResourceGroupName: dnsResourceGroupName + name: resourceNames.webAppServicePrivateEndpoint + resourceGroupName: resourceNames.spokeResourceGroup + subnetId: subnets[resourceNames.spokeWebInboundSubnet].id + } : null + restrictToFrontDoor: frontDoorSettings.frontDoorId + servicePrefix: 'web-callcenter-service' + useExistingAppServicePlan: useCommonAppServicePlan + } +} + +module webServiceFrontDoorRoute '../core/security/front-door-route.bicep' = if (deploymentSettings.isPrimaryLocation) { + name: 'web-service-front-door-route-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + frontDoorEndpointName: frontDoorSettings.endpointName + frontDoorProfileName: frontDoorSettings.profileName + healthProbeMethod:'GET' + originPath: '/api/' + originPrefix: 'web-service' + serviceAddress: webService.outputs.app_service_hostname + routePattern: '/api/*' + privateLinkSettings: deploymentSettings.isNetworkIsolated ? { + privateEndpointResourceId: webService.outputs.app_service_id + linkResourceType: 'sites' + location: deploymentSettings.location + } : {} + } +} + +module webFrontend './application-appservice.bicep' = { + name: 'application-webfrontend-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + deploymentSettings: deploymentSettings + diagnosticSettings: diagnosticSettings + // mapping code projects to web apps by tags matching names from azure.yaml + tags: moduleTags + + // Dependencies + appConfigurationName: appConfiguration.outputs.name + applicationInsightsId: applicationInsightsId + appServicePlanName: useCommonAppServicePlan ? commonAppServicePlan.outputs.name : resourceNames.webAppServicePlan + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + managedIdentityName: appManagedIdentity.outputs.name + + // Settings + appServiceName: resourceNames.webAppFrontend + outboundSubnetId: deploymentSettings.isNetworkIsolated ? subnets[resourceNames.spokeWebOutboundSubnet].id : '' + privateEndpointSettings: deploymentSettings.isNetworkIsolated ? { + dnsResourceGroupName: dnsResourceGroupName + name: resourceNames.webAppFrontendPrivateEndpoint + resourceGroupName: resourceNames.spokeResourceGroup + subnetId: subnets[resourceNames.spokeWebInboundSubnet].id + } : null + restrictToFrontDoor: frontDoorSettings.frontDoorId + servicePrefix: 'web-callcenter-frontend' + useExistingAppServicePlan: useCommonAppServicePlan + } +} + +module webFrontendFrontDoorRoute '../core/security/front-door-route.bicep' = if (deploymentSettings.isPrimaryLocation) { + name: 'web-frontend-front-door-route-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + frontDoorEndpointName: frontDoorSettings.endpointName + frontDoorProfileName: frontDoorSettings.profileName + healthProbeMethod:'GET' + originPath: '/' + originPrefix: 'web-frontend' + serviceAddress: webFrontend.outputs.app_service_hostname + routePattern: '/*' + privateLinkSettings: deploymentSettings.isNetworkIsolated ? { + privateEndpointResourceId: webFrontend.outputs.app_service_id + linkResourceType: 'sites' + location: deploymentSettings.location + } : {} + } +} + +/* +** Azure Cache for Redis +*/ + +module redis '../core/database/azure-cache-for-redis.bicep' = { + name: 'application-redis-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.redis + location: deploymentSettings.location + diagnosticSettings: diagnosticSettings + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + // vault provided by Hub resource group when network isolated + redisCacheSku : deploymentSettings.isProduction ? 'Standard' : 'Basic' + redisCacheFamily : 'C' + redisCacheCapacity: deploymentSettings.isProduction ? 1 : 0 + + privateEndpointSettings: deploymentSettings.isNetworkIsolated ? { + dnsResourceGroupName: dnsResourceGroupName + name: resourceNames.redisPrivateEndpoint + resourceGroupName: resourceNames.spokeResourceGroup + subnetId: subnets[resourceNames.spokePrivateEndpointSubnet].id + } : null + } +} + +/* +** Azure Storage +*/ + +module storageAccount '../core/storage/storage-account.bicep' = { + name: 'application-storage-account-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + location: deploymentSettings.location + name: resourceNames.storageAccount + + // Settings + allowSharedKeyAccess: false + ownerIdentities: [ + { principalId: deploymentSettings.principalId, principalType: deploymentSettings.principalType } + { principalId: ownerManagedIdentity.outputs.principal_id, principalType: 'ServicePrincipal' } + ] + privateEndpointSettings: deploymentSettings.isNetworkIsolated ? { + dnsResourceGroupName: dnsResourceGroupName + name: resourceNames.storageAccountPrivateEndpoint + resourceGroupName: resourceNames.spokeResourceGroup + subnetId: subnets[resourceNames.spokePrivateEndpointSubnet].id + } : null + } +} + +module storageAccountContainer '../core/storage/storage-account-blob.bicep' = { + name: 'application-storage-account-container-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.storageAccountContainer + storageAccountName: storageAccount.outputs.name + diagnosticSettings: diagnosticSettings + containers: [ + { name: ticketContainerName } + ] + } +} + +module approveFrontDoorPrivateLinks '../core/security/front-door-route-approval.bicep' = if (deploymentSettings.isNetworkIsolated && deploymentSettings.isPrimaryLocation) { + name: 'approve-front-door-routes-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + location: deploymentSettings.location + managedIdentityName: ownerManagedIdentityRoleAssignment.outputs.identity_name + } + // private endpoint approval between front door and web app depends on both resources + dependsOn: [ + webService + webServiceFrontDoorRoute + webFrontend + webFrontendFrontDoorRoute + ] +} + +module applicationBudget '../core/cost-management/budget.bicep' = { + name: 'application-budget-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.budget + amount: budgetAmount + contactEmails: [ + deploymentSettings.tags['azd-owner-email'] + ] + resourceGroups: union([ resourceGroup.name ], deploymentSettings.isNetworkIsolated ? [ resourceNames.spokeResourceGroup ] : []) + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output app_config_uri string = appConfiguration.outputs.app_config_uri +output key_vault_name string = deploymentSettings.isNetworkIsolated ? resourceNames.keyVault : keyVault.outputs.name +output redis_cache_name string = redis.outputs.name + +output owner_managed_identity_id string = ownerManagedIdentity.outputs.id +output app_managed_identity_id string = appManagedIdentity.outputs.id + +output service_managed_identities object[] = [ + { principalId: ownerManagedIdentity.outputs.principal_id, principalType: 'ServicePrincipal', role: 'owner' } + { principalId: appManagedIdentity.outputs.principal_id, principalType: 'ServicePrincipal', role: 'application' } +] + +output service_web_endpoints string[] = [ deploymentSettings.isPrimaryLocation ? webFrontendFrontDoorRoute.outputs.endpoint : webFrontend.outputs.app_service_uri ] +output web_uri string = deploymentSettings.isPrimaryLocation ? webFrontendFrontDoorRoute.outputs.uri : webFrontend.outputs.app_service_uri + +output sql_server_name string = sqlServer.outputs.name +output sql_database_name string = sqlDatabase.outputs.name diff --git a/infra/modules/azure-fqdns.jsonc b/infra/modules/azure-fqdns.jsonc new file mode 100644 index 00000000..f2b2c693 --- /dev/null +++ b/infra/modules/azure-fqdns.jsonc @@ -0,0 +1,51 @@ +{ + "azureMonitor": [ + "dc.applicationinsights.azure.com", + "dc.applicationinsights.microsoft.com", + "dc.services.visualstudio.com", + "*.in.applicationinsights.azure.com", + "live.applicationinsights.azure.com", + "rt.applicationinsights.microsoft.com", + "rt.services.visualstudio.com", + "*.livediagnostics.monitor.azure.com", + "*.monitoring.azure.com", + "agent.azureserviceprofiler.net", + "*.agent.azureserviceprofiler.net", + "*.monitor.azure.com" + ], + "certificateServices": [ + "*.delivery.mp.microsoft.com", + "ctldl.windowsupdate.com", + "ocsp.msocsp.com", + "oneocsp.microsoft.com", + "crl.microsoft.com", + "www.microsoft.com", + "*.digicert.com", + "*.symantec.com", + "*.symcb.com", + "*.d-trust.net" + ], + "coreServices": [ + "management.azure.com", + "management.core.windows.net", + "login.microsoftonline.com", + "login.windows.net", + "login.live.com", + "graph.windows.net" + ], + "developerServices": [ + "github.com", + "*.github.com", + "*.nuget.org", + "*.blob.core.windows.net", + "raw.githubusercontent.com", + "dev.azure.com", + "portal.azure.com", + "*.portal.azure.com", + "*.portal.azure.net", + "appservice.azureedge.net", + "*.azurewebsites.net", + "edge.management.azure.com", + "*.azurefd.net" + ] +} \ No newline at end of file diff --git a/infra/modules/azure-monitor.bicep b/infra/modules/azure-monitor.bicep new file mode 100644 index 00000000..511c8ac8 --- /dev/null +++ b/infra/modules/azure-monitor.bicep @@ -0,0 +1,139 @@ +targetScope = 'subscription' + +/* +** Azure Monitor Workload +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DeploymentSettings.bicep +@description('Type that describes the global deployment settings') +type DeploymentSettings = { + @description('If \'true\', then two regional deployments will be performed.') + isMultiLocationDeployment: bool + + @description('If \'true\', use production SKUs and settings.') + isProduction: bool + + @description('If \'true\', isolate the workload in a virtual network.') + isNetworkIsolated: bool + + @description('If \'false\', then this is a multi-location deployment for the second location.') + isPrimaryLocation: bool + + @description('The Azure region to host resources') + location: string + + @description('The name of the workload.') + name: string + + @description('The ID of the principal that is being used to deploy resources.') + principalId: string + + @description('The type of the \'principalId\' property.') + principalType: 'ServicePrincipal' | 'User' + + @description('The token to use for naming resources. This should be unique to the deployment.') + resourceToken: string + + @description('The development stage for this application') + stage: 'dev' | 'prod' + + @description('The common tags that should be used for all created resources') + tags: object + + @description('The common tags that should be used for all workload resources') + workloadTags: object +} + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The deployment settings to use for this deployment.') +param deploymentSettings DeploymentSettings + +@description('The resource names for the resources to be created.') +param resourceNames object + +@description('The name of the resource group which should hold Azure Monitor resources.') +param resourceGroupName string + +// ======================================================================== +// VARIABLES +// ======================================================================== + +// The tags to apply to all resources in this workload +var moduleTags = union(deploymentSettings.tags, { + WorkloadName: deploymentSettings.name + Environment: deploymentSettings.stage + OwnerName: deploymentSettings.tags['azd-owner-email'] + ServiceClass: deploymentSettings.isProduction ? 'Silver' : 'Dev' + OpsCommitment: 'Workload operations' +}) + +// ======================================================================== +// AZURE MODULES +// ======================================================================== + +resource resourceGroup 'Microsoft.Resources/resourceGroups@2022-09-01' existing = { + name: resourceGroupName +} + +module logAnalytics '../core/monitor/log-analytics-workspace.bicep' = { + name: 'workload-log-analytics' + scope: resourceGroup + params: { + name: resourceNames.logAnalyticsWorkspace + location: deploymentSettings.location + tags: moduleTags + + // Settings + sku: 'PerGB2018' + } +} + +module applicationInsights '../core/monitor/application-insights.bicep' = { + name: 'workload-application-insights' + scope: resourceGroup + params: { + name: resourceNames.applicationInsights + location: deploymentSettings.location + tags: moduleTags + + // Dependencies + logAnalyticsWorkspaceId: logAnalytics.outputs.id + + // Settings + kind: 'web' + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output application_insights_id string = applicationInsights.outputs.id +output log_analytics_workspace_id string = logAnalytics.outputs.id diff --git a/infra/modules/build-agent.bicep b/infra/modules/build-agent.bicep new file mode 100644 index 00000000..cfe63319 --- /dev/null +++ b/infra/modules/build-agent.bicep @@ -0,0 +1,194 @@ +targetScope = 'subscription' + +/* +** Create a Build Agent for Devops +** All Rights Reserved +** +*************************************************************************** +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DeploymentSettings.bicep +@description('Type that describes the global deployment settings') +type DeploymentSettings = { + @description('If \'true\', then two regional deployments will be performed.') + isMultiLocationDeployment: bool + + @description('If \'true\', use production SKUs and settings.') + isProduction: bool + + @description('If \'true\', isolate the workload in a virtual network.') + isNetworkIsolated: bool + + @description('If \'false\', then this is a multi-location deployment for the second location.') + isPrimaryLocation: bool + + @description('The Azure region to host resources') + location: string + + @description('The name of the workload.') + name: string + + @description('The ID of the principal that is being used to deploy resources.') + principalId: string + + @description('The type of the \'principalId\' property.') + principalType: 'ServicePrincipal' | 'User' + + @description('The token to use for naming resources. This should be unique to the deployment.') + resourceToken: string + + @description('The development stage for this application') + stage: 'dev' | 'prod' + + @description('The common tags that should be used for all created resources') + tags: object + + @description('The common tags that should be used for all workload resources') + workloadTags: object +} + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// From: infra/types/BuildAgentSettings.bicep +@description('Describes the required settings for a Azure DevOps Pipeline runner') +type AzureDevopsSettings = { + @description('The URL of the Azure DevOps organization to use for this agent') + organizationUrl: string + + @description('The Personal Access Token (PAT) to use for the Azure DevOps agent') + token: string +} + +@description('Describes the required settings for a GitHub Actions runner') +type GithubActionsSettings = { + @description('The URL of the GitHub repository to use for this agent') + repositoryUrl: string + + @description('The Personal Access Token (PAT) to use for the GitHub Actions runner') + token: string +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The deployment settings to use for this deployment.') +param deploymentSettings DeploymentSettings + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The resource names for the resources to be created.') +param resourceNames object + +/* +** Dependencies +*/ +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +@description('The ID of the managed identity to use as the identity for communicating with other services.') +param managedIdentityId string + +@description('The list of subnets that are used for linking into the virtual network if using network isolation.') +param subnets object + +/* +** Settings +*/ +@secure() +@minLength(8) +@description('The password for the administrator account on the build agent.') +param administratorPassword string + +@minLength(8) +@description('The username for the administrator account on the build agent.') +param administratorUsername string + +@description('The URL of the Azure DevOps organization. If this and the adoToken is provided, then an Azure DevOps build agent will be deployed.') +param adoOrganizationUrl string = '' + +@description('The access token for the Azure DevOps organization. If this and the adoOrganizationUrl is provided, then an Azure DevOps build agent will be deployed.') +param adoToken string = '' + +// Settings for setting up a build agent for GitHub Actions +@description('The URL of the GitHub repository. If this and the githubToken is provided, then a GitHub Actions build agent will be deployed.') +param githubRepositoryUrl string = '' + +@description('The personal access token for the GitHub repository. If this and the githubRepositoryUrl is provided, then a GitHub Actions build agent will be deployed.') +param githubToken string = '' + +// ======================================================================== +// VARIABLES +// ======================================================================== + +// The tags to apply to all resources in this workload +var moduleTags = union(deploymentSettings.tags, deploymentSettings.workloadTags, { + WorkloadType: 'Devops' +}) + +// ======================================================================== +// EXISTING RESOURCES +// ======================================================================== + +resource resourceGroup 'Microsoft.Resources/resourceGroups@2022-09-01' existing = { + name: resourceNames.spokeResourceGroup +} + +// ======================================================================== +// NEW RESOURCES +// ======================================================================== + +module buildAgent '../core/compute/windows-buildagent.bicep' = { + name: 'devops-build-agent' + scope: resourceGroup + params: { + name: resourceNames.buildAgent + location: deploymentSettings.location + tags: moduleTags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + managedIdentityId: managedIdentityId + subnetId: subnets[resourceNames.spokeDevopsSubnet].id + + // Settings + administratorPassword: administratorPassword + administratorUsername: administratorUsername + azureDevopsSettings: !empty(adoOrganizationUrl) && !empty(adoToken) ? { + organizationUrl: adoOrganizationUrl + token: adoToken + } : null + diagnosticSettings: diagnosticSettings + githubActionsSettings: !empty(githubRepositoryUrl) && !empty(githubToken) ? { + repositoryUrl: githubRepositoryUrl + token: githubToken + } : null + } +} + +// ======================================================================== +// NEW RESOURCES +// ======================================================================== + +output build_agent_id string = buildAgent.outputs.id +output build_agent_name string = buildAgent.outputs.name +output build_agent_hostname string = buildAgent.outputs.computer_name diff --git a/infra/modules/grant-secret-user.bicep b/infra/modules/grant-secret-user.bicep new file mode 100644 index 00000000..e9929f32 --- /dev/null +++ b/infra/modules/grant-secret-user.bicep @@ -0,0 +1,57 @@ +targetScope = 'resourceGroup' + +/* +** Find existing secrets and grant access to the reader identities. +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +*/ + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('Name of the existing Key Vault that contains the secret') +param keyVaultName string + +@description('List of user assigned managed identities that will receive Secrets User role to the shared key vault') +param readerIdentities object[] + +@description('Name of the existing Key Vault secret that will be readable') +param secretName string + +// ======================================================================== +// VARIABLES +// ======================================================================== + +@description('Built in \'Key Vault Secrets User\' role ID: https://docs.microsoft.com/en-us/azure/role-based-access-control/built-in-roles') +var vaultSecretsUserRoleId = '4633458b-17de-408a-b874-0445c86b69e6' + +// ======================================================================== +// EXISTING RESOURCES +// ======================================================================== + +resource existingKeyVault 'Microsoft.KeyVault/vaults@2019-09-01' existing = { + name: keyVaultName +} + +resource existingSecret 'Microsoft.KeyVault/vaults/secrets@2019-09-01' existing = { + name: secretName + parent: existingKeyVault +} + +// ======================================================================== +// AZURE MODULES +// ======================================================================== + +resource grantSecretsUserAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [ for id in readerIdentities: if (!empty(id.principalId)) { + name: guid(vaultSecretsUserRoleId, id.principalId, existingSecret.id, resourceGroup().name) + scope: existingSecret + properties: { + principalType: id.principalType + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', vaultSecretsUserRoleId) + principalId: id.principalId + } +}] diff --git a/infra/modules/hub-network.bicep b/infra/modules/hub-network.bicep new file mode 100644 index 00000000..5961fd24 --- /dev/null +++ b/infra/modules/hub-network.bicep @@ -0,0 +1,483 @@ +targetScope = 'subscription' + +/* +** Hub Network Infrastructure +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** The Hub Network consists of a virtual network that hosts resources that +** are generally associated with a hub. +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DeploymentSettings.bicep +@description('Type that describes the global deployment settings') +type DeploymentSettings = { + @description('If \'true\', then two regional deployments will be performed.') + isMultiLocationDeployment: bool + + @description('If \'true\', use production SKUs and settings.') + isProduction: bool + + @description('If \'true\', isolate the workload in a virtual network.') + isNetworkIsolated: bool + + @description('If \'false\', then this is a multi-location deployment for the second location.') + isPrimaryLocation: bool + + @description('The Azure region to host resources') + location: string + + @description('The name of the workload.') + name: string + + @description('The ID of the principal that is being used to deploy resources.') + principalId: string + + @description('The type of the \'principalId\' property.') + principalType: 'ServicePrincipal' | 'User' + + @description('The token to use for naming resources. This should be unique to the deployment.') + resourceToken: string + + @description('The development stage for this application') + stage: 'dev' | 'prod' + + @description('The common tags that should be used for all created resources') + tags: object + + @description('The common tags that should be used for all workload resources') + workloadTags: object +} + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The deployment settings to use for this deployment.') +param deploymentSettings DeploymentSettings + +@description('The diagnostic settings to use for this deployment.') +param diagnosticSettings DiagnosticSettings + +@description('The resource names for the resources to be created.') +param resourceNames object + +/* +** Dependencies +*/ +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +/* +** Settings +*/ +@secure() +@minLength(8) +@description('The password for the administrator account on the jump box.') +param administratorPassword string = newGuid() + +@minLength(8) +@description('The username for the administrator account on the jump box.') +param administratorUsername string = 'adminuser' + +@description('If enabled, an Ubuntu jump box will be deployed. Ensure you enable the bastion host as well.') +param enableJumpBox bool = false + +@description('The CIDR block to use for the address prefix of this virtual network.') +param addressPrefix string = '10.0.0.0/20' + +@description('If enabled, a Bastion Host will be deployed with a public IP address.') +param enableBastionHost bool = false + +@description('If enabled, DDoS Protection will be enabled on the virtual network') +param enableDDoSProtection bool = true + +@description('If enabled, an Azure Firewall will be deployed with a public IP address.') +param enableFirewall bool = true + +@description('The address spaces allowed to connect through the firewall. By default, we allow all RFC1918 address spaces') +param internalAddressSpace string[] = [ '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16' ] + +@description('If true, create a subnet for Devops resources') +param createDevopsSubnet bool = false + +// ======================================================================== +// VARIABLES +// ======================================================================== + +// The tags to apply to all resources in this workload +var moduleTags = union(deploymentSettings.tags, { + WorkloadName: 'NetworkHub' + OpsCommitment: 'Platform operations' + ServiceClass: deploymentSettings.isProduction ? 'Gold' : 'Dev' +}) + +// The subnet prefixes for the individual subnets inside the virtual network +var subnetPrefixes = [ for i in range(0, 16): cidrSubnet(addressPrefix, 26, i)] + +// The individual subnet definitions. +var bastionHostSubnetDefinition = { + name: resourceNames.hubSubnetBastionHost + properties: { + addressPrefix: subnetPrefixes[2] + privateEndpointNetworkPolicies: 'Disabled' + } +} + +var firewallSubnetDefinition = { + name: resourceNames.hubSubnetFirewall + properties: { + addressPrefix: subnetPrefixes[1] + privateEndpointNetworkPolicies: 'Disabled' + } +} + +var privateEndpointSubnet = { + name: resourceNames.hubSubnetPrivateEndpoint + properties: { + addressPrefix: subnetPrefixes[0] + privateEndpointNetworkPolicies: 'Disabled' + } +} + +var devopsSubnet = { + name: resourceNames.spokeDevopsSubnet + properties: { + addressPrefix: subnetPrefixes[6] + privateEndpointNetworkPolicies: 'Disabled' + } +} + +var subnets = union( + [privateEndpointSubnet], + enableBastionHost ? [bastionHostSubnetDefinition] : [], + enableFirewall ? [firewallSubnetDefinition] : [], + createDevopsSubnet ? [devopsSubnet] : [] +) + +// Some helpers for the firewall rules +var allowTraffic = { type: 'allow' } +var httpProtocol = { port: '80', protocolType: 'HTTP' } +var httpsProtocol = { port: '443', protocolType: 'HTTPS' } +var azureFqdns = loadJsonContent('./azure-fqdns.jsonc') + +// The firewall application rules +var applicationRuleCollections = [ + { + name: 'Azure-Monitor' + properties: { + action: allowTraffic + priority: 201 + rules: [ + { + name: 'allow-azure-monitor' + protocols: [ httpsProtocol ] + sourceAddresses: internalAddressSpace + targetFqdns: azureFqdns.azureMonitor + } + ] + } + } + { + name: 'Core-Dependencies' + properties: { + action: allowTraffic + priority: 200 + rules: [ + { + name: 'allow-core-apis' + protocols: [ httpsProtocol ] + sourceAddresses: internalAddressSpace + targetFqdns: azureFqdns.coreServices + } + { + name: 'allow-developer-services' + protocols: [ httpsProtocol ] + sourceAddresses: internalAddressSpace + targetFqdns: azureFqdns.developerServices + } + { + name: 'allow-certificate-dependencies' + protocols: [ httpProtocol, httpsProtocol ] + sourceAddresses: internalAddressSpace + targetFqdns: azureFqdns.certificateServices + } + ] + } + } +] + +// The subnet prefixes for the individual subnets inside the virtual network + +var networkRuleCollections = [ + { + name: 'Windows-VM-Connectivity-Requirements' + properties: { + action: { + type: 'allow' + } + priority: 202 + rules: [ + { + destinationAddresses: [ + '20.118.99.224' + '40.83.235.53' + '23.102.135.246' + '51.4.143.248' + '23.97.0.13' + '52.126.105.2' + ] + destinationPorts: [ + '*' + ] + name: 'allow-kms-activation' + protocols: [ + 'Any' + ] + sourceAddresses: [ subnetPrefixes[6] ] + } + { + destinationAddresses: [ + '*' + ] + destinationPorts: [ + '123' + '12000' + ] + name: 'allow-ntp' + protocols: [ + 'Any' + ] + sourceAddresses: [ subnetPrefixes[6] ] + } + ] + } + }] +// Our firewall does not use NAT rule collections, but you can set them up here. +var natRuleCollections = [] + +// Budget amounts +// All values are calculated in dollars (rounded to nearest dollar) in the South Central US region. +var budgetCategories = deploymentSettings.isProduction ? { + ddosProtectionPlan: 0 /* Includes protection for 100 public IP addresses */ + azureMonitor: 87 /* Estimate 1GiB/day Analytics, 1GiB/day Basic Logs */ + applicationInsights: 152 /* Estimate 5GiB/day Application Insights */ + keyVault: 1 /* Minimal usage - < 100 operations per month */ + virtualNetwork: 0 /* Virtual networks are free - peering included in spoke */ + firewall: 290 /* Basic plan, 100GiB processed */ + bastionHost: 212 /* Standard plan */ + jumpbox: 85 /* Standard_B2ms, S10 managed disk, minimal bandwidth usage */ +} : { + ddosProtectionPlan: 0 /* Includes protection for 100 public IP addresses */ + azureMonitor: 69 /* Estimate 1GiB/day Analytics + Basic Logs */ + applicationInsights: 187 /* Estimate 1GiB/day Application Insights */ + keyVault: 1 /* Minimal usage - < 100 operations per month */ + virtualNetwork: 0 /* Virtual networks are free - peering included in spoke */ + firewall: 290 /* Standard plan, 100GiB processed */ + bastionHost: 139 /* Basic plan */ + jumpbox: 85 /* Standard_B2ms, S10 managed disk, minimal bandwidth usage */ +} +var budgetAmount = reduce(map(items(budgetCategories), (obj) => obj.value), 0, (total, amount) => total + amount) + +// ======================================================================== +// AZURE MODULES +// ======================================================================== + +resource resourceGroup 'Microsoft.Resources/resourceGroups@2022-09-01' existing = { + name: resourceNames.hubResourceGroup +} + +module ddosProtectionPlan '../core/network/ddos-protection-plan.bicep' = if (enableDDoSProtection) { + name: 'hub-ddos-protection-plan-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.hubDDoSProtectionPlan + location: deploymentSettings.location + tags: moduleTags + } +} + +module virtualNetwork '../core/network/virtual-network.bicep' = { + name: 'hub-virtual-network-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.hubVirtualNetwork + location: deploymentSettings.location + tags: moduleTags + + // Dependencies + ddosProtectionPlanId: enableDDoSProtection ? ddosProtectionPlan.outputs.id : '' + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + + // Settings + addressPrefix: addressPrefix + diagnosticSettings: diagnosticSettings + subnets: subnets + } +} + +module firewall '../core/network/firewall.bicep' = if (enableFirewall) { + name: 'hub-firewall-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.hubFirewall + location: deploymentSettings.location + tags: moduleTags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + subnetId: virtualNetwork.outputs.subnets[resourceNames.hubSubnetFirewall].id + + // Settings + diagnosticSettings: diagnosticSettings + publicIpAddressName: resourceNames.hubFirewallPublicIpAddress + sku: 'Standard' + threatIntelMode: 'Deny' + zoneRedundant: deploymentSettings.isProduction + + // Firewall rules + applicationRuleCollections: applicationRuleCollections + natRuleCollections: natRuleCollections + networkRuleCollections: networkRuleCollections + } +} + + +module jumpbox '../core/compute/ubuntu-jumpbox.bicep' = if (enableJumpBox) { + name: 'hub-jumpbox-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.hubJumpbox + location: deploymentSettings.location + tags: moduleTags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + subnetId: virtualNetwork.outputs.subnets[resourceNames.spokeDevopsSubnet].id + + // Settings + adminPasswordOrKey: administratorPassword + adminUsername: administratorUsername + diagnosticSettings: diagnosticSettings + } +} + + +module bastionHost '../core/network/bastion-host.bicep' = if (enableBastionHost) { + name: 'hub-bastion-host-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.hubBastionHost + location: deploymentSettings.location + tags: moduleTags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + subnetId: virtualNetwork.outputs.subnets[resourceNames.hubSubnetBastionHost].id + + // Settings + diagnosticSettings: diagnosticSettings + publicIpAddressName: resourceNames.hubBastionPublicIpAddress + sku: deploymentSettings.isProduction ? 'Standard' : 'Basic' + zoneRedundant: deploymentSettings.isProduction + } +} + +/* + The vault will always be deployed because it stores Microsoft Entra app registration details. + The dynamic part of this feature is whether or not the Vault is located in the Hub (yes, when Network Isolated) + or if it is located in the Workload resource group (yes, when Network Isolation is not enabled). + */ +module sharedKeyVault '../core/security/key-vault.bicep' = { + name: 'shared-key-vault-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.keyVault + location: deploymentSettings.location + tags: moduleTags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + + // Settings + diagnosticSettings: diagnosticSettings + enablePublicNetworkAccess: true + ownerIdentities: [ + { principalId: deploymentSettings.principalId, principalType: deploymentSettings.principalType } + ] + privateEndpointSettings: { + dnsResourceGroupName: resourceGroup.name + name: resourceNames.keyVaultPrivateEndpoint + resourceGroupName: resourceGroup.name + subnetId: virtualNetwork.outputs.subnets[privateEndpointSubnet.name].id + } + } +} + +module hubBudget '../core/cost-management/budget.bicep' = { + name: 'hub-budget-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.hubBudget + amount: budgetAmount + contactEmails: [ + deploymentSettings.tags['azd-owner-email'] + ] + resourceGroups: [ + resourceGroup.name + ] + } +} + +var virtualNetworkLinks = [ + { + vnetName: virtualNetwork.outputs.name + vnetId: virtualNetwork.outputs.id + registrationEnabled: false + } +] + +module privateDnsZones './private-dns-zones.bicep' = { + name: 'hub-private-dns-zone-deploy-${deploymentSettings.resourceToken}' + params:{ + deploymentSettings: deploymentSettings + hubResourceGroupName: resourceGroup.name + virtualNetworkLinks: virtualNetworkLinks + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output bastion_name string = enableBastionHost ? bastionHost.outputs.name : '' +output bastion_hostname string = enableBastionHost ? bastionHost.outputs.hostname : '' +output firewall_hostname string = enableFirewall ? firewall.outputs.hostname : '' +output firewall_ip_address string = enableFirewall ? firewall.outputs.internal_ip_address : '' +output virtual_network_id string = virtualNetwork.outputs.id +output virtual_network_name string = virtualNetwork.outputs.name +output key_vault_name string = enableJumpBox ? sharedKeyVault.outputs.name : '' +output jumpbox_computer_name string = enableJumpBox ? jumpbox.outputs.computer_name : '' +output jumpbox_resource_id string = enableJumpBox ? jumpbox.outputs.id : '' diff --git a/infra/modules/naming.bicep b/infra/modules/naming.bicep new file mode 100644 index 00000000..6b1ad42e --- /dev/null +++ b/infra/modules/naming.bicep @@ -0,0 +1,231 @@ +targetScope = 'subscription' + +/* +** Resource Naming +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Provides a name for every resource that may be created. +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DeploymentSettings.bicep +@description('Type that describes the global deployment settings') +type DeploymentSettings = { + @description('If \'true\', then two regional deployments will be performed.') + isMultiLocationDeployment: bool + + @description('If \'true\', use production SKUs and settings.') + isProduction: bool + + @description('If \'true\', isolate the workload in a virtual network.') + isNetworkIsolated: bool + + @description('If \'false\', then this is a multi-location deployment for the second location.') + isPrimaryLocation: bool + + @description('The Azure region to host resources') + location: string + + @description('The name of the workload.') + name: string + + @description('The ID of the principal that is being used to deploy resources.') + principalId: string + + @description('The type of the \'principalId\' property.') + principalType: 'ServicePrincipal' | 'User' + + @description('The token to use for naming resources. This should be unique to the deployment.') + resourceToken: string + + @description('The development stage for this application') + stage: 'dev' | 'prod' + + @description('The common tags that should be used for all created resources') + tags: object + + @description('The common tags that should be used for all workload resources') + workloadTags: object +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The global deployment settings') +param deploymentSettings DeploymentSettings + +@description('A differentiator for the environment. Set this to a build number or date to ensure that the resource groups and resources are unique.') +param differentiator string = '' + +@description('The primary Azure location to deploy resources and the location of the hub.') +param primaryLocation string + +var resourceToken = deploymentSettings.resourceToken + +@description('The overrides for the naming scheme. Load this from the naming.overrides.jsonc file.') +param overrides object = {} + +// ======================================================================== +// VARIABLES +// ======================================================================== + +// The prefix for resource groups +var diffPrefix = !empty(differentiator) ? '-${differentiator}' : '' +var hubResourceGroupPrefix = 'rg-${deploymentSettings.name}-${deploymentSettings.stage}-${primaryLocation}' +var resourceGroupPrefix = 'rg-${deploymentSettings.name}-${deploymentSettings.stage}-${deploymentSettings.location}${diffPrefix}' + +// The list of resource names that are used in the deployment. The default +// names use Cloud Adoption Framework abbreviations. +// See: https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-abbreviations +var defaultResourceNames = { + // Hub network resources + hubBastionHost: 'bas-${resourceToken}' + hubBastionPublicIpAddress: 'pip-bas-${resourceToken}' + hubBudget: 'budget-hub-${resourceToken}' + hubDDoSProtectionPlan: 'ddos-${resourceToken}' + hubFirewall: 'afw-${resourceToken}' + hubFirewallPublicIpAddress: 'pip-afw-${resourceToken}' + hubJumpbox: 'vm-jump-${resourceToken}' + hubResourceGroup: '${hubResourceGroupPrefix}-hub' + hubSubnetBastionHost: 'AzureBastionSubnet' + hubSubnetFirewall: 'AzureFirewallSubnet' + hubSubnetJumpbox: 'JumpboxSubnet' + hubSubnetPrivateEndpoint: 'PrivateEndpointSubnet' + hubVirtualNetwork: 'vnet-hub-${resourceToken}' + + // Spoke network resources + spokeApiInboundSubnet: 'API-Inbound' + spokeApiInboundNSG: 'nsg-api-in-${resourceToken}' + spokeApiOutboundSubnet: 'API-Outbound' + spokeApiOutboundNSG: 'nsg-api-out-${resourceToken}' + spokeDevopsSubnet: 'DevopsBuildAgents' + spokeDeploymentSubnet: 'Deployment' + spokeResourceGroup: '${resourceGroupPrefix}-spoke' + spokeRouteTable: 'rt-${resourceToken}' + spokePrivateEndpointNSG: 'nsg-pep-${resourceToken}' + spokePrivateEndpointSubnet: 'Private-Endpoints' + spokeVirtualNetwork: 'vnet-spoke-${resourceToken}' + spokeWebInboundSubnet: 'Web-Inbound-${resourceToken}' + spokeWebInboundNSG: 'nsg-web-in-${resourceToken}' + spokeWebOutboundSubnet: 'Web-Outbound-${resourceToken}' + spokeWebOutboundNSG: 'nsg-web-out-${resourceToken}' + + // Common resources - may be in hub or application resource group + applicationInsights: 'appi-${resourceToken}' + buildAgent: 'vm-buildagent-${resourceToken}' + logAnalyticsWorkspace: 'log-${resourceToken}' + keyVault: 'kv-${resourceToken}' + keyVaultPrivateEndpoint: 'pep-kv-${resourceToken}' + + // Application resources + apiAppService: 'app-api-${resourceToken}' + apiAppServicePlan: 'asp-api-${resourceToken}' + apiPrivateEndpoint: 'pep-api-${resourceToken}' + appConfiguration: 'appconfig-${resourceToken}' + appConfigurationPrivateEndpoint: 'pep-appconfig-${resourceToken}' + appManagedIdentity: 'id-app-${resourceToken}' + budget: 'budget-${deploymentSettings.name}-${deploymentSettings.stage}-${deploymentSettings.location}${diffPrefix}' + commonAppServicePlan: 'asp-common-${resourceToken}' + frontDoorEndpoint: 'fde-${resourceToken}' + frontDoorProfile: 'afd-${resourceToken}' + ownerManagedIdentity: 'id-owner-${resourceToken}' + resourceGroup: '${resourceGroupPrefix}-application' + redis: 'redis-${resourceToken}' + redisPrivateEndpoint: 'pep-redis-${resourceToken}' + storageAccount: 'st${deploymentSettings.stage}${resourceToken}' + storageAccountPrivateEndpoint: 'pep-st-${resourceToken}' + storageAccountContainer: 'tickets' + sqlDatabase: 'relecloud-${resourceToken}' + sqlDatabasePrivateEndpoint: 'pep-sqldb-${resourceToken}' + sqlServer: 'sql-${resourceToken}' + sqlResourceGroup: '${resourceGroupPrefix}-application' + webAppFrontend: 'app-webfrontend-${resourceToken}' + webAppService: 'app-webservice-${resourceToken}' + webAppServicePlan: 'asp-web-${resourceToken}' + webApplicationFirewall: 'waf${resourceToken}' + webAppFrontendPrivateEndpoint: 'pep-web-frontend-${resourceToken}' + webAppServicePrivateEndpoint: 'pep-web-service-${resourceToken}' +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output resourceToken string = resourceToken + +output resourceNames object = { + // Hub network resources + hubBastionHost: contains(overrides, 'hubBastionHost') && !empty(overrides.hubBastionHost) ? overrides.hubBastionHost : defaultResourceNames.hubBastionHost + hubBastionPublicIpAddress: contains(overrides, 'hubBastionPublicIpAddress') && !empty(overrides.hubBastionPublicIpAddress) ? overrides.hubBastionPublicIpAddress : defaultResourceNames.hubBastionPublicIpAddress + hubBudget: contains(overrides, 'hubBudget') && !empty(overrides.hubBudget) ? overrides.hubBudget : defaultResourceNames.hubBudget + hubDDoSProtectionPlan: contains(overrides, 'hubDDoSProtectionPlan') && !empty(overrides.hubDDoSProtectionPlan) ? overrides.hubDDoSProtectionPlan : defaultResourceNames.hubDDoSProtectionPlan + hubFirewall: contains(overrides, 'hubFirewall') && !empty(overrides.hubFirewall) ? overrides.hubFirewall : defaultResourceNames.hubFirewall + hubFirewallPublicIpAddress: contains(overrides, 'hubFirewallPublicIpAddress') && !empty(overrides.hubFirewallPublicIpAddress) ? overrides.hubFirewallPublicIpAddress : defaultResourceNames.hubFirewallPublicIpAddress + hubJumpbox: contains(overrides, 'hubJumpbox') && !empty(overrides.hubJumpbox) ? overrides.hubJumpbox : defaultResourceNames.hubJumpbox + hubResourceGroup: contains(overrides, 'hubResourceGroup') && !empty(overrides.hubResourceGroup) ? overrides.hubResourceGroup : defaultResourceNames.hubResourceGroup + hubSubnetBastionHost: contains(overrides, 'hubSubnetBastionHost') && !empty(overrides.hubSubnetBastionHost) ? overrides.hubSubnetBastionHost : defaultResourceNames.hubSubnetBastionHost + hubSubnetFirewall: contains(overrides, 'hubSubnetFirewall') && !empty(overrides.hubSubnetFirewall) ? overrides.hubSubnetFirewall : defaultResourceNames.hubSubnetFirewall + hubSubnetJumpbox: contains(overrides, 'hubSubnetJumpbox') && !empty(overrides.hubSubnetJumpbox) ? overrides.hubSubnetJumpbox : defaultResourceNames.hubSubnetJumpbox + hubSubnetPrivateEndpoint: contains(overrides, 'hubSubnetPrivateEndpoint') && !empty(overrides.hubSubnetPrivateEndpoint) ? overrides.hubSubnetPrivateEndpoint : defaultResourceNames.hubSubnetPrivateEndpoint + hubVirtualNetwork: contains(overrides, 'hubVirtualNetwork') && !empty(overrides.hubVirtualNetwork) ? overrides.hubVirtualNetwork : defaultResourceNames.hubVirtualNetwork + + // Spoke network resources + spokeApiInboundSubnet: contains(overrides, 'spokeApiInboundSubnet') && !empty(overrides.spokeApiInboundSubnet) ? overrides.spokeApiInboundSubnet : defaultResourceNames.spokeApiInboundSubnet + spokeApiInboundNSG: contains(overrides, 'spokeApiInboundNSG') && !empty(overrides.spokeApiInboundNSG) ? overrides.spokeApiInboundNSG : defaultResourceNames.spokeApiInboundNSG + spokeApiOutboundSubnet: contains(overrides, 'spokeApiOutboundSubnet') && !empty(overrides.spokeApiOutboundSubnet) ? overrides.spokeApiOutboundSubnet : defaultResourceNames.spokeApiOutboundSubnet + spokeApiOutboundNSG: contains(overrides, 'spokeApiOutboundNSG') && !empty(overrides.spokeApiOutboundNSG) ? overrides.spokeApiOutboundNSG : defaultResourceNames.spokeApiOutboundNSG + spokeDevopsSubnet: contains(overrides, 'spokeDevopsSubnet') && !empty(overrides.spokeDevopsSubnet) ? overrides.spokeDevopsSubnet : defaultResourceNames.spokeDevopsSubnet + spokeDeploymentSubnet: contains(overrides, 'spokeDeploymentSubnet') && !empty(overrides.spokeDeploymentSubnet) ? overrides.spokeDeploymentSubnet : defaultResourceNames.spokeDeploymentSubnet + spokeResourceGroup: contains(overrides, 'spokeResourceGroup') && !empty(overrides.spokeResourceGroup) ? overrides.spokeResourceGroup : defaultResourceNames.spokeResourceGroup + spokeRouteTable: contains(overrides, 'spokeRouteTable') && !empty(overrides.spokeRouteTable) ? overrides.spokeRouteTable : defaultResourceNames.spokeRouteTable + spokePrivateEndpointNSG: contains(overrides, 'spokePrivateEndpointNSG') && !empty(overrides.spokePrivateEndpointNSG) ? overrides.spokePrivateEndpointNSG : defaultResourceNames.spokePrivateEndpointNSG + spokePrivateEndpointSubnet: contains(overrides, 'spokePrivateEndpointSubnet') && !empty(overrides.spokePrivateEndpointSubnet) ? overrides.spokePrivateEndpointSubnet : defaultResourceNames.spokePrivateEndpointSubnet + spokeVirtualNetwork: contains(overrides, 'spokeVirtualNetwork') && !empty(overrides.spokeVirtualNetwork) ? overrides.spokeVirtualNetwork : defaultResourceNames.spokeVirtualNetwork + spokeWebInboundSubnet: contains(overrides, 'spokeWebInboundSubnet') && !empty(overrides.spokeWebInboundSubnet) ? overrides.spokeWebInboundSubnet : defaultResourceNames.spokeWebInboundSubnet + spokeWebInboundNSG: contains(overrides, 'spokeWebInboundNSG') && !empty(overrides.spokeWebInboundNSG) ? overrides.spokeWebInboundNSG : defaultResourceNames.spokeWebInboundNSG + spokeWebOutboundSubnet: contains(overrides, 'spokeWebOutboundSubnet') && !empty(overrides.spokeWebOutboundSubnet) ? overrides.spokeWebOutboundSubnet : defaultResourceNames.spokeWebOutboundSubnet + spokeWebOutboundNSG: contains(overrides, 'spokeWebOutboundNSG') && !empty(overrides.spokeWebOutboundNSG) ? overrides.spokeWebOutboundNSG : defaultResourceNames.spokeWebOutboundNSG + + // Common services - may be in hub or application resource group + applicationInsights: contains(overrides, 'applicationInsights') && !empty(overrides.applicationInsights) ? overrides.applicationInsights : defaultResourceNames.applicationInsights + buildAgent: contains(overrides, 'buildAgent') && !empty(overrides.buildAgent) ? overrides.buildAgent : defaultResourceNames.buildAgent + logAnalyticsWorkspace: contains(overrides, 'logAnalyticsWorkspace') && !empty(overrides.logAnalyticsWorkspace) ? overrides.logAnalyticsWorkspace : defaultResourceNames.logAnalyticsWorkspace + + // Application resources + apiAppService: contains(overrides, 'apiAppService') && !empty(overrides.apiAppService) ? overrides.apiAppService : defaultResourceNames.apiAppService + apiAppServicePlan: contains(overrides, 'apiAppServicePlan') && !empty(overrides.apiAppServicePlan) ? overrides.apiAppServicePlan : defaultResourceNames.apiAppServicePlan + apiPrivateEndpoint: contains(overrides, 'apiPrivateEndpoint') && !empty(overrides.apiPrivateEndpoint) ? overrides.apiPrivateEndpoint : defaultResourceNames.apiPrivateEndpoint + appConfiguration: contains(overrides, 'appConfiguration') && !empty(overrides.appConfiguration) ? overrides.appConfiguration : defaultResourceNames.appConfiguration + appConfigurationPrivateEndpoint: contains(overrides, 'appConfigurationPrivateEndpoint') && !empty(overrides.appConfigurationPrivateEndpoint) ? overrides.appConfigurationPrivateEndpoint : defaultResourceNames.appConfigurationPrivateEndpoint + appManagedIdentity: contains(overrides, 'appManagedIdentity') && !empty(overrides.appManagedIdentity) ? overrides.appManagedIdentity : defaultResourceNames.appManagedIdentity + budget: contains(overrides, 'budget') && !empty(overrides.budget) ? overrides.budget : defaultResourceNames.budget + commonAppServicePlan: contains(overrides, 'commonAppServicePlan') && !empty(overrides.commonAppServicePlan) ? overrides.commonAppServicePlan : defaultResourceNames.commonAppServicePlan + frontDoorEndpoint: contains(overrides, 'frontDoorEndpoint') && !empty(overrides.frontDoorEndpoint) ? overrides.frontDoorEndpoint : defaultResourceNames.frontDoorEndpoint + frontDoorProfile: contains(overrides, 'frontDoorProfile') && !empty(overrides.frontDoorProfile) ? overrides.frontDoorProfile : defaultResourceNames.frontDoorProfile + keyVault: contains(overrides, 'keyVault') && !empty(overrides.keyVault) ? overrides.keyVault : defaultResourceNames.keyVault + keyVaultPrivateEndpoint: contains(overrides, 'keyVaultPrivateEndpoint') && !empty(overrides.keyVaultPrivateEndpoint) ? overrides.keyVaultPrivateEndpoint : defaultResourceNames.keyVaultPrivateEndpoint + ownerManagedIdentity: contains(overrides, 'ownerManagedIdentity') && !empty(overrides.ownerManagedIdentity) ? overrides.ownerManagedIdentity : defaultResourceNames.ownerManagedIdentity + redis: contains(overrides, 'redis') && !empty(overrides.redis) ? overrides.redis : defaultResourceNames.redis + redisPrivateEndpoint: contains(overrides, 'redisPrivateEndpoint') && !empty(overrides.redisPrivateEndpoint) ? overrides.redisPrivateEndpoint : defaultResourceNames.redisPrivateEndpoint + resourceGroup: contains(overrides, 'resourceGroup') && !empty(overrides.resourceGroup) ? overrides.resourceGroup : defaultResourceNames.resourceGroup + storageAccount: contains(overrides, 'storageAccount') && !empty(overrides.storageAccount) ? overrides.storageAccount : defaultResourceNames.storageAccount + storageAccountPrivateEndpoint: contains(overrides, 'storageAccountPrivateEndpoint') && !empty(overrides.storageAccountPrivateEndpoint) ? overrides.storageAccountPrivateEndpoint : defaultResourceNames.storageAccountPrivateEndpoint + storageAccountContainer: contains(overrides, 'storageAccountContainer') && !empty(overrides.storageAccountContainer) ? overrides.storageAccountContainer : defaultResourceNames.storageAccountContainer + sqlDatabase: contains(overrides, 'sqlDatabase') && !empty(overrides.sqlDatabase) ? overrides.sqlDatabase : defaultResourceNames.sqlDatabase + sqlDatabasePrivateEndpoint: contains(overrides, 'sqlDatabasePrivateEndpoint') && !empty(overrides.sqlDatabasePrivateEndpoint) ? overrides.sqlDatabasePrivateEndpoint : defaultResourceNames.sqlDatabasePrivateEndpoint + sqlServer: contains(overrides, 'sqlServer') && !empty(overrides.sqlServer) ? overrides.sqlServer : defaultResourceNames.sqlServer + sqlResourceGroup: contains(overrides, 'sqlResourceGroup') && !empty(overrides.sqlResourceGroup) ? overrides.sqlResourceGroup : defaultResourceNames.sqlResourceGroup + webAppFrontend: contains(overrides, 'webAppFrontend') && !empty(overrides.webAppFrontend) ? overrides.webAppFrontend : defaultResourceNames.webAppFrontend + webAppService: contains(overrides, 'webAppService') && !empty(overrides.webAppService) ? overrides.webAppService : defaultResourceNames.webAppService + webAppServicePlan: contains(overrides, 'webAppServicePlan') && !empty(overrides.webAppServicePlan) ? overrides.webAppServicePlan : defaultResourceNames.webAppServicePlan + webApplicationFirewall: contains(overrides, 'webApplicationFirewall') && !empty(overrides.webApplicationFirewall) ? overrides.webApplicationFirewall : defaultResourceNames.webApplicationFirewall + webAppFrontendPrivateEndpoint: contains(overrides, 'webAppFrontendPrivateEndpoint') && !empty(overrides.webAppFrontendPrivateEndpoint) ? overrides.webAppFrontendPrivateEndpoint : defaultResourceNames.webAppFrontendPrivateEndpoint + webAppServicePrivateEndpoint: contains(overrides, 'webAppServicePrivateEndpoint') && !empty(overrides.webAppServicePrivateEndpoint) ? overrides.webAppServicePrivateEndpoint : defaultResourceNames.webAppServicePrivateEndpoint +} diff --git a/infra/modules/peer-networks.bicep b/infra/modules/peer-networks.bicep new file mode 100644 index 00000000..034f6fd5 --- /dev/null +++ b/infra/modules/peer-networks.bicep @@ -0,0 +1,66 @@ +targetScope = 'subscription' + +/* +** Two-way Virtual Network Peering +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +@description('A type to describe a virtual network') +type VirtualNetworkDefinition = { + @description('The name of the virtual network') + name: string + + @description('The name of the resource group that contains the virtual network') + resourceGroupName: string +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The network definition for the hub network') +param hubNetwork VirtualNetworkDefinition + +@description('The network definition of the spoke network') +param spokeNetwork VirtualNetworkDefinition + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource hubVirtualNetwork 'Microsoft.Network/virtualNetworks@2020-06-01' existing = { + name: hubNetwork.name + scope: resourceGroup(hubNetwork.resourceGroupName) +} + +resource spokeVirtualNetwork 'Microsoft.Network/virtualNetworks@2020-06-01' existing = { + name: spokeNetwork.name + scope: resourceGroup(spokeNetwork.resourceGroupName) +} + +module peerSpokeToHub '../core/network/peer-virtual-network.bicep' = { + name: 'peer-${spokeNetwork.name}-to-${hubNetwork.name}-network' + scope: resourceGroup(spokeNetwork.resourceGroupName) + params: { + name: 'peerTo-${hubVirtualNetwork.name}' + virtualNetworkName: spokeVirtualNetwork.name + remoteVirtualNetworkId: hubVirtualNetwork.id + } +} + +module peerHubToSpoke '../core/network/peer-virtual-network.bicep' = { + name: 'peer-${hubNetwork.name}-to-${spokeNetwork.name}-network' + scope: resourceGroup(hubNetwork.resourceGroupName) + params: { + name: 'peerTo-${spokeVirtualNetwork.name}' + virtualNetworkName: hubVirtualNetwork.name + remoteVirtualNetworkId: spokeVirtualNetwork.id + } +} diff --git a/infra/modules/private-dns-zones.bicep b/infra/modules/private-dns-zones.bicep new file mode 100644 index 00000000..d720f169 --- /dev/null +++ b/infra/modules/private-dns-zones.bicep @@ -0,0 +1,123 @@ +targetScope = 'subscription' + +/* +** Private DNS Zones +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** The Hub Network contains these Private DNS Zones that provide dynamic +** DNS registration for private endpoints in all virtual networks +** associated with this deployment by virtualNetworkLinks. +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DeploymentSettings.bicep +@description('Type that describes the global deployment settings') +type DeploymentSettings = { + @description('If \'true\', then two regional deployments will be performed.') + isMultiLocationDeployment: bool + + @description('If \'true\', use production SKUs and settings.') + isProduction: bool + + @description('If \'true\', isolate the workload in a virtual network.') + isNetworkIsolated: bool + + @description('If \'false\', then this is a multi-location deployment for the second location.') + isPrimaryLocation: bool + + @description('The Azure region to host resources') + location: string + + @description('The name of the workload.') + name: string + + @description('The ID of the principal that is being used to deploy resources.') + principalId: string + + @description('The type of the \'principalId\' property.') + principalType: 'ServicePrincipal' | 'User' + + @description('The token to use for naming resources. This should be unique to the deployment.') + resourceToken: string + + @description('The development stage for this application') + stage: 'dev' | 'prod' + + @description('The common tags that should be used for all created resources') + tags: object + + @description('The common tags that should be used for all workload resources') + workloadTags: object +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The deployment settings to use for this deployment.') +param deploymentSettings DeploymentSettings + +@description('The list of private DNS zones to create in this virtual network.') +param privateDnsZones array = [ + 'privatelink.vaultcore.azure.net' + 'privatelink${az.environment().suffixes.sqlServerHostname}' + 'privatelink.azurewebsites.net' + 'privatelink.redis.cache.windows.net' + 'privatelink.azconfig.io' + 'privatelink.blob.${environment().suffixes.storage}' +] + +@description('The hub resource group name.') +param hubResourceGroupName string + +@description('Specifies if DNS zone will be created, or if we are attaching to an existing one') +param createDnsZone bool = true + +@description('Array of custom objects describing vNet links of the DNS zone. Each object should contain vnetName, vnetId, registrationEnabled') +param virtualNetworkLinks array = [] + +// ======================================================================== +// VARIABLES +// ======================================================================== + +// The tags to apply to all resources in this workload +var moduleTags = union(deploymentSettings.tags, { + WorkloadName: 'NetworkHub' + OpsCommitment: 'Platform operations' + ServiceClass: deploymentSettings.isProduction ? 'Gold' : 'Dev' +}) + +// ======================================================================== +// AZURE Resources +// ======================================================================== + +resource resourceGroup 'Microsoft.Resources/resourceGroups@2022-09-01' existing = { + name: hubResourceGroupName +} + +module createNewDnsZones '../core/network/private-dns-zone.bicep' = [ for dnsZoneName in createDnsZone ? privateDnsZones : []: { + name: 'create-new-dns-zone-${dnsZoneName}' + scope: resourceGroup + params: { + name: dnsZoneName + tags: moduleTags + virtualNetworkLinks: virtualNetworkLinks + } +}] + +module updateVnetLinkForDnsZones '../core/network/private-dns-zone-link.bicep' = [ for dnsZoneName in !createDnsZone ? privateDnsZones : []: { + name: createDnsZone ? 'hub-vnet-link-for-dns-${dnsZoneName}' : deploymentSettings.isPrimaryLocation ? 'spk-0-vnet-link-for-dns-${dnsZoneName}' : 'spk-1-link-for-dns-${dnsZoneName}' + scope: resourceGroup + params: { + name: dnsZoneName + virtualNetworkLinks: virtualNetworkLinks + } +}] + +output dns_resource_group_name string = resourceGroup.name diff --git a/infra/modules/resource-groups.bicep b/infra/modules/resource-groups.bicep new file mode 100644 index 00000000..b6fe2a7b --- /dev/null +++ b/infra/modules/resource-groups.bicep @@ -0,0 +1,109 @@ +targetScope = 'subscription' + +/* +** Resource Groups +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** Creates all the resource groups needed by this deployment +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DeploymentSettings.bicep +@description('Type that describes the global deployment settings') +type DeploymentSettings = { + @description('If \'true\', then two regional deployments will be performed.') + isMultiLocationDeployment: bool + + @description('If \'true\', use production SKUs and settings.') + isProduction: bool + + @description('If \'true\', isolate the workload in a virtual network.') + isNetworkIsolated: bool + + @description('If \'false\', then this is a multi-location deployment for the second location.') + isPrimaryLocation: bool + + @description('The Azure region to host resources') + location: string + + @description('The name of the workload.') + name: string + + @description('The ID of the principal that is being used to deploy resources.') + principalId: string + + @description('The type of the \'principalId\' property.') + principalType: 'ServicePrincipal' | 'User' + + @description('The token to use for naming resources. This should be unique to the deployment.') + resourceToken: string + + @description('The development stage for this application') + stage: 'dev' | 'prod' + + @description('The common tags that should be used for all created resources') + tags: object + + @description('The common tags that should be used for all workload resources') + workloadTags: object +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The global deployment settings') +param deploymentSettings DeploymentSettings + +@description('The list of resource names to use') +param resourceNames object + +@description('If true, deploy a hub network') +param deployHubNetwork bool + +// ======================================================================== +// VARIABLES +// ======================================================================== + +var createHub = deployHubNetwork && resourceNames.hubResourceGroup != resourceNames.resourceGroup && deploymentSettings.isPrimaryLocation +var createSpoke = deploymentSettings.isNetworkIsolated && resourceNames.spokeResourceGroup != resourceNames.resourceGroup + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource hubResourceGroup 'Microsoft.Resources/resourceGroups@2022-09-01' = if (createHub) { + name: resourceNames.hubResourceGroup + location: deploymentSettings.location + tags: union(deploymentSettings.tags, { + WorkloadName: 'NetworkHub' + OpsCommitment: 'Platform operations' + }) +} + +resource spokeResourceGroup 'Microsoft.Resources/resourceGroups@2022-09-01' = if (createSpoke) { + name: resourceNames.spokeResourceGroup + location: deploymentSettings.location + tags: union(deploymentSettings.tags, deploymentSettings.workloadTags) +} + +resource applicationResourceGroup 'Microsoft.Resources/resourceGroups@2022-09-01' = { + name: resourceNames.resourceGroup + location: deploymentSettings.location + tags: union(deploymentSettings.tags, deploymentSettings.workloadTags) +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + + +output application_resource_group_name string = applicationResourceGroup.name +output spoke_resource_group_name string = createSpoke ? spokeResourceGroup.name : 'spoke-not-created' +output hub_resource_group_name string = createHub ? hubResourceGroup.name : 'hub-not-created' diff --git a/infra/modules/shared-frontdoor.bicep b/infra/modules/shared-frontdoor.bicep new file mode 100644 index 00000000..31da1692 --- /dev/null +++ b/infra/modules/shared-frontdoor.bicep @@ -0,0 +1,157 @@ +targetScope = 'subscription' + +/* +** Azure Front Door resource for the front-end and API web apps +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DeploymentSettings.bicep +@description('Type that describes the global deployment settings') +type DeploymentSettings = { + @description('If \'true\', then two regional deployments will be performed.') + isMultiLocationDeployment: bool + + @description('If \'true\', use production SKUs and settings.') + isProduction: bool + + @description('If \'true\', isolate the workload in a virtual network.') + isNetworkIsolated: bool + + @description('If \'false\', then this is a multi-location deployment for the second location.') + isPrimaryLocation: bool + + @description('The Azure region to host resources') + location: string + + @description('The name of the workload.') + name: string + + @description('The ID of the principal that is being used to deploy resources.') + principalId: string + + @description('The type of the \'principalId\' property.') + principalType: 'ServicePrincipal' | 'User' + + @description('The token to use for naming resources. This should be unique to the deployment.') + resourceToken: string + + @description('The development stage for this application') + stage: 'dev' | 'prod' + + @description('The common tags that should be used for all created resources') + tags: object + + @description('The common tags that should be used for all workload resources') + workloadTags: object +} + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// From: infra/types/FrontDoorSettings.bicep +@description('Type describing the settings for Azure Front Door.') +type FrontDoorSettings = { + @description('The name of the Azure Front Door endpoint') + endpointName: string + + @description('Front Door Id used for traffic restriction') + frontDoorId: string + + @description('The hostname that can be used to access Azure Front Door content.') + hostname: string + + @description('The profile name that is used for configuring Front Door routes.') + profileName: string +} + + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The deployment settings to use for this deployment.') +param deploymentSettings DeploymentSettings + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The resource names for the resources to be created.') +param resourceNames object + +/* +** Dependencies +*/ + +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +// ======================================================================== +// VARIABLES +// ======================================================================== + +// The tags to apply to all resources in this workload +var moduleTags = union(deploymentSettings.tags, deploymentSettings.workloadTags) + +// ======================================================================== +// EXISTING RESOURCES +// ======================================================================== + +resource resourceGroup 'Microsoft.Resources/resourceGroups@2022-09-01' existing = { + name: resourceNames.resourceGroup +} + +// ======================================================================== +// NEW RESOURCES +// ======================================================================== + +/* +** Azure Front Door with Web Application Firewall +*/ +module frontDoor '../core/security/front-door-with-waf.bicep' = { + name: 'application-front-door-with-waf' + scope: resourceGroup + params: { + frontDoorEndpointName: resourceNames.frontDoorEndpoint + frontDoorProfileName: resourceNames.frontDoorProfile + webApplicationFirewallName: resourceNames.webApplicationFirewall + tags: moduleTags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + + // Service settings + diagnosticSettings: diagnosticSettings + managedRules: deploymentSettings.isProduction ? [ + { name: 'Microsoft_DefaultRuleSet', version: '2.1' } + { name: 'Microsoft_BotManagerRuleSet', version: '1.0' } + ] : [] + sku: deploymentSettings.isProduction || deploymentSettings.isNetworkIsolated ? 'Premium' : 'Standard' + } +} + +output settings FrontDoorSettings = { + endpointName: frontDoor.outputs.endpoint_name + frontDoorId: frontDoor.outputs.front_door_id + hostname: frontDoor.outputs.hostname + profileName: frontDoor.outputs.profile_name +} diff --git a/infra/modules/spoke-network.bicep b/infra/modules/spoke-network.bicep new file mode 100644 index 00000000..740ef0e6 --- /dev/null +++ b/infra/modules/spoke-network.bicep @@ -0,0 +1,393 @@ +targetScope = 'subscription' + +/* +** Spoke Network Infrastructure +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** +** The Spoke Network consists of a virtual network that hosts resources that +** are associated with the web app workload (e.g. private endpoints). +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DeploymentSettings.bicep +@description('Type that describes the global deployment settings') +type DeploymentSettings = { + @description('If \'true\', then two regional deployments will be performed.') + isMultiLocationDeployment: bool + + @description('If \'true\', use production SKUs and settings.') + isProduction: bool + + @description('If \'true\', isolate the workload in a virtual network.') + isNetworkIsolated: bool + + @description('If \'false\', then this is a multi-location deployment for the second location.') + isPrimaryLocation: bool + + @description('The Azure region to host resources') + location: string + + @description('The name of the workload.') + name: string + + @description('The ID of the principal that is being used to deploy resources.') + principalId: string + + @description('The type of the \'principalId\' property.') + principalType: 'ServicePrincipal' | 'User' + + @description('The token to use for naming resources. This should be unique to the deployment.') + resourceToken: string + + @description('The development stage for this application') + stage: 'dev' | 'prod' + + @description('The common tags that should be used for all created resources') + tags: object + + @description('The common tags that should be used for all workload resources') + workloadTags: object +} + +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The deployment settings to use for this deployment.') +param deploymentSettings DeploymentSettings + +@description('The diagnostic settings to use for logging and metrics.') +param diagnosticSettings DiagnosticSettings + +@description('The resource names for the resources to be created.') +param resourceNames object + +/* +** Dependencies +*/ +@description('The ID of the Log Analytics workspace to use for diagnostics and logging.') +param logAnalyticsWorkspaceId string = '' + +@description('If set, the ID of the table holding the outbound route to the firewall in the hub network') +param firewallInternalIpAddress string = '' + +/* +** Settings +*/ + +@description('The CIDR block to use for the address prefix of this virtual network.') +param addressPrefix string = '10.0.16.0/20' + + + +// ======================================================================== +// VARIABLES +// ======================================================================== + +var enableFirewall = !empty(firewallInternalIpAddress) + +// The tags to apply to all resources in this workload +var moduleTags = union(deploymentSettings.tags, deploymentSettings.workloadTags) + +// The subnet prefixes for the individual subnets inside the virtual network +var subnetPrefixes = [ for i in range(0, 16): cidrSubnet(addressPrefix, 26, i)] + +// When creating the virtual network, we need to set up a service delegation for app services. +var appServiceDelegation = [ + { + name: 'ServiceDelegation' + properties: { + serviceName: 'Microsoft.Web/serverFarms' + } + } +] + +// Network security group rules +var allowHttpsInbound = { + name: 'Allow-HTTPS-Inbound' + properties: { + access: 'Allow' + description: 'Allow HTTPS inbound traffic' + destinationAddressPrefix: '*' + destinationPortRange: '443' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourceAddressPrefix: '*' + sourcePortRange: '*' + } +} + +var allowSqlInbound = { + name: 'Allow-SQL-Inbound' + properties: { + access: 'Allow' + description: 'Allow SQL inbound traffic' + destinationAddressPrefix: '*' + destinationPortRange: '1433' + direction: 'Inbound' + priority: 110 + protocol: 'Tcp' + sourceAddressPrefix: '*' + sourcePortRange: '*' + } +} + +var denyAllInbound = { + name: 'Deny-All-Inbound' + properties: { + access: 'Deny' + description: 'Deny all inbound traffic' + destinationAddressPrefix: '*' + destinationPortRange: '*' + direction: 'Inbound' + priority: 1000 + protocol: 'Tcp' + sourceAddressPrefix: '*' + sourcePortRange: '*' + } +} + +// Sets up the route table when there is one specified. +var routeTableSettings = enableFirewall ? { + routeTable: { id: routeTable.outputs.id } +} : {} + + +// ======================================================================== +// AZURE MODULES +// ======================================================================== + +resource resourceGroup 'Microsoft.Resources/resourceGroups@2022-09-01' existing = { + name: resourceNames.spokeResourceGroup +} + +module apiInboundNSG '../core/network/network-security-group.bicep' = { + name: 'spoke-api-inbound-nsg-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.spokeApiInboundNSG + location: deploymentSettings.location + tags: moduleTags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + + // Settings + diagnosticSettings: diagnosticSettings + securityRules: [ + allowHttpsInbound + denyAllInbound + ] + } +} + +module apiOutboundNSG '../core/network/network-security-group.bicep' = { + name: 'spoke-api-outbound-nsg-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.spokeApiOutboundNSG + location: deploymentSettings.location + tags: moduleTags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + + // Settings + diagnosticSettings: diagnosticSettings + securityRules: [ + denyAllInbound + ] + } +} + +module privateEndpointNSG '../core/network/network-security-group.bicep' = { + name: 'spoke-pep-nsg-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.spokePrivateEndpointNSG + location: deploymentSettings.location + tags: moduleTags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + + // Settings + diagnosticSettings: diagnosticSettings + securityRules: [ + allowHttpsInbound + allowSqlInbound + denyAllInbound + ] + } +} + +module webInboundNSG '../core/network/network-security-group.bicep' = { + name: 'spoke-web-inbound-nsg-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.spokeWebInboundNSG + location: deploymentSettings.location + tags: moduleTags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + + // Settings + diagnosticSettings: diagnosticSettings + securityRules: [ + allowHttpsInbound + denyAllInbound + ] + } +} + +module webOutboundNSG '../core/network/network-security-group.bicep' = { + name: 'spoke-web-outbound-nsg-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.spokeWebOutboundNSG + location: deploymentSettings.location + tags: moduleTags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + + // Settings + diagnosticSettings: diagnosticSettings + securityRules: [ + denyAllInbound + ] + } +} + +module virtualNetwork '../core/network/virtual-network.bicep' = { + name: 'spoke-virtual-network-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.spokeVirtualNetwork + location: deploymentSettings.location + tags: moduleTags + + // Dependencies + logAnalyticsWorkspaceId: logAnalyticsWorkspaceId + + // Settings + addressPrefix: addressPrefix + diagnosticSettings: diagnosticSettings + subnets: [ + { + name: resourceNames.spokePrivateEndpointSubnet + properties: { + addressPrefix: subnetPrefixes[0] + networkSecurityGroup: { id: privateEndpointNSG.outputs.id } + privateEndpointNetworkPolicies: 'Disabled' + } + } + { + name: resourceNames.spokeApiInboundSubnet + properties: { + addressPrefix: subnetPrefixes[1] + networkSecurityGroup: { id: apiInboundNSG.outputs.id } + privateEndpointNetworkPolicies: 'Disabled' + } + } + { + name: resourceNames.spokeApiOutboundSubnet + properties: union({ + addressPrefix: subnetPrefixes[2] + delegations: appServiceDelegation + networkSecurityGroup: { id: apiOutboundNSG.outputs.id } + privateEndpointNetworkPolicies: 'Enabled' + }, routeTableSettings) + } + { + name: resourceNames.spokeWebInboundSubnet + properties: { + addressPrefix: subnetPrefixes[3] + networkSecurityGroup: { id: webInboundNSG.outputs.id } + privateEndpointNetworkPolicies: 'Disabled' + } + } + { + name: resourceNames.spokeWebOutboundSubnet + properties: union({ + addressPrefix: subnetPrefixes[4] + delegations: appServiceDelegation + networkSecurityGroup: { id: webOutboundNSG.outputs.id } + privateEndpointNetworkPolicies: 'Enabled' + }, routeTableSettings) + }] + } +} + +module routeTable '../core/network/route-table.bicep' = if (enableFirewall) { + name: 'spoke-route-table-${deploymentSettings.resourceToken}' + scope: resourceGroup + params: { + name: resourceNames.spokeRouteTable + location: deploymentSettings.location + tags: moduleTags + + // Settings + routes: [ + { + name: 'defaultEgress' + properties: { + addressPrefix: '0.0.0.0/0' + nextHopIpAddress: firewallInternalIpAddress + nextHopType: 'VirtualAppliance' + } + } + ] + } +} + + +var virtualNetworkLinks = [ + { + vnetName: virtualNetwork.outputs.name + vnetId: virtualNetwork.outputs.id + registrationEnabled: false + } +] + +module privateDnsZones './private-dns-zones.bicep' = { + name: 'spoke-prvt-dns-zone-deploy-${deploymentSettings.resourceToken}' + params:{ + createDnsZone: false //we are reusing the existing DNS zone and linking a vnet + deploymentSettings: deploymentSettings + hubResourceGroupName: resourceNames.hubResourceGroup + virtualNetworkLinks: virtualNetworkLinks + } +} + +// ======================================================================== +// OUTPUTS +// ======================================================================== + +output virtual_network_id string = virtualNetwork.outputs.id +output virtual_network_name string = virtualNetwork.outputs.name +output subnets object = virtualNetwork.outputs.subnets diff --git a/infra/modules/telemetry.bicep b/infra/modules/telemetry.bicep new file mode 100644 index 00000000..dc870c49 --- /dev/null +++ b/infra/modules/telemetry.bicep @@ -0,0 +1,84 @@ +targetScope = 'subscription' + +/* +** Enterprise App Patterns Telemetry +** Copyright (C) 2023 Microsoft, Inc. +** All Rights Reserved +** +*************************************************************************** +** Review the enableTelemetry parameter to understand telemetry collection +*/ + +// ======================================================================== +// USER-DEFINED TYPES +// ======================================================================== + +// From: infra/types/DeploymentSettings.bicep +@description('Type that describes the global deployment settings') +type DeploymentSettings = { + @description('If \'true\', then two regional deployments will be performed.') + isMultiLocationDeployment: bool + + @description('If \'true\', use production SKUs and settings.') + isProduction: bool + + @description('If \'true\', isolate the workload in a virtual network.') + isNetworkIsolated: bool + + @description('If \'false\', then this is a multi-location deployment for the second location.') + isPrimaryLocation: bool + + @description('The Azure region to host resources') + location: string + + @description('The name of the workload.') + name: string + + @description('The ID of the principal that is being used to deploy resources.') + principalId: string + + @description('The type of the \'principalId\' property.') + principalType: 'ServicePrincipal' | 'User' + + @description('The token to use for naming resources. This should be unique to the deployment.') + resourceToken: string + + @description('The development stage for this application') + stage: 'dev' | 'prod' + + @description('The common tags that should be used for all created resources') + tags: object + + @description('The common tags that should be used for all workload resources') + workloadTags: object +} + +// ======================================================================== +// PARAMETERS +// ======================================================================== + +@description('The deployment settings to use for this deployment.') +param deploymentSettings DeploymentSettings + +// ======================================================================== +// VARIABLES +// ======================================================================== + +var telemetryId = '063f9e42-c824-4573-8a47-5f6112612fe2' + +// ======================================================================== +// AZURE RESOURCES +// ======================================================================== + +resource telemetrySubscription 'Microsoft.Resources/deployments@2021-04-01' = { + name: '${telemetryId}-${deploymentSettings.location}' + location: deploymentSettings.location + properties: { + mode: 'Incremental' + template: { + '$schema': 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#' + contentVersion: '1.0.0.0' + resources: {} + } + } +} diff --git a/infra/naming.overrides.jsonc b/infra/naming.overrides.jsonc new file mode 100644 index 00000000..a46d42b0 --- /dev/null +++ b/infra/naming.overrides.jsonc @@ -0,0 +1,77 @@ +{ + /* + ** Use this file to override any of the names that are chosen for resources. + */ + + // Hub network resources + // "hubBastionHost": "", + // "hubBastionPublicIpAddress": "", + // "hubBudget": "", + // "hubDDoSProtectionPlan": "", + // "hubFirewall": "", + // "hubFirewallPublicIpAddress": "", + // "hubJumpbox": "", + // "hubResourceGroup": "", + // "hubSubnetBastionHost": "", + // "hubSubnetFirewall": "", + // "hubSubnetJumpbox": "", + // "hubSubnetPrivateEndpoint": "", + // "hubVirtualNetwork": "", + + // Spoke network resources + // "spokeApiInboundSubnet": "", + // "spokeApiInboundNSG": "", + // "spokeApiOutboundSubnet": "", + // "spokeApiOutboundNSG": "", + // "spokeDevopsSubnet": "", + // "spokeDeploymentSubnet": "", + // "spokeResourceGroup": "", + // "spokeRouteTableRouteTable": "", + // "spokePrivateEndpointNSG": "", + // "spokePrivateEndpointSubnet": "", + // "spokeVirtualNetwork": "", + // "spokeWebInboundSubnet": "", + // "spokeWebInboundNSG": "", + // "spokeWebOutboundSubnet": "", + // "spokeWebOutboundNSG": "", + + // Common services - may be in hub or application resource group + // "applicationInsights": "", + // "buildAgent": "", + // "keyVault": "", + // "keyVaultPrivateEndpoint": "", + // "logAnalyticsWorkspace": "", + + // Application resources + // "apiAppService": "", + // "apiAppServicePlan": "", + // "apiPrivateEndpoint": "", + // "appConfiguration": "", + // "appConfigurationPrivateEndpoint": "", + // "appManagedIdentity": "", + // "budget": "", + // "commonAppServicePlan": "", + // "frontDoorEndpoint": "", + // "frontDoorProfile": "", + // "ownerManagedIdentity": "", + // "redis": "", + // "redisPrivateEndpoint": "", + // "resourceGroup": "", + // "storageAccount": "", + // "storageAccountContainer": "", + // "sqlDatabase": "", + // "sqlDatabasePrivateEndpoint": "", + // "sqlServer": "", + // "sqlResourceGroup": "", + // "webAppFrontend": "", + // "webAppService": "", + // "webAppServicePlan": "", + // "webApplicationFirewall": "", + // "webAppFrontendPrivateEndpoint": "", + // "webwebAppServicePrivateEndpoint": "", + + /* + ** End of file - don't remove the next line as it signifies the end. + */ + "__end__": "" +} \ No newline at end of file diff --git a/infra/resources.bicep b/infra/resources.bicep deleted file mode 100644 index 214a1de8..00000000 --- a/infra/resources.bicep +++ /dev/null @@ -1,794 +0,0 @@ -@description('Enables the template to choose different SKU by environment') -param isProd bool - -@description('The id for the user-assigned managed identity that runs deploymentScripts') -param devOpsManagedIdentityId string - -@secure() -@minLength(1) -@description('Specifies a password that will be used to secure the Azure SQL Database') -param azureSqlPassword string - -@minLength(1) -@description('Primary location for all resources. Should specify an Azure region. e.g. `eastus2` ') -param location string - -@minLength(1) -@description('The user running the deployment will be given access to the deployed resources such as Key Vault and App Config svc') -param principalId string - -@description('A generated identifier used to create unique resources') -param resourceToken string - -// Adding RBAC permissions via the script enables the sample to work around a permission propagation issue outlined in the issue -// https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/138 -@minLength(1) -@description('When the deployment is executed by a user we give the principal RBAC access to key vault') -param principalType string - -@description('An object collection that contains annotations to describe the deployed azure resources to improve operational visibility') -param tags object - -/* -The following Azure AD parameters enable the code to reuse an existing app registration -https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/160 -These values are created by the createAppRegistration.ps1 script found in deploy-solution.md -These values are not optional when the code runs, but they are optional at deployment time -as you may choose to re-use an existing app registration or choose to create a new one. -*/ -@description('A scope used by the front-end public web app to get authorized access to the public web api. Looks similar to api://33333333-bbbb-4444-cccc-555555555555/relecloud.api') -param azureAdApiScopeFrontEnd string - -@description('A unique identifier of the API web app') -param azureAdClientIdForBackEnd string - -@description('A unique identifier of the front-end web app') -param azureAdClientIdForFrontEnd string - -@secure() -@description('A secret generated by Azure AD so that the web app can establish trust with Azure AD') -param azureAdClientSecretForFrontEnd string - -@description('A unique identifier of the Azure AD tenant') -param azureAdTenantId string - -module setUpAzureAdSettings 'azureAdSettings.bicep' = { - name: 'setUpAzureAdSettings' - params: { - keyVaultName: keyVault.name - appConfigurationServiceName: appConfigService.name - azureAdApiScopeFrontEnd: azureAdApiScopeFrontEnd - azureAdClientIdForBackEnd: azureAdClientIdForBackEnd - azureAdClientIdForFrontEnd: azureAdClientIdForFrontEnd - azureAdClientSecretForFrontEnd: azureAdClientSecretForFrontEnd - azureAdTenantId: azureAdTenantId - } -} - -@description('A user-assigned managed identity that is used by the App Service app') -resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2018-11-30' = { - name: 'web-${resourceToken}-identity' - location: location - tags: tags -} - -@description('Built in \'Data Reader\' role ID: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles') -var appConfigurationRoleDefinitionId = '516239f1-63e1-4d78-a4de-a74fb236a071' - -@description('Grant the \'Data Reader\' role to the user-assigned managed identity, at the scope of the resource group.') -resource appConfigRoleAssignmentForWebApps 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { - name: guid(appConfigurationRoleDefinitionId, appConfigService.id, managedIdentity.name, resourceToken) - scope: resourceGroup() - properties: { - principalType: 'ServicePrincipal' - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', appConfigurationRoleDefinitionId) - principalId: managedIdentity.properties.principalId - description: 'Grant the "Data Reader" role to the user-assigned managed identity so it can access the azure app configuration service.' - } -} - -@description('Grant the \'Data Reader\' role to the principal, at the scope of the resource group.') -resource appConfigRoleAssignmentForPrincipal 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = if (principalType == 'user') { - name: guid(appConfigurationRoleDefinitionId, appConfigService.id, principalId, resourceToken) - scope: resourceGroup() - properties: { - principalType: 'User' - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', appConfigurationRoleDefinitionId) - principalId: principalId - description: 'Grant the "Data Reader" role to the principal identity so it can access the azure app configuration service.' - } -} - -@description('Built in \'Key Secrets User\' role ID: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles') -var keyVaultSecretsUserRoleDefinitionId = '4633458b-17de-408a-b874-0445c86b69e6' - -@description('Grant the \'Data Reader\' role to the principal, at the scope of the resource group.') -resource keyVaultRoleAssignmentForWebApp 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { - name: guid(keyVaultSecretsUserRoleDefinitionId, appConfigService.id, principalId, resourceToken) - scope: resourceGroup() - properties: { - principalType: 'ServicePrincipal' - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', keyVaultSecretsUserRoleDefinitionId) - principalId: managedIdentity.properties.principalId - description: 'Grant the "Key Secrets User" role to the principal identity so it can manage the key vault service.' - } -} - -@description('Built in \'Key Vault Administrator\' role ID: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles') -var keyVaultAdminRoleDefinitionId = '00482a5a-887f-4fb3-b363-3b7fe8e74483' - -@description('Grant the \'Data Reader\' role to the principal, at the scope of the resource group.') -resource keyVaultRoleAssignmentForPrincipal 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = if (principalType == 'user') { - name: guid(keyVaultAdminRoleDefinitionId, appConfigService.id, principalId, resourceToken) - scope: resourceGroup() - properties: { - principalType: 'User' - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', keyVaultAdminRoleDefinitionId) - principalId: principalId - description: 'Grant the "Key Vault Administrator" role to the principal identity so it can manage the key vault service.' - } -} - -// for non-prod scenarios we allow public network connections for the local dev experience -var keyVaultPublicNetworkAccess = isProd ? 'disabled' : 'enabled' - -resource keyVault 'Microsoft.KeyVault/vaults@2021-11-01-preview' = { - name: 'rc-${resourceToken}-kv' // keyvault name cannot start with a number - location: location - tags: tags - properties: { - publicNetworkAccess: keyVaultPublicNetworkAccess - networkAcls:{ - defaultAction: 'Allow' - bypass: 'AzureServices' - } - enableRbacAuthorization: true - sku: { - family: 'A' - name: 'standard' - } - tenantId: subscription().tenantId - } -} - -resource appConfigService 'Microsoft.AppConfiguration/configurationStores@2022-05-01' = { - name: '${resourceToken}-appconfig' - location: location - tags: tags - sku: { - name: 'Standard' - } - properties:{ - // This network mode supports making the sample easier to get started - // It uses public network access because the values are set by the Azure Resource Provider - // by this declarative bicep file. To disable public network access would require - // access to the vnet and connecting over the private endpoint - // https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/230 - publicNetworkAccess:'Enabled' - } - - resource baseApiUrlAppConfigSetting 'keyValues@2022-05-01' = { - name: 'App:RelecloudApi:BaseUri' - properties: { - value: 'https://${api.properties.defaultHostName}' - } - } - - resource sqlConnStrAppConfigSetting 'keyValues@2022-05-01' = { - name: 'App:SqlDatabase:ConnectionString' - properties: { - value: 'Server=tcp:${sqlSetup.outputs.sqlServerFqdn},1433;Initial Catalog=${sqlSetup.outputs.sqlCatalogName};Authentication=Active Directory Default' - } - } - - resource redisConnAppConfigKvRef 'keyValues@2022-05-01' = { - name: 'App:RedisCache:ConnectionString' - properties: { - value: string({ - uri: '${keyVault.properties.vaultUri}secrets/${redisSetup.outputs.keyVaultRedisConnStrName}' - }) - contentType: 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8' - } - } - - resource storageAccountBlobUrlAppConfigSetting 'keyValues@2022-05-01' = { - name: 'App:StorageAccount:Url' - properties: { - value: storageSetup.outputs.storageAccocuntBlobURL - } - } - - resource storageAccountBlobContainerAppConfigSetting 'keyValues@2022-05-01' = { - name: 'App:StorageAccount:Container' - properties: { - value: storageSetup.outputs.containerName - } - } -} - -// provides additional diagnostic information from aspNet when deploying non-prod environments -var aspNetCoreEnvironment = isProd ? 'Production' : 'Development' - -resource web 'Microsoft.Web/sites@2021-03-01' = { - name: 'web-${resourceToken}-web-app' - location: location - tags: union(tags, { - 'azd-service-name': 'web' - }) - properties: { - serverFarmId: webAppServicePlan.id - clientAffinityEnabled: false - siteConfig: { - alwaysOn: true - ftpsState: 'Disabled' - // Set to true to route all outbound app traffic into virtual network (see https://learn.microsoft.com/azure/app-service/overview-vnet-integration#application-routing) - vnetRouteAllEnabled: false - } - httpsOnly: true - - // Enable regional virtual network integration. - virtualNetworkSubnetId: vnet::webSubnet.id - } - - identity: { - type: 'UserAssigned' - userAssignedIdentities: { - '${managedIdentity.id}': {} - } - } - - resource appSettings 'config' = { - name: 'appsettings' - properties: { - ASPNETCORE_ENVIRONMENT: aspNetCoreEnvironment - AZURE_CLIENT_ID: managedIdentity.properties.clientId - APPLICATIONINSIGHTS_CONNECTION_STRING: webApplicationInsightsResources.outputs.APPLICATIONINSIGHTS_CONNECTION_STRING - 'App:AppConfig:Uri': appConfigService.properties.endpoint - SCM_DO_BUILD_DURING_DEPLOYMENT: 'false' - // App Insights settings - // https://learn.microsoft.com/azure/azure-monitor/app/azure-web-apps-net#application-settings-definitions - APPINSIGHTS_INSTRUMENTATIONKEY: webApplicationInsightsResources.outputs.APPLICATIONINSIGHTS_INSTRUMENTATION_KEY - ApplicationInsightsAgent_EXTENSION_VERSION: '~2' - XDT_MicrosoftApplicationInsights_Mode: 'recommended' - InstrumentationEngine_EXTENSION_VERSION: '~1' - XDT_MicrosoftApplicationInsights_BaseExtensions: '~1' - } - } - - resource logs 'config' = { - name: 'logs' - properties: { - applicationLogs: { - fileSystem: { - level: 'Verbose' - } - } - detailedErrorMessages: { - enabled: true - } - failedRequestsTracing: { - enabled: true - } - httpLogs: { - fileSystem: { - enabled: true - retentionInDays: 1 - retentionInMb: 35 - } - } - } - dependsOn: [ - appSettings - ] - } -} - -resource api 'Microsoft.Web/sites@2021-01-15' = { - name: 'api-${resourceToken}-web-app' - location: location - tags: union(tags, { - 'azd-service-name': 'api' - }) - properties: { - serverFarmId: apiAppServicePlan.id - clientAffinityEnabled: false - siteConfig: { - alwaysOn: true - ftpsState: 'Disabled' - - // Set to true to route all outbound app traffic into virtual network (see https://learn.microsoft.com/azure/app-service/overview-vnet-integration#application-routing) - vnetRouteAllEnabled: false - } - httpsOnly: true - - // Enable regional virtual network integration. - virtualNetworkSubnetId: vnet::apiSubnet.id - } - - identity: { - type: 'UserAssigned' - userAssignedIdentities: { - '${managedIdentity.id}': {} - } - } - - resource appSettings 'config' = { - name: 'appsettings' - properties: { - ASPNETCORE_ENVIRONMENT: aspNetCoreEnvironment - AZURE_CLIENT_ID: managedIdentity.properties.clientId - APPLICATIONINSIGHTS_CONNECTION_STRING: webApplicationInsightsResources.outputs.APPLICATIONINSIGHTS_CONNECTION_STRING - 'Api:AppConfig:Uri': appConfigService.properties.endpoint - SCM_DO_BUILD_DURING_DEPLOYMENT: 'false' - // App Insights settings - // https://learn.microsoft.com/azure/azure-monitor/app/azure-web-apps-net#application-settings-definitions - APPINSIGHTS_INSTRUMENTATIONKEY: webApplicationInsightsResources.outputs.APPLICATIONINSIGHTS_INSTRUMENTATION_KEY - ApplicationInsightsAgent_EXTENSION_VERSION: '~2' - XDT_MicrosoftApplicationInsights_Mode: 'recommended' - InstrumentationEngine_EXTENSION_VERSION: '~1' - XDT_MicrosoftApplicationInsights_BaseExtensions: '~1' - } - } - - resource logs 'config' = { - name: 'logs' - properties: { - applicationLogs: { - fileSystem: { - level: 'Verbose' - } - } - detailedErrorMessages: { - enabled: true - } - failedRequestsTracing: { - enabled: true - } - httpLogs: { - fileSystem: { - enabled: true - retentionInDays: 1 - retentionInMb: 35 - } - } - } - dependsOn: [ - appSettings - ] - } -} - -var appServicePlanSku = (isProd) ? 'P1v2' : 'B1' - -resource webAppServicePlan 'Microsoft.Web/serverfarms@2021-03-01' = { - name: '${resourceToken}-web-plan' - location: location - tags: tags - sku: { - name: appServicePlanSku - } - properties: { - - } - dependsOn: [ - // found that Redis network connectivity was not available if web app is deployed first (until restart) - // delaying deployment allows us to skip the restart - redisSetup - ] -} - -module webServicePlanAutoScale './appSvcAutoScaleSettings.bicep' = { - name: 'deploy-${webAppServicePlan.name}-scalesettings' - params: { - appServicePlanName: webAppServicePlan.name - location: location - isProd: isProd - tags: tags - } -} - -resource apiAppServicePlan 'Microsoft.Web/serverfarms@2021-03-01' = { - name: '${resourceToken}-api-plan' - location: location - tags: tags - sku: { - name: appServicePlanSku - } - properties: { - - } - dependsOn: [ - // found that Redis network connectivity was not available if web app is deployed first (until restart) - // delaying deployment allows us to skip the restart - redisSetup - ] -} - -module apiServicePlanAutoScale './appSvcAutoScaleSettings.bicep' = { - name: 'deploy-${apiAppServicePlan.name}-scalesettings' - params: { - appServicePlanName: apiAppServicePlan.name - location: location - isProd: isProd - tags: tags - } -} - -resource webLogAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2020-03-01-preview' = { - name: 'web-${resourceToken}-log' - location: location - tags: tags - properties: { - retentionInDays: 30 - sku: { - name: 'PerGB2018' - } - } -} - -module webApplicationInsightsResources './applicationinsights.bicep' = { - name: 'web-${resourceToken}-app-insights' - params: { - resourceToken: resourceToken - location: location - tags: tags - workspaceId: webLogAnalyticsWorkspace.id - } -} - -module sqlSetup 'azureSqlDatabase.bicep' = { - name: 'sqlSetup' - scope: resourceGroup() - params: { - devOpsManagedIdentityId: devOpsManagedIdentityId - isProd: isProd - location: location - managedIdentity: { - name: managedIdentity.name - id: managedIdentity.id - properties: { - clientId: managedIdentity.properties.clientId - principalId: managedIdentity.properties.principalId - tenantId: managedIdentity.properties.tenantId - } - } - resourceToken: resourceToken - sqlAdministratorLogin: 'sqladmin${resourceToken}' - sqlAdministratorPassword: azureSqlPassword - tags: tags - } - dependsOn: [ - vnet - ] -} - -var privateEndpointNameForRedis = 'privateEndpointForRedis' -module redisSetup 'azureRedisCache.bicep' = { - name: 'redisSetup' - scope: resourceGroup() - params: { - devOpsManagedIdentityId: devOpsManagedIdentityId - isProd: isProd - keyVaultName: keyVault.name - location: location - resourceToken: resourceToken - tags: tags - privateEndpointNameForRedis: privateEndpointNameForRedis - privateEndpointSubnetName: privateEndpointSubnetName - privateEndpointVnetName: vnet.name - } -} - -@description('Built in \'Storage Blob Data Owner\' role ID: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles') -// Allows read and write access to storage blob data -var storageBlobDataOwnerRoleDefinitionId = 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' - -var storageAccountRoleAssignments =[ - { - principalId: managedIdentity.properties.principalId - roleDefinitionId: storageBlobDataOwnerRoleDefinitionId - description: 'Give the application read and write permission to storage account.' - principalType:'ServicePrincipal' - } -] - -module storageSetup 'azureStorage.bicep' = { - name: 'storageSetup' - scope: resourceGroup() - params: { - isProd: isProd - location: location - resourceToken: resourceToken - roleAssignmentsList: storageAccountRoleAssignments - tags: tags - privateLinkSubnetId: vnet::privateEndpointSubnet.id - privateDnsZoneId: privateDnsZoneForStorage.id - } -} - -resource storageRoleAssignmentForPrincipal 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = if (principalType == 'user') { - name: guid(storageBlobDataOwnerRoleDefinitionId, storageSetup.name, principalId, resourceToken) - scope: resourceGroup() - properties: { - principalType: 'User' - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', storageBlobDataOwnerRoleDefinitionId) - principalId: principalId - description: 'Grant the "Storage Blob Data Owner" role to the developer so they can write to Azure storage while doing local development.' - } -} - -var privateEndpointSubnetName = 'subnetPrivateEndpoints' -var subnetApiAppService = 'subnetApiAppService' -var subnetWebAppService = 'subnetWebAppService' - -resource vnet 'Microsoft.Network/virtualNetworks@2020-07-01' = { - name: 'rc-${resourceToken}-vnet' - location: location - tags: tags - properties: { - addressSpace: { - addressPrefixes: [ - '10.0.0.0/16' - ] - } - subnets: [ - { - name: privateEndpointSubnetName - properties: { - addressPrefix: '10.0.0.0/24' - privateEndpointNetworkPolicies: 'Disabled' - } - } - { - name: subnetWebAppService - properties: { - addressPrefix: '10.0.1.0/24' - delegations: [ - { - name: 'delegation' - properties: { - serviceName: 'Microsoft.Web/serverfarms' - } - } - ] - } - } - { - name: subnetApiAppService - properties: { - addressPrefix: '10.0.2.0/24' - delegations: [ - { - name: 'delegation' - properties: { - serviceName: 'Microsoft.Web/serverfarms' - } - } - ] - } - } - ] - } - - resource apiSubnet 'subnets' existing = { - name: subnetApiAppService - } - - resource webSubnet 'subnets' existing = { - name: subnetWebAppService - } - - resource privateEndpointSubnet 'subnets' existing = { - name: privateEndpointSubnetName - } -} - -resource privateEndpointForSql 'Microsoft.Network/privateEndpoints@2020-07-01' = { - name: 'privateEndpointForSql' - location: location - tags: tags - properties: { - subnet: { - id: resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, privateEndpointSubnetName) - } - privateLinkServiceConnections: [ - { - name: '${sqlSetup.outputs.sqlServerName}/${sqlSetup.outputs.sqlDatabaseName}' - properties: { - privateLinkServiceId: sqlSetup.outputs.sqlServerId - groupIds: [ - 'sqlServer' - ] - } - } - ] - } -} - -resource privateDnsZoneNameForSql 'Microsoft.Network/privateDnsZones@2020-06-01' = { - name: 'privatelink${environment().suffixes.sqlServerHostname}' - location: 'global' - tags: tags -} - -resource privateDnsZoneNameForSql_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = { - parent: privateDnsZoneNameForSql - name: '${privateDnsZoneNameForSql.name}-link' - location: 'global' - tags: tags - properties: { - registrationEnabled: false - virtualNetwork: { - id: vnet.id - } - } -} - -resource sqlPvtEndpointDnsGroupName 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2020-07-01' = { - name: '${privateEndpointForSql.name}/mydnsgroupname' - properties: { - privateDnsZoneConfigs: [ - { - name: 'config1' - properties: { - privateDnsZoneId: privateDnsZoneNameForSql.id - } - } - ] - } -} - -resource redisPvtEndpointDnsGroupName 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2020-07-01' = { - name: '${privateEndpointNameForRedis}/mydnsgroupname' - properties: { - privateDnsZoneConfigs: [ - { - name: 'config1' - properties: { - privateDnsZoneId: redisSetup.outputs.privateDnsZoneId - } - } - ] - } -} - -// private link for Key vault - -resource privateDnsZoneNameForKv 'Microsoft.Network/privateDnsZones@2020-06-01' = { - name: 'privatelink.vaultcore.azure.net' - location: 'global' - tags: tags -} - -resource privateDnsZoneNameForKv_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = { - parent: privateDnsZoneNameForKv - name: '${privateDnsZoneNameForKv.name}-link' - location: 'global' - tags: tags - properties: { - registrationEnabled: false - virtualNetwork: { - id: vnet.id - } - } -} - -resource pvtEndpointDnsGroupNameForKv 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2020-07-01' = { - name: '${privateEndpointForKv.name}/mydnsgroupname' - properties: { - privateDnsZoneConfigs: [ - { - name: 'config1' - properties: { - privateDnsZoneId: privateDnsZoneNameForKv.id - } - } - ] - } -} - -resource privateEndpointForKv 'Microsoft.Network/privateEndpoints@2020-07-01' = { - name: 'privateEndpointForKv' - location: location - tags: tags - properties: { - subnet: { - id: resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, privateEndpointSubnetName) - } - privateLinkServiceConnections: [ - { - name: keyVault.name - properties: { - privateLinkServiceId: keyVault.id - groupIds: [ - 'vault' - ] - } - } - ] - } -} - -resource privateDnsZoneForStorage 'Microsoft.Network/privateDnsZones@2020-06-01' = { - name: 'privatelink.blob.${environment().suffixes.storage}' - location: 'global' - tags: tags - dependsOn: [ - vnet - ] -} - -resource privateDnsZoneForStorage_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = { - parent: privateDnsZoneForStorage - name: '${privateDnsZoneForStorage.name}-link' - location: 'global' - tags: tags - properties: { - registrationEnabled: false - virtualNetwork: { - id: vnet.id - } - } -} - -// private link for App Config Svc - -resource privateDnsZoneNameForAppConfig 'Microsoft.Network/privateDnsZones@2020-06-01' = { - name: 'privatelink.azconfig.io' - location: 'global' - tags: tags -} - -resource privateDnsZoneNameForAppConfig_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = { - parent: privateDnsZoneNameForAppConfig - name: '${privateDnsZoneNameForAppConfig.name}-link' - location: 'global' - tags: tags - properties: { - registrationEnabled: false - virtualNetwork: { - id: vnet.id - } - } -} - -resource pvtEndpointDnsGroupNameForAppConfig 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2020-07-01' = { - name: '${privateEndpointForAppConfig.name}/mydnsgroupname' - properties: { - privateDnsZoneConfigs: [ - { - name: 'config1' - properties: { - privateDnsZoneId: privateDnsZoneNameForAppConfig.id - } - } - ] - } -} - -resource privateEndpointForAppConfig 'Microsoft.Network/privateEndpoints@2020-07-01' = { - name: 'privateEndpointForAppConfig' - location: location - tags: tags - properties: { - subnet: { - id: resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, privateEndpointSubnetName) - } - privateLinkServiceConnections: [ - { - name: appConfigService.name - properties: { - privateLinkServiceId: appConfigService.id - groupIds: [ - 'configurationStores' - ] - } - } - ] - } -} - -output KEY_VAULT_NAME string = keyVault.name -output APP_CONFIGURATION_SVC_NAME string = appConfigService.name -output WEB_URI string = web.properties.defaultHostName -output API_URI string = api.properties.defaultHostName diff --git a/infra/scripts/devexperience/call-make-sql-account.ps1 b/infra/scripts/devexperience/call-make-sql-account.ps1 new file mode 100644 index 00000000..72a6f14d --- /dev/null +++ b/infra/scripts/devexperience/call-make-sql-account.ps1 @@ -0,0 +1,48 @@ +<# +.SYNOPSIS +Calls the make-sql-account.ps1 script to create a SQL account for a given resource group, SQL server, and database. + +.DESCRIPTION +This script retrieves the necessary parameters from the AZD environment variables and Key Vault, and then calls the make-sql-account.ps1 script with the appropriate arguments. + +.PARAMETER resourceGroupName +The name of the Azure resource group where the SQL server and database are located. + +.PARAMETER sqlServerName +The name of the SQL server. + +.PARAMETER sqlDatabaseName +The name of the SQL database. + +.PARAMETER keyVaultName +The name of the Azure Key Vault where the SQL administrator credentials are stored. + +.EXAMPLE +./call-make-sql-account.ps1 + +This example demonstrates how to call the script to create a SQL account using the default environment variables and Key Vault. + +#> + +$resourceGroupName = ((azd env get-values --output json | ConvertFrom-Json).AZURE_RESOURCE_GROUP) +$sqlServerName = ((azd env get-values --output json | ConvertFrom-Json).SQL_SERVER_NAME) +$sqlDatabaseName = ((azd env get-values --output json | ConvertFrom-Json).SQL_DATABASE_NAME) +$keyVaultName = ((azd env get-values --output json | ConvertFrom-Json).AZURE_OPS_VAULT_NAME) + +$sqlAdmin = (Get-AzKeyVaultSecret -VaultName $keyVaultName -Name "Application--SqlAdministratorUsername" -AsPlainText) +$secureSqlPassword = ConvertTo-SecureString -String (Get-AzKeyVaultSecret -VaultName $keyVaultName -Name "Application--SqlAdministratorPassword" -AsPlainText) -AsPlainText -Force + +$accountId = (Get-AzContext).Account.ExtendedProperties["HomeAccountId"].Split(".")[0] +$accountAlias = (Get-AzContext).Account.Id + +$Cred = New-Object System.Management.Automation.PSCredential ($sqlAdmin, $secureSqlPassword) + +Write-Host "Calling make-sql-account.ps1 for group:'$resourceGroupName'..." + +./infra/scripts/devexperience/make-sql-account.ps1 ` + -ResourceGroup $resourceGroupName ` + -SqlServerName $sqlServerName ` + -SqlDatabaseName $sqlDatabaseName ` + -AccountAlias $accountAlias ` + -AccountId $accountId ` + -Credential $Cred \ No newline at end of file diff --git a/infra/scripts/devexperience/make-sql-account.ps1 b/infra/scripts/devexperience/make-sql-account.ps1 new file mode 100644 index 00000000..81b37389 --- /dev/null +++ b/infra/scripts/devexperience/make-sql-account.ps1 @@ -0,0 +1,132 @@ +<# +.SYNOPSIS +This script creates a SQL account for a specified Entra ID account so that the user can connect to Azure SQL. + +.PARAMETER ResourceGroup +The name of the resource group where the SQL Server is located. + +.PARAMETER SqlServerName +The name of the SQL Server. + +.PARAMETER SqlDatabaseName +The name of the SQL database. + +.PARAMETER AccountAlias +The account alias of the Entra ID account to be added to Azure SQL. + +.PARAMETER AccountId +The ID of the Entra ID account to be added to Azure SQL. + +.EXAMPLE +./make-sql-account.ps1 -ResourceGroup "myResourceGroup" -SqlServerName "mySqlServer" -SqlDatabaseName "mySqlDatabase" -AccountId "mySqlAccount" -Credential $Creds +Creates a SQL account with the specified parameters. + +#> + +Param( + [Parameter(Mandatory=$true)] + [string] $ResourceGroup, + + [Parameter(Mandatory=$true)] + [string] $SqlServerName, + + [Parameter(Mandatory=$true)] + [string] $SqlDatabaseName, + + [Parameter(Mandatory=$true)] + [string] $AccountAlias, + + [Parameter(Mandatory=$true)] + [string] $AccountId, + + [Parameter(Mandatory=$true)] + [System.Management.Automation.PSCredential]$Credential +) + +<# +.SYNOPSIS + Tests to ensure that the Powershell module we need is installed and imported before use. +.PARAMETER ModuleName + The name of the module to test for. +#> +function Test-ModuleImported { + param( + [Parameter(Mandatory=$true)] + [string] $ModuleName + ) + + if ((Get-Module -ListAvailable -Name $ModuleName) -and (Get-Module -Name $ModuleName -ErrorAction SilentlyContinue)) { + Write-Verbose "The '$($ModuleName)' module is installed and imported." + } + else { + $SavedVerbosePreference = $global:VerbosePreference + try { + Write-Verbose "Importing '$($ModuleName)' module" + $global:VerbosePreference = 'SilentlyContinue' + Import-Module -Name $ModuleName -ErrorAction Stop + $global:VerbosePreference = $SavedVerbosePreference + Write-Verbose "The '$($ModuleName)' module is imported successfully." + } + catch { + Write-Error "Failed to import the '$($ModuleName)' module. Please install the '$($ModuleName)' module before running this script." + exit 12 + } + finally { + $global:VerbosePreference = $SavedVerbosePreference + } + } +} + +<# +.SYNOPSIS + Checks to ensure that the user is authenticated with Azure before running the script. +#> +function Test-AzureConnected { + if (Get-AzContext -ErrorAction SilentlyContinue) { + Write-Verbose "The user is authenticated with Azure." + } + else { + Write-Error "You are not authenticated with Azure. Please run 'Connect-AzAccount' to authenticate before running this script." + exit 10 + } +} + +Test-ModuleImported -ModuleName Az.Resources +Test-ModuleImported -ModuleName SqlServer +Test-AzureConnected + +# Prompt formatting features + +$defaultColor = if ($PSVersionTable.PSVersion.Major -ge 6) { "`e[0m" } else { "" } +$successColor = if ($PSVersionTable.PSVersion.Major -ge 6) { "`e[32m" } else { "" } + +[guid]$guid = [System.Guid]::Parse($accountId) + +foreach ($byte in $guid.ToByteArray()) { + $byteGuid += [System.String]::Format("{0:X2}", $byte) +} +$Sid = "0x" + $byteGuid + +$fullyQualifiedDomainName = (Get-AzSqlServer -ResourceGroupName $ResourceGroup -ServerName $SqlServerName).FullyQualifiedDomainName + + +# Prepare SQL cmd to CREATE USER +$createUserSQL = "IF NOT EXISTS (SELECT * FROM sys.database_principals WHERE name = N'$AccountAlias') create user [$AccountAlias] with sid = $Sid, type = E;" + +# Connect as SQL Admin acct and execute SQL cmd +Invoke-Sqlcmd -ServerInstance $fullyQualifiedDomainName -database $sqlDatabaseName -Credential $Credential -Query $createUserSQL +Write-Host "`tCreated user" + +Invoke-Sqlcmd -ServerInstance $fullyQualifiedDomainName -database 'master' -Credential $Credential -Query $createUserSQL +Write-Host "`tCreated for root db" + +# Prepare SQL cmd to grant db_owner role +$grantDbOwner = "IF NOT EXISTS (SELECT * FROM sys.database_principals p JOIN sys.database_role_members db_owner_role ON db_owner_role.member_principal_id = p.principal_id JOIN sys.database_principals role_names ON role_names.principal_id = db_owner_role.role_principal_id AND role_names.[name] = 'db_owner' WHERE p.[name]=N'$AccountAlias') ALTER ROLE db_owner ADD MEMBER [$AccountAlias];" + +# Connect as SQL Admin acct and execute SQL cmd +Invoke-Sqlcmd -ServerInstance $fullyQualifiedDomainName -database $sqlDatabaseName -Credential $Credential -Query $grantDbOwner + +Write-Host "`tGranted db_owner" + +Write-Host "`nFinished $($successColor)successfully$($defaultColor)." +Write-Host "An account for the current user was created in Azure SQL" \ No newline at end of file diff --git a/infra/scripts/postdeploy/show-webapp-uri.ps1 b/infra/scripts/postdeploy/show-webapp-uri.ps1 new file mode 100644 index 00000000..d6cb1b71 --- /dev/null +++ b/infra/scripts/postdeploy/show-webapp-uri.ps1 @@ -0,0 +1,14 @@ + +# The AZD deploy command shows the links to the azurewebsites.net resources +# We block access to these resources and instead want to show the Azure Front Door URL + +# Prompt formatting features + +$defaultColor = if ($Host.UI.SupportsVirtualTerminal) { "`e[0m" } else { "" } +$highlightColor = if ($Host.UI.SupportsVirtualTerminal) { "`e[36m" } else { "" } + +# End of Prompt formatting features + +Write-Host "`nUse this URI to access the web app:" +$azureFrontDoorUri=(azd env get-values --output json | ConvertFrom-Json).WEB_URI +Write-Host "`t$($highlightColor)$azureFrontDoorUri$($defaultColor)" \ No newline at end of file diff --git a/infra/scripts/postdeploy/show-webapp-uri.sh b/infra/scripts/postdeploy/show-webapp-uri.sh new file mode 100755 index 00000000..d0ef1c06 --- /dev/null +++ b/infra/scripts/postdeploy/show-webapp-uri.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +# The AZD deploy command shows the links to the azurewebsites.net resources +# We block access to these resources and instead want to show the Azure Front Door URL + +pwsh ./infra/scripts/postdeploy/show-webapp-uri.ps1 \ No newline at end of file diff --git a/infra/scripts/postprovision/call-create-app-registrations.ps1 b/infra/scripts/postprovision/call-create-app-registrations.ps1 new file mode 100644 index 00000000..070440cb --- /dev/null +++ b/infra/scripts/postprovision/call-create-app-registrations.ps1 @@ -0,0 +1,28 @@ +<# +.SYNOPSIS + This script will be run by the Azure Developer CLI, and will have access to the AZD_* vars + This calls the create app registration.ps1 with the correct AZD provisioned resource group. + +.DESCRIPTION + This script will be run by the Azure Developer CLI, and will set the required + app configuration settings for the Relecloud web app as part of the code deployment process. + + Depends on the AZURE_RESOURCE_GROUP environment variable being set. AZD requires this to + understand which resource group to deploy to so this script uses it to learn about the + environment where the configuration settings should be set. + +#> + +# if this is CI/CD then we want to skip this step because the app registrations already exist +$principalType = (azd env get-values --output json | ConvertFrom-Json).AZURE_PRINCIPAL_TYPE + +if ($principalType -eq "ServicePrincipal") { + Write-Host "Skipping create-app-registrations.ps1 because principalType is ServicePrincipal" + exit 0 +} + +$resourceGroupName=(azd env get-values --output json | ConvertFrom-Json).AZURE_RESOURCE_GROUP + +Write-Host "Calling create-app-registrations.ps1 for group:'$resourceGroupName'..." + +./infra/scripts/postprovision/create-app-registrations.ps1 -ResourceGroupName $resourceGroupName -NoPrompt \ No newline at end of file diff --git a/infra/scripts/postprovision/call-create-app-registrations.sh b/infra/scripts/postprovision/call-create-app-registrations.sh new file mode 100755 index 00000000..09fc541f --- /dev/null +++ b/infra/scripts/postprovision/call-create-app-registrations.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# if this is CI/CD then we want to skip this step because the app registrations already exist +principalType=$((azd env get-values --output json) | jq -r .AZURE_PRINCIPAL_TYPE) + +if [ "$principalType" == "ServicePrincipal" ]; then + echo "Skipping create-app-registrations.ps1 because principalType is ServicePrincipal" + exit 0 +fi + +# This script is run by azd pre-provision hook and is part of the deployment lifecycle run when deploying the code for the Relecloud web app. +resourceGroupName=$((azd env get-values --output json) | jq -r .AZURE_RESOURCE_GROUP) + +echo "Calling create-app-registrations.ps1 for group:'resourceGroupName'..." + +pwsh ./infra/scripts/postprovision/create-app-registrations.ps1 -ResourceGroupName $resourceGroupName -NoPrompt \ No newline at end of file diff --git a/infra/scripts/postprovision/create-app-registrations.ps1 b/infra/scripts/postprovision/create-app-registrations.ps1 new file mode 100644 index 00000000..e0faffce --- /dev/null +++ b/infra/scripts/postprovision/create-app-registrations.ps1 @@ -0,0 +1,504 @@ +<# +.SYNOPSIS + Creates Microsoft Entra ID App Registrations for the call center web and api applications + and saves the configuration data in App Configuration Svc and Key Vault. + Depends on Az module. + + + +.DESCRIPTION + The web app uses Microsoft Entra ID to authenticate and authorize the users that can + make concert ticket purchases. This script configures the required settings and saves them in Key Vault. + The following settings are configured: + + Api--MicrosoftEntraId--ClientId Identifies the web app to Microsoft Entra ID + Api--MicrosoftEntraId--TenantId Identifies which Microsoft Entra ID instance holds the users that should be authorized + MicrosoftEntraId--CallbackPath The path that Microsoft Entra ID should redirect to after a successful login + MicrosoftEntraId--ClientId Identifies the web app to Microsoft Entra ID + MicrosoftEntraId--ClientSecret Provides a secret known by Microsoft Entra ID, and shared with the web app, to validate that Microsoft Entra ID can trust this web app + MicrosoftEntraId--Instance Identifies which Microsoft Entra ID instance holds the users that should be authorized + MicrosoftEntraId--SignedOutCallbackPath The path that Microsoft Entra ID should redirect to after a successful logout + MicrosoftEntraId--TenantId Identifies which Microsoft Entra ID instance holds the users that should be authorized + + This script will create the App Registrations that provide these configurations. Once those + are created the configuration data will be saved to Azure App Configuration and the secret + will be saved in Azure Key Vault so that the web app can read these values and provide them + to Microsoft Entra ID during the authentication process. + + NOTE: This functionality assumes that the web app, app configuration service, and app + service have already been successfully deployed. + +.PARAMETER ResourceGroupName + A required parameter for the name of resource group that contains the environment that was + created by the azd command. The cmdlet will populate the App Config Svc and Key + Vault services in this resource group with Microsoft Entra ID app registration config data. + +.EXAMPLE + PS C:\> .\create-app-registrations.ps1 -ResourceGroupName rg-rele231127v4-dev-westus3-application + + This example will create the app registrations for the rele231127v4 environment. +#> + +Param( + [Alias("g")] + [Parameter(Mandatory = $true, HelpMessage = "Name of the application resource group that was created by azd")] + [String]$ResourceGroupName, + [Parameter(Mandatory = $false, HelpMessage = "Use default values for all prompts")] + [Switch]$NoPrompt +) + +$MAX_RETRY_ATTEMPTS = 10 +$API_SCOPE_NAME = "relecloud.api" + +# Prompt formatting features + +$defaultColor = if ($Host.UI.SupportsVirtualTerminal) { "`e[0m" } else { "" } +$successColor = if ($Host.UI.SupportsVirtualTerminal) { "`e[32m" } else { "" } +$highlightColor = if ($Host.UI.SupportsVirtualTerminal) { "`e[36m" } else { "" } + +# End of Prompt formatting features + +# Function definitions + +function Get-CachedResourceGroup { + param( + [Parameter(Mandatory = $true)] + [string]$ResourceGroupName + ) + + if ($global:resourceGroups -and $global:resourceGroups.ContainsKey($ResourceGroupName)) { + return $global:resourceGroups[$ResourceGroupName] + } + + $resourceGroup = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue + + if (!$global:resourceGroups) { + $global:resourceGroups = @{} + } + + $global:resourceGroups[$ResourceGroupName] = $resourceGroup + + return $resourceGroup +} + +function Get-WorkloadName { + param( + [Parameter(Mandatory = $true)] + [string]$ResourceGroupName + ) + + $resourceGroup = Get-CachedResourceGroup -ResourceGroupName $ResourceGroupName + # Something like 'rele231116v1' + return $resourceGroup.Tags["WorkloadName"] +} + +function Get-WorkloadResourceToken { + param( + [Parameter(Mandatory = $true)] + [string]$ResourceGroupName + ) + $resourceGroup = Get-CachedResourceGroup -ResourceGroupName $ResourceGroupName + # Something like 'c2auhsbjt6h6i' + return $resourceGroup.Tags["ResourceToken"] +} + +function Get-WorkloadEnvironment { + param( + [Parameter(Mandatory = $true)] + [string]$ResourceGroupName + ) + $resourceGroup = Get-CachedResourceGroup -ResourceGroupName $ResourceGroupName + # Something like 'dev', 'test', 'prod' + return $resourceGroup.Tags["Environment"] +} + +function Get-ApiAppRegistration { + param( + [Parameter(Mandatory = $true)] + [string]$AppRegistrationName, + [Parameter(Mandatory = $true)] + [string]$ExistingAppRegistrationId + ) + + # get an existing Front-end App Registration + $apiAppRegistration = Get-AzADApplication -DisplayName $AppRegistrationName -ErrorAction SilentlyContinue + + # if it doesn't exist, then return a new one we created + if (!$apiAppRegistration) { + Write-Host "`tCreating the API registration $highlightColor'$($AppRegistrationName)'$defaultColor" + + return New-ApiAppRegistration ` + -AppRegistrationName $AppRegistrationName -ExistingAppRegistrationId $ExistingAppRegistrationId + } + + Write-Host "`tRetrieved the existing API registration $highlightColor'$($apiAppRegistration.Id)'$defaultColor" + return $apiAppRegistration +} + +function New-ApiAppRegistration { + param( + [Parameter(Mandatory = $true)] + [string]$AppRegistrationName, + [Parameter(Mandatory = $true)] + [string]$ExistingAppRegistrationId + ) + + $delegatedPermissionId = (New-Guid).ToString() + + # Define the OAuth2 permissions (scopes) for the API + # https://learn.microsoft.com/en-us/dotnet/api/microsoft.azure.powershell.cmdlets.resources.msgraph.models.apiv10.imicrosoftgraphapiapplication?view=az-ps-latest + # typing is case sensitive on the following objects and properites + $apiPermissions = [Microsoft.Azure.PowerShell.Cmdlets.Resources.MSGraph.Models.ApiV10.IMicrosoftGraphApiApplication]@{ + Oauth2PermissionScope = [Microsoft.Azure.PowerShell.Cmdlets.Resources.MSGraph.Models.ApiV10.IMicrosoftGraphPermissionScope[]]@( + [Microsoft.Azure.PowerShell.Cmdlets.Resources.MSGraph.Models.ApiV10.IMicrosoftGraphPermissionScope ]@{ + Id = $delegatedPermissionId + Type = "User" + AdminConsentDescription = "Allow the app to access the web API as a user" + AdminConsentDisplayName = "Access the web API" + IsEnabled = $true + Value = $API_SCOPE_NAME + UserConsentDescription = "Allow the app to access the web API on your behalf" + UserConsentDisplayName = "Access the web API" + }) + PreAuthorizedApplication = [Microsoft.Azure.PowerShell.Cmdlets.Resources.MSGraph.Models.ApiV10.IMicrosoftGraphPreAuthorizedApplication[]]@( + [Microsoft.Azure.PowerShell.Cmdlets.Resources.MSGraph.Models.ApiV10.IMicrosoftGraphPreAuthorizedApplication]@{ + AppId = $ExistingAppRegistrationId + DelegatedPermissionId = @($delegatedPermissionId) + } + ) + } + + # log the API permissions to console for debugging + #Write-Host "`t`tAPI Permissions:" + #Write-Host "`t`t`t$($apiPermissions | ConvertTo-Json -Depth 100)" + + # create a Microsoft Entra ID App Registration for the front-end web app + $apiAppRegistration = New-AzADApplication ` + -DisplayName $AppRegistrationName ` + -SignInAudience "AzureADMyOrg" ` + -Api $apiPermissions ` + -ErrorAction Stop + + # set the identifier URI to the app ID (this is the default behavior) + $apiAppRegistration.IdentifierUri = @("api://$($apiAppRegistration.AppId)") + + # save the change + Update-AzADApplication -ObjectId $apiAppRegistration.Id -IdentifierUris $apiAppRegistration.IdentifierUri + + # $clientId = "" + # while ($clientId -eq "" -and $attempts -lt $MAX_RETRY_ATTEMPTS) + # { + # $MAX_RETRY_ATTEMPTS = $MAX_RETRY_ATTEMPTS + 1 + # try { + # $clientId = (Get-AzADApplication -DisplayName $AppRegistrationName -ErrorAction Stop).ApplicationId + # } + # catch { + # Write-Host "`t`tFailed to retrieve the client ID for the front-end app registration. Will try again in 3 seconds." + # Start-Sleep -Seconds 3 + # } + # } + + return $apiAppRegistration +} + +function Get-FrontendAppRegistration { + param( + [Parameter(Mandatory = $true)] + [string]$AppRegistrationName, + [Parameter(Mandatory = $true)] + [string]$AzureWebsiteRedirectUri, + [Parameter(Mandatory = $true)] + [string]$AzureWebsiteLogoutUri, + [Parameter(Mandatory = $true)] + [string]$LocalhostWebsiteRedirectUri + ) + + # get an existing Front-end App Registration + $frontendAppRegistration = Get-AzADApplication -DisplayName $AppRegistrationName -ErrorAction SilentlyContinue + + # if it doesn't exist, then return a new one we created + if (!$frontendAppRegistration) { + Write-Host "`tCreating the front-end app registration $highlightColor'$($AppRegistrationName)'$defaultColor" + + return New-FrontendAppRegistration ` + -AzureWebsiteRedirectUri $AzureWebsiteRedirectUri ` + -AzureWebsiteLogoutUri $AzureWebsiteLogoutUri ` + -LocalhostWebsiteRedirectUri $LocalhostWebsiteRedirectUri ` + -AppRegistrationName $AppRegistrationName + } + + Write-Host "`tRetrieved the existing front-end app registration $highlightColor'$($frontendAppRegistration.Id)'$defaultColor" + return $frontendAppRegistration +} + +function New-FrontendAppRegistration { + param( + [Parameter(Mandatory = $true)] + [string]$AppRegistrationName, + [Parameter(Mandatory = $true)] + [string]$AzureWebsiteRedirectUri, + [Parameter(Mandatory = $true)] + [string]$AzureWebsiteLogoutUri, + [Parameter(Mandatory = $true)] + [string]$LocalhostWebsiteRedirectUri + ) + $websiteApp = @{ + "LogoutUrl" = $AzureWebsiteLogoutUri + "RedirectUris" = @($AzureWebsiteRedirectUri, $LocalhostWebsiteRedirectUri) + "ImplicitGrantSetting" = @{ + "EnableAccessTokenIssuance" = $false + "EnableIdTokenIssuance" = $true + } + } + + # create a Microsoft Entra ID App Registration for the front-end web app + $frontendAppRegistration = New-AzADApplication ` + -DisplayName $AppRegistrationName ` + -SignInAudience "AzureADMyOrg" ` + -Web $websiteApp ` + -ErrorAction Stop + + # $clientId = "" + # while ($clientId -eq "" -and $attempts -lt $MAX_RETRY_ATTEMPTS) + # { + # $MAX_RETRY_ATTEMPTS = $MAX_RETRY_ATTEMPTS + 1 + # try { + # $clientId = (Get-AzADApplication -DisplayName $AppRegistrationName -ErrorAction Stop).ApplicationId + # } + # catch { + # Write-Host "`t`tFailed to retrieve the client ID for the front-end app registration. Will try again in 3 seconds." + # Start-Sleep -Seconds 3 + # } + # } + + return $frontendAppRegistration +} + +# End of function definitions + + +# Check for required features + +if ((Get-Module -ListAvailable -Name Az.Resources) -and (Get-Module -Name Az.Resources -ErrorAction SilentlyContinue)) { + Write-Debug "The 'Az.Resources' module is installed and imported." + if (Get-AzContext -ErrorAction SilentlyContinue) { + Write-Debug "The user is authenticated with Azure." + } + else { + Write-Error "You are not authenticated with Azure. Please run 'Connect-AzAccount' to authenticate before running this script." + exit 10 + } +} +else { + try { + Write-Host "Importing 'Az.Resources' module" + Import-Module -Name Az.Resources -ErrorAction Stop + Write-Debug "The 'Az.Resources' module is imported successfully." + if (Get-AzContext -ErrorAction SilentlyContinue) { + Write-Debug "The user is authenticated with Azure." + } + else { + Write-Error "You are not authenticated with Azure. Please run 'Connect-AzAccount' to authenticate before running this script." + exit 11 + } + } + catch { + Write-Error "Failed to import the 'Az.Resources' module. Please install and import the 'Az' module before running this script." + exit 12 + } +} + +# End of feature checking + +# Set defaults +$defaultFrontEndAppRegistrationName = "$(Get-WorkloadName -ResourceGroupName $ResourceGroupName)-$(Get-WorkloadEnvironment -ResourceGroupName $ResourceGroupName)-front-webapp-$(Get-WorkloadResourceToken -ResourceGroupName $ResourceGroupName)" +$defaultApiAppRegistrationName = "$(Get-WorkloadName -ResourceGroupName $ResourceGroupName)-$(Get-WorkloadEnvironment -ResourceGroupName $ResourceGroupName)-api-webapp-$(Get-WorkloadResourceToken -ResourceGroupName $ResourceGroupName)" +$defaultKeyVaultname = "kv-$(Get-WorkloadResourceToken -ResourceGroupName $ResourceGroupName)" + +$frontDoorProfile = (Get-AzFrontDoorCdnProfile -ResourceGroupName $ResourceGroupName) +$frontDoorEndpoint = (Get-AzFrontDoorCdnEndpoint -ProfileName $frontDoorProfile.Name -ResourceGroupName $ResourceGroupName) +$defaultAzureWebsiteUri = "https://$($frontDoorEndpoint.HostName)" + +# End of Set defaults + +# Gather inputs + +# The web app has two websites so we need to create two app registrations. +# This app registration is for the back-end API that the front-end website will call. +$apiAppRegistrationName = "" +if (-not $NoPrompt) { + $apiAppRegistrationName = Read-Host -Prompt "`nWhat should the name of the API web app registration be? [default: $highlightColor$defaultApiAppRegistrationName$defaultColor]" +} + +if ($apiAppRegistrationName -eq "") { + $apiAppRegistrationName = $defaultApiAppRegistrationName +} + +# This app registration is for the front-end website that users will interact with. +$frontendAppRegistrationName = "" +if (-not $NoPrompt) { + $frontendAppRegistrationName = Read-Host -Prompt "`nWhat should the name of the Front-end web app registration be? [default: $highlightColor$defaultFrontEndAppRegistrationName$defaultColor]" +} + +if ($frontendAppRegistrationName -eq "") { + $frontendAppRegistrationName = $defaultFrontEndAppRegistrationName +} + +# This is where the App Registration details will be stored +$keyVaultName = "" +if (-not $NoPrompt) { + $keyVaultName = Read-Host -Prompt "`nWhat is the name of the Key Vault that should store the App Registration details? [default: $highlightColor$defaultKeyVaultname$defaultColor]" +} + +if ($keyVaultName -eq "") { + $keyVaultName = $defaultKeyVaultname +} + +$azureWebsiteUri = "" +if (-not $NoPrompt) { + $azureWebsiteUri = Read-Host -Prompt "`nWhat is the login redirect uri of the website? [default: $highlightColor$defaultAzureWebsiteUri$defaultColor]" +} + +if ($azureWebsiteUri -eq "") { + $azureWebsiteUri = $defaultAzureWebsiteUri +} + +$tenantId = (Get-AzContext).Tenant.Id + +# hard coded localhost URL comes from startup properties of the web app +$localhostWebsiteRedirectUri = "https://localhost:7227/signin-oidc" +$azureWebsiteRedirectUri = "$azureWebsiteUri/signin-oidc" +$azureWebsiteLogoutUri = "$azureWebsiteUri/signout-oidc" + +# End of Gather inputs + +# Display working state for confirmation +Write-Host "`nSetup for App Registrations" -ForegroundColor Yellow +Write-Host "`ttenantId='$tenantId'" +Write-Host "`tresourceGroupName='$resourceGroupName'" +Write-Host "`tfrontendAppRegistrationName='$frontendAppRegistrationName'" +Write-Host "`tkeyVaultName='$keyVaultName'" +Write-Host "`tlocalhostWebsiteRedirectUri='$localhostWebsiteRedirectUri'" +Write-Host "`tazureWebsiteRedirectUri='$azureWebsiteRedirectUri'" +Write-Host "`tazureWebsiteLogoutUri='$azureWebsiteLogoutUri'" +Write-Host "`tapiAppRegistrationName='$apiAppRegistrationName'" + +$confirmation = "" +if (-not $NoPrompt) { + $confirmation = Read-Host -Prompt "`nHit enter proceed with creating app registrations" +} + +if ($confirmation -ne "") { + Write-Host "`nExiting without creating app registrations." + exit 13 +} + +# End of Display working state for confirmation + +# Test the existence of the Key Vault +$keyVault = Get-AzKeyVault -VaultName $keyVaultName -ErrorAction SilentlyContinue + +if (!$keyVault) { + Write-Error "The Key Vault '$keyVaultName' does not exist. Please create the Key Vault before running this script." + exit 14 +} + +# Test to see if the current user has permissions to create secrets in the Key Vault +try { + $secretValue = ConvertTo-SecureString -String 'https://login.microsoftonline.com/' -AsPlainText -Force + Set-AzKeyVaultSecret -VaultName $keyVault.VaultName -Name 'MicrosoftEntraId--Instance' -SecretValue $secretValue -ErrorAction Stop > $null +} catch { + Write-Error "Unable to save data to '$keyVaultName'. Please check your permissions and the network restrictions on the Key Vault." + exit 15 +} + +# Set static values +$secretValue = ConvertTo-SecureString -String '/signin-oidc' -AsPlainText -Force +Set-AzKeyVaultSecret -VaultName $keyVault.VaultName -Name 'MicrosoftEntraId--CallbackPath' -SecretValue $secretValue -ErrorAction Stop > $null +Write-Host "`tSaved the $highlightColor'MicrosoftEntraId--CallbackPath'$defaultColor to Key Vault" + +$secretValue = ConvertTo-SecureString -String '/signout-oidc' -AsPlainText -Force +Set-AzKeyVaultSecret -VaultName $keyVault.VaultName -Name 'MicrosoftEntraId--SignedOutCallbackPath' -SecretValue $secretValue -ErrorAction Stop > $null +Write-Host "`tSaved the $highlightColor'MicrosoftEntraId--SignedOutCallbackPath'$defaultColor to Key Vault" + +$secretInstance = ConvertTo-SecureString -String 'https://login.microsoftonline.com/' -AsPlainText -Force +Set-AzKeyVaultSecret -VaultName $keyVault.VaultName -Name 'Api--MicrosoftEntraId--Instance' -SecretValue $secretInstance -ErrorAction Stop > $null +Write-Host "`tSaved the $highlightColor'Api--MicrosoftEntraId--Instance'$defaultColor to Key Vault" + +Set-AzKeyVaultSecret -VaultName $keyVault.VaultName -Name 'MicrosoftEntraId--Instance' -SecretValue $secretInstance -ErrorAction Stop > $null +Write-Host "`tSaved the $highlightColor'MicrosoftEntraId--Instance'$defaultColor to Key Vault" + +# Write TenantId to Key Vault +$secretValue = ConvertTo-SecureString -String $tenantId -AsPlainText -Force +Set-AzKeyVaultSecret -VaultName $keyVault.VaultName -Name 'Api--MicrosoftEntraId--TenantId' -SecretValue $secretValue -ErrorAction Stop > $null +Write-Host "`tSaved the $highlightColor'Api--MicrosoftEntraId--TenantId'$defaultColor to Key Vault" + +$secretValue = ConvertTo-SecureString -String $tenantId -AsPlainText -Force +Set-AzKeyVaultSecret -VaultName $keyVault.VaultName -Name 'MicrosoftEntraId--TenantId' -SecretValue $secretValue -ErrorAction Stop > $null +Write-Host "`tSaved the $highlightColor'MicrosoftEntraId--TenantId'$defaultColor to Key Vault" + +# Get or Create the front-end app registration +$frontendAppRegistration = Get-FrontendAppRegistration ` + -AzureWebsiteRedirectUri $azureWebsiteRedirectUri ` + -AzureWebsiteLogoutUri $azureWebsiteLogoutUri ` + -LocalhostWebsiteRedirectUri $localhostWebsiteRedirectUri ` + -AppRegistrationName $frontendAppRegistrationName + +# Write to Key Vault +$secretValue = ConvertTo-SecureString -String $frontendAppRegistration.AppId -AsPlainText -Force +Set-AzKeyVaultSecret -VaultName $keyVault.VaultName -Name 'MicrosoftEntraId--ClientId' -SecretValue $secretValue -ErrorAction Stop > $null +Write-Host "`tSaved the $highlightColor'MicrosoftEntraId--ClientId'$defaultColor to Key Vault" + +# List client secrets +$clientSecrets = Get-AzADAppCredential -ObjectId $frontendAppRegistration.Id -ErrorAction SilentlyContinue +# If there are secrets, then delete them +if ($clientSecrets) { + # for each client secret + foreach ($clientSecret in $clientSecrets) { + # delete the client secret + Remove-AzADAppCredential -ObjectId $frontendAppRegistration.Id -KeyId $clientSecret.KeyId -ErrorAction Stop > $null + } +} + +# Create a new client secret with a 1 year expiration +$clientSecrets = New-AzADAppCredential -ObjectId $frontendAppRegistration.Id -EndDate (Get-Date).AddYears(1) -ErrorAction Stop + +# Write to Key Vault +$secretValue = ConvertTo-SecureString -String $clientSecrets.SecretText -AsPlainText -Force +Set-AzKeyVaultSecret -VaultName $keyVault.VaultName -Name 'MicrosoftEntraId--ClientSecret' -SecretValue $secretValue -ErrorAction Stop > $null +Write-Host "`tSaved the $highlightColor'MicrosoftEntraId--ClientSecret'$defaultColor to Key Vault" + +# Get or Create the api app registration +$apiAppRegistration = Get-ApiAppRegistration ` + -AppRegistrationName $apiAppRegistrationName ` + -ExistingAppRegistrationId $frontendAppRegistration.AppId + +# Write to Key Vault +$secretValue = ConvertTo-SecureString -String $apiAppRegistration.AppId -AsPlainText -Force +Set-AzKeyVaultSecret -VaultName $keyVault.VaultName -Name 'Api--MicrosoftEntraId--ClientId' -SecretValue $secretValue -ErrorAction Stop > $null +Write-Host "`tSaved the $highlightColor'Api--MicrosoftEntraId--ClientId'$defaultColor to Key Vault" + +$scopeDetails = $apiAppRegistration.Api.Oauth2PermissionScope | Where-Object { $_.Value -eq $API_SCOPE_NAME } +if (!$scopeDetails) { + Write-Error "Unable to find the scope '$API_SCOPE_NAME' in the API app registration. Please check the API app registration in Microsoft Entra ID." + exit 16 +} + +Write-Host "`tFound the scope $highlightColor'$($scopeDetails.Value)'$defaultColor with ID $highlightColor'$($scopeDetails.Id)'$defaultColor" + +# Check permission for front-end app registration to verify it has access to the API app registration +$apiPermission = Get-AzADAppPermission -ObjectId $frontendAppRegistration.Id -ErrorAction SilentlyContinue | Where-Object { $_.ApiId -eq $apiAppRegistration.AppId -and $_.Type -eq 'Scope' } +if (!$apiPermission) { + Write-Host "`tCreating the permission for the front-end app registration to access the API app registration" + $apiPermission = Add-AzADAppPermission -ObjectId $frontendAppRegistration.Id -ApiId $apiAppRegistration.AppId -PermissionId $scopeDetails.Id -ErrorAction Stop +} + +$formattedScope = "$($apiAppRegistration.IdentifierUri)/$($scopeDetails.Value)" +$secretValue = ConvertTo-SecureString -String $formattedScope -AsPlainText -Force +Set-AzKeyVaultSecret -VaultName $keyVault.VaultName -Name 'App--RelecloudApi--AttendeeScope' -SecretValue $secretValue -ErrorAction Stop > $null +Write-Host "`tSaved the $highlightColor'App--RelecloudApi--AttendeeScope'$defaultColor to Key Vault" + +Write-Host "`nFinished create-app-registrations $($successColor)successfully$($defaultColor)." + +# all done +exit 0 \ No newline at end of file diff --git a/infra/scripts/predeploy/call-set-app-configuration.ps1 b/infra/scripts/predeploy/call-set-app-configuration.ps1 new file mode 100644 index 00000000..6b1c9710 --- /dev/null +++ b/infra/scripts/predeploy/call-set-app-configuration.ps1 @@ -0,0 +1,21 @@ +<# +.SYNOPSIS + This script will be run by the Azure Developer CLI, and will have access to the AZD_* vars + This ensures the the app configuration service is reachable from the current environment. + +.DESCRIPTION + This script will be run by the Azure Developer CLI, and will set the required + app configuration settings for the Relecloud web app as part of the code deployment process. + + Depends on the AZURE_RESOURCE_GROUP environment variable being set. AZD requires this to + understand which resource group to deploy to so this script uses it to learn about the + environment where the configuration settings should be set. + +#> + +$resourceGroupName = ((azd env get-values --output json) | ConvertFrom-Json).AZURE_RESOURCE_GROUP +$webUri = ((azd env get-values --output json) | ConvertFrom-Json).WEB_URI + +Write-Host "Calling set-app-configuration.ps1 for group:'$resourceGroupName' with webUri:'$webUri' ..." + +./infra/scripts/predeploy/set-app-configuration.ps1 -ResourceGroupName $resourceGroupName -WebUri $webUri -NoPrompt \ No newline at end of file diff --git a/infra/scripts/predeploy/call-set-app-configuration.sh b/infra/scripts/predeploy/call-set-app-configuration.sh new file mode 100755 index 00000000..7154bde3 --- /dev/null +++ b/infra/scripts/predeploy/call-set-app-configuration.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +# This script is run by azd pre-provision hook and is part of the deployment lifecycle run when deploying the code for the Relecloud web app. +resourceGroupName=$((azd env get-values --output json) | jq -r .AZURE_RESOURCE_GROUP) +webUri=$((azd env get-values --output json) | jq -r .WEB_URI) + +echo "Calling set-app-configuration.ps1 for group:'$resourceGroupName' with webUri:'$webUri' ..." + +pwsh ./infra/scripts/predeploy/set-app-configuration.ps1 -ResourceGroupName $resourceGroupName -WebUri $webUri -NoPrompt \ No newline at end of file diff --git a/infra/scripts/predeploy/set-app-configuration.ps1 b/infra/scripts/predeploy/set-app-configuration.ps1 new file mode 100644 index 00000000..12f24f5f --- /dev/null +++ b/infra/scripts/predeploy/set-app-configuration.ps1 @@ -0,0 +1,266 @@ +<# +.SYNOPSIS + This script will be run by the Azure Developer CLI, and will have access to the AZD_* vars + This ensures the the app configuration service is reachable from the current environment. + +.DESCRIPTION + This script will be run by the Azure Developer CLI, and will set the required + app configuration settings for the Relecloud web app as part of the code deployment process. + + Depends on the AZURE_RESOURCE_GROUP environment variable being set. AZD requires this to + understand which resource group to deploy to so this script uses it to learn about the + environment where the configuration settings should be set. + +#> + +Param( + [Alias("g")] + [Parameter(Mandatory = $true, HelpMessage = "Name of the application resource group that was created by azd")] + [String]$ResourceGroupName, + [Parameter(Mandatory = $true, HelpMessage = "URI used for OAuth with Microsoft Entra ID. This is the URI of the web app.")] + [String]$WebUri, + [Parameter(Mandatory = $false, HelpMessage = "Use default values for all prompts")] + [Switch]$NoPrompt +) + +if ((Get-Module -ListAvailable -Name Az.Resources) -and (Get-Module -Name Az.Resources -ErrorAction SilentlyContinue)) { + Write-Debug "The 'Az.Resources' module is installed and imported." + if (Get-AzContext -ErrorAction SilentlyContinue) { + Write-Debug "The user is authenticated with Azure." + } + else { + Write-Error "You are not authenticated with Azure. Please run 'Connect-AzAccount' to authenticate before running this script." + exit 10 + } +} +else { + try { + Write-Host "Importing 'Az.Resources' module" + Import-Module -Name Az.Resources -ErrorAction Stop + Write-Debug "The 'Az.Resources' module is imported successfully." + if (Get-AzContext -ErrorAction SilentlyContinue) { + Write-Debug "The user is authenticated with Azure." + } + else { + Write-Error "You are not authenticated with Azure. Please run 'Connect-AzAccount' to authenticate before running this script." + exit 11 + } + } + catch { + Write-Error "Failed to import the 'Az' module. Please install and import the 'Az' module before running this script." + exit 12 + } +} + +# Prompt formatting features + +$defaultColor = if ($PSVersionTable.PSVersion.Major -ge 6) { "`e[0m" } else { "" } +$successColor = if ($PSVersionTable.PSVersion.Major -ge 6) { "`e[32m" } else { "" } +$highlightColor = if ($PSVersionTable.PSVersion.Major -ge 6) { "`e[36m" } else { "" } + +# End of Prompt formatting features + +function Get-WorkloadSqlManagedIdentityConnectionString { + param( + [Parameter(Mandatory = $true)] + [string]$ResourceGroupName + ) + Write-Host "`tGetting sql server connection for $highlightColor'$ResourceGroupName'$defaultColor" + + $group = Get-AzResourceGroup -Name $ResourceGroupName + + # the group contains tags that explain what the default name of the Azure SQL resource should be + $sqlServerResourceName = "sql-$($group.Tags["ResourceToken"])" + $sqlDatabaseCatalogName = "relecloud-$($group.Tags["ResourceToken"])" + + # if sql server is not found, then throw an error + if ($sqlServerResourceName.Length -lt 4) { + throw "SQL server not found in resource group $group.ResourceGroupName" + } + + $sqlServerResource = Get-AzSqlServer -ServerName $sqlServerResourceName -ResourceGroupName $group.ResourceGroupName + + return "Server=tcp:$($sqlServerResource.FullyQualifiedDomainName),1433;Initial Catalog=$($sqlDatabaseCatalogName);Authentication=Active Directory Default; Connect Timeout=180" +} + +function Get-WorkloadStorageAccount { + param( + [Parameter(Mandatory = $true)] + [string]$ResourceGroupName + ) + Write-Host "`tGetting storage account for $highlightColor'$ResourceGroupName'$defaultColor" + + $group = Get-AzResourceGroup -Name $ResourceGroupName + + # the group contains tags that explain what the default name of the storage account should be + $storageAccountName = "st$($group.Tags["Environment"])$($group.Tags["ResourceToken"])" + + # if storage account is not found, then throw an error + if ($storageAccountName.Length -lt 6) { + throw "Storage account not found in resource group $group.ResourceGroupName" + } + + return Get-AzStorageAccount -Name $storageAccountName -ResourceGroupName $group.ResourceGroupName +} + +function Get-WorkloadKeyVault { + param( + [Parameter(Mandatory = $true)] + [string]$ResourceGroupName + ) + Write-Host "`tGetting key vault for $highlightColor'$ResourceGroupName'$defaultColor" + + $group = Get-AzResourceGroup -Name $ResourceGroupName + $hubGroup = Get-AzResourceGroup -Name $group.Tags["HubGroupName"] + + # the group contains tags that explain what the default name of the kv should be + $keyVaultName = "kv-$($hubGroup.Tags["ResourceToken"])" + + # if key vault is not found, then throw an error + if ($keyVaultName.Length -lt 4) { + throw "Key vault not found in resource group $hubGroup.ResourceGroupName" + } + + return Get-AzKeyVault -VaultName $keyVaultName -ResourceGroupName $hubGroup.ResourceGroupName +} + +function Get-RedisCacheKeyName { + param( + [Parameter(Mandatory = $true)] + [string]$ResourceGroupName + ) + Write-Host "`tGetting redis cache key name for $highlightColor'$ResourceGroupName'$defaultColor" + + $group = Get-AzResourceGroup -Name $ResourceGroupName + + # if the group contains a tag 'IsPrimary' then use the primary redis cache + if ($group.Tags["IsPrimaryLocation"] -eq "true") { + # matches hard coded value in application-post-config.bicep module + return "App--RedisCache--ConnectionString-Primary" + } + + # matches hard coded value in application-post-config.bicep module + return "App--RedisCache--ConnectionString-Secondary" + +} + +Write-Host "Configuring app settings for $highlightColor'$ResourceGroupName'$defaultColor" + +# default settings +$defaultAzureStorageTicketContainerName = "tickets" # matches the default defined in application-resources.bicep file +$defaultSqlConnectionString = (Get-WorkloadSqlManagedIdentityConnectionString -ResourceGroupName $ResourceGroupName) # the connection string to the SQL database set with Managed Identity +$defaultAzureStorageTicketUri = (Get-WorkloadStorageAccount -ResourceGroupName $ResourceGroupName).PrimaryEndpoints.Blob # the URI of the storage account container where tickets are stored +$defaultAzureFrontDoorHostName = $WebUri.Substring("https://".Length) # the hostname of the front door +$defaultRelecloudBaseUri = "$WebUri/api" # used by the frontend to call the backend through the front door +$defaultKeyVaultUri = (Get-WorkloadKeyVault -ResourceGroupName $ResourceGroupName).VaultUri # the URI of the key vault where secrets are stored +$defaultRedisCacheKeyName = (Get-RedisCacheKeyName -ResourceGroupName $ResourceGroupName) # workloads use independent redis caches and a shared vault to store the connection string + +# prompt to confirm settings +$azureStorageTicketContainerName = "" +if (-not $NoPrompt) { + $azureStorageTicketContainerName = Read-Host -Prompt "`nWhat value should be used for the Azure storage container name? [default: $highlightColor$defaultAzureStorageTicketContainerName$defaultColor]" +} + +if ($azureStorageTicketContainerName -eq "") { + $azureStorageTicketContainerName = $defaultAzureStorageTicketContainerName +} + +$sqlConnectionString = "" +if (-not $NoPrompt) { + $sqlConnectionString = Read-Host -Prompt "`nWhat value should be used for the SQL connection string? [default: $highlightColor$defaultSqlConnectionString$defaultColor]" +} + +if ($sqlConnectionString -eq "") { + $sqlConnectionString = $defaultSqlConnectionString +} + +$azureStorageTicketUri = "" +if (-not $NoPrompt) { + $azureStorageTicketUri = Read-Host -Prompt "`nWhat value should be used for the Azure storage URI? [default: $highlightColor$defaultAzureStorageTicketUri$defaultColor]" +} + +if ($azureStorageTicketUri -eq "") { + $azureStorageTicketUri = $defaultAzureStorageTicketUri +} + +$azureFrontDoorHostName = "" +if (-not $NoPrompt) { + $azureFrontDoorHostName = Read-Host -Prompt "`nWhat value should be used for the Azure Front Door Host? [default: $highlightColor$defaultAzureFrontDoorHostName$defaultColor]" +} + +if ($azureFrontDoorHostName -eq "") { + $azureFrontDoorHostName = $defaultAzureFrontDoorHostName +} + +$relecloudBaseUri = "" +if (-not $NoPrompt) { + $relecloudBaseUri = Read-Host -Prompt "`nWhat value should be used for the Relecloud Base URI? [default: $highlightColor$defaultRelecloudBaseUri$defaultColor]" +} + +if ($relecloudBaseUri -eq "") { + $relecloudBaseUri = $defaultRelecloudBaseUri +} + +$keyVaultUri = "" +if (-not $NoPrompt) { + $keyVaultUri = Read-Host -Prompt "`nWhat value should be used for the Key Vault URI? [default: $highlightColor$defaultKeyVaultUri$defaultColor]" +} + +if ($keyVaultUri -eq "") { + $keyVaultUri = $defaultKeyVaultUri +} + +$redisCacheKeyName = "" +if (-not $NoPrompt) { + $redisCacheKeyName = Read-Host -Prompt "`nWhat value should be used for the RedisCacheKeyName? [default: $highlightColor$defaultRedisCacheKeyName$defaultColor]" +} + +if ($redisCacheKeyName -eq "") { + $redisCacheKeyName = $defaultRedisCacheKeyName +} + +# display the settings so that the user can verify them in the output log +Write-Host "`nWorking settings:" +Write-Host "`tazureStorageTicketContainerName: $highlightColor'$azureStorageTicketContainerName'$defaultColor" +Write-Host "`tresourceGroupName: $highlightColor'$resourceGroupName'$defaultColor" +Write-Host "`tSqlConnectionString: $highlightColor'$sqlConnectionString'$defaultColor" +Write-Host "`tAzureStorageTicketUri: $highlightColor'$azureStorageTicketUri'$defaultColor" +Write-Host "`tAzureFrontDoorHostName: $highlightColor'$azureFrontDoorHostName'$defaultColor" +Write-Host "`tRelecloudBaseUri: $highlightColor'$relecloudBaseUri'$defaultColor" +Write-Host "`tRedisCacheKeyName: $highlightColor'$redisCacheKeyName'$defaultColor" + +# handles multi-regional app configuration because the app config must be in the same region as the code deployment +$configStore = Get-AzAppConfigurationStore -ResourceGroupName $resourceGroupName + +try { + Write-Host "`nSetting values in $highlightColor$($configStore.Endpoint)$defaultColor..." + + Write-Host "`nSet values for backend..." + Set-AzAppConfigurationKeyValue -Endpoint $configStore.Endpoint -Key App:SqlDatabase:ConnectionString -Value $sqlConnectionString > $null + Set-AzAppConfigurationKeyValue -Endpoint $configStore.Endpoint -Key App:StorageAccount:Container -Value $azureStorageTicketContainerName > $null + Set-AzAppConfigurationKeyValue -Endpoint $configStore.Endpoint -Key App:StorageAccount:Uri -Value $azureStorageTicketUri > $null + + Write-Host "Set values for frontend..." + Set-AzAppConfigurationKeyValue -Endpoint $configStore.Endpoint -Key App:FrontDoorHostname -Value $azureFrontDoorHostName > $null + Set-AzAppConfigurationKeyValue -Endpoint $configStore.Endpoint -Key App:RelecloudApi:BaseUri -Value $relecloudBaseUri > $null + + Write-Host "Set values for key vault references..." + Set-AzAppConfigurationKeyValue -Endpoint $configStore.Endpoint -Key Api:MicrosoftEntraId:ClientId -Value "{ `"uri`":`"$($keyVaultUri)secrets/Api--MicrosoftEntraId--ClientId`"}" -ContentType 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8' > $null + Set-AzAppConfigurationKeyValue -Endpoint $configStore.Endpoint -Key Api:MicrosoftEntraId:Instance -Value "{ `"uri`":`"$($keyVaultUri)secrets/Api--MicrosoftEntraId--Instance`"}" -ContentType 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8' > $null + Set-AzAppConfigurationKeyValue -Endpoint $configStore.Endpoint -Key Api:MicrosoftEntraId:TenantId -Value "{ `"uri`":`"$($keyVaultUri)secrets/Api--MicrosoftEntraId--TenantId`"}" -ContentType 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8' > $null + Set-AzAppConfigurationKeyValue -Endpoint $configStore.Endpoint -Key App:RedisCache:ConnectionString -Value "{ `"uri`":`"$($keyVaultUri)secrets/$($redisCacheKeyName)`"}" -ContentType 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8' > $null + Set-AzAppConfigurationKeyValue -Endpoint $configStore.Endpoint -Key App:RelecloudApi:AttendeeScope -Value "{ `"uri`":`"$($keyVaultUri)secrets/App--RelecloudApi--AttendeeScope`"}" -ContentType 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8' > $null + Set-AzAppConfigurationKeyValue -Endpoint $configStore.Endpoint -Key MicrosoftEntraId:Instance -Value "{ `"uri`":`"$($keyVaultUri)secrets/MicrosoftEntraId--Instance`"}" -ContentType 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8' > $null + Set-AzAppConfigurationKeyValue -Endpoint $configStore.Endpoint -Key MicrosoftEntraId:CallbackPath -Value "{ `"uri`":`"$($keyVaultUri)secrets/MicrosoftEntraId--CallbackPath`"}" -ContentType 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8' > $null + Set-AzAppConfigurationKeyValue -Endpoint $configStore.Endpoint -Key MicrosoftEntraId:ClientId -Value "{ `"uri`":`"$($keyVaultUri)secrets/MicrosoftEntraId--ClientId`"}" -ContentType 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8' > $null + Set-AzAppConfigurationKeyValue -Endpoint $configStore.Endpoint -Key MicrosoftEntraId:ClientSecret -Value "{ `"uri`":`"$($keyVaultUri)secrets/MicrosoftEntraId--ClientSecret`"}" -ContentType 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8' > $null + Set-AzAppConfigurationKeyValue -Endpoint $configStore.Endpoint -Key MicrosoftEntraId:Instance -Value "{ `"uri`":`"$($keyVaultUri)secrets/MicrosoftEntraId--Instance`"}" -ContentType 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8' > $null + Set-AzAppConfigurationKeyValue -Endpoint $configStore.Endpoint -Key MicrosoftEntraId:SignedOutCallbackPath -Value "{ `"uri`":`"$($keyVaultUri)secrets/MicrosoftEntraId--SignedOutCallbackPath`"}" -ContentType 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8' > $null + Set-AzAppConfigurationKeyValue -Endpoint $configStore.Endpoint -Key MicrosoftEntraId:TenantId -Value "{ `"uri`":`"$($keyVaultUri)secrets/MicrosoftEntraId--TenantId`"}" -ContentType 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8' > $null + + Write-Host "`nFinished set-app-configuration $($successColor)successfully$($defaultColor).`n" +} +catch { + "Failed to set app configuration values" | Write-Error + throw $_ +} diff --git a/infra/scripts/predown/call-cleanup.ps1 b/infra/scripts/predown/call-cleanup.ps1 new file mode 100644 index 00000000..0e6c4632 --- /dev/null +++ b/infra/scripts/predown/call-cleanup.ps1 @@ -0,0 +1,31 @@ +<# +.SYNOPSIS + This script will be run by the Azure Developer CLI, and will have access to the AZD_* vars + This calls the cleanup.ps1 script with the correct AZD resource group. + +.DESCRIPTION + This script will be run by the Azure Developer CLI, and will remove resources + that are not deleted as part of the `azd down` command such as the following: + - App registrations + - Azure budgets + - Azure diagnostic settings + + Script also deletes private endpoints. + + Depends on the AZURE_RESOURCE_GROUP environment variable being set. AZD requires this to + understand which resource group to deploy to so this script uses it to learn about the + environment where the configuration settings should be set. + +#> + +$resourceGroupName=(azd env get-values --output json | ConvertFrom-Json).AZURE_RESOURCE_GROUP + +# if the resource group is not set, then exit +if (-not $resourceGroupName) { + Write-Host "AZURE_RESOURCE_GROUP not set..." + exit 0 +} + +Write-Host "Calling cleanup.ps1 for group:'$resourceGroupName'..." + +./testscripts/cleanup.ps1 -ResourceGroup $resourceGroupName -NoPrompt -Purge \ No newline at end of file diff --git a/infra/scripts/predown/call-cleanup.sh b/infra/scripts/predown/call-cleanup.sh new file mode 100755 index 00000000..047f23ab --- /dev/null +++ b/infra/scripts/predown/call-cleanup.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# This script will be run by the Azure Developer CLI, and will have access to the AZD_* vars +# This calls the cleanup.ps1 script with the correct AZD resource group. + +# This script will be run by the Azure Developer CLI, and will remove resources +# that are not deleted as part of the `azd down` command such as the following: +# - App registrations +# - Azure budgets +# - Azure diagnostic settings +# Script also deletes private endpoints. +# Depends on the AZURE_RESOURCE_GROUP environment variable being set. AZD requires this to +# understand which resource group to deploy to so this script uses it to learn about the +# environment where the configuration settings should be set. + +resourceGroupName=$(azd env get-values --output json | jq -r '.AZURE_RESOURCE_GROUP') + +# if the resource group equals the string 'null', then exit +if [ "$resourceGroupName" == "null" ]; then + echo "AZURE_RESOURCE_GROUP not set..." + exit 0 +fi + + +echo "Calling cleanup.ps1 for group:'$resourceGroupName'..." + +pwsh ./testscripts/cleanup.ps1 -ResourceGroup "$resourceGroupName" -NoPrompt -Purge diff --git a/infra/scripts/preprovision/validate-params.ps1 b/infra/scripts/preprovision/validate-params.ps1 new file mode 100644 index 00000000..6af268b8 --- /dev/null +++ b/infra/scripts/preprovision/validate-params.ps1 @@ -0,0 +1,30 @@ +<# +.SYNOPSIS + This script validates the parameters for the deployment of the Azure DevOps environment. + +.DESCRIPTION + The script retrieves the configuration values from Azure DevOps and validates the environment type and network isolation settings. + It checks if the environment type is either 'dev' or 'prod' and if the network isolation is enabled for the 'prod' environment type. + If any of the parameters are invalid, an error message is displayed and the script exits with a non-zero status code. + +.NOTES + - This script requires the Azure CLI to be installed and logged in to Azure DevOps. + - The configuration values are retrieved using the 'azd env get-values' command. + +.EXAMPLE + ./validate-params.ps1 + + This example runs the script to validate the parameters using the default configuration values. +#> + + +$azdConfig = azd env get-values -o json | ConvertFrom-Json -Depth 9 -AsHashtable + + +$environmentType = $azdConfig['ENVIRONMENT'] ?? 'dev' + +# Block invalid deployment scenarios by helping the user understand the valid AZD options +if ($environmentType -ne 'dev' -and $environmentType -ne 'prod') { + Write-Error "Invalid AZD environment type: '$environmentType'. Valid values are 'dev' or 'prod'." + exit 1 +} \ No newline at end of file diff --git a/infra/scripts/preprovision/validate-params.sh b/infra/scripts/preprovision/validate-params.sh new file mode 100755 index 00000000..9de16cc8 --- /dev/null +++ b/infra/scripts/preprovision/validate-params.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# This script validates the parameters for the deployment of the Azure DevOps environment. + +# The script retrieves the configuration values from Azure DevOps and validates the environment type and network isolation settings. +# It checks if the environment type is either 'dev' or 'prod' and if the network isolation is enabled for the 'prod' environment type. +# If any of the parameters are invalid, an error message is displayed and the script exits with a non-zero status code. + +# This script requires the Azure CLI to be installed and logged in to Azure DevOps. +# The configuration values are retrieved using the 'azd env get-values' command. + +# Example usage: ./validate-params.sh + +environmentType=$(azd env get-values -o json | jq -r '.ENVIRONMENT') + +# default environmentType to dev if not set +if [[ $environmentType == "null" ]]; then + environmentType="dev" +fi + +# Block invalid deployment scenarios by helping the user understand the valid AZD options +if [[ $environmentType != "dev" && $environmentType != "prod" ]]; then + echo "" + echo " Invalid AZD environment type: '$environmentType'. Valid values are 'dev' or 'prod'." + exit 1 +fi \ No newline at end of file diff --git a/infra/scripts/preprovision/whats-my-ip.ps1 b/infra/scripts/preprovision/whats-my-ip.ps1 new file mode 100644 index 00000000..8f7e3ffc --- /dev/null +++ b/infra/scripts/preprovision/whats-my-ip.ps1 @@ -0,0 +1,19 @@ +<# +.SYNOPSIS + This script will be run by the Azure Developer CLI. + + Retrieves the public IP address of the current system, as seen by Azure. To do this, it + uses ipinfo.io as an external service. Afterwards, it sets the AZD_IP_ADDRESS environment + variable and sets the `azd env set` command to set it within Azure Developer CLI as well. +#> + +$ipaddr = Invoke-RestMethod -Uri https://ipinfo.io/ip + +# if $ipaddress is empty, exit with error +if ([string]::IsNullOrEmpty($ipaddr)) { + Write-Error "Unable to retrieve public IP address" + exit 1 +} + +$env:AZD_IP_ADDRESS = $ipaddr +azd env set AZD_IP_ADDRESS $ipaddr \ No newline at end of file diff --git a/infra/scripts/preprovision/whats-my-ip.sh b/infra/scripts/preprovision/whats-my-ip.sh new file mode 100755 index 00000000..63e016a5 --- /dev/null +++ b/infra/scripts/preprovision/whats-my-ip.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +# This script will be run by the Azure Developer CLI. +# +# Retrieves the public IP address of the current system, as seen by Azure. To do this, it +# uses ipinfo.io as an external service. Afterwards, it sets the AZD_IP_ADDRESS environment +# variable and sets the `azd env set` command to set it within Azure Developer CLI as well. + +echo '...make API call' +ipaddress=`curl -s https://ipinfo.io/ip` + +# if $ipaddress is empty, exit with error +if [ -z "$ipaddress" ]; then + echo '...no IP address returned' + exit 1 +fi + +echo '...export' +export AZD_IP_ADDRESS=$ipaddress + +echo '...set value' +azd env set AZD_IP_ADDRESS $ipaddress \ No newline at end of file diff --git a/infra/types/ApplicationIdentity.bicep b/infra/types/ApplicationIdentity.bicep new file mode 100644 index 00000000..a71c8746 --- /dev/null +++ b/infra/types/ApplicationIdentity.bicep @@ -0,0 +1,9 @@ +// From: infra/types/ApplicationIdentity.bicep +@description('Type describing an application identity.') +type ApplicationIdentity = { + @description('The ID of the identity') + principalId: string + + @description('The type of identity - either ServicePrincipal or User') + principalType: 'ServicePrincipal' | 'User' +} diff --git a/infra/types/BuildAgentSettings.bicep b/infra/types/BuildAgentSettings.bicep new file mode 100644 index 00000000..a9a706a6 --- /dev/null +++ b/infra/types/BuildAgentSettings.bicep @@ -0,0 +1,18 @@ +// From: infra/types/BuildAgentSettings.bicep +@description('Describes the required settings for a Azure DevOps Pipeline runner') +type AzureDevopsSettings = { + @description('The URL of the Azure DevOps organization to use for this agent') + organizationUrl: string + + @description('The Personal Access Token (PAT) to use for the Azure DevOps agent') + token: string +} + +@description('Describes the required settings for a GitHub Actions runner') +type GithubActionsSettings = { + @description('The URL of the GitHub repository to use for this agent') + repositoryUrl: string + + @description('The Personal Access Token (PAT) to use for the GitHub Actions runner') + token: string +} diff --git a/infra/types/DeploymentSettings.bicep b/infra/types/DeploymentSettings.bicep new file mode 100644 index 00000000..9041a137 --- /dev/null +++ b/infra/types/DeploymentSettings.bicep @@ -0,0 +1,39 @@ +// From: infra/types/DeploymentSettings.bicep +@description('Type that describes the global deployment settings') +type DeploymentSettings = { + @description('If \'true\', then two regional deployments will be performed.') + isMultiLocationDeployment: bool + + @description('If \'true\', use production SKUs and settings.') + isProduction: bool + + @description('If \'true\', isolate the workload in a virtual network.') + isNetworkIsolated: bool + + @description('If \'false\', then this is a multi-location deployment for the second location.') + isPrimaryLocation: bool + + @description('The Azure region to host resources') + location: string + + @description('The name of the workload.') + name: string + + @description('The ID of the principal that is being used to deploy resources.') + principalId: string + + @description('The type of the \'principalId\' property.') + principalType: 'ServicePrincipal' | 'User' + + @description('The token to use for naming resources. This should be unique to the deployment.') + resourceToken: string + + @description('The development stage for this application') + stage: 'dev' | 'prod' + + @description('The common tags that should be used for all created resources') + tags: object + + @description('The common tags that should be used for all workload resources') + workloadTags: object +} diff --git a/infra/types/DiagnosticSettings.bicep b/infra/types/DiagnosticSettings.bicep new file mode 100644 index 00000000..79642756 --- /dev/null +++ b/infra/types/DiagnosticSettings.bicep @@ -0,0 +1,15 @@ +// From: infra/types/DiagnosticSettings.bicep +@description('The diagnostic settings for a resource') +type DiagnosticSettings = { + @description('The number of days to retain log data.') + logRetentionInDays: int + + @description('The number of days to retain metric data.') + metricRetentionInDays: int + + @description('If true, enable diagnostic logging.') + enableLogs: bool + + @description('If true, enable metrics logging.') + enableMetrics: bool +} diff --git a/infra/types/FrontDoorSettings.bicep b/infra/types/FrontDoorSettings.bicep new file mode 100644 index 00000000..8c7e4479 --- /dev/null +++ b/infra/types/FrontDoorSettings.bicep @@ -0,0 +1,15 @@ +// From: infra/types/FrontDoorSettings.bicep +@description('Type describing the settings for Azure Front Door.') +type FrontDoorSettings = { + @description('The name of the Azure Front Door endpoint') + endpointName: string + + @description('Front Door Id used for traffic restriction') + frontDoorId: string + + @description('The hostname that can be used to access Azure Front Door content.') + hostname: string + + @description('The profile name that is used for configuring Front Door routes.') + profileName: string +} diff --git a/infra/types/PrivateEndpointSettings.bicep b/infra/types/PrivateEndpointSettings.bicep new file mode 100644 index 00000000..1b011476 --- /dev/null +++ b/infra/types/PrivateEndpointSettings.bicep @@ -0,0 +1,15 @@ +// From: infra/types/PrivateEndpointSettings.bicep +@description('Type describing the private endpoint settings.') +type PrivateEndpointSettings = { + @description('The name of the resource group to hold the Private DNS Zone. By default, this uses the same resource group as the resource.') + dnsResourceGroupName: string + + @description('The name of the private endpoint resource.') + name: string + + @description('The name of the resource group to hold the private endpoint.') + resourceGroupName: string + + @description('The ID of the subnet to link the private endpoint to.') + subnetId: string +} diff --git a/known-issues.md b/known-issues.md index de0fa8fe..b1a302d2 100644 --- a/known-issues.md +++ b/known-issues.md @@ -1,58 +1,6 @@ # Known issues This document helps with troubleshooting and provides an introduction to the most requested features, gotchas, and questions. -## Work from our backlog -These issues relate to content in our sample that we're working to modify. Open issues are provided for further detail and status updates. +## Data consistency for multi-regional deployments -### Improved DevOps flows and QA -- [GH Action to Deploy Web App with App Registration](https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/298) - -### Data consistency for multi-regional deployments - -This sample includes a feature to deploy to two Azure regions. The feature is intended to support the high availability scenario by deploying resources in an active/passive configuration. The sample currently supports the ability to fail-over web-traffic so requests can be handled from a second region. However it does not support data synchronization between two regions. - -This can result in users losing trust in the system when they observe that the system is online but their data is missing. The following issues represent the work remaining to address data synchronization. - -Open issues: -* [Implement multiregional Azure SQL](https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/44) -* [Implement multiregional Storage](https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/122) - -## Troubleshooting -The following topics are intended to help readers with our most commonly reported issues. - -* **Cannot execute shellscript `/bin/bash^M: bad interpreter`** - This error happens when Windows users checked out code from a Windows environment - and try to execute the code from Windows Subsystem for Linux (WSL). The issue is - caused by Git tools that automatically convert `LF` characters based on the local - environment. - - Run the following commands to change the windows line endings to linux line endings: - - ```bash - sed "s/$(printf '\r')\$//" -i ./infra/createAppRegistrations.sh - sed "s/$(printf '\r')\$//" -i ./infra/deploymentScripts/validateDeployment.sh - sed "s/$(printf '\r')\$//" -i ./infra/localDevScripts/addLocalIPToSqlFirewall.sh - sed "s/$(printf '\r')\$//" -i ./infra/localDevScripts/getSecretsForLocalDev.sh - sed "s/$(printf '\r')\$//" -i ./infra/localDevScripts/makeSqlUserAccount.sh - ``` - -* **Error: no project exists; to create a new project, run 'azd init'** - This error is most often reported when users try to run `azd` commands before running the `cd` command to switch to the directory where the repo was cloned. - - > You may need to `cd` into the directory you cloned to run this command. - -* **The deployment 'relecloudresources' already exists in location** - This error most often happens when trying a new region with the same for `$myEnvironment` - - When the `azd provision` command runs it creates a deployment resource in your subscription. You must delete this deployment before you can change the Azure region. - - > Please see the [teardown instructions](README.md#clean-up-azure-resources) to address this issue. - -* **Error: Invalid value specified for property 'web' of resource 'Application'** - This error most often happens when the user is running the 'createAppRegistrations.sh' file to create new app registrations and the region of user is different than the region of provisioned azure resources. For ex. the user from Canada is running this script on their local machine and the Azure region selected is East US. - - > You may need to wait a bit longer (15-20 minutes) before running the below command - - ```bash - ./infra/createAppRegistrations.sh -g "$myEnvironmentName-rg" - ``` \ No newline at end of file +This sample includes a feature to deploy to two Azure regions. The feature is intended to support the high availability scenario by deploying resources in an active/passive configuration. The sample currently supports the ability to fail-over web-traffic so requests can be handled from a second region. However it does not support data synchronization between two regions. \ No newline at end of file diff --git a/prerequisites.md b/prerequisites.md new file mode 100644 index 00000000..ccd5cedd --- /dev/null +++ b/prerequisites.md @@ -0,0 +1,74 @@ +# Prerequisites + + If you want to use a [VSCode Dev Container](https://code.visualstudio.com/docs/devcontainers/containers#_system-requirements) see the `VSCode Dev Container prerequisites` section below. + + +## Pre-requisites + +> ⚠️ Note that for the `Connect-AzAccount` and `azd auth login` you must use the same account. And, you may also need to specify the `--tenant` option or `--tenant-id` as required by your administrator. + +The following tools are pre-requisites to running the associated deployment steps on Windows without using the Dev Container. + +1. To run the scripts, Windows users require PowerShell 7.2 (LTS) or above. + + 1. PowerShell users - [Install PowerShell](https://learn.microsoft.com/powershell/scripting/install/installing-powershell-on-windows) + Run the following to verify that you're running the latest PowerShell + + ```ps1 + $PsVersionTable + ``` + +1. [Install Git](https://github.com/git-guides/install-git) + Run the following to verify that git is available + ```ps1 + git version + ``` + +1. [Install the Azure CLI](https://docs.microsoft.com/cli/azure/install-azure-cli). + Run the following command to verify that you're running version + 2.38.0 or higher. + + ```ps1 + az version + ``` + + After the installation, run the following command to [sign in to Azure PowerShell interactively](https://learn.microsoft.com/powershell/azure/authenticate-interactive). + + ```ps1 + Connect-AzAccount + ``` +1. [Upgrade the Azure CLI Bicep extension](https://learn.microsoft.com/azure/azure-resource-manager/bicep/install#azure-cli). + Run the following command to verify that you're running version 0.12.40 or higher. + + ```ps1 + az bicep version + ``` + +1. [Install the Azure Dev CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd). + Run the following command to verify that the Azure Dev CLI is installed. + + ```ps1 + azd auth login + ``` + +1. [Install .NET 7 SDK](https://dotnet.microsoft.com/download/dotnet/7.0) + Run the following command to verify that the .NET SDK 7.0 is installed. + ```ps1 + dotnet --version + ``` + +## Platform compatibility + +| | Native | Dev Container | +|-------------|-----------|--------------| +| Windows | ✅ | ✅ | +| Windows WSL | ✅ | ✅ | +| macOS | ✅ | ✅ | +| macOS arm64 | ✅ | ✅ | + +## VSCode Dev Container prerequisites + +1. Docker Desktop +1. VSCode +1. VSCode ms-vscode-remote.remote-containers extension +1. git \ No newline at end of file diff --git a/prod-deployment.md b/prod-deployment.md new file mode 100644 index 00000000..9e3f38c2 --- /dev/null +++ b/prod-deployment.md @@ -0,0 +1,269 @@ +# Steps to deploy the production deployment +This section describes the deployment steps for the reference implementation of a reliable web application pattern with .NET on Microsoft Azure. These steps guide you through using the jump box that is deployed when performing a network isolated deployment because access to resources will be restricted from public network access and must be performed from a machine connected to the vnet. + +![Diagram showing the network focused architecture of the reference implementation.](./assets/icons/reliable-web-app-vnet.svg) + +## Prerequisites + +We recommend that you use a Dev Container to deploy this application. The requirements are as follows: + +- [Azure Subscription](https://azure.microsoft.com/pricing/member-offers/msdn-benefits-details/). +- [Visual Studio Code](https://code.visualstudio.com/). +- [Docker Desktop](https://www.docker.com/get-started/). +- [Permissions to register an application in Microsoft Entra ID](https://learn.microsoft.com/azure/active-directory/develop/quickstart-register-app). +- Visual Studio Code [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers). + +If you do not wish to use a Dev Container, please refer to the [prerequisites](prerequisites.md) for detailed information on how to set up your development system to build, run, and deploy the application. + +> **Note** +> +> These steps are used to connect to a Linux jump box where you can deploy the code. The jump box is not designed to be a build server. You should use a devOps pipeline to manage build agents and deploy code into the environment. Also note that for this content the jump box is a Linux VM. This can be swapped with a Windows VM based on your organization's requirements. + +## Steps to deploy the reference implementation + +The following detailed deployment steps assume you are using a Dev Container inside Visual Studio Code. + +### 1. Log in to Azure + +1. Start a powershell session in the dev container terminal: + + ```sh + pwsh + ``` + +1. Import the Azure cmdlets: + + ```pwsh + Import-Module Az.Resources + ``` + +1. Log in to Azure: + + ```pwsh + Connect-AzAccount + ``` + +1. Set the subscription to the one you want to use (you can use [Get-AzSubscription](https://learn.microsoft.com/powershell/module/az.accounts/get-azsubscription?view=azps-11.3.0) to list available subscriptions): + + + ```pwsh + $AZURE_SUBSCRIPTION_ID="" + ``` + + ```pwsh + Set-AzContext -SubscriptionId $AZURE_SUBSCRIPTION_ID + ``` + +1. Azure Developer CLI (azd) has its own authentication context. Run the following command to authenticate to Azure: + + ```pwsh + azd auth login + ``` + + +### 2. Provision the app + +1. Create a new AZD environment to store your deployment configuration values: + + ```pwsh + azd env new + ``` + +1. Set the default subscription for the azd context: + + ```pwsh + azd env set AZURE_SUBSCRIPTION_ID $AZURE_SUBSCRIPTION_ID + ``` + +1. To create the prod deployment: + + ```pwsh + azd env set ENVIRONMENT prod + ``` + +1. Production is a multi-region deployment. Choose an Azure region for the primary deployment (Run `(Get-AzLocation).Location` to see a list of locations): + + ```pwsh + azd env set AZURE_LOCATION + ``` + + *You want to make sure the region has availability zones. Azure App Service is configured with [Availability zone support](https://learn.microsoft.com/en-us/azure/reliability/reliability-app-service?tabs=graph%2Ccli#availability-zone-support).* + +1. Choose an Azure region for the secondary deployment: + + ```pwsh + azd env set AZURE_SECONDARY_LOCATION + ``` + + *We encourage readers to choose paired regions for multi-regional web apps. Paired regions typically offer low network latency, data residency in the same geography, and sequential updating. Read [Azure paired regions](https://learn.microsoft.com/en-us/azure/reliability/cross-region-replication-azure#azure-paired-regions) to learn more about these regions.* + +1. Run the following command to create the Azure resources (about 45-minutes to provision): + + ```pwsh + azd provision + ``` + +### 3. Upload the code to the jump box + +> **WARNING** +> +> When the prod deployment is performed the Key Vault resource will be deployed with public network access enabled. This allows the reader to access the Key Vault to retrieve the username and password for the jump box. This also allows you to save data created by the `create-app-registration` script directly to the Key Vault. We recommend reviewing this approach with your security team as you may want to change this approach. One option to consider is adding the jump box to the domain, disabling public network access for Key Vault, and running the `create-app-registration` script from the jump box. + +To retrieve the generated password: + +1. Retrieve the username and password for your jump box: + + - Locate the Hub resource group in the Azure Portal. + - Open the Azure Key Vault from the list of resources. + - Select **Secrets** from the menu sidebar. + - Select **Jumpbox--AdministratorPassword**. + - Select the currently enabled version. + - Press **Show Secret Value**. + - Note the secret value for later use. + - Repeat the proecess for the **Jumpbox--AdministratorUsername** secret. + +1. Start a new PowerShell session in the terminal (In VS Code use `Ctrl+Shift+~`). Run the following command from the dev container terminal to start a new PowerShell session: + ``` + pwsh + ``` + +1. We use the [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/) to create a bastion tunnel that allows us to connect to the jump box: + + + + ```pwsh + az login + ``` + + ```pwsh + $AZURE_SUBSCRIPTION_ID = ((azd env get-values --output json | ConvertFrom-Json).AZURE_SUBSCRIPTION_ID) + ``` + + ```pwsh + az account set --subscription $AZURE_SUBSCRIPTION_ID + ``` + + +1. Run the following to set the environment variables for the bastion tunnel: + + ```pwsh + $bastionName = ((azd env get-values --output json | ConvertFrom-Json).BASTION_NAME) + $resourceGroupName = ((azd env get-values --output json | ConvertFrom-Json).BASTION_RESOURCE_GROUP) + $targetResourceId = ((azd env get-values --output json | ConvertFrom-Json).JUMPBOX_RESOURCE_ID) + ``` + +1. Run the following command to create a bastion tunnel to the jump box: + ```pwsh + az network bastion tunnel --name $bastionName --resource-group $resourceGroupName --target-resource-id $targetResourceId --resource-port 22 --port 50022 + ``` + + > **NOTE** + > + > Now that the tunnel is open, change back to use the original PowerShell session to deploy the code. + + +1. From PowerShell use the following SCP command to upload the code to the jump box (use the password you retrieved from Key Vault to authenticate the SCP command): + ```shell + scp -r -P 50022 * azureadmin@127.0.0.1:/home/azureadmin/web-app-pattern + ``` + + > If you were unable to connect due to [Remote host identification has changed](troubleshooting.md#remote-host-identification-has-changed) + +1. From PowerShell use the SCP command to upload the AZD environment to the jump box: + ```shell + scp -r -P 50022 ./.azure azureadmin@127.0.0.1:/home/azureadmin/web-app-pattern + ``` + +1. Run the following command to start a shell session on the jump box: + + ```shell + ssh azureadmin@127.0.0.1 -p 50022 + ``` + +### 4. Deploy code from the jump box + +1. Change to the directory where you uploaded the code: + + ```shell + cd web-app-pattern + ``` + +1. Change the exeuatable permissions on the scripts: + + + ```shell + chmod +x ./infra/scripts/**/*.sh + ``` + +1. Start a PowerShell session: + + ```shell + pwsh + ``` + +1. [Sign in to Azure PowerShell interactively](https://learn.microsoft.com/powershell/azure/authenticate-interactive): + + ```pwsh + Connect-AzAccount -UseDeviceAuthentication + ``` + + ```pwsh + Set-AzContext -SubscriptionId ((azd env get-values --output json | ConvertFrom-Json).AZURE_SUBSCRIPTION_ID) + ``` + +1. [Sign in to azd](https://learn.microsoft.com/azure/developer/azure-developer-cli/reference#azd-auth-login): + + ```pwsh + azd auth login --use-device-code + ``` + +1. Deploy the application to the primary region using: + + + + ```pwsh + azd deploy + ``` + + It takes approximately 5 minutes to deploy the code. + + > **WARNING** + > + > In some scenarios, the DNS entries for resources secured with Private Endpoint may have been cached incorrectly. It can take up to 10-minutes for the DNS cache to expire. + +1. Deploy the application to the secondary region using: + + ```pwsh + azd env set AZURE_RESOURCE_GROUP ((azd env get-values --output json | ConvertFrom-Json).SECONDARY_RESOURCE_GROUP) + ``` + + ```pwsh + azd deploy + ``` + +1. Use the URL displayed in the console output to launch the Relecloud application that you have deployed: + + ![screenshot of Relecloud app home page](assets/images/WebAppHomePage.png) + +### 5. Teardown + +1. Close the PowerShell session on the jump box: + + ```shell + exit + ``` + +1. Close your SSH session: + + ```shell + exit + ``` + +1. Close your background shell that opened the bastion tunnel with the interrupt command Ctrl+C. + +1. To tear down the deployment, run the following command from your dev container to remove all resources from Azure: + + ```pwsh + azd down --purge --force + ``` \ No newline at end of file diff --git a/src/Relecloud.Web.Api/Infrastructure/RetryTestingMiddleware.cs b/src/Relecloud.Web.Api/Infrastructure/RetryTestingMiddleware.cs deleted file mode 100644 index 50b5d7c5..00000000 --- a/src/Relecloud.Web.Api/Infrastructure/RetryTestingMiddleware.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Net; - -/* -NOTICE: This class is not intended for production scenarios. - -This middleware feature is included to demonstrate the Retry and -Circuit Breaker patterns that are discussed in the guide. - -Adding this feature to a production web app may cause stability issues. -*/ -namespace Relecloud.Web.Api.Infrastructure -{ - public class RetryTestingMiddleware - { - private readonly RequestDelegate _next; - private int _requestCount = 0; - - public RetryTestingMiddleware(RequestDelegate next) - { - _next = next; - } - - public async Task InvokeAsync(HttpContext context) - { - var config = context.RequestServices.GetService(); - if (config != null) - { - if (!string.IsNullOrEmpty(config["Api:App:RetryDemo"])) - { - int errorRate = 2; - if (int.TryParse(config["Api:App:RetryDemo"], out int newErrorRate)) - { - //by default this middleware throws an error every-other time - //we can use the config to override this and change the frequency - errorRate = newErrorRate; - } - if (_requestCount++ % errorRate == 0) - { - // When enabled this simulation demonstrates the retry pattern - await ReturnErrorResponse(context); - } - } - } - - await _next(context); - } - - private Task ReturnErrorResponse(HttpContext context) - { - context.Response.ContentType = "application/json"; - context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; - - return Task.CompletedTask; - } - } - - public static class RetryTestingMiddlewareExtensions - { - public static IApplicationBuilder UseRetryTestingMiddleware( - this IApplicationBuilder builder) - { - return builder.UseMiddleware(); - } - } -} diff --git a/src/Relecloud.Web.Api/Services/TicketManagementService/TicketImageService.cs b/src/Relecloud.Web.Api/Services/TicketManagementService/TicketImageService.cs deleted file mode 100644 index 171567f1..00000000 --- a/src/Relecloud.Web.Api/Services/TicketManagementService/TicketImageService.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Azure.Identity; -using Azure.Storage.Blobs; - -namespace Relecloud.Web.Api.Services.TicketManagementService -{ - public class TicketImageService : ITicketImageService - { - private readonly IConfiguration configuration; - private readonly ILogger logger; - public TicketImageService(IConfiguration configuration, ILogger logger) - { - this.configuration = configuration; - this.logger = logger; - } - - public Task GetTicketImagesAsync(string imageName) - { - try - { - var storageUrl = configuration["App:StorageAccount:Url"]; - var storageContainer = configuration["App:StorageAccount:Container"]; - Uri blobUri = new($"{storageUrl}/{storageContainer}/{imageName}"); - - BlobClient blobClient = new(blobUri, new DefaultAzureCredential()); - return blobClient.OpenReadAsync(); - } - catch (Exception ex) - { - this.logger.LogError(ex, $"Unable to retrieve image {imageName}"); - return Task.FromResult(Stream.Null); - } - } - } -} \ No newline at end of file diff --git a/src/Relecloud.Web.Api/Controllers/ConcertController.cs b/src/Relecloud.Web.CallCenter.Api/Controllers/ConcertController.cs similarity index 100% rename from src/Relecloud.Web.Api/Controllers/ConcertController.cs rename to src/Relecloud.Web.CallCenter.Api/Controllers/ConcertController.cs diff --git a/src/Relecloud.Web.Api/Controllers/ImageController.cs b/src/Relecloud.Web.CallCenter.Api/Controllers/ImageController.cs similarity index 100% rename from src/Relecloud.Web.Api/Controllers/ImageController.cs rename to src/Relecloud.Web.CallCenter.Api/Controllers/ImageController.cs diff --git a/src/Relecloud.Web.Api/Controllers/SearchController.cs b/src/Relecloud.Web.CallCenter.Api/Controllers/SearchController.cs similarity index 100% rename from src/Relecloud.Web.Api/Controllers/SearchController.cs rename to src/Relecloud.Web.CallCenter.Api/Controllers/SearchController.cs diff --git a/src/Relecloud.Web.Api/Controllers/TicketController.cs b/src/Relecloud.Web.CallCenter.Api/Controllers/TicketController.cs similarity index 87% rename from src/Relecloud.Web.Api/Controllers/TicketController.cs rename to src/Relecloud.Web.CallCenter.Api/Controllers/TicketController.cs index 209d0afc..7fefc438 100644 --- a/src/Relecloud.Web.Api/Controllers/TicketController.cs +++ b/src/Relecloud.Web.CallCenter.Api/Controllers/TicketController.cs @@ -90,23 +90,36 @@ public async Task PurchaseTicketsAsync(PurchaseTicketsRequest pur } var errors = new List(); - if (purchaseTicketRequest.PaymentDetails == null) + if (purchaseTicketRequest == null) { - errors.Add("Missing required payment details"); + errors.Add("Purchase ticket request must not be null"); } - if (purchaseTicketRequest.ConcertIdsAndTicketCounts == null) + else { - errors.Add("Missing required concert ticket details"); - } - if (string.IsNullOrEmpty(purchaseTicketRequest.UserId)) - { - errors.Add("Missing required userId"); + if (purchaseTicketRequest.PaymentDetails == null) + { + errors.Add("Missing required payment details"); + } + if (purchaseTicketRequest.ConcertIdsAndTicketCounts == null) + { + errors.Add("Missing required concert ticket details"); + } + if (string.IsNullOrEmpty(purchaseTicketRequest.UserId)) + { + errors.Add("Missing required userId"); + } } + if (errors.Any()) { return BadRequest(PurchaseTicketsResult.ErrorResponse(errors)); } + await this.concertRepository.CreateOrUpdateUserAsync(new User{ + Id = purchaseTicketRequest!.UserId ?? Guid.Empty.ToString(), + DisplayName = purchaseTicketRequest!.PaymentDetails!.Name + }); + var orderTotal = await TotalInvoiceAmountAsync(purchaseTicketRequest); var preAuthRequest = new PreAuthPaymentRequest { @@ -121,11 +134,7 @@ public async Task PurchaseTicketsAsync(PurchaseTicketsRequest pur return BadRequest(PurchaseTicketsResult.ErrorResponse("We were unable to process this card. Please review your payment details.")); } - #pragma warning disable CS8602 // Dereference of a possibly null reference. - //null chec handled by error messages above - var customer = await this.concertRepository.GetCustomerByEmailAsync(purchaseTicketRequest.PaymentDetails.Email); - #pragma warning restore CS8602 // Dereference of a possibly null reference. - + var customer = await this.concertRepository.GetCustomerByEmailAsync(purchaseTicketRequest.PaymentDetails!.Email); var customerId = customer?.Id ?? 0; if (customerId == 0) { diff --git a/src/Relecloud.Web.Api/Controllers/UserController.cs b/src/Relecloud.Web.CallCenter.Api/Controllers/UserController.cs similarity index 100% rename from src/Relecloud.Web.Api/Controllers/UserController.cs rename to src/Relecloud.Web.CallCenter.Api/Controllers/UserController.cs diff --git a/src/Relecloud.Web.Api/Infrastructure/ApplicationInitializer.cs b/src/Relecloud.Web.CallCenter.Api/Infrastructure/ApplicationInitializer.cs similarity index 100% rename from src/Relecloud.Web.Api/Infrastructure/ApplicationInitializer.cs rename to src/Relecloud.Web.CallCenter.Api/Infrastructure/ApplicationInitializer.cs diff --git a/src/Relecloud.Web.Api/Infrastructure/CacheKeys.cs b/src/Relecloud.Web.CallCenter.Api/Infrastructure/CacheKeys.cs similarity index 100% rename from src/Relecloud.Web.Api/Infrastructure/CacheKeys.cs rename to src/Relecloud.Web.CallCenter.Api/Infrastructure/CacheKeys.cs diff --git a/src/Relecloud.Web.CallCenter.Api/Infrastructure/IntermittentErrorRequestMiddleware.cs b/src/Relecloud.Web.CallCenter.Api/Infrastructure/IntermittentErrorRequestMiddleware.cs new file mode 100644 index 00000000..6b420bc6 --- /dev/null +++ b/src/Relecloud.Web.CallCenter.Api/Infrastructure/IntermittentErrorRequestMiddleware.cs @@ -0,0 +1,74 @@ +namespace Relecloud.Web.Api.Infrastructure; + +using System.Net; + +/* +NOTICE: This class is not intended for production scenarios. + +This middleware feature is included to demonstrate the Retry and +Circuit Breaker patterns that are discussed in the guide. + +Adding this feature to a production web app may cause stability issues. +*/ +public class IntermittentErrorRequestMiddleware +{ + private readonly RequestDelegate _next; + private static int _requestCount = 0; + private static int _backToBackExceptionCount = -1; + + public IntermittentErrorRequestMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + if (_backToBackExceptionCount == -1) + { + LoadConfiguration(context); + } + + if (_backToBackExceptionCount > 0 && ++_requestCount % (_backToBackExceptionCount + 1) > 0) + { + // When enabled this simulation demonstrates the retry pattern + await ReturnErrorResponse(context); + return; + } + + await _next(context); + } + + private static void LoadConfiguration(HttpContext context) + { + var config = context.RequestServices.GetService(); + if (config != null) + { + if (!string.IsNullOrEmpty(config["Api:App:RetryDemo"])) + { + if (int.TryParse(config["Api:App:RetryDemo"], out int newErrorRate)) + { + // When set to 1 this simulation will return an error every other request + // When set to 2 this simulation will return two errors between successful requests + _backToBackExceptionCount = newErrorRate; + } + } + } + } + + private Task ReturnErrorResponse(HttpContext context) + { + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; + + return Task.CompletedTask; + } +} + +public static class IntermittentErrorRequestMiddlewareExtensions +{ + public static IApplicationBuilder UseIntermittentErrorRequestMiddleware( + this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } +} diff --git a/src/Relecloud.Web.Api/Infrastructure/ModelStateDictionaryExtensions.cs b/src/Relecloud.Web.CallCenter.Api/Infrastructure/ModelStateDictionaryExtensions.cs similarity index 100% rename from src/Relecloud.Web.Api/Infrastructure/ModelStateDictionaryExtensions.cs rename to src/Relecloud.Web.CallCenter.Api/Infrastructure/ModelStateDictionaryExtensions.cs diff --git a/src/Relecloud.Web.Api/Infrastructure/Roles.cs b/src/Relecloud.Web.CallCenter.Api/Infrastructure/Roles.cs similarity index 100% rename from src/Relecloud.Web.Api/Infrastructure/Roles.cs rename to src/Relecloud.Web.CallCenter.Api/Infrastructure/Roles.cs diff --git a/src/Relecloud.Web.Api/Migrations/20220125000051_AddVisibleFields.Designer.cs b/src/Relecloud.Web.CallCenter.Api/Migrations/20220125000051_AddVisibleFields.Designer.cs similarity index 100% rename from src/Relecloud.Web.Api/Migrations/20220125000051_AddVisibleFields.Designer.cs rename to src/Relecloud.Web.CallCenter.Api/Migrations/20220125000051_AddVisibleFields.Designer.cs diff --git a/src/Relecloud.Web.Api/Migrations/20220125000051_AddVisibleFields.cs b/src/Relecloud.Web.CallCenter.Api/Migrations/20220125000051_AddVisibleFields.cs similarity index 100% rename from src/Relecloud.Web.Api/Migrations/20220125000051_AddVisibleFields.cs rename to src/Relecloud.Web.CallCenter.Api/Migrations/20220125000051_AddVisibleFields.cs diff --git a/src/Relecloud.Web.Api/Migrations/20220125000722_AddAuditFieldsToConcert.Designer.cs b/src/Relecloud.Web.CallCenter.Api/Migrations/20220125000722_AddAuditFieldsToConcert.Designer.cs similarity index 100% rename from src/Relecloud.Web.Api/Migrations/20220125000722_AddAuditFieldsToConcert.Designer.cs rename to src/Relecloud.Web.CallCenter.Api/Migrations/20220125000722_AddAuditFieldsToConcert.Designer.cs diff --git a/src/Relecloud.Web.Api/Migrations/20220125000722_AddAuditFieldsToConcert.cs b/src/Relecloud.Web.CallCenter.Api/Migrations/20220125000722_AddAuditFieldsToConcert.cs similarity index 100% rename from src/Relecloud.Web.Api/Migrations/20220125000722_AddAuditFieldsToConcert.cs rename to src/Relecloud.Web.CallCenter.Api/Migrations/20220125000722_AddAuditFieldsToConcert.cs diff --git a/src/Relecloud.Web.Api/Migrations/20220126181356_AddCheckoutTables.Designer.cs b/src/Relecloud.Web.CallCenter.Api/Migrations/20220126181356_AddCheckoutTables.Designer.cs similarity index 100% rename from src/Relecloud.Web.Api/Migrations/20220126181356_AddCheckoutTables.Designer.cs rename to src/Relecloud.Web.CallCenter.Api/Migrations/20220126181356_AddCheckoutTables.Designer.cs diff --git a/src/Relecloud.Web.Api/Migrations/20220126181356_AddCheckoutTables.cs b/src/Relecloud.Web.CallCenter.Api/Migrations/20220126181356_AddCheckoutTables.cs similarity index 100% rename from src/Relecloud.Web.Api/Migrations/20220126181356_AddCheckoutTables.cs rename to src/Relecloud.Web.CallCenter.Api/Migrations/20220126181356_AddCheckoutTables.cs diff --git a/src/Relecloud.Web.Api/Migrations/20220208203826_CreateTicketNumbers.Designer.cs b/src/Relecloud.Web.CallCenter.Api/Migrations/20220208203826_CreateTicketNumbers.Designer.cs similarity index 100% rename from src/Relecloud.Web.Api/Migrations/20220208203826_CreateTicketNumbers.Designer.cs rename to src/Relecloud.Web.CallCenter.Api/Migrations/20220208203826_CreateTicketNumbers.Designer.cs diff --git a/src/Relecloud.Web.Api/Migrations/20220208203826_CreateTicketNumbers.cs b/src/Relecloud.Web.CallCenter.Api/Migrations/20220208203826_CreateTicketNumbers.cs similarity index 100% rename from src/Relecloud.Web.Api/Migrations/20220208203826_CreateTicketNumbers.cs rename to src/Relecloud.Web.CallCenter.Api/Migrations/20220208203826_CreateTicketNumbers.cs diff --git a/src/Relecloud.Web.Api/Migrations/20220208231619_SelectTicketManagementService.Designer.cs b/src/Relecloud.Web.CallCenter.Api/Migrations/20220208231619_SelectTicketManagementService.Designer.cs similarity index 100% rename from src/Relecloud.Web.Api/Migrations/20220208231619_SelectTicketManagementService.Designer.cs rename to src/Relecloud.Web.CallCenter.Api/Migrations/20220208231619_SelectTicketManagementService.Designer.cs diff --git a/src/Relecloud.Web.Api/Migrations/20220208231619_SelectTicketManagementService.cs b/src/Relecloud.Web.CallCenter.Api/Migrations/20220208231619_SelectTicketManagementService.cs similarity index 100% rename from src/Relecloud.Web.Api/Migrations/20220208231619_SelectTicketManagementService.cs rename to src/Relecloud.Web.CallCenter.Api/Migrations/20220208231619_SelectTicketManagementService.cs diff --git a/src/Relecloud.Web.Api/Migrations/20220209201351_TicketServiceConcertIdIsNullable.Designer.cs b/src/Relecloud.Web.CallCenter.Api/Migrations/20220209201351_TicketServiceConcertIdIsNullable.Designer.cs similarity index 100% rename from src/Relecloud.Web.Api/Migrations/20220209201351_TicketServiceConcertIdIsNullable.Designer.cs rename to src/Relecloud.Web.CallCenter.Api/Migrations/20220209201351_TicketServiceConcertIdIsNullable.Designer.cs diff --git a/src/Relecloud.Web.Api/Migrations/20220209201351_TicketServiceConcertIdIsNullable.cs b/src/Relecloud.Web.CallCenter.Api/Migrations/20220209201351_TicketServiceConcertIdIsNullable.cs similarity index 100% rename from src/Relecloud.Web.Api/Migrations/20220209201351_TicketServiceConcertIdIsNullable.cs rename to src/Relecloud.Web.CallCenter.Api/Migrations/20220209201351_TicketServiceConcertIdIsNullable.cs diff --git a/src/Relecloud.Web.Api/Migrations/20220215010613_AddTicketNumberToTicket.Designer.cs b/src/Relecloud.Web.CallCenter.Api/Migrations/20220215010613_AddTicketNumberToTicket.Designer.cs similarity index 100% rename from src/Relecloud.Web.Api/Migrations/20220215010613_AddTicketNumberToTicket.Designer.cs rename to src/Relecloud.Web.CallCenter.Api/Migrations/20220215010613_AddTicketNumberToTicket.Designer.cs diff --git a/src/Relecloud.Web.Api/Migrations/20220215010613_AddTicketNumberToTicket.cs b/src/Relecloud.Web.CallCenter.Api/Migrations/20220215010613_AddTicketNumberToTicket.cs similarity index 100% rename from src/Relecloud.Web.Api/Migrations/20220215010613_AddTicketNumberToTicket.cs rename to src/Relecloud.Web.CallCenter.Api/Migrations/20220215010613_AddTicketNumberToTicket.cs diff --git a/src/Relecloud.Web.Api/Migrations/ConcertDataContextModelSnapshot.cs b/src/Relecloud.Web.CallCenter.Api/Migrations/ConcertDataContextModelSnapshot.cs similarity index 100% rename from src/Relecloud.Web.Api/Migrations/ConcertDataContextModelSnapshot.cs rename to src/Relecloud.Web.CallCenter.Api/Migrations/ConcertDataContextModelSnapshot.cs diff --git a/src/Relecloud.Web.Api/Program.cs b/src/Relecloud.Web.CallCenter.Api/Program.cs similarity index 64% rename from src/Relecloud.Web.Api/Program.cs rename to src/Relecloud.Web.CallCenter.Api/Program.cs index a17c4b7a..ba8675b2 100644 --- a/src/Relecloud.Web.Api/Program.cs +++ b/src/Relecloud.Web.CallCenter.Api/Program.cs @@ -1,17 +1,21 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. +// Licensed under the MIT License. + using Azure.Identity; using Microsoft.IdentityModel.Logging; using Relecloud.Web.Api; var builder = WebApplication.CreateBuilder(args); -var hasRequiredConfigSettings = !string.IsNullOrEmpty(builder.Configuration["Api:AppConfig:Uri"]); +var hasRequiredConfigSettings = !string.IsNullOrEmpty(builder.Configuration["App:AppConfig:Uri"]); if (hasRequiredConfigSettings) { builder.Configuration.AddAzureAppConfiguration(options => { options - .Connect(new Uri(builder.Configuration["Api:AppConfig:Uri"]), new DefaultAzureCredential()) + .Connect(new Uri(builder.Configuration["App:AppConfig:Uri"]), new DefaultAzureCredential()) + .UseFeatureFlags() // Feature flags will be loaded and, by default, refreshed every 30 seconds .ConfigureKeyVault(kv => { // Some of the values coming from Azure App Configuration are stored Key Vault, use @@ -32,7 +36,7 @@ } // Apps migrating to 6.0 don't need to use the new minimal hosting model -// https://learn.microsoft.com/aspnet/core/migration/50-to-60?view=aspnetcore-6.0&tabs=visual-studio#apps-migrating-to-60-dont-need-to-use-the-new-minimal-hosting-model +// https://learn.microsoft.com/en-us/aspnet/core/migration/50-to-60?view=aspnetcore-6.0&tabs=visual-studio#apps-migrating-to-60-dont-need-to-use-the-new-minimal-hosting-model var startup = new Startup(builder.Configuration); // Add services to the container. @@ -41,7 +45,7 @@ startup.ConfigureServices(builder.Services); } -var hasAzureAdSettings = !string.IsNullOrEmpty(builder.Configuration["Api:AzureAd:ClientId"]); +var hasMicrosoftEntraIdSettings = !string.IsNullOrEmpty(builder.Configuration["Api:MicrosoftEntraId:ClientId"]); var app = builder.Build(); @@ -49,9 +53,9 @@ { startup.Configure(app, app.Environment); } -else if (!hasAzureAdSettings) +else if (!hasMicrosoftEntraIdSettings) { - app.MapGet("/", () => "Could not find required Azure AD settings. Check your App Config Service, you may need to run the createAppRegistrations script."); + app.MapGet("/", () => "Could not find required Microsoft Entra ID settings. Check your App Config Service, you may need to run the createAppRegistrations script."); } else { diff --git a/src/Relecloud.Web.Api/Properties/launchSettings.json b/src/Relecloud.Web.CallCenter.Api/Properties/launchSettings.json similarity index 100% rename from src/Relecloud.Web.Api/Properties/launchSettings.json rename to src/Relecloud.Web.CallCenter.Api/Properties/launchSettings.json diff --git a/src/Relecloud.Web.Api/Relecloud.Web.Api.csproj b/src/Relecloud.Web.CallCenter.Api/Relecloud.Web.CallCenter.Api.csproj similarity index 96% rename from src/Relecloud.Web.Api/Relecloud.Web.Api.csproj rename to src/Relecloud.Web.CallCenter.Api/Relecloud.Web.CallCenter.Api.csproj index 81825756..348e8127 100644 --- a/src/Relecloud.Web.Api/Relecloud.Web.Api.csproj +++ b/src/Relecloud.Web.CallCenter.Api/Relecloud.Web.CallCenter.Api.csproj @@ -38,7 +38,7 @@ - + diff --git a/src/Relecloud.Web.Api/Services/IConcertRepository.cs b/src/Relecloud.Web.CallCenter.Api/Services/IConcertRepository.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/IConcertRepository.cs rename to src/Relecloud.Web.CallCenter.Api/Services/IConcertRepository.cs diff --git a/src/Relecloud.Web.Api/Services/IPaymentGatewayService.cs b/src/Relecloud.Web.CallCenter.Api/Services/IPaymentGatewayService.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/IPaymentGatewayService.cs rename to src/Relecloud.Web.CallCenter.Api/Services/IPaymentGatewayService.cs diff --git a/src/Relecloud.Web.Api/Services/MockServices/MockConcertRepository.cs b/src/Relecloud.Web.CallCenter.Api/Services/MockServices/MockConcertRepository.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/MockServices/MockConcertRepository.cs rename to src/Relecloud.Web.CallCenter.Api/Services/MockServices/MockConcertRepository.cs diff --git a/src/Relecloud.Web.Api/Services/MockServices/MockConcertSearchService.cs b/src/Relecloud.Web.CallCenter.Api/Services/MockServices/MockConcertSearchService.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/MockServices/MockConcertSearchService.cs rename to src/Relecloud.Web.CallCenter.Api/Services/MockServices/MockConcertSearchService.cs diff --git a/src/Relecloud.Web.Api/Services/MockServices/MockPaymentGatewayService.cs b/src/Relecloud.Web.CallCenter.Api/Services/MockServices/MockPaymentGatewayService.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/MockServices/MockPaymentGatewayService.cs rename to src/Relecloud.Web.CallCenter.Api/Services/MockServices/MockPaymentGatewayService.cs diff --git a/src/Relecloud.Web.Api/Services/MockServices/MockTicketImageService.cs b/src/Relecloud.Web.CallCenter.Api/Services/MockServices/MockTicketImageService.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/MockServices/MockTicketImageService.cs rename to src/Relecloud.Web.CallCenter.Api/Services/MockServices/MockTicketImageService.cs diff --git a/src/Relecloud.Web.Api/Services/MockServices/MockTicketManagementService.cs b/src/Relecloud.Web.CallCenter.Api/Services/MockServices/MockTicketManagementService.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/MockServices/MockTicketManagementService.cs rename to src/Relecloud.Web.CallCenter.Api/Services/MockServices/MockTicketManagementService.cs diff --git a/src/Relecloud.Web.Api/Services/MockServices/MockTicketRenderingService.cs b/src/Relecloud.Web.CallCenter.Api/Services/MockServices/MockTicketRenderingService.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/MockServices/MockTicketRenderingService.cs rename to src/Relecloud.Web.CallCenter.Api/Services/MockServices/MockTicketRenderingService.cs diff --git a/src/Relecloud.Web.Api/Services/PaymentGatewayService/CapturePaymentRequest.cs b/src/Relecloud.Web.CallCenter.Api/Services/PaymentGatewayService/CapturePaymentRequest.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/PaymentGatewayService/CapturePaymentRequest.cs rename to src/Relecloud.Web.CallCenter.Api/Services/PaymentGatewayService/CapturePaymentRequest.cs diff --git a/src/Relecloud.Web.Api/Services/PaymentGatewayService/CapturePaymentResult.cs b/src/Relecloud.Web.CallCenter.Api/Services/PaymentGatewayService/CapturePaymentResult.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/PaymentGatewayService/CapturePaymentResult.cs rename to src/Relecloud.Web.CallCenter.Api/Services/PaymentGatewayService/CapturePaymentResult.cs diff --git a/src/Relecloud.Web.Api/Services/PaymentGatewayService/CapturePaymentResultStatus.cs b/src/Relecloud.Web.CallCenter.Api/Services/PaymentGatewayService/CapturePaymentResultStatus.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/PaymentGatewayService/CapturePaymentResultStatus.cs rename to src/Relecloud.Web.CallCenter.Api/Services/PaymentGatewayService/CapturePaymentResultStatus.cs diff --git a/src/Relecloud.Web.Api/Services/PaymentGatewayService/PreAuthPaymentRequest.cs b/src/Relecloud.Web.CallCenter.Api/Services/PaymentGatewayService/PreAuthPaymentRequest.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/PaymentGatewayService/PreAuthPaymentRequest.cs rename to src/Relecloud.Web.CallCenter.Api/Services/PaymentGatewayService/PreAuthPaymentRequest.cs diff --git a/src/Relecloud.Web.Api/Services/PaymentGatewayService/PreAuthPaymentResult.cs b/src/Relecloud.Web.CallCenter.Api/Services/PaymentGatewayService/PreAuthPaymentResult.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/PaymentGatewayService/PreAuthPaymentResult.cs rename to src/Relecloud.Web.CallCenter.Api/Services/PaymentGatewayService/PreAuthPaymentResult.cs diff --git a/src/Relecloud.Web.Api/Services/PaymentGatewayService/PreAuthPaymentResultStatus.cs b/src/Relecloud.Web.CallCenter.Api/Services/PaymentGatewayService/PreAuthPaymentResultStatus.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/PaymentGatewayService/PreAuthPaymentResultStatus.cs rename to src/Relecloud.Web.CallCenter.Api/Services/PaymentGatewayService/PreAuthPaymentResultStatus.cs diff --git a/src/Relecloud.Web.Api/Services/Search/AzureSearchConcertSearchService.cs b/src/Relecloud.Web.CallCenter.Api/Services/Search/AzureSearchConcertSearchService.cs similarity index 98% rename from src/Relecloud.Web.Api/Services/Search/AzureSearchConcertSearchService.cs rename to src/Relecloud.Web.CallCenter.Api/Services/Search/AzureSearchConcertSearchService.cs index c06ce4e8..d8f31a55 100644 --- a/src/Relecloud.Web.Api/Services/Search/AzureSearchConcertSearchService.cs +++ b/src/Relecloud.Web.CallCenter.Api/Services/Search/AzureSearchConcertSearchService.cs @@ -32,7 +32,7 @@ public AzureSearchConcertSearchService(string searchServiceName, string concerts this.searchServiceUri = new Uri($"https://{searchServiceName}.search.windows.net"); this.concertsSqlDatabaseConnectionString = concertsSqlDatabaseConnectionString; - // https://learn.microsoft.com/azure/architecture/best-practices/retry-service-specific#retry-mechanism-5 + // https://learn.microsoft.com/en-us/azure/architecture/best-practices/retry-service-specific#retry-mechanism-5 // The default policy retries with exponential backoff when Azure Search returns a 5xx or 408 (Request Timeout) response. this.concertsIndexClient = new SearchClient(this.searchServiceUri, IndexNameConcerts, new DefaultAzureCredential()); } diff --git a/src/Relecloud.Web.Api/Services/Search/SqlDatabaseConcertSearchService.cs b/src/Relecloud.Web.CallCenter.Api/Services/Search/SqlDatabaseConcertSearchService.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/Search/SqlDatabaseConcertSearchService.cs rename to src/Relecloud.Web.CallCenter.Api/Services/Search/SqlDatabaseConcertSearchService.cs diff --git a/src/Relecloud.Web.Api/Services/SqlDatabaseConcertRepository/ConcertDataContext.cs b/src/Relecloud.Web.CallCenter.Api/Services/SqlDatabaseConcertRepository/ConcertDataContext.cs similarity index 98% rename from src/Relecloud.Web.Api/Services/SqlDatabaseConcertRepository/ConcertDataContext.cs rename to src/Relecloud.Web.CallCenter.Api/Services/SqlDatabaseConcertRepository/ConcertDataContext.cs index ec3adda3..534c5adf 100644 --- a/src/Relecloud.Web.Api/Services/SqlDatabaseConcertRepository/ConcertDataContext.cs +++ b/src/Relecloud.Web.CallCenter.Api/Services/SqlDatabaseConcertRepository/ConcertDataContext.cs @@ -45,8 +45,6 @@ public void Initialize() } // Add random concerts to the database. - // This is a work of fiction. Names, characters, places and incidents are used fictitiously as content for this demo - // Any resemblance to actual events or locales or persons, living or dead, is entirely coincidental var random = new Random(); var artists = new[] { new { Name = "Marina Rodríguez", Genre = "Pop", Tour = "Cosmic Festival", Description = "Marina Rodríguez (born August 16, 1978) is a fictional musician and singer-songwriter. She grew up in a rural town and began playing the piano at 5 years old. As a teenager, she started writing her own songs and performing at local venues. She gained a reputation as a talented musician and singer." }, diff --git a/src/Relecloud.Web.Api/Services/SqlDatabaseConcertRepository/SqlDatabaseConcertRepository.cs b/src/Relecloud.Web.CallCenter.Api/Services/SqlDatabaseConcertRepository/SqlDatabaseConcertRepository.cs similarity index 97% rename from src/Relecloud.Web.Api/Services/SqlDatabaseConcertRepository/SqlDatabaseConcertRepository.cs rename to src/Relecloud.Web.CallCenter.Api/Services/SqlDatabaseConcertRepository/SqlDatabaseConcertRepository.cs index 42c83386..eb417cba 100644 --- a/src/Relecloud.Web.Api/Services/SqlDatabaseConcertRepository/SqlDatabaseConcertRepository.cs +++ b/src/Relecloud.Web.CallCenter.Api/Services/SqlDatabaseConcertRepository/SqlDatabaseConcertRepository.cs @@ -141,8 +141,9 @@ public void Dispose() return null; } + // assumes email address is stored as LowerCase return await this.database.Customers.AsNoTracking() - .Where(u => u.Email.ToLower() == email.ToLower()).SingleOrDefaultAsync(); + .Where(u => u.Email == email.ToLower()).SingleOrDefaultAsync(); } public async Task CreateCustomerAsync(Customer newCustomer) diff --git a/src/Relecloud.Web.Api/Services/TicketManagementService/ITicketImageService.cs b/src/Relecloud.Web.CallCenter.Api/Services/TicketManagementService/ITicketImageService.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/TicketManagementService/ITicketImageService.cs rename to src/Relecloud.Web.CallCenter.Api/Services/TicketManagementService/ITicketImageService.cs diff --git a/src/Relecloud.Web.Api/Services/TicketManagementService/ITicketManagementService.cs b/src/Relecloud.Web.CallCenter.Api/Services/TicketManagementService/ITicketManagementService.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/TicketManagementService/ITicketManagementService.cs rename to src/Relecloud.Web.CallCenter.Api/Services/TicketManagementService/ITicketManagementService.cs diff --git a/src/Relecloud.Web.Api/Services/TicketManagementService/ITicketRenderingService.cs b/src/Relecloud.Web.CallCenter.Api/Services/TicketManagementService/ITicketRenderingService.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/TicketManagementService/ITicketRenderingService.cs rename to src/Relecloud.Web.CallCenter.Api/Services/TicketManagementService/ITicketRenderingService.cs diff --git a/src/Relecloud.Web.CallCenter.Api/Services/TicketManagementService/TicketImageService.cs b/src/Relecloud.Web.CallCenter.Api/Services/TicketManagementService/TicketImageService.cs new file mode 100644 index 00000000..b3db2b83 --- /dev/null +++ b/src/Relecloud.Web.CallCenter.Api/Services/TicketManagementService/TicketImageService.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. +// Licensed under the MIT License. + +using Azure.Storage.Blobs; + +namespace Relecloud.Web.Api.Services.TicketManagementService +{ + public class TicketImageService : ITicketImageService + { + private readonly ILogger logger; + private readonly BlobContainerClient blobContainerClient; + + public TicketImageService(IConfiguration configuration, BlobServiceClient blobServiceClient, ILogger logger) + { + this.logger = logger; + + // It is best practice to create Azure SDK clients once and reuse them. + // https://learn.microsoft.com/azure/storage/blobs/storage-blob-client-management#manage-client-objects + // https://devblogs.microsoft.com/azure-sdk/lifetime-management-and-thread-safety-guarantees-of-azure-sdk-net-clients/ + this.blobContainerClient = blobServiceClient.GetBlobContainerClient(configuration["App:StorageAccount:Container"]); + } + + public Task GetTicketImagesAsync(string imageName) + { + try + { + this.logger.LogInformation("Retrieving image {ImageName} from blob storage container {ContainerName}.", imageName, blobContainerClient.Name); + var blobClient = blobContainerClient.GetBlobClient(imageName); + + return blobClient.OpenReadAsync(); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Unable to retrieve image {ImageName} from blob storage container {ContainerName}", imageName, blobContainerClient.Name); + return Task.FromResult(Stream.Null); + } + } + } +} diff --git a/src/Relecloud.Web.Api/Services/TicketManagementService/TicketManagementService.cs b/src/Relecloud.Web.CallCenter.Api/Services/TicketManagementService/TicketManagementService.cs similarity index 100% rename from src/Relecloud.Web.Api/Services/TicketManagementService/TicketManagementService.cs rename to src/Relecloud.Web.CallCenter.Api/Services/TicketManagementService/TicketManagementService.cs diff --git a/src/Relecloud.Web.Api/Services/TicketManagementService/TicketRenderingService.cs b/src/Relecloud.Web.CallCenter.Api/Services/TicketManagementService/TicketRenderingService.cs similarity index 98% rename from src/Relecloud.Web.Api/Services/TicketManagementService/TicketRenderingService.cs rename to src/Relecloud.Web.CallCenter.Api/Services/TicketManagementService/TicketRenderingService.cs index f55da842..e13d34c2 100644 --- a/src/Relecloud.Web.Api/Services/TicketManagementService/TicketRenderingService.cs +++ b/src/Relecloud.Web.CallCenter.Api/Services/TicketManagementService/TicketRenderingService.cs @@ -12,7 +12,7 @@ namespace Relecloud.Web.Api.Services.TicketManagementService { - public class TicketRenderingService : ITicketRenderingService + public class TicketRenderingService : ITicketRenderingService { private const string BlobNameFormatString = "ticket-{EntityId}.png"; @@ -101,7 +101,7 @@ private MemoryStream RenderImage(Ticket ticket) private async Task SaveImageAsync(Ticket ticket, MemoryStream ticketImageBlob) { - var storageUrl = configuration["App:StorageAccount:Url"]; + var storageUrl = configuration["App:StorageAccount:Uri"]; var storageContainer = configuration["App:StorageAccount:Container"]; var blobName = BlobNameFormatString.Replace("{EntityId}", ticket.Id.ToString()); Uri blobUri = new($"{storageUrl}/{storageContainer}/{blobName}"); diff --git a/src/Relecloud.Web.Api/Startup.cs b/src/Relecloud.Web.CallCenter.Api/Startup.cs similarity index 76% rename from src/Relecloud.Web.Api/Startup.cs rename to src/Relecloud.Web.CallCenter.Api/Startup.cs index 51384d54..236f1973 100644 --- a/src/Relecloud.Web.Api/Startup.cs +++ b/src/Relecloud.Web.CallCenter.Api/Startup.cs @@ -1,13 +1,19 @@ -using Microsoft.EntityFrameworkCore; +// Copyright (c) Microsoft Corporation. All Rights Reserved. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Identity; +using Azure.Storage.Blobs; +using Microsoft.EntityFrameworkCore; using Microsoft.Identity.Web; using Microsoft.IdentityModel.Logging; +using Relecloud.Web.Models.Services; using Relecloud.Web.Api.Infrastructure; using Relecloud.Web.Api.Services; using Relecloud.Web.Api.Services.MockServices; using Relecloud.Web.Api.Services.Search; using Relecloud.Web.Api.Services.SqlDatabaseConcertRepository; using Relecloud.Web.Api.Services.TicketManagementService; -using Relecloud.Web.Models.Services; using Relecloud.Web.Services.Search; using System.Diagnostics; @@ -23,11 +29,15 @@ public Startup(IConfiguration configuration) public IConfiguration Configuration { get; } public void ConfigureServices(IServiceCollection services) { + var azureCredential = GetAzureCredential(); + // Add services to the container. - AddAzureAdServices(services); + AddMicrosoftEntraIdServices(services); services.AddControllers(); + services.AddAzureAppConfiguration(); + // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle services.AddEndpointsApiExplorer(); services.AddSwaggerGen(); @@ -40,17 +50,18 @@ public void ConfigureServices(IServiceCollection services) AddPaymentGatewayService(services); AddTicketManagementService(services); AddTicketImageService(services); - services.AddHealthChecks(); // The ApplicationInitializer is injected in the Configure method with all its dependencies and will ensure // they are all properly initialized upon construction. services.AddScoped(); + + services.AddHealthChecks(); } - private void AddAzureAdServices(IServiceCollection services) + private void AddMicrosoftEntraIdServices(IServiceCollection services) { // Adds Microsoft Identity platform (AAD v2.0) support to protect this Api - services.AddMicrosoftIdentityWebApiAuthentication(Configuration, "Api:AzureAd"); + services.AddMicrosoftIdentityWebApiAuthentication(Configuration, "Api:MicrosoftEntraId"); } private void AddTicketManagementService(IServiceCollection services) @@ -68,12 +79,6 @@ private void AddTicketManagementService(IServiceCollection services) } } - - private void AddTicketImageService(IServiceCollection services) - { - services.AddScoped(); - } - private void AddAzureSearchService(IServiceCollection services) { var azureSearchServiceName = Configuration["App:AzureSearch:ServiceName"]; @@ -140,8 +145,33 @@ private void AddPaymentGatewayService(IServiceCollection services) services.AddScoped(); } + private void AddTicketImageService(IServiceCollection services) + { + // It is best practice to create Azure SDK clients once and reuse them. + // https://learn.microsoft.com/azure/storage/blobs/storage-blob-client-management#manage-client-objects + // https://devblogs.microsoft.com/azure-sdk/lifetime-management-and-thread-safety-guarantees-of-azure-sdk-net-clients/ + services.AddSingleton(); + var storageAccountUri = Configuration["App:StorageAccount:Uri"] + ?? throw new InvalidOperationException("Required configuration missing. Could not find App:StorageAccount:Uri setting."); + services.AddSingleton(sp => new BlobServiceClient(new Uri(storageAccountUri), GetAzureCredential())); + } + + private TokenCredential GetAzureCredential() => + Configuration["App:AzureCredentialType"] switch + { + "AzureCLI" => new AzureCliCredential(), + "Environment" => new EnvironmentCredential(), + "ManagedIdentity" => new ManagedIdentityCredential(Configuration["AZURE_CLIENT_ID"]), + "VisualStudio" => new VisualStudioCredential(), + "VisualStudioCode" => new VisualStudioCodeCredential(), + _ => new DefaultAzureCredential(new DefaultAzureCredentialOptions { ManagedIdentityClientId = Configuration["AZURE_CLIENT_ID"] }), + }; + public void Configure(WebApplication app, IWebHostEnvironment env) { + // Allows refreshing configuration values from Azure App Configuration + app.UseAzureAppConfiguration(); + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { @@ -165,15 +195,17 @@ public void Configure(WebApplication app, IWebHostEnvironment env) IdentityModelEventSource.ShowPII = true; } - app.UseRetryTestingMiddleware(); + app.UseIntermittentErrorRequestMiddleware(); app.UseHttpsRedirection(); app.UseAuthentication(); app.UseAuthorization(); + + app.MapHealthChecks("/healthz"); + app.MapGet("/", () => "Default Web API endpoint"); app.MapControllers(); - app.MapHealthChecks("/healthz"); } } } diff --git a/src/Relecloud.Web.Api/appsettings.Development.json b/src/Relecloud.Web.CallCenter.Api/appsettings.Development.json similarity index 100% rename from src/Relecloud.Web.Api/appsettings.Development.json rename to src/Relecloud.Web.CallCenter.Api/appsettings.Development.json diff --git a/src/Relecloud.Web.Api/appsettings.json b/src/Relecloud.Web.CallCenter.Api/appsettings.json similarity index 89% rename from src/Relecloud.Web.Api/appsettings.json rename to src/Relecloud.Web.CallCenter.Api/appsettings.json index e2a8415f..19694647 100644 --- a/src/Relecloud.Web.Api/appsettings.json +++ b/src/Relecloud.Web.CallCenter.Api/appsettings.json @@ -5,7 +5,7 @@ "Microsoft.AspNetCore": "Warning" } }, - "Api:AzureAd": { + "Api:MicrosoftEntraId": { "Instance": "https://login.microsoftonline.com/", "ClientId": "", "TenantId": "" diff --git a/src/Relecloud.Web/Controllers/CartController.cs b/src/Relecloud.Web.CallCenter/Controllers/CartController.cs similarity index 97% rename from src/Relecloud.Web/Controllers/CartController.cs rename to src/Relecloud.Web.CallCenter/Controllers/CartController.cs index 4aef4747..04da42d9 100644 --- a/src/Relecloud.Web/Controllers/CartController.cs +++ b/src/Relecloud.Web.CallCenter/Controllers/CartController.cs @@ -1,15 +1,15 @@ using Microsoft.ApplicationInsights; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Relecloud.Web.Infrastructure; +using Relecloud.Web.CallCenter.Infrastructure; +using Relecloud.Web.CallCenter.Services; +using Relecloud.Web.CallCenter.ViewModels; using Relecloud.Web.Models.ConcertContext; using Relecloud.Web.Models.Services; using Relecloud.Web.Models.TicketManagement; using Relecloud.Web.Models.TicketManagement.Payment; -using Relecloud.Web.Services; -using Relecloud.Web.ViewModels; -namespace Relecloud.Web.Controllers +namespace Relecloud.Web.CallCenter.Controllers { public class CartController : Controller { diff --git a/src/Relecloud.Web/Controllers/ConcertController.cs b/src/Relecloud.Web.CallCenter/Controllers/ConcertController.cs similarity index 98% rename from src/Relecloud.Web/Controllers/ConcertController.cs rename to src/Relecloud.Web.CallCenter/Controllers/ConcertController.cs index f2585c02..17a1b269 100644 --- a/src/Relecloud.Web/Controllers/ConcertController.cs +++ b/src/Relecloud.Web.CallCenter/Controllers/ConcertController.cs @@ -1,12 +1,12 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Relecloud.Web.Infrastructure; +using Relecloud.Web.CallCenter.Infrastructure; +using Relecloud.Web.CallCenter.ViewModels; using Relecloud.Web.Models.ConcertContext; using Relecloud.Web.Models.Search; using Relecloud.Web.Models.Services; -using Relecloud.Web.ViewModels; -namespace Relecloud.Web.Controllers +namespace Relecloud.Web.CallCenter.Controllers { public class ConcertController : Controller { diff --git a/src/Relecloud.Web/Controllers/HomeController.cs b/src/Relecloud.Web.CallCenter/Controllers/HomeController.cs similarity index 91% rename from src/Relecloud.Web/Controllers/HomeController.cs rename to src/Relecloud.Web.CallCenter/Controllers/HomeController.cs index 220d6ac8..8df32492 100644 --- a/src/Relecloud.Web/Controllers/HomeController.cs +++ b/src/Relecloud.Web.CallCenter/Controllers/HomeController.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Mvc; -namespace Relecloud.Web.Controllers +namespace Relecloud.Web.CallCenter.Controllers { public class HomeController : Controller { diff --git a/src/Relecloud.Web/Controllers/ImageController.cs b/src/Relecloud.Web.CallCenter/Controllers/ImageController.cs similarity index 88% rename from src/Relecloud.Web/Controllers/ImageController.cs rename to src/Relecloud.Web.CallCenter/Controllers/ImageController.cs index 83ec222e..e95e9482 100644 --- a/src/Relecloud.Web/Controllers/ImageController.cs +++ b/src/Relecloud.Web.CallCenter/Controllers/ImageController.cs @@ -1,10 +1,11 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Relecloud.Web.Services; +using Relecloud.Web.CallCenter.Services; -namespace Relecloud.Web.Api.Controllers; -[Route("api/[controller]")] +namespace Relecloud.Web.CallCenter.Controllers; + +[Route("webapi/[controller]")] [ApiController] public class ImageController : ControllerBase { @@ -33,4 +34,4 @@ public async Task GetTicketImage(string imageName) return Problem("Unable to get the image"); } } -} +} \ No newline at end of file diff --git a/src/Relecloud.Web/Controllers/TicketController.cs b/src/Relecloud.Web.CallCenter/Controllers/TicketController.cs similarity index 91% rename from src/Relecloud.Web/Controllers/TicketController.cs rename to src/Relecloud.Web.CallCenter/Controllers/TicketController.cs index cb353f38..3eb66e9e 100644 --- a/src/Relecloud.Web/Controllers/TicketController.cs +++ b/src/Relecloud.Web.CallCenter/Controllers/TicketController.cs @@ -1,11 +1,11 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Relecloud.Web.Infrastructure; -using Relecloud.Web.Models; +using Relecloud.Web.CallCenter.Infrastructure; +using Relecloud.Web.CallCenter.ViewModels; using Relecloud.Web.Models.ConcertContext; using Relecloud.Web.Models.Services; -namespace Relecloud.Web.Controllers +namespace Relecloud.Web.CallCenter.Controllers { [Authorize] public class TicketController : Controller diff --git a/src/Relecloud.Web/Infrastructure/CacheKeys.cs b/src/Relecloud.Web.CallCenter/Infrastructure/CacheKeys.cs similarity index 68% rename from src/Relecloud.Web/Infrastructure/CacheKeys.cs rename to src/Relecloud.Web.CallCenter/Infrastructure/CacheKeys.cs index a8f2a02f..b8000b35 100644 --- a/src/Relecloud.Web/Infrastructure/CacheKeys.cs +++ b/src/Relecloud.Web.CallCenter/Infrastructure/CacheKeys.cs @@ -1,4 +1,4 @@ -namespace Relecloud.Web.Infrastructure +namespace Relecloud.Web.CallCenter.Infrastructure { public static class CacheKeys { diff --git a/src/Relecloud.Web/Infrastructure/ExtensionMethods.cs b/src/Relecloud.Web.CallCenter/Infrastructure/ExtensionMethods.cs similarity index 93% rename from src/Relecloud.Web/Infrastructure/ExtensionMethods.cs rename to src/Relecloud.Web.CallCenter/Infrastructure/ExtensionMethods.cs index f841ccc1..06e3cc5b 100644 --- a/src/Relecloud.Web/Infrastructure/ExtensionMethods.cs +++ b/src/Relecloud.Web.CallCenter/Infrastructure/ExtensionMethods.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Html; +// Copyright (c) Microsoft Corporation. All Rights Reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; using Relecloud.Web.Models.ConcertContext; @@ -6,13 +9,13 @@ using System.Security.Claims; using System.Text.Json; -namespace Relecloud.Web.Infrastructure +namespace Relecloud.Web.CallCenter.Infrastructure { public static class ExtensionMethods { public static string GetUniqueId(this ClaimsPrincipal user) { - // Azure AD issues a globally unique user ID in the objectidentifier claim. + // Microsoft Entra ID issues a globally unique user ID in the objectidentifier claim. return user?.FindFirstValue("http://schemas.microsoft.com/identity/claims/objectidentifier") ?? new Guid().ToString(); } diff --git a/src/Relecloud.Web/Infrastructure/RelecloudApiConfiguration.cs b/src/Relecloud.Web.CallCenter/Infrastructure/RelecloudApiConfiguration.cs similarity index 86% rename from src/Relecloud.Web/Infrastructure/RelecloudApiConfiguration.cs rename to src/Relecloud.Web.CallCenter/Infrastructure/RelecloudApiConfiguration.cs index 3856d6ff..2e5b924a 100644 --- a/src/Relecloud.Web/Infrastructure/RelecloudApiConfiguration.cs +++ b/src/Relecloud.Web.CallCenter/Infrastructure/RelecloudApiConfiguration.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace Relecloud.Web.Infrastructure +namespace Relecloud.Web.CallCenter.Infrastructure { public class RelecloudApiConfiguration { diff --git a/src/Relecloud.Web/Infrastructure/Roles.cs b/src/Relecloud.Web.CallCenter/Infrastructure/Roles.cs similarity index 66% rename from src/Relecloud.Web/Infrastructure/Roles.cs rename to src/Relecloud.Web.CallCenter/Infrastructure/Roles.cs index b0726cbc..baec2a90 100644 --- a/src/Relecloud.Web/Infrastructure/Roles.cs +++ b/src/Relecloud.Web.CallCenter/Infrastructure/Roles.cs @@ -1,4 +1,4 @@ -namespace Relecloud.Web.Infrastructure +namespace Relecloud.Web.CallCenter.Infrastructure { public static class Roles { diff --git a/src/Relecloud.Web/Program.cs b/src/Relecloud.Web.CallCenter/Program.cs similarity index 70% rename from src/Relecloud.Web/Program.cs rename to src/Relecloud.Web.CallCenter/Program.cs index 0f746528..e7101b27 100644 --- a/src/Relecloud.Web/Program.cs +++ b/src/Relecloud.Web.CallCenter/Program.cs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. +// Licensed under the MIT License. + using Azure.Identity; using Microsoft.IdentityModel.Logging; using Relecloud.Web; @@ -32,7 +35,7 @@ } // Apps migrating to 6.0 don't need to use the new minimal hosting model -// https://learn.microsoft.com/aspnet/core/migration/50-to-60?view=aspnetcore-6.0&tabs=visual-studio#apps-migrating-to-60-dont-need-to-use-the-new-minimal-hosting-model +// https://learn.microsoft.com/en-us/aspnet/core/migration/50-to-60?view=aspnetcore-6.0&tabs=visual-studio#apps-migrating-to-60-dont-need-to-use-the-new-minimal-hosting-model var startup = new Startup(builder.Configuration); // Add services to the container. @@ -41,18 +44,18 @@ startup.ConfigureServices(builder.Services); } -var hasAzureAdSettings = !string.IsNullOrEmpty(builder.Configuration["AzureAd:ClientId"]); +var hasMicrosoftEntraIdSettings = !string.IsNullOrEmpty(builder.Configuration["MicrosoftEntraId:ClientId"]); var app = builder.Build(); -if (hasRequiredConfigSettings && hasAzureAdSettings) +if (hasRequiredConfigSettings && hasMicrosoftEntraIdSettings) { startup.Configure(app, app.Environment); } -else if (!hasAzureAdSettings) +else if (!hasMicrosoftEntraIdSettings) { app.MapGet("/", () => $"" + - "Could not find required Azure AD settings. Check your App Config Service, you may need to run the createAppRegistrations script."); + "Could not find required Microsoft Entra ID settings. Check your App Config Service, you may need to run the create-app-registrations script."); } else { diff --git a/src/Relecloud.Web/Properties/launchSettings.json b/src/Relecloud.Web.CallCenter/Properties/launchSettings.json similarity index 100% rename from src/Relecloud.Web/Properties/launchSettings.json rename to src/Relecloud.Web.CallCenter/Properties/launchSettings.json diff --git a/src/Relecloud.Web/Relecloud.Web.csproj b/src/Relecloud.Web.CallCenter/Relecloud.Web.CallCenter.csproj similarity index 95% rename from src/Relecloud.Web/Relecloud.Web.csproj rename to src/Relecloud.Web.CallCenter/Relecloud.Web.CallCenter.csproj index fa657e4e..641ce9c3 100644 --- a/src/Relecloud.Web/Relecloud.Web.csproj +++ b/src/Relecloud.Web.CallCenter/Relecloud.Web.CallCenter.csproj @@ -30,7 +30,7 @@ - + diff --git a/src/Relecloud.Web.CallCenter/Services/ITicketImageService.cs b/src/Relecloud.Web.CallCenter/Services/ITicketImageService.cs new file mode 100644 index 00000000..3bb66f0f --- /dev/null +++ b/src/Relecloud.Web.CallCenter/Services/ITicketImageService.cs @@ -0,0 +1,6 @@ +namespace Relecloud.Web.CallCenter.Services; + +public interface ITicketImageService +{ + Task GetTicketImagesAsync(string imageName); +} \ No newline at end of file diff --git a/src/Relecloud.Web/Services/ITicketPurchaseService.cs b/src/Relecloud.Web.CallCenter/Services/ITicketPurchaseService.cs similarity index 81% rename from src/Relecloud.Web/Services/ITicketPurchaseService.cs rename to src/Relecloud.Web.CallCenter/Services/ITicketPurchaseService.cs index 58ec7708..cba3a584 100644 --- a/src/Relecloud.Web/Services/ITicketPurchaseService.cs +++ b/src/Relecloud.Web.CallCenter/Services/ITicketPurchaseService.cs @@ -1,6 +1,6 @@ using Relecloud.Web.Models.TicketManagement; -namespace Relecloud.Web.Services +namespace Relecloud.Web.CallCenter.Services { public interface ITicketPurchaseService { diff --git a/src/Relecloud.Web/Services/MockServices/MockConcertContextService.cs b/src/Relecloud.Web.CallCenter/Services/MockServices/MockConcertContextService.cs similarity index 96% rename from src/Relecloud.Web/Services/MockServices/MockConcertContextService.cs rename to src/Relecloud.Web.CallCenter/Services/MockServices/MockConcertContextService.cs index d1078e32..7de3d6a5 100644 --- a/src/Relecloud.Web/Services/MockServices/MockConcertContextService.cs +++ b/src/Relecloud.Web.CallCenter/Services/MockServices/MockConcertContextService.cs @@ -1,7 +1,7 @@ using Relecloud.Web.Models.ConcertContext; using Relecloud.Web.Models.Services; -namespace Relecloud.Web.Services.MockServices +namespace Relecloud.Web.CallCenter.Services.MockServices { public class MockConcertContextService : IConcertContextService { diff --git a/src/Relecloud.Web/Services/MockServices/MockConcertSearchService.cs b/src/Relecloud.Web.CallCenter/Services/MockServices/MockConcertSearchService.cs similarity index 91% rename from src/Relecloud.Web/Services/MockServices/MockConcertSearchService.cs rename to src/Relecloud.Web.CallCenter/Services/MockServices/MockConcertSearchService.cs index 260791e7..e205e10f 100644 --- a/src/Relecloud.Web/Services/MockServices/MockConcertSearchService.cs +++ b/src/Relecloud.Web.CallCenter/Services/MockServices/MockConcertSearchService.cs @@ -1,7 +1,7 @@ using Relecloud.Web.Models.Search; using Relecloud.Web.Models.Services; -namespace Relecloud.Web.Services.MockServices +namespace Relecloud.Web.CallCenter.Services.MockServices { public class MockConcertSearchService : IConcertSearchService { diff --git a/src/Relecloud.Web/Services/MockServices/MockTicketImageService.cs b/src/Relecloud.Web.CallCenter/Services/MockServices/MockTicketImageService.cs similarity index 77% rename from src/Relecloud.Web/Services/MockServices/MockTicketImageService.cs rename to src/Relecloud.Web.CallCenter/Services/MockServices/MockTicketImageService.cs index 1947a5ee..16f30894 100644 --- a/src/Relecloud.Web/Services/MockServices/MockTicketImageService.cs +++ b/src/Relecloud.Web.CallCenter/Services/MockServices/MockTicketImageService.cs @@ -1,4 +1,4 @@ -namespace Relecloud.Web.Services.MockServices; +namespace Relecloud.Web.CallCenter.Services.MockServices; public class MockTicketImageService : ITicketImageService { @@ -6,4 +6,4 @@ public Task GetTicketImagesAsync(string imageName) { return Task.FromResult(new MemoryStream() as Stream); } -} +} \ No newline at end of file diff --git a/src/Relecloud.Web/Services/MockServices/MockTicketPurchaseService.cs b/src/Relecloud.Web.CallCenter/Services/MockServices/MockTicketPurchaseService.cs similarity index 87% rename from src/Relecloud.Web/Services/MockServices/MockTicketPurchaseService.cs rename to src/Relecloud.Web.CallCenter/Services/MockServices/MockTicketPurchaseService.cs index d6809734..d5230c1a 100644 --- a/src/Relecloud.Web/Services/MockServices/MockTicketPurchaseService.cs +++ b/src/Relecloud.Web.CallCenter/Services/MockServices/MockTicketPurchaseService.cs @@ -1,6 +1,6 @@ using Relecloud.Web.Models.TicketManagement; -namespace Relecloud.Web.Services.MockServices +namespace Relecloud.Web.CallCenter.Services.MockServices { public class MockTicketPurchaseService : ITicketPurchaseService { diff --git a/src/Relecloud.Web/Services/RelecloudApiServices/RelecloudApiConcertSearchService.cs b/src/Relecloud.Web.CallCenter/Services/RelecloudApiServices/RelecloudApiConcertSearchService.cs similarity index 94% rename from src/Relecloud.Web/Services/RelecloudApiServices/RelecloudApiConcertSearchService.cs rename to src/Relecloud.Web.CallCenter/Services/RelecloudApiServices/RelecloudApiConcertSearchService.cs index f335b05e..eeb043da 100644 --- a/src/Relecloud.Web/Services/RelecloudApiServices/RelecloudApiConcertSearchService.cs +++ b/src/Relecloud.Web.CallCenter/Services/RelecloudApiServices/RelecloudApiConcertSearchService.cs @@ -1,10 +1,10 @@ -using Relecloud.Web.Infrastructure; +using Relecloud.Web.CallCenter.Infrastructure; using Relecloud.Web.Models.Search; using Relecloud.Web.Models.Services; using System.Net; using System.Text.Json; -namespace Relecloud.Web.Services.RelecloudApiServices +namespace Relecloud.Web.CallCenter.Services.RelecloudApiServices { public class RelecloudApiConcertSearchService : IConcertSearchService { diff --git a/src/Relecloud.Web/Services/RelecloudApiServices/RelecloudApiConcertService.cs b/src/Relecloud.Web.CallCenter/Services/RelecloudApiServices/RelecloudApiConcertService.cs similarity index 98% rename from src/Relecloud.Web/Services/RelecloudApiServices/RelecloudApiConcertService.cs rename to src/Relecloud.Web.CallCenter/Services/RelecloudApiServices/RelecloudApiConcertService.cs index d7c6754f..35c8fa09 100644 --- a/src/Relecloud.Web/Services/RelecloudApiServices/RelecloudApiConcertService.cs +++ b/src/Relecloud.Web.CallCenter/Services/RelecloudApiServices/RelecloudApiConcertService.cs @@ -1,14 +1,14 @@ using Microsoft.Extensions.Options; using Microsoft.Identity.Web; -using Relecloud.Web.Infrastructure; +using Relecloud.Web.CallCenter.Infrastructure; +using Relecloud.Web.CallCenter.Services.RelecloudApiServices; using Relecloud.Web.Models.ConcertContext; using Relecloud.Web.Models.Services; -using Relecloud.Web.Services.RelecloudApiServices; using System.Net; using System.Net.Http.Headers; using System.Text.Json; -namespace Relecloud.Web.Services.ApiConcertService +namespace Relecloud.Web.CallCenter.Services.ApiConcertService { public class RelecloudApiConcertService : IConcertContextService { diff --git a/src/Relecloud.Web/Services/RelecloudApiServices/RelecloudApiOptions.cs b/src/Relecloud.Web.CallCenter/Services/RelecloudApiServices/RelecloudApiOptions.cs similarity index 68% rename from src/Relecloud.Web/Services/RelecloudApiServices/RelecloudApiOptions.cs rename to src/Relecloud.Web.CallCenter/Services/RelecloudApiServices/RelecloudApiOptions.cs index df2909e7..46d0a2e2 100644 --- a/src/Relecloud.Web/Services/RelecloudApiServices/RelecloudApiOptions.cs +++ b/src/Relecloud.Web.CallCenter/Services/RelecloudApiServices/RelecloudApiOptions.cs @@ -1,4 +1,4 @@ -namespace Relecloud.Web.Services.RelecloudApiServices +namespace Relecloud.Web.CallCenter.Services.RelecloudApiServices { public class RelecloudApiOptions { diff --git a/src/Relecloud.Web/Services/RelecloudApiServices/RelecloudApiTicketImageService.cs b/src/Relecloud.Web.CallCenter/Services/RelecloudApiServices/RelecloudApiTicketImageService.cs similarity index 94% rename from src/Relecloud.Web/Services/RelecloudApiServices/RelecloudApiTicketImageService.cs rename to src/Relecloud.Web.CallCenter/Services/RelecloudApiServices/RelecloudApiTicketImageService.cs index 79671687..bf1e6fc6 100644 --- a/src/Relecloud.Web/Services/RelecloudApiServices/RelecloudApiTicketImageService.cs +++ b/src/Relecloud.Web.CallCenter/Services/RelecloudApiServices/RelecloudApiTicketImageService.cs @@ -1,9 +1,9 @@ -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Options; using Microsoft.Identity.Web; using System.Net.Http.Headers; -namespace Relecloud.Web.Services.RelecloudApiServices; +namespace Relecloud.Web.CallCenter.Services.RelecloudApiServices; public class RelecloudApiTicketImageService : ITicketImageService { @@ -43,4 +43,4 @@ private async Task PrepareAuthenticatedClient() httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); } } -} +} \ No newline at end of file diff --git a/src/Relecloud.Web/Services/RelecloudApiServices/RelecloudApiTicketPurchaseService.cs b/src/Relecloud.Web.CallCenter/Services/RelecloudApiServices/RelecloudApiTicketPurchaseService.cs similarity index 95% rename from src/Relecloud.Web/Services/RelecloudApiServices/RelecloudApiTicketPurchaseService.cs rename to src/Relecloud.Web.CallCenter/Services/RelecloudApiServices/RelecloudApiTicketPurchaseService.cs index a85f3b37..c53b5992 100644 --- a/src/Relecloud.Web/Services/RelecloudApiServices/RelecloudApiTicketPurchaseService.cs +++ b/src/Relecloud.Web.CallCenter/Services/RelecloudApiServices/RelecloudApiTicketPurchaseService.cs @@ -1,12 +1,12 @@ using Microsoft.Extensions.Options; using Microsoft.Identity.Web; -using Relecloud.Web.Infrastructure; +using Relecloud.Web.CallCenter.Infrastructure; using Relecloud.Web.Models.TicketManagement; using System.Net; using System.Net.Http.Headers; using System.Text.Json; -namespace Relecloud.Web.Services.RelecloudApiServices +namespace Relecloud.Web.CallCenter.Services.RelecloudApiServices { public class RelecloudApiTicketPurchaseService : ITicketPurchaseService { diff --git a/src/Relecloud.Web/Startup.cs b/src/Relecloud.Web.CallCenter/Startup.cs similarity index 80% rename from src/Relecloud.Web/Startup.cs rename to src/Relecloud.Web.CallCenter/Startup.cs index fadafa61..1d0fb0e6 100644 --- a/src/Relecloud.Web/Startup.cs +++ b/src/Relecloud.Web.CallCenter/Startup.cs @@ -1,23 +1,23 @@ -using Microsoft.AspNetCore.Authentication.OpenIdConnect; +// Copyright (c) Microsoft Corporation. All Rights Reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Identity.Web; using Microsoft.Identity.Web.TokenCacheProviders.Distributed; using Microsoft.Identity.Web.UI; using Microsoft.IdentityModel.Logging; using Microsoft.Net.Http.Headers; - using Polly; using Polly.Contrib.WaitAndRetry; using Polly.Extensions.Http; - -using Relecloud.Web.Infrastructure; using Relecloud.Web.Models.ConcertContext; using Relecloud.Web.Models.Services; -using Relecloud.Web.Services; -using Relecloud.Web.Services.ApiConcertService; -using Relecloud.Web.Services.MockServices; -using Relecloud.Web.Services.RelecloudApiServices; - +using Relecloud.Web.CallCenter.Infrastructure; +using Relecloud.Web.CallCenter.Services; +using Relecloud.Web.CallCenter.Services.ApiConcertService; +using Relecloud.Web.CallCenter.Services.MockServices; +using Relecloud.Web.CallCenter.Services.RelecloudApiServices; using System.Diagnostics; namespace Relecloud.Web @@ -36,7 +36,7 @@ public void ConfigureServices(IServiceCollection services) services.AddHttpContextAccessor(); services.Configure(Configuration.GetSection("App:RelecloudApi")); services.AddOptions(); - AddAzureAdServices(services); + AddMicrosoftEntraIdServices(services); services.AddControllersWithViews(); services.AddApplicationInsightsTelemetry(Configuration["App:Api:ApplicationInsights:ConnectionString"]); @@ -48,7 +48,7 @@ public void ConfigureServices(IServiceCollection services) services.AddHealthChecks(); // Add support for session state. - // NOTE: If there is a distributed cache service (e.g. Redis) then this will be used to store session data. + // NOTE: If there is a distibuted cache service (e.g. Redis) then this will be used to store session data. services.AddSession(); } @@ -80,7 +80,7 @@ private void AddTicketPurchaseService(IServiceCollection services) { httpClient.BaseAddress = new Uri(baseUri); httpClient.DefaultRequestHeaders.Add(HeaderNames.Accept, "application/json"); - httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, "Relecloud.Web"); + httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, "Relecloud.Web.CallCenter"); }) .AddPolicyHandler(GetRetryPolicy()) .AddPolicyHandler(GetCircuitBreakerPolicy()); @@ -100,13 +100,13 @@ private void AddConcertSearchService(IServiceCollection services) { httpClient.BaseAddress = new Uri(baseUri); httpClient.DefaultRequestHeaders.Add(HeaderNames.Accept, "application/json"); - httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, "Relecloud.Web"); + httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, "Relecloud.Web.CallCenter"); }) .AddPolicyHandler(GetRetryPolicy()) .AddPolicyHandler(GetCircuitBreakerPolicy()); } } - + private void AddTicketImageService(IServiceCollection services) { var baseUri = Configuration["App:RelecloudApi:BaseUri"]; @@ -120,7 +120,7 @@ private void AddTicketImageService(IServiceCollection services) { httpClient.BaseAddress = new Uri(baseUri); httpClient.DefaultRequestHeaders.Add(HeaderNames.Accept, "application/octet-stream"); - httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, "Relecloud.Web"); + httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, "Relecloud.Web.CallCenter"); }) .AddPolicyHandler(GetRetryPolicy()) .AddPolicyHandler(GetCircuitBreakerPolicy()); @@ -146,7 +146,7 @@ private static IAsyncPolicy GetCircuitBreakerPolicy() private void AddConcertContextService(IServiceCollection services) { - string baseUri = Configuration["App:RelecloudApi:BaseUri"]; + var baseUri = Configuration["App:RelecloudApi:BaseUri"]; if (string.IsNullOrWhiteSpace(baseUri)) { services.AddScoped(); @@ -164,7 +164,7 @@ private void AddConcertContextService(IServiceCollection services) } } - private void AddAzureAdServices(IServiceCollection services) + private void AddMicrosoftEntraIdServices(IServiceCollection services) { services.AddRazorPages().AddMicrosoftIdentityUI(); @@ -176,13 +176,13 @@ private void AddAzureAdServices(IServiceCollection services) }); }); - var builder = services.AddMicrosoftIdentityWebAppAuthentication(Configuration, "AzureAd") + var builder = services.AddMicrosoftIdentityWebAppAuthentication(Configuration, "MicrosoftEntraId") .EnableTokenAcquisitionToCallDownstreamApi(new string[] { }) .AddDownstreamWebApi("relecloud-api", Configuration.GetSection("GraphBeta")); // when using Microsoft.Identity.Web to retrieve an access token on behalf of the authenticated user // you should use a shared session state provider. - // https://learn.microsoft.com/azure/active-directory-b2c/configure-authentication-sample-web-app-with-api?tabs=visual-studio#token-cache-for-a-web-app + // https://learn.microsoft.com/en-us/azure/active-directory-b2c/configure-authentication-sample-web-app-with-api?tabs=visual-studio#token-cache-for-a-web-app if (string.IsNullOrEmpty(Configuration["App:RedisCache:ConnectionString"])) { builder.AddInMemoryTokenCaches(); @@ -195,40 +195,42 @@ private void AddAzureAdServices(IServiceCollection services) options.DisableL1Cache = true; }); } - - // this sample uses AFD for the URL registered with Azure AD to make it easier to get started - // but we recommend host name preservation for production scenarios - // https://learn.microsoft.com/en-us/azure/architecture/best-practices/host-name-preservation - services.Configure(options => - { - // not needed when using host name preservation - options.ForwardedHeaders = ForwardedHeaders.XForwardedHost | ForwardedHeaders.XForwardedProto; - }); - services.Configure(Configuration.GetSection("AzureAd")); - services.Configure((Action)(options => + services.Configure(Configuration.GetSection("MicrosoftEntraId")); + if (!Debugger.IsAttached) { - var frontDoorUri = Configuration["App:FrontDoorUri"]; - var callbackPath = Configuration["AzureAd:CallbackPath"]; + // this sample uses AFD for the URL registered with Microsoft Entra ID to make it easier to get started + // but we recommend host name preservation for production scenarios + // https://learn.microsoft.com/en-us/azure/architecture/best-practices/host-name-preservation + services.Configure(options => + { + // not needed when using host name preservation + options.ForwardedHeaders = ForwardedHeaders.XForwardedHost | ForwardedHeaders.XForwardedProto; + }); - options.Events = new OpenIdConnectEvents + services.Configure((Action)(options => { - OnRedirectToIdentityProvider = ctx => { + var frontDoorHostname = Configuration["App:FrontDoorHostname"]; + var callbackPath = Configuration["MicrosoftEntraId:CallbackPath"]; + + options.Events.OnTokenValidated += async ctx => + { + await CreateOrUpdateUserInformation(ctx); + }; + options.Events.OnRedirectToIdentityProvider += ctx => + { // not needed when using host name preservation - ctx.ProtocolMessage.RedirectUri = $"https://{frontDoorUri}{callbackPath}"; + ctx.ProtocolMessage.RedirectUri = $"https://{frontDoorHostname}{callbackPath}"; return Task.CompletedTask; - }, - OnRedirectToIdentityProviderForSignOut = ctx => { + }; + options.Events.OnRedirectToIdentityProviderForSignOut += ctx => + { // not needed when using host name preservation - ctx.ProtocolMessage.PostLogoutRedirectUri = $"https://{frontDoorUri}"; + ctx.ProtocolMessage.PostLogoutRedirectUri = $"https://{frontDoorHostname}"; return Task.CompletedTask; - }, - OnTokenValidated = async ctx => - { - await CreateOrUpdateUserInformation(ctx); - } - }; - })); + }; + })); + } } private static async Task CreateOrUpdateUserInformation(TokenValidatedContext ctx) @@ -251,7 +253,7 @@ private static async Task CreateOrUpdateUserInformation(TokenValidatedContext ct catch (Exception ex) { var logger = ctx.HttpContext.RequestServices.GetRequiredService>(); - logger.LogError(ex, "Unhandled exception from Startup.TransformRoleClaims"); + logger.LogError(ex, "Unhandled exception from Startup.CreateOrUpdateUserInformation"); } } @@ -272,11 +274,10 @@ public void Configure(WebApplication app, IWebHostEnvironment env) IdentityModelEventSource.ShowPII = true; } - // this sample uses AFD for the URL registered with Azure AD to make it easier to get started + // this sample uses AFD for the URL registered with Microsoft Entra ID to make it easier to get started // but we recommend host name preservation for production scenarios // https://learn.microsoft.com/en-us/azure/architecture/best-practices/host-name-preservation app.UseForwardedHeaders(); - app.UseRetryTestingMiddleware(); app.UseHttpsRedirection(); app.UseStaticFiles(); @@ -296,7 +297,6 @@ public void Configure(WebApplication app, IWebHostEnvironment env) name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); endpoints.MapRazorPages(); - endpoints.MapControllers(); }); } } diff --git a/src/Relecloud.Web/ViewModels/CartViewModel.cs b/src/Relecloud.Web.CallCenter/ViewModels/CartViewModel.cs similarity index 91% rename from src/Relecloud.Web/ViewModels/CartViewModel.cs rename to src/Relecloud.Web.CallCenter/ViewModels/CartViewModel.cs index e179e7d1..7f5aad10 100644 --- a/src/Relecloud.Web/ViewModels/CartViewModel.cs +++ b/src/Relecloud.Web.CallCenter/ViewModels/CartViewModel.cs @@ -1,6 +1,6 @@ using Relecloud.Web.Models.ConcertContext; -namespace Relecloud.Web.ViewModels +namespace Relecloud.Web.CallCenter.ViewModels { public class CartViewModel { diff --git a/src/Relecloud.Web/ViewModels/CheckoutViewModel.cs b/src/Relecloud.Web.CallCenter/ViewModels/CheckoutViewModel.cs similarity index 96% rename from src/Relecloud.Web/ViewModels/CheckoutViewModel.cs rename to src/Relecloud.Web.CallCenter/ViewModels/CheckoutViewModel.cs index 12c52de3..491108f4 100644 --- a/src/Relecloud.Web/ViewModels/CheckoutViewModel.cs +++ b/src/Relecloud.Web.CallCenter/ViewModels/CheckoutViewModel.cs @@ -2,7 +2,7 @@ using Relecloud.Web.Models.TicketManagement.Payment; using System.ComponentModel.DataAnnotations; -namespace Relecloud.Web.ViewModels +namespace Relecloud.Web.CallCenter.ViewModels { public class CheckoutViewModel { diff --git a/src/Relecloud.Web/ViewModels/ConcertViewModel.cs b/src/Relecloud.Web.CallCenter/ViewModels/ConcertViewModel.cs similarity index 75% rename from src/Relecloud.Web/ViewModels/ConcertViewModel.cs rename to src/Relecloud.Web.CallCenter/ViewModels/ConcertViewModel.cs index 8d027065..b31df951 100644 --- a/src/Relecloud.Web/ViewModels/ConcertViewModel.cs +++ b/src/Relecloud.Web.CallCenter/ViewModels/ConcertViewModel.cs @@ -1,6 +1,6 @@ using Relecloud.Web.Models.ConcertContext; -namespace Relecloud.Web.ViewModels +namespace Relecloud.Web.CallCenter.ViewModels { public class ConcertViewModel { diff --git a/src/Relecloud.Web/ViewModels/TicketViewModel.cs b/src/Relecloud.Web.CallCenter/ViewModels/TicketViewModel.cs similarity index 87% rename from src/Relecloud.Web/ViewModels/TicketViewModel.cs rename to src/Relecloud.Web.CallCenter/ViewModels/TicketViewModel.cs index e00d74f1..bd8e5bb5 100644 --- a/src/Relecloud.Web/ViewModels/TicketViewModel.cs +++ b/src/Relecloud.Web.CallCenter/ViewModels/TicketViewModel.cs @@ -1,6 +1,6 @@ using Relecloud.Web.Models.ConcertContext; -namespace Relecloud.Web.Models +namespace Relecloud.Web.CallCenter.ViewModels { public class TicketViewModel { diff --git a/src/Relecloud.Web/Views/Cart/Add.cshtml b/src/Relecloud.Web.CallCenter/Views/Cart/Add.cshtml similarity index 100% rename from src/Relecloud.Web/Views/Cart/Add.cshtml rename to src/Relecloud.Web.CallCenter/Views/Cart/Add.cshtml diff --git a/src/Relecloud.Web/Views/Cart/Checkout.cshtml b/src/Relecloud.Web.CallCenter/Views/Cart/Checkout.cshtml similarity index 98% rename from src/Relecloud.Web/Views/Cart/Checkout.cshtml rename to src/Relecloud.Web.CallCenter/Views/Cart/Checkout.cshtml index cae0876e..14925de2 100644 --- a/src/Relecloud.Web/Views/Cart/Checkout.cshtml +++ b/src/Relecloud.Web.CallCenter/Views/Cart/Checkout.cshtml @@ -1,4 +1,4 @@ -@model Relecloud.Web.ViewModels.CheckoutViewModel +@model Relecloud.Web.CallCenter.ViewModels.CheckoutViewModel @{ ViewData["Title"] = "Checkout"; } diff --git a/src/Relecloud.Web/Views/Cart/Index.cshtml b/src/Relecloud.Web.CallCenter/Views/Cart/Index.cshtml similarity index 97% rename from src/Relecloud.Web/Views/Cart/Index.cshtml rename to src/Relecloud.Web.CallCenter/Views/Cart/Index.cshtml index 68ced096..406e45f3 100644 --- a/src/Relecloud.Web/Views/Cart/Index.cshtml +++ b/src/Relecloud.Web.CallCenter/Views/Cart/Index.cshtml @@ -1,4 +1,4 @@ -@model Relecloud.Web.ViewModels.CartViewModel +@model Relecloud.Web.CallCenter.ViewModels.CartViewModel @{ ViewData["Title"] = "Your Cart"; } diff --git a/src/Relecloud.Web/Views/Concert/Create.cshtml b/src/Relecloud.Web.CallCenter/Views/Concert/Create.cshtml similarity index 98% rename from src/Relecloud.Web/Views/Concert/Create.cshtml rename to src/Relecloud.Web.CallCenter/Views/Concert/Create.cshtml index 3a51c987..43d6ca3c 100644 --- a/src/Relecloud.Web/Views/Concert/Create.cshtml +++ b/src/Relecloud.Web.CallCenter/Views/Concert/Create.cshtml @@ -1,4 +1,4 @@ -@using Relecloud.Web.ViewModels +@using Relecloud.Web.CallCenter.ViewModels @model ConcertViewModel diff --git a/src/Relecloud.Web/Views/Concert/Delete.cshtml b/src/Relecloud.Web.CallCenter/Views/Concert/Delete.cshtml similarity index 100% rename from src/Relecloud.Web/Views/Concert/Delete.cshtml rename to src/Relecloud.Web.CallCenter/Views/Concert/Delete.cshtml diff --git a/src/Relecloud.Web/Views/Concert/Details.cshtml b/src/Relecloud.Web.CallCenter/Views/Concert/Details.cshtml similarity index 100% rename from src/Relecloud.Web/Views/Concert/Details.cshtml rename to src/Relecloud.Web.CallCenter/Views/Concert/Details.cshtml diff --git a/src/Relecloud.Web/Views/Concert/Edit.cshtml b/src/Relecloud.Web.CallCenter/Views/Concert/Edit.cshtml similarity index 98% rename from src/Relecloud.Web/Views/Concert/Edit.cshtml rename to src/Relecloud.Web.CallCenter/Views/Concert/Edit.cshtml index f39403b4..f71968cf 100644 --- a/src/Relecloud.Web/Views/Concert/Edit.cshtml +++ b/src/Relecloud.Web.CallCenter/Views/Concert/Edit.cshtml @@ -1,4 +1,4 @@ -@using Relecloud.Web.ViewModels +@using Relecloud.Web.CallCenter.ViewModels @model ConcertViewModel diff --git a/src/Relecloud.Web/Views/Concert/Index.cshtml b/src/Relecloud.Web.CallCenter/Views/Concert/Index.cshtml similarity index 100% rename from src/Relecloud.Web/Views/Concert/Index.cshtml rename to src/Relecloud.Web.CallCenter/Views/Concert/Index.cshtml diff --git a/src/Relecloud.Web/Views/Concert/Search.cshtml b/src/Relecloud.Web.CallCenter/Views/Concert/Search.cshtml similarity index 100% rename from src/Relecloud.Web/Views/Concert/Search.cshtml rename to src/Relecloud.Web.CallCenter/Views/Concert/Search.cshtml diff --git a/src/Relecloud.Web/Views/Concert/Search.cshtml.cs b/src/Relecloud.Web.CallCenter/Views/Concert/Search.cshtml.cs similarity index 100% rename from src/Relecloud.Web/Views/Concert/Search.cshtml.cs rename to src/Relecloud.Web.CallCenter/Views/Concert/Search.cshtml.cs diff --git a/src/Relecloud.Web/Views/Home/Index.cshtml b/src/Relecloud.Web.CallCenter/Views/Home/Index.cshtml similarity index 100% rename from src/Relecloud.Web/Views/Home/Index.cshtml rename to src/Relecloud.Web.CallCenter/Views/Home/Index.cshtml diff --git a/src/Relecloud.Web/Views/Shared/Error.cshtml b/src/Relecloud.Web.CallCenter/Views/Shared/Error.cshtml similarity index 100% rename from src/Relecloud.Web/Views/Shared/Error.cshtml rename to src/Relecloud.Web.CallCenter/Views/Shared/Error.cshtml diff --git a/src/Relecloud.Web/Views/Shared/_Layout.cshtml b/src/Relecloud.Web.CallCenter/Views/Shared/_Layout.cshtml similarity index 97% rename from src/Relecloud.Web/Views/Shared/_Layout.cshtml rename to src/Relecloud.Web.CallCenter/Views/Shared/_Layout.cshtml index 8448d9a2..11873b2b 100644 --- a/src/Relecloud.Web/Views/Shared/_Layout.cshtml +++ b/src/Relecloud.Web.CallCenter/Views/Shared/_Layout.cshtml @@ -7,7 +7,7 @@ - + diff --git a/src/Relecloud.Web/Views/Shared/_Layout.cshtml.css b/src/Relecloud.Web.CallCenter/Views/Shared/_Layout.cshtml.css similarity index 100% rename from src/Relecloud.Web/Views/Shared/_Layout.cshtml.css rename to src/Relecloud.Web.CallCenter/Views/Shared/_Layout.cshtml.css diff --git a/src/Relecloud.Web/Views/Shared/_ValidationScriptsPartial.cshtml b/src/Relecloud.Web.CallCenter/Views/Shared/_ValidationScriptsPartial.cshtml similarity index 100% rename from src/Relecloud.Web/Views/Shared/_ValidationScriptsPartial.cshtml rename to src/Relecloud.Web.CallCenter/Views/Shared/_ValidationScriptsPartial.cshtml diff --git a/src/Relecloud.Web/Views/Ticket/Index.cshtml b/src/Relecloud.Web.CallCenter/Views/Ticket/Index.cshtml similarity index 89% rename from src/Relecloud.Web/Views/Ticket/Index.cshtml rename to src/Relecloud.Web.CallCenter/Views/Ticket/Index.cshtml index 508bad4f..b6b1fa9d 100644 --- a/src/Relecloud.Web/Views/Ticket/Index.cshtml +++ b/src/Relecloud.Web.CallCenter/Views/Ticket/Index.cshtml @@ -1,5 +1,4 @@ -@model TicketViewModel - +@model Relecloud.Web.CallCenter.ViewModels.TicketViewModel @{ ViewData["Title"] = "Tickets you've booked"; } @@ -39,11 +38,11 @@ else { if (ticket.Concert == null) { - Unknown Ticket @ticket.ConcertId + Unknown Ticket @ticket.ConcertId } else { - @ticket.Concert.Artist + @ticket.Concert.Artist } } diff --git a/src/Relecloud.Web/Views/_ViewImports.cshtml b/src/Relecloud.Web.CallCenter/Views/_ViewImports.cshtml similarity index 57% rename from src/Relecloud.Web/Views/_ViewImports.cshtml rename to src/Relecloud.Web.CallCenter/Views/_ViewImports.cshtml index 3342f574..24550684 100644 --- a/src/Relecloud.Web/Views/_ViewImports.cshtml +++ b/src/Relecloud.Web.CallCenter/Views/_ViewImports.cshtml @@ -1,5 +1,5 @@ @using Relecloud.Web -@using Relecloud.Web.Infrastructure -@using Relecloud.Web.Models @using Relecloud.Web.Models.ConcertContext +@using Relecloud.Web.CallCenter.Infrastructure +@using Relecloud.Web.CallCenter.ViewModels @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/Relecloud.Web/Views/_ViewStart.cshtml b/src/Relecloud.Web.CallCenter/Views/_ViewStart.cshtml similarity index 100% rename from src/Relecloud.Web/Views/_ViewStart.cshtml rename to src/Relecloud.Web.CallCenter/Views/_ViewStart.cshtml diff --git a/src/Relecloud.Web/appsettings.Development.json b/src/Relecloud.Web.CallCenter/appsettings.Development.json similarity index 100% rename from src/Relecloud.Web/appsettings.Development.json rename to src/Relecloud.Web.CallCenter/appsettings.Development.json diff --git a/src/Relecloud.Web/appsettings.json b/src/Relecloud.Web.CallCenter/appsettings.json similarity index 97% rename from src/Relecloud.Web/appsettings.json rename to src/Relecloud.Web.CallCenter/appsettings.json index 27896721..288c7b6f 100644 --- a/src/Relecloud.Web/appsettings.json +++ b/src/Relecloud.Web.CallCenter/appsettings.json @@ -5,7 +5,7 @@ "Microsoft.AspNetCore": "Warning" } }, - "AzureAd": { + "MicrosoftEntraId": { "Instance": "https://login.microsoftonline.com/", "ClientId": "", "TenantId": "", diff --git a/src/Relecloud.Web/wwwroot/css/site.css b/src/Relecloud.Web.CallCenter/wwwroot/css/site.css similarity index 100% rename from src/Relecloud.Web/wwwroot/css/site.css rename to src/Relecloud.Web.CallCenter/wwwroot/css/site.css diff --git a/src/Relecloud.Web/wwwroot/favicon.ico b/src/Relecloud.Web.CallCenter/wwwroot/favicon.ico similarity index 100% rename from src/Relecloud.Web/wwwroot/favicon.ico rename to src/Relecloud.Web.CallCenter/wwwroot/favicon.ico diff --git a/src/Relecloud.Web/wwwroot/img/banner.jpg b/src/Relecloud.Web.CallCenter/wwwroot/img/banner.jpg similarity index 100% rename from src/Relecloud.Web/wwwroot/img/banner.jpg rename to src/Relecloud.Web.CallCenter/wwwroot/img/banner.jpg diff --git a/src/Relecloud.Web/wwwroot/js/site.js b/src/Relecloud.Web.CallCenter/wwwroot/js/site.js similarity index 100% rename from src/Relecloud.Web/wwwroot/js/site.js rename to src/Relecloud.Web.CallCenter/wwwroot/js/site.js diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/LICENSE b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/LICENSE similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/LICENSE rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/LICENSE diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.css b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap.css similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.css rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap.css diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.js b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.js similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.js rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.js diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js diff --git a/src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map diff --git a/src/Relecloud.Web/wwwroot/lib/jquery-validation-unobtrusive/LICENSE.txt b/src/Relecloud.Web.CallCenter/wwwroot/lib/jquery-validation-unobtrusive/LICENSE.txt similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/jquery-validation-unobtrusive/LICENSE.txt rename to src/Relecloud.Web.CallCenter/wwwroot/lib/jquery-validation-unobtrusive/LICENSE.txt diff --git a/src/Relecloud.Web/wwwroot/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js b/src/Relecloud.Web.CallCenter/wwwroot/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js rename to src/Relecloud.Web.CallCenter/wwwroot/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js diff --git a/src/Relecloud.Web/wwwroot/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js b/src/Relecloud.Web.CallCenter/wwwroot/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js rename to src/Relecloud.Web.CallCenter/wwwroot/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js diff --git a/src/Relecloud.Web/wwwroot/lib/jquery-validation/LICENSE.md b/src/Relecloud.Web.CallCenter/wwwroot/lib/jquery-validation/LICENSE.md similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/jquery-validation/LICENSE.md rename to src/Relecloud.Web.CallCenter/wwwroot/lib/jquery-validation/LICENSE.md diff --git a/src/Relecloud.Web/wwwroot/lib/jquery-validation/dist/additional-methods.js b/src/Relecloud.Web.CallCenter/wwwroot/lib/jquery-validation/dist/additional-methods.js similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/jquery-validation/dist/additional-methods.js rename to src/Relecloud.Web.CallCenter/wwwroot/lib/jquery-validation/dist/additional-methods.js diff --git a/src/Relecloud.Web/wwwroot/lib/jquery-validation/dist/additional-methods.min.js b/src/Relecloud.Web.CallCenter/wwwroot/lib/jquery-validation/dist/additional-methods.min.js similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/jquery-validation/dist/additional-methods.min.js rename to src/Relecloud.Web.CallCenter/wwwroot/lib/jquery-validation/dist/additional-methods.min.js diff --git a/src/Relecloud.Web/wwwroot/lib/jquery-validation/dist/jquery.validate.js b/src/Relecloud.Web.CallCenter/wwwroot/lib/jquery-validation/dist/jquery.validate.js similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/jquery-validation/dist/jquery.validate.js rename to src/Relecloud.Web.CallCenter/wwwroot/lib/jquery-validation/dist/jquery.validate.js diff --git a/src/Relecloud.Web/wwwroot/lib/jquery-validation/dist/jquery.validate.min.js b/src/Relecloud.Web.CallCenter/wwwroot/lib/jquery-validation/dist/jquery.validate.min.js similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/jquery-validation/dist/jquery.validate.min.js rename to src/Relecloud.Web.CallCenter/wwwroot/lib/jquery-validation/dist/jquery.validate.min.js diff --git a/src/Relecloud.Web/wwwroot/lib/jquery/LICENSE.txt b/src/Relecloud.Web.CallCenter/wwwroot/lib/jquery/LICENSE.txt similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/jquery/LICENSE.txt rename to src/Relecloud.Web.CallCenter/wwwroot/lib/jquery/LICENSE.txt diff --git a/src/Relecloud.Web/wwwroot/lib/jquery/dist/jquery.js b/src/Relecloud.Web.CallCenter/wwwroot/lib/jquery/dist/jquery.js similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/jquery/dist/jquery.js rename to src/Relecloud.Web.CallCenter/wwwroot/lib/jquery/dist/jquery.js diff --git a/src/Relecloud.Web/wwwroot/lib/jquery/dist/jquery.min.js b/src/Relecloud.Web.CallCenter/wwwroot/lib/jquery/dist/jquery.min.js similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/jquery/dist/jquery.min.js rename to src/Relecloud.Web.CallCenter/wwwroot/lib/jquery/dist/jquery.min.js diff --git a/src/Relecloud.Web/wwwroot/lib/jquery/dist/jquery.min.map b/src/Relecloud.Web.CallCenter/wwwroot/lib/jquery/dist/jquery.min.map similarity index 100% rename from src/Relecloud.Web/wwwroot/lib/jquery/dist/jquery.min.map rename to src/Relecloud.Web.CallCenter/wwwroot/lib/jquery/dist/jquery.min.map diff --git a/src/Relecloud.Web/wwwroot/robots.txt b/src/Relecloud.Web.CallCenter/wwwroot/robots.txt similarity index 100% rename from src/Relecloud.Web/wwwroot/robots.txt rename to src/Relecloud.Web.CallCenter/wwwroot/robots.txt diff --git a/src/Relecloud.Models/ConcertContext/Concert.cs b/src/Relecloud.Web.Models/ConcertContext/Concert.cs similarity index 100% rename from src/Relecloud.Models/ConcertContext/Concert.cs rename to src/Relecloud.Web.Models/ConcertContext/Concert.cs diff --git a/src/Relecloud.Models/ConcertContext/CreateResult.cs b/src/Relecloud.Web.Models/ConcertContext/CreateResult.cs similarity index 100% rename from src/Relecloud.Models/ConcertContext/CreateResult.cs rename to src/Relecloud.Web.Models/ConcertContext/CreateResult.cs diff --git a/src/Relecloud.Models/ConcertContext/Customer.cs b/src/Relecloud.Web.Models/ConcertContext/Customer.cs similarity index 100% rename from src/Relecloud.Models/ConcertContext/Customer.cs rename to src/Relecloud.Web.Models/ConcertContext/Customer.cs diff --git a/src/Relecloud.Models/ConcertContext/DeleteResult.cs b/src/Relecloud.Web.Models/ConcertContext/DeleteResult.cs similarity index 100% rename from src/Relecloud.Models/ConcertContext/DeleteResult.cs rename to src/Relecloud.Web.Models/ConcertContext/DeleteResult.cs diff --git a/src/Relecloud.Models/ConcertContext/PagedResult.cs b/src/Relecloud.Web.Models/ConcertContext/PagedResult.cs similarity index 100% rename from src/Relecloud.Models/ConcertContext/PagedResult.cs rename to src/Relecloud.Web.Models/ConcertContext/PagedResult.cs diff --git a/src/Relecloud.Models/ConcertContext/Ticket.cs b/src/Relecloud.Web.Models/ConcertContext/Ticket.cs similarity index 100% rename from src/Relecloud.Models/ConcertContext/Ticket.cs rename to src/Relecloud.Web.Models/ConcertContext/Ticket.cs diff --git a/src/Relecloud.Models/ConcertContext/TicketNumber.cs b/src/Relecloud.Web.Models/ConcertContext/TicketNumber.cs similarity index 100% rename from src/Relecloud.Models/ConcertContext/TicketNumber.cs rename to src/Relecloud.Web.Models/ConcertContext/TicketNumber.cs diff --git a/src/Relecloud.Models/ConcertContext/UpdateResult.cs b/src/Relecloud.Web.Models/ConcertContext/UpdateResult.cs similarity index 100% rename from src/Relecloud.Models/ConcertContext/UpdateResult.cs rename to src/Relecloud.Web.Models/ConcertContext/UpdateResult.cs diff --git a/src/Relecloud.Models/ConcertContext/User.cs b/src/Relecloud.Web.Models/ConcertContext/User.cs similarity index 100% rename from src/Relecloud.Models/ConcertContext/User.cs rename to src/Relecloud.Web.Models/ConcertContext/User.cs diff --git a/src/Relecloud.Models/Relecloud.Web.Models.csproj b/src/Relecloud.Web.Models/Relecloud.Web.Models.csproj similarity index 100% rename from src/Relecloud.Models/Relecloud.Web.Models.csproj rename to src/Relecloud.Web.Models/Relecloud.Web.Models.csproj diff --git a/src/Relecloud.Models/Search/ConcertSearchResult.cs b/src/Relecloud.Web.Models/Search/ConcertSearchResult.cs similarity index 100% rename from src/Relecloud.Models/Search/ConcertSearchResult.cs rename to src/Relecloud.Web.Models/Search/ConcertSearchResult.cs diff --git a/src/Relecloud.Models/Search/SearchFacet.cs b/src/Relecloud.Web.Models/Search/SearchFacet.cs similarity index 100% rename from src/Relecloud.Models/Search/SearchFacet.cs rename to src/Relecloud.Web.Models/Search/SearchFacet.cs diff --git a/src/Relecloud.Models/Search/SearchFacetValue.cs b/src/Relecloud.Web.Models/Search/SearchFacetValue.cs similarity index 100% rename from src/Relecloud.Models/Search/SearchFacetValue.cs rename to src/Relecloud.Web.Models/Search/SearchFacetValue.cs diff --git a/src/Relecloud.Models/Search/SearchRequest.cs b/src/Relecloud.Web.Models/Search/SearchRequest.cs similarity index 100% rename from src/Relecloud.Models/Search/SearchRequest.cs rename to src/Relecloud.Web.Models/Search/SearchRequest.cs diff --git a/src/Relecloud.Models/Search/SearchResponse.cs b/src/Relecloud.Web.Models/Search/SearchResponse.cs similarity index 100% rename from src/Relecloud.Models/Search/SearchResponse.cs rename to src/Relecloud.Web.Models/Search/SearchResponse.cs diff --git a/src/Relecloud.Models/Services/IConcertContextService.cs b/src/Relecloud.Web.Models/Services/IConcertContextService.cs similarity index 100% rename from src/Relecloud.Models/Services/IConcertContextService.cs rename to src/Relecloud.Web.Models/Services/IConcertContextService.cs diff --git a/src/Relecloud.Models/Services/IConcertSearchService.cs b/src/Relecloud.Web.Models/Services/IConcertSearchService.cs similarity index 100% rename from src/Relecloud.Models/Services/IConcertSearchService.cs rename to src/Relecloud.Web.Models/Services/IConcertSearchService.cs diff --git a/src/Relecloud.Models/Services/IServiceProviderResult.cs b/src/Relecloud.Web.Models/Services/IServiceProviderResult.cs similarity index 100% rename from src/Relecloud.Models/Services/IServiceProviderResult.cs rename to src/Relecloud.Web.Models/Services/IServiceProviderResult.cs diff --git a/src/Relecloud.Models/TicketManagement/CountAvailableTicketsResult.cs b/src/Relecloud.Web.Models/TicketManagement/CountAvailableTicketsResult.cs similarity index 100% rename from src/Relecloud.Models/TicketManagement/CountAvailableTicketsResult.cs rename to src/Relecloud.Web.Models/TicketManagement/CountAvailableTicketsResult.cs diff --git a/src/Relecloud.Models/TicketManagement/HaveTicketsBeenSoldResult.cs b/src/Relecloud.Web.Models/TicketManagement/HaveTicketsBeenSoldResult.cs similarity index 100% rename from src/Relecloud.Models/TicketManagement/HaveTicketsBeenSoldResult.cs rename to src/Relecloud.Web.Models/TicketManagement/HaveTicketsBeenSoldResult.cs diff --git a/src/Relecloud.Models/TicketManagement/Payment/CardTypes.cs b/src/Relecloud.Web.Models/TicketManagement/Payment/CardTypes.cs similarity index 100% rename from src/Relecloud.Models/TicketManagement/Payment/CardTypes.cs rename to src/Relecloud.Web.Models/TicketManagement/Payment/CardTypes.cs diff --git a/src/Relecloud.Models/TicketManagement/Payment/PaymentDetails.cs b/src/Relecloud.Web.Models/TicketManagement/Payment/PaymentDetails.cs similarity index 100% rename from src/Relecloud.Models/TicketManagement/Payment/PaymentDetails.cs rename to src/Relecloud.Web.Models/TicketManagement/Payment/PaymentDetails.cs diff --git a/src/Relecloud.Models/TicketManagement/PurchaseTicketsRequest.cs b/src/Relecloud.Web.Models/TicketManagement/PurchaseTicketsRequest.cs similarity index 100% rename from src/Relecloud.Models/TicketManagement/PurchaseTicketsRequest.cs rename to src/Relecloud.Web.Models/TicketManagement/PurchaseTicketsRequest.cs diff --git a/src/Relecloud.Models/TicketManagement/PurchaseTicketsResult.cs b/src/Relecloud.Web.Models/TicketManagement/PurchaseTicketsResult.cs similarity index 100% rename from src/Relecloud.Models/TicketManagement/PurchaseTicketsResult.cs rename to src/Relecloud.Web.Models/TicketManagement/PurchaseTicketsResult.cs diff --git a/src/Relecloud.Models/TicketManagement/PurchaseTicketsResultStatus.cs b/src/Relecloud.Web.Models/TicketManagement/PurchaseTicketsResultStatus.cs similarity index 100% rename from src/Relecloud.Models/TicketManagement/PurchaseTicketsResultStatus.cs rename to src/Relecloud.Web.Models/TicketManagement/PurchaseTicketsResultStatus.cs diff --git a/src/Relecloud.Models/TicketManagement/ReserveTicketsResult.cs b/src/Relecloud.Web.Models/TicketManagement/ReserveTicketsResult.cs similarity index 100% rename from src/Relecloud.Models/TicketManagement/ReserveTicketsResult.cs rename to src/Relecloud.Web.Models/TicketManagement/ReserveTicketsResult.cs diff --git a/src/Relecloud.Models/TicketManagement/ReserveTicketsResultStatus.cs b/src/Relecloud.Web.Models/TicketManagement/ReserveTicketsResultStatus.cs similarity index 100% rename from src/Relecloud.Models/TicketManagement/ReserveTicketsResultStatus.cs rename to src/Relecloud.Web.Models/TicketManagement/ReserveTicketsResultStatus.cs diff --git a/src/Relecloud.Web/Infrastructure/RedirectSampleMiddleware.cs b/src/Relecloud.Web/Infrastructure/RedirectSampleMiddleware.cs deleted file mode 100644 index d701c090..00000000 --- a/src/Relecloud.Web/Infrastructure/RedirectSampleMiddleware.cs +++ /dev/null @@ -1,56 +0,0 @@ -/* -NOTICE: This class is not intended for production scenarios. - -This middleware feature is included to help readers try the sample. -When a user visits the app service directly we will redirect to -Azure Front Door which is registered with Azure AD. -In a prod scenario we recommend using Access Restrictions -to ensure the Front Door cannot be bypassed which would show an error page. - -Note that we also recommend host name preservation which -means that end users would never see the azure web app url, or the azure front door url -https://learn.microsoft.com/en-us/azure/architecture/best-practices/host-name-preservation -*/ -using Microsoft.AspNetCore.Http; - -namespace Relecloud.Web.Infrastructure -{ - public class RedirectSampleMiddleware - { - private readonly RequestDelegate _next; - - public RedirectSampleMiddleware(RequestDelegate next) - { - _next = next; - } - - public async Task InvokeAsync(HttpContext context) - { - var config = context.RequestServices.GetService(); - if (config != null && !string.IsNullOrEmpty(config["App:FrontDoorUri"])) - { - var hostUri = context.Request.GetTypedHeaders().Host.ToString(); - var frontDoorUri = config["App:FrontDoorUri"]; - - if (context.Request.Path.Value != "/healthz" && hostUri != frontDoorUri) - { - // the forwarded host header should be populated by Front Door - // block this attempt to access the web app directly by redirecting to Front Door - context.Response.Redirect($"https://{frontDoorUri}"); - return; - } - } - - await _next(context); - } - } - - public static class RedirectSampleMiddlewareExtensions - { - public static IApplicationBuilder UseRetryTestingMiddleware( - this IApplicationBuilder builder) - { - return builder.UseMiddleware(); - } - } -} diff --git a/src/Relecloud.Web/Services/ITicketImageService.cs b/src/Relecloud.Web/Services/ITicketImageService.cs deleted file mode 100644 index 04246e66..00000000 --- a/src/Relecloud.Web/Services/ITicketImageService.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Relecloud.Web.Infrastructure; - -using Relecloud.Web.Models.TicketManagement; - -using System.Net; -using Azure.Core; - -namespace Relecloud.Web.Services; - -public interface ITicketImageService -{ - Task GetTicketImagesAsync(string imageName); -} diff --git a/src/Relecloud.sln b/src/Relecloud.sln index 4f0d6205..4818fa75 100644 --- a/src/Relecloud.sln +++ b/src/Relecloud.sln @@ -3,13 +3,13 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.1.32104.313 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Relecloud.Web.Api", "Relecloud.Web.Api\Relecloud.Web.Api.csproj", "{13320B78-0B26-4026-ADCE-FE84E1735097}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Relecloud.Web.CallCenter", "Relecloud.Web.CallCenter\Relecloud.Web.CallCenter.csproj", "{2A48F2D7-51D1-4C69-8E7E-3F7A6E4F7BED}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Relecloud.Web.Models", "Relecloud.Models\Relecloud.Web.Models.csproj", "{CFDE9EEE-4994-43DF-8F99-123E88BEDDE8}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Relecloud.Web.Models", "Relecloud.Web.Models\Relecloud.Web.Models.csproj", "{257EA44D-0632-4029-ADD9-92EDD4B70931}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Relecloud.Web", "Relecloud.Web\Relecloud.Web.csproj", "{58966CC0-F7FC-467F-8DEF-6D8EC4B2DC3D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Relecloud.Web.CallCenter.Api", "Relecloud.Web.CallCenter.Api\Relecloud.Web.CallCenter.Api.csproj", "{0B28CA97-C813-41B7-A415-EFDCA4FB372E}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{5A9852C0-088E-411F-B195-450AFF5B954D}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{FDD97D72-A655-45EB-8709-0E5265C12BE0}" ProjectSection(SolutionItems) = preProject global.json = global.json EndProjectSection @@ -20,18 +20,18 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {13320B78-0B26-4026-ADCE-FE84E1735097}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {13320B78-0B26-4026-ADCE-FE84E1735097}.Debug|Any CPU.Build.0 = Debug|Any CPU - {13320B78-0B26-4026-ADCE-FE84E1735097}.Release|Any CPU.ActiveCfg = Release|Any CPU - {13320B78-0B26-4026-ADCE-FE84E1735097}.Release|Any CPU.Build.0 = Release|Any CPU - {CFDE9EEE-4994-43DF-8F99-123E88BEDDE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CFDE9EEE-4994-43DF-8F99-123E88BEDDE8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CFDE9EEE-4994-43DF-8F99-123E88BEDDE8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CFDE9EEE-4994-43DF-8F99-123E88BEDDE8}.Release|Any CPU.Build.0 = Release|Any CPU - {58966CC0-F7FC-467F-8DEF-6D8EC4B2DC3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {58966CC0-F7FC-467F-8DEF-6D8EC4B2DC3D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {58966CC0-F7FC-467F-8DEF-6D8EC4B2DC3D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {58966CC0-F7FC-467F-8DEF-6D8EC4B2DC3D}.Release|Any CPU.Build.0 = Release|Any CPU + {2A48F2D7-51D1-4C69-8E7E-3F7A6E4F7BED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A48F2D7-51D1-4C69-8E7E-3F7A6E4F7BED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A48F2D7-51D1-4C69-8E7E-3F7A6E4F7BED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A48F2D7-51D1-4C69-8E7E-3F7A6E4F7BED}.Release|Any CPU.Build.0 = Release|Any CPU + {257EA44D-0632-4029-ADD9-92EDD4B70931}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {257EA44D-0632-4029-ADD9-92EDD4B70931}.Debug|Any CPU.Build.0 = Debug|Any CPU + {257EA44D-0632-4029-ADD9-92EDD4B70931}.Release|Any CPU.ActiveCfg = Release|Any CPU + {257EA44D-0632-4029-ADD9-92EDD4B70931}.Release|Any CPU.Build.0 = Release|Any CPU + {0B28CA97-C813-41B7-A415-EFDCA4FB372E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0B28CA97-C813-41B7-A415-EFDCA4FB372E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0B28CA97-C813-41B7-A415-EFDCA4FB372E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0B28CA97-C813-41B7-A415-EFDCA4FB372E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/global.json b/src/global.json index a03cb3d1..0dbd016b 100644 --- a/src/global.json +++ b/src/global.json @@ -3,7 +3,7 @@ // updated to the latest released Major version // SDK and runtime version numbers do not require sync // https://learn.microsoft.com/en-us/dotnet/core/tools/global-json - "version": "7.0.100", + "version": "8.0.100", "rollForward": "latestFeature" } } \ No newline at end of file diff --git a/testscripts/README.md b/testscripts/README.md new file mode 100644 index 00000000..8bade422 --- /dev/null +++ b/testscripts/README.md @@ -0,0 +1,74 @@ +# Testing scripts +These scripts are used by the engineering team to accelerate the testing process through deployment automation. + +## Workflow + +1. From terminal in the devcontainer start powershell + + ```sh + pwsh + ``` + +1. Install the required PowerShell modules + + ```pwsh + Install-Module Az + ``` + + ```pwsh + Import-Module Az + ``` + +1. Validate your connection settings + + ```pwsh + Get-AzContext + ``` + + ```pwsh + azd config get defaults.subscription + ``` + + * If you are not authenticated then run the following to set your account context. + + ```pwsh + Connect-AzAccount + ``` + + ```pwsh + azd auth login + ``` + + * If you need to change your default subscription. + + ```pwsh + Set-AzContext -Subscription {your_subscription_id} + ``` + + ```pwsh + azd config set defaults.subscription {your_subscription_id} + ``` + +1. Start a provision + + > It is encouraged to use a distinct name for each deployment + + ```pwsh + .\testscripts\setup.ps1 -NotIsolated -Development -CommonAppServicePlan -SingleLocation -Name reledev7 + ``` + + + +1. Run a deployment + + ```pwsh + azd deploy + ``` + +1. Clean up a provisioned environment + + > Find the full name of the application resource group to be supplied as the value for *ResourceGroup* param + + ```pwsh + .\testscripts\cleanup.ps1 -ResourceGroup rg-reledev7-dev-westus3-application + ``` \ No newline at end of file diff --git a/testscripts/call-validate-deployment.sh b/testscripts/call-validate-deployment.sh new file mode 100755 index 00000000..92eecf5f --- /dev/null +++ b/testscripts/call-validate-deployment.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +# This script is run by GitHub workflow and is part of the deployment lifecycle run when validatinng the deployment for the Relecloud web app. +resourceGroupName=$((azd env get-values --output json) | jq -r .AZURE_RESOURCE_GROUP) +echo "Calling validate-deployment.sh for group:'$resourceGroupName'..." +./testscripts/validate-deployment.sh -g $resourceGroupName \ No newline at end of file diff --git a/testscripts/cleanup.ps1 b/testscripts/cleanup.ps1 new file mode 100644 index 00000000..17566fb0 --- /dev/null +++ b/testscripts/cleanup.ps1 @@ -0,0 +1,376 @@ +<# +.SYNOPSIS + Cleans up the Azure resources for the Reliable Web App pattern for a given azd environment. +.DESCRIPTION + There are times that azd down doesn't work well. At time of writing, this includes complex + environments with multiple resource groups and networking. To remedy this, this script removes + the Azure resources in the correct order. + + If you do not provide any parameters, this script will clean up the most current azd environment. +.PARAMETER Prefix + The prefix of the Azure environment to clean up. Provide this OR the ResourceGroup parameter to + clean up a specific environment. +.PARAMETER ResourceGroup + The name of the application resource group to clean up. Provide this OR the Prefix parameter to clean + up a specific environment. +.PARAMETER SpokeResourceGroup + If you provide the ResourceGroup parameter and are using network isolation, then you must also provide + the SpokeResourceGroup if it is a different resource group. If you don't, then the spoke network will + not be cleaned up. +.PARAMETER HubResourceGroup + If you provide the ResourceGroup parameter and have deployed a hub network, then you must also provide + the HubResourceGroup if it is a different resource group. If you don't, then the hub network will not + be cleaned up. +.PARAMETER DeleteGroups + Defaults to true, but if you set this to false, then the resource groups will not be deleted. This is + expected behavior when combined with the `azd down` command which will take responsibility for deleting + the resource groups. +.NOTES + This command requires that Az modules are installed and imported. It also requires that you have an + active Azure session. If you are not authenticated with Azure, you will be prompted to authenticate. +#> + +Param( + [Parameter(Mandatory = $false)][string]$Prefix, + [Parameter(Mandatory = $false)][string]$ResourceGroup, + [Parameter(Mandatory = $false)][string]$SecondaryResourceGroup, + [Parameter(Mandatory = $false)][string]$SpokeResourceGroup, + [Parameter(Mandatory = $false)][string]$SecondarySpokeResourceGroup, + [Parameter(Mandatory = $false)][string]$HubResourceGroup, + [Parameter(Mandatory = $false)][switch]$SkipResourceGroupDeletion, + [Parameter(Mandatory = $false)][switch]$Purge, + [Parameter(Mandatory = $false)][switch]$NoPrompt +) + + +if ((Get-Module -ListAvailable -Name Az) -and (Get-Module -Name Az.Resources -ErrorAction SilentlyContinue)) { + Write-Debug "The 'Az.Resources' module is installed and imported." + if (Get-AzContext -ErrorAction SilentlyContinue) { + Write-Debug "The user is authenticated with Azure." + } + else { + Write-Error "You are not authenticated with Azure. Please run 'Connect-AzAccount' to authenticate before running this script." + exit 10 + } +} +else { + try { + Write-Host "Importing 'Az.Resources' module" + Import-Module -Name Az.Resources -ErrorAction Stop + Write-Debug "The 'Az.Resources' module is imported successfully." + if (Get-AzContext -ErrorAction SilentlyContinue) { + Write-Debug "The user is authenticated with Azure." + } + else { + Write-Error "You are not authenticated with Azure. Please run 'Connect-AzAccount' to authenticate before running this script." + exit 11 + } + } + catch { + Write-Error "Failed to import the 'Az' module. Please install and import the 'Az' module before running this script." + exit 12 + } +} + +function Test-ResourceGroupExists($resourceGroupName) { + $resourceGroup = Get-AzResourceGroup -Name $resourceGroupName -ErrorAction SilentlyContinue + return $null -ne $resourceGroup +} + +# Default Settings +$rgPrefix = "" +$rgApplication = "" +$rgSpoke = "" +$rgHub = "" +$rgSecondaryApplication = "" +$rgSecondarySpoke = "" +#$CleanupAzureDirectory = $false + +$azdConfig = azd env get-values -o json | ConvertFrom-Json -Depth 9 -AsHashtable + +if ($Prefix) { + $rgPrefix = $Prefix + $rgApplication = "$rgPrefix-application" + $rgSpoke = "$rgPrefix-spoke" + $rgSecondaryApplication = "$rgPrefix-2-application" + $rgSecondarySpoke = "$rgPrefix-2-spoke" + $rgHub = "$rgPrefix-hub" +} else { + if (!$ResourceGroup) { + if (!(Test-Path -Path ./.azure -PathType Container)) { + "No .azure directory found and no resource group information provided - cannot clean up" + exit 8 + } + $environmentName = $azdConfig['AZURE_ENV_NAME'] + $environmentType = $azdConfig['AZURE_ENV_TYPE'] ?? 'dev' + $location = $azdConfig['AZURE_LOCATION'] + $locationSecondary = $azdConfig['AZURE_LOCATION'] ?? $azdConfig['AZURE_LOCATION'] + $rgPrefix = "rg-$environmentName-$environmentType" + $rgApplication = "$rgPrefix-$location-application" + $rgSpoke = "$rgPrefix-$location-spoke" + $rgSecondaryApplication = "$rgPrefix-$locationSecondary-2-application" + Write-Host "Secondary Application Resource Group: $rgSecondaryApplication" + $rgSecondarySpoke = "$rgPrefix-$locationSecondary-2-spoke" + Write-Host "Secondary Spoke Resource Group: $rgSecondarySpoke" + $rgHub = "$rgPrefix-hub" + #$CleanupAzureDirectory = $true + } else { + $rgApplication = $ResourceGroup + if (Test-ResourceGroupExists -ResourceGroupName $rgApplication) { + # Tags on the group describe the environment + $rgResource = Get-AzResourceGroup -Name $rgApplication -ErrorAction SilentlyContinue + $rgPrefix = $ResourceGroup.Substring(0, $ResourceGroup.IndexOf('-application') - $rgResource.Location.Length - 1) + $location = $rgResource.Location + $locationSecondary = $rgResource.Tags['SecondaryLocation'] ?? $rgResource.Location + } + } +} + +if ($SecondaryResourceGroup) { + $rgSecondaryApplication = $SecondaryResourceGroup +} elseif ($rgSecondaryApplication -eq '') { + $rgSecondaryApplication = "$rgPrefix-$locationSecondary-2-application" +} +if ($SpokeResourceGroup) { + $rgSpoke = $SpokeResourceGroup +} elseif ($rgSpoke -eq '') { + $rgSpoke = "$rgPrefix-$location-spoke" +} +if ($SecondarySpokeResourceGroup) { + $rgSecondarySpoke = $SecondarySpokeResourceGroup +} elseif ($rgSecondarySpoke -eq '') { + $rgSecondarySpoke = "$rgPrefix-$locationSecondary-2-spoke" +} +if ($HubResourceGroup) { + $rgHub = $HubResourceGroup +} elseif ($rgHub -eq '') { + $rgHub = "$rgPrefix-$location-hub" +} + +# Gets an access token for accessing Azure Resource Manager APIs +function Get-AzAccessToken { + $azContext = Get-AzContext + $azProfile = [Microsoft.Azure.Commands.Common.Authentication.Abstractions.AzureRmProfileProvider]::Instance.Profile + $profileClient = New-Object -TypeName Microsoft.Azure.Commands.ResourceManager.Common.RMProfileClient -ArgumentList ($azProfile) + $token = $profileClient.AcquireAccessToken($azContext.Subscription.TenantId) + return $token +} + +# Get-AzConsumptionBudget doesn't seem to return the list of budgets, +# so we use the REST API instead. +function Get-AzBudget($resourceGroupName) { + $azContext = Get-AzContext + $token = Get-AzAccessToken + $authHeader = @{ + 'Content-Type'='application/json' + 'Authorization'='Bearer ' + $token.AccessToken + } + $baseUri = "https://management.azure.com/subscriptions/$($azContext.Subscription)/resourceGroups/$($resourceGroupName)/providers/Microsoft.Consumption/budgets" + $apiVersion = "?api-version=2023-05-01" + $restUri = "$($baseUri)$($apiVersion)" + $result = Invoke-RestMethod -Uri $restUri -Method GET -Header $authHeader + return $result.value +} + +function Remove-ConsumptionBudgetForResourceGroup($resourceGroupName) { + Get-AzBudget -ResourceGroupName $resourceGroupName + | Foreach-Object { + "`tRemoving $resourceGroupName::$($_.name)" | Write-Output + Remove-AzConsumptionBudget -Name $_.name -ResourceGroupName $resourceGroupName + } +} + +function Remove-DiagnosticSettingsForResourceGroup($resourceGroupName) { + Get-AzResource -ResourceGroupName $resourceGroupName + | Foreach-Object { + $resourceName = $_.Name + $resourceId = $_.ResourceId + Get-AzDiagnosticSetting -ResourceId $resourceId -ErrorAction SilentlyContinue | Foreach-Object { + "`tRemoving $resourceGroupName::$resourceName::$($_.Name)" | Write-Output + Remove-AzDiagnosticSetting -ResourceId $resourceId -Name $_.Name + } + } +} + +function Remove-PrivateEndpointsForResourceGroup($resourceGroupName) { + Get-AzPrivateEndpoint -ResourceGroupName $resourceGroupName + | Foreach-Object { + "`tRemoving $resourceGroupName::$($_.Name)" | Write-Output + Remove-AzPrivateEndpoint -Name $_.Name -ResourceGroupName $_.ResourceGroupName -Force + } +} + +function Remove-ResourceGroupFromAzure($resourceGroupName) { + if (Test-ResourceGroupExists -ResourceGroupName $resourceGroupName) { + "`tRemoving $resourceGroupName" | Write-Output + Remove-AzResourceGroup -Name $resourceGroupName -Force + } +} + +function Test-EntraAppRegistrationExists($name) { + $appRegistration = Get-AzADApplication -DisplayName $name -ErrorAction SilentlyContinue + return $null -ne $appRegistration +} + +function Remove-AzADApplicationByName($name) { + $appRegistration = Get-AzADApplication -DisplayName $name -ErrorAction SilentlyContinue + if ($appRegistration) { + "`tRemoving $name" | Write-Output + Remove-AzADApplication -ObjectId $appRegistration.Id + } +} + +function Get-ResourceToken($resourceGroupName) { + $defaultRedisNamePrefix = 'redis-' + $redisInstances = Get-AzRedisCache -ResourceGroupName $resourceGroupName -ErrorAction SilentlyContinue + + if ($redisInstances.Count -eq 0) { + return "notfound" + } + + return ($redisInstances | Select-Object -First 1).Name.Substring($defaultRedisNamePrefix.Length) +} + +<# +.SYNOPSIS + Reads input from the user, but taking care of default value and request to + not prompt the user. +.PARAMETER Prompt + The prompt to display to the user. +.PARAMETER DefaultValue + The default value to use if the user just hits Enter. +.PARAMETER NoPrompt + If specified, don't prompt - just use the default value. +#> +function Read-ApplicationPrompt { + param( + [Parameter(Mandatory = $true)] + [string] $Prompt, + + [Parameter(Mandatory = $true)] + [string] $DefaultValue, + + [Parameter(Mandatory = $false)] + [switch] $NoPrompt = $false + ) + + $returnValue = "" + if (-not $NoPrompt) { + $returnValue = Read-Host -Prompt "`n$($Prompt) [default: $(Get-HighlightedText($DefaultValue))] " + } + if ([string]::IsNullOrWhiteSpace($returnValue)) { + $returnValue = $DefaultValue + } + return $returnValue +} +"`nCleaning up environment for application '$rgApplication'" | Write-Output + +# Get the list of resource groups to deal with +$resourceGroups = [System.Collections.ArrayList]@() +if (Test-ResourceGroupExists -ResourceGroupName $rgApplication) { + "`tFound application resource group: $rgApplication" | Write-Output + $resourceGroups.Add($rgApplication) | Out-Null +} else { + "`tConfirm the correct subscription was selected and check the spelling of the group to be deleted" | Write-Warning + "`tCould not find resource group: $rgApplication" | Write-Error + exit 9 +} +if (Test-ResourceGroupExists -ResourceGroupName $rgSecondaryApplication) { + "`tFound secondary application resource group: $rgSecondaryApplication" | Write-Output + $resourceGroups.Add($rgSecondaryApplication) | Out-Null +} + + +if (Test-ResourceGroupExists -ResourceGroupName $rgSpoke) { + "`tFound spoke resource group: $rgSpoke" | Write-Output + $resourceGroups.Add($rgSpoke) | Out-Null +} +if (Test-ResourceGroupExists -ResourceGroupName $rgSecondarySpoke) { + "`tFound secondary spoke resource group: $rgSecondarySpoke" | Write-Output + $resourceGroups.Add($rgSecondarySpoke) | Out-Null +} +if (Test-ResourceGroupExists -ResourceGroupName $rgHub) { + "`tFound hub resource group: $rgHub" | Write-Output + $resourceGroups.Add($rgHub) | Out-Null +} + +$resourceToken=(Get-ResourceToken -resourceGroupName $rgApplication) # expecting to be something like 'fjmjdbizcdxt4' +$appRegistrations = [System.Collections.ArrayList]@() +$calculatedAppRegistrationNameForApi = "$rgPrefix-api-webapp-$resourceToken".Substring(3) +$calculatedAppRegistrationNameForFrontend = "$rgPrefix-front-webapp-$resourceToken".Substring(3) + +if (Test-EntraAppRegistrationExists -Name $calculatedAppRegistrationNameForApi) { + "`tFound Entra ID App Registration: $calculatedAppRegistrationNameForApi" | Write-Output + $appRegistrations.Add($calculatedAppRegistrationNameForApi) | Out-Null +} +if (Test-EntraAppRegistrationExists -Name $calculatedAppRegistrationNameForFrontend) { + "`tFound Entra ID App Registration: $calculatedAppRegistrationNameForFrontend" | Write-Output + $appRegistrations.Add($calculatedAppRegistrationNameForFrontend) | Out-Null +} + +# Determine if we need to purge the App Configuration and Key Vault. +$defaultPurgeResources = if ($Purge) { "y" } else { "n" } +$purgeResources = Read-ApplicationPrompt -Prompt "Do you wish to puge resources that cannot be reassigned immediately (such as Key Vault)? [y/n]" -DefaultValue $defaultPurgeResources -NoPrompt:$NoPrompt + +# press enter to proceed +if (-not $NoPrompt) { + "`nPress enter to proceed with cleanup or CTRL+C to cancel" | Write-Output + $null = Read-Host +} + +# we don't want to delete the app registrations because we reuse them when running in pipeline +# when running in pipeline, the AZURE_PRINCIPAL_TYPE is set to 'ServicePrincipal' +if ($azdConfig['AZURE_PRINCIPAL_TYPE'] -eq 'User') { + "`nRemoving Entra ID App Registration..." | Write-Output + foreach($appRegistration in $appRegistrations) { + Remove-AzADApplicationByName -Name $appRegistration + } +} + +if ($purgeResources -eq "y") { + "> Remove and purge purgeable resources:" | Write-Output + foreach ($resourceGroupName in $resourceGroups) { + Get-AzKeyVault -ResourceGroupName $resourceGroupName | Foreach-Object { + "`tRemoving $($_.VaultName)" | Write-Output + Remove-AzKeyVault -VaultName $_.VaultName -ResourceGroupName $resourceGroupName -Force + "`tPurging $($_.VaultName)" | Write-Output + Remove-AzKeyVault -VaultName $_.VaultName -Location $_.Location -InRemovedState -Force -ErrorAction SilentlyContinue + } + + Get-AzAppConfigurationStore -ResourceGroupName $resourceGroupName | Foreach-Object { + "`tRemoving $($_.Name)" | Write-Output + Remove-AzAppConfigurationStore -Name $_.Name -ResourceGroupName $resourceGroupName + "`tPurging $($_.Name)" | Write-Output + Clear-AzAppConfigurationDeletedStore -Location $_.Location -Name $_.Name -ErrorAction SilentlyContinue + } + } +} + +"`nRemoving resources from resource groups..." | Write-Output +"> Private Endpoints:" | Write-Output +foreach ($resourceGroupName in $resourceGroups) { + Remove-PrivateEndpointsForResourceGroup -ResourceGroupName $resourceGroupName +} + +"> Budgets:" | Write-Output +foreach ($resourceGroupName in $resourceGroups) { + Remove-ConsumptionBudgetForResourceGroup -ResourceGroupName $resourceGroupName +} + +"> Diagnostic Settings:" | Write-Output +foreach ($resourceGroupName in $resourceGroups) { + Remove-DiagnosticSettingsForResourceGroup -ResourceGroupName $resourceGroupName +} + +# if $SkipResourceGroupDeletion is false, then we skip the resource group deletion +# flag is expected to be set to false when combined with the `azd down` command +if (-not $SkipResourceGroupDeletion) { + "`nRemoving resource groups in order..." | Write-Output + Remove-ResourceGroupFromAzure -ResourceGroupName $rgApplication + Remove-ResourceGroupFromAzure -ResourceGroupName $rgSecondaryApplication + Remove-ResourceGroupFromAzure -ResourceGroupName $rgSpoke + Remove-ResourceGroupFromAzure -ResourceGroupName $rgSecondarySpoke + Remove-ResourceGroupFromAzure -ResourceGroupName $rgHub + + "`nCleanup complete." | Write-Output +} \ No newline at end of file diff --git a/testscripts/setup.ps1 b/testscripts/setup.ps1 new file mode 100644 index 00000000..5943f213 --- /dev/null +++ b/testscripts/setup.ps1 @@ -0,0 +1,376 @@ +<# +.SYNOPSIS + Sets the deployment up with one command. This tries to be smart about + what you want and does the provisioning and deployment steps in one + command. +.DESCRIPTION + When installing the Relecloud web app, you have to make many choices - are + you running in network isolation mode? Do you need a hub? Would you like + to save money by deploying with a common app service plan? This script + will prompt you for these choices and then deploy the infrastructure for + you. +.PARAMETER CommonAppServicePlan + If included, deploy a common app service plan. +.PARAMETER NoCommonAppServicePlan + If included, do not deploy a common app service plan. +.PARAMETER Hub + If included, deploy a hub network. No effect if not using network isolation +.PARAMETER NoHub + If included, do not deploy a hub network. +.PARAMETER Isolated + If included, isolate the application in a VNET. +.PARAMETER NotIsolated + If included, do not isolate the application in a VNET. +.PARAMETER Name + The environment name to use. +.PARAMETER Production + If included, use production settings. +.PARAMETER Development + If included, use development settings. +.PARAMETER SingleLocation + The default behavior creating an Azure deployment targeting a single Azure region. +.PARAMETER MultiLocation + If included, do not prompt for any information. This will use the default + settings for all options. +.PARAMETER NoPrompt + If included, do not prompt for any information. This will use the default + settings for all options. +#> + +Param( + [switch]$CommonAppServicePlan, + [switch]$NoCommonAppServicePlan, + [switch]$Hub, + [switch]$NoHub, + [switch]$Isolated, + [switch]$NotIsolated, + [string]$Name = "", + [switch]$Production, + [switch]$Development, + [switch]$NoPrompt, + [switch]$SingleLocation, + [string]$AzureLocation = "", + [switch]$MultiLocation, + [string]$SecondAzureLocation = "" +) + +function FormatMenu { + param([array]$items, [int]$position) + + for ($i = 0 ; $i -le $items.Length; $i++) { + $item = $items[$i] + if ($i -eq $position) { + Write-Host "> $($item)" -ForegroundColor Green + } else { + Write-Host " $($item)" + } + } +} + +function ShowMenu { + param([string]$title, [array]$keys, [array]$items, [string]$defaultValue) + + $vkeycode = 0 + $pos = [array]::FindIndex($keys, [Predicate[string]] { param($s) $s -eq $defaultValue }) + $startPos = [System.Console]::CursorTop + if ($items.Length -gt 0) { + try { + [System.Console]::CursorVisible = $false + FormatMenu -items $items -position $pos + while ($vkeycode -ne 13 -and $vkeycode -ne 27) { + $press = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") + $vkeycode = $press.VirtualKeyCode + if ($vkeycode -eq 27) { + Write-Host "`nERROR: Escape pressed; aborting setup" -ForegroundColor Red + [System.Console]::CursorVisible = $true + [System.Environment]::Exit(1) + } + if ($vkeycode -eq 35) { + $pos = $items.Length - 1 + } + if ($vkeycode -eq 36) { + $pos = 0 + } + if ($vkeycode -eq 38 -or $press.Character -eq 'k') { + $pos-- + } + if ($vkeycode -eq 40 -or $press.Character -eq 'j') { + $pos++ + } + + if ($pos -lt 0) { + $pos = 0 + } + if ($pos -ge $items.Length) { + $pos = $items.Length - 1 + } + + [System.Console]::SetCursorPosition(0, $startPos) + FormatMenu -items $items -position $pos + } + } + finally { + $yPos = $startPos + $items.Length + if ($yPos -ge $Host.UI.RawUI.BufferSize.Height) { + Clear-Host + } else { + [System.Console]::SetCursorPosition(0, $yPos) + } + [System.Console]::CursorVisible = $true + } + } + else { + Write-Host "`nERROR: No items provided for question; aborting setup" -ForegroundColor Red + [System.Console]::CursorVisible = $true + [System.Environment]::Exit(1) + } + + return $keys[$pos] +} + +# End of function definitions + +# Check for required features + +if ((Get-Module -ListAvailable -Name Az) -and (Get-Module -Name Az.Resources -ErrorAction SilentlyContinue)) { + Write-Debug "The 'Az.Resources' module is installed and imported." + if (Get-AzContext -ErrorAction SilentlyContinue) { + Write-Debug "The user is authenticated with Azure." + } + else { + Write-Error "You are not authenticated with Azure. Please run 'Connect-AzAccount' to authenticate before running this script." + exit 10 + } +} +else { + try { + Write-Host "Importing 'Az.Resources' module" + Import-Module -Name Az.Resources -ErrorAction Stop + Write-Debug "The 'Az.Resources' module is imported successfully." + if (Get-AzContext -ErrorAction SilentlyContinue) { + Write-Debug "The user is authenticated with Azure." + } + else { + Write-Error "You are not authenticated with Azure. Please run 'Connect-AzAccount' to authenticate before running this script." + exit 11 + } + } + catch { + Write-Error "Failed to import the 'Az' module. Please install and import the 'Az' module before running this script." + exit 12 + } +} + +# End of feature checking + +# Check for conflicting parameters + +if ($CommonAppServicePlan -and $NoCommonAppServicePlan) { + "You cannot specify both -CommonAppServicePlan and -NoCommonAppServicePlan" + exit 1 +} + +if ($Hub -and $NoHub) { + "You cannot specify both -Hub and -NoHub" + exit 1 +} + +if ($Isolated -and $NotIsolated) { + "You cannot specify both -Isolated and -NotIsolated" + exit 1 +} + +if ($Production -and $Development) { + "You cannot specify both -Production and -Development" + exit 1 +} + +if ($SingleLocation -and $MultiLocation) { + "You cannot specify both -SingleLocation and -MultiLocation" + exit 1 +} + +if ($Production -and $NotIsolated) { + "The Production scenario requires network isolation to be enabled" + exit 1 +} + +if (!$SingleLocation -and !$MultiLocation -and !$NotIsolated) { + "You must specify either -SingleLocation or -MultiLocation" + exit 1 +} + +if ($Isolated -and $NoHub) { + Write-Host 'Warning:' -ForegroundColor Yellow -BackgroundColor Black + Write-Host "When deployed with isolation certain features, and access, will only be availble from within the vnet. You must attach a hub to activate these features." +} + + +# End of parameter checking + +# Start of script + +Write-Host "Relecloud Application Setup" -ForegroundColor Yellow -BackgroundColor Black + +$defaultEmailAddress = (Get-AzContext).Account.Id +if (!$NoPrompt) { + $emailAddr = Read-Host -Prompt "`nWhat is your email address? [default: $defaultEmailAddress]" + if ($emailAddr -eq "") { + $emailAddr = $defaultEmailAddress + } +} + +$defaultName = (Get-AzAdUser -UserPrincipalName $emailAddr).DisplayName +if (!$NoPrompt) { + $ownerName = Read-Host -Prompt "`nWhat is your name? [default: $defaultName]" + if ($ownerName -eq "") { + $ownerName = $defaultName + } + if ($ownerName -eq "") { + $ownerName = $emailAddr + } +} + +$currentDate = Get-Date -Format "yyyyMMddHHmm" +$defaultName = "fe-$currentDate" +$environmentName = $defaultName +$truefalse = @("true", "false") +if ($Name -ne "") { + $environmentName = $Name +} elseif (!$NoPrompt) { + $environmentName = Read-Host -Prompt "`nWhat should the environment name be? [default: $defaultName]" + if ($environmentName -eq "") { + $environmentName = $defaultName + } +} + +$environmentType = "dev" +if ($Development) { + $environmentType = "dev" +} elseif ($Production) { + $environmentType = "prod" +} elseif (!$NoPrompt) { + Write-Host "`nWhat environment stage are you deploying?" + $items = @( "Development", "Production" ) + $environmentType = ShowMenu -keys @( "dev", "prod") -items $items -defaultValue $environmentType +} + +$networkIsolation = $environmentType -eq "prod" +if ($Isolated) { + $networkIsolation = $true +} elseif ($NotIsolated) { + $networkIsolation = $false +} elseif (!$NoPrompt) { + Write-Host "`nDo you want the environment to be network isolated (in a VNET)?" + $items = @( "Yes - use network isolation", "No - do not use network isolation" ) + $isIsolated = ShowMenu -keys $truefalse -items $items -defaultValue $(if ($networkIsolation -eq $true) { "true" } else { "false" }) + $networkIsolation = $isIsolated -eq "true" +} + +$deployHubNetwork = $networkIsolation -eq $true -and $environmentType -eq "dev" +if ($networkIsolation) { + if ($Hub) { + $deployHubNetwork = $true + } elseif ($NoHub) { + $deployHubNetwork = $false + } elseif (!$NoPrompt) { + Write-Host "`nDo you want to deploy a hub network with an Azure Firewall, Bastion, and Jump box?" + $items = @( "Yes - deploy a hub network", "No - do not deploy a hub network" ) + $useHub = ShowMenu -keys $truefalse -items $items -defaultValue $(if ($deployHubNetwork -eq $true) { "true" } else { "false" }) + $deployHubNetwork = $useHub -eq "true" + } +} + +$casp = $environmentType -eq "dev" +if ($CommonAppServicePlan) { + $casp = $true +} elseif ($NoCommonAppServicePlan) { + $casp = $false +} elseif (!$NoPrompt) { + Write-Host "`nDo you want to use a common App Service Plan for all App Services?" + $items = @( "Use a common App Service Plan for all App Services", "Use a dedicated App Service Plan for each App Service" ) + $sCasp = ShowMenu -keys $truefalse -items $items -defaultValue $(if ($casp -eq $true) { "true" } else { "false" }) + $casp = $sCasp -eq "true" +} + +$defaultAzureLocation = "westus3" +# if azure location was set then use it, otherwise use the default +$azureLocationCmd = $AzureLocation + +if ($null -eq $AzureLocation -or $AzureLocation -eq "") { + $azureLocationCmd = $defaultAzureLocation +} + +$defaultSecondAzureLocation = "eastus" +if ($null -eq $SecondAzureLocation -or $SecondAzureLocation -eq "") { + $secondAzureLocationCmd = $defaultSecondAzureLocation +} + +$subscriptionName = (Get-AzContext).Subscription.Name + +Write-Host "`nProposed settings:" -ForegroundColor Yellow +Write-Host "`tSubscription name: $subscriptionName" +Write-Host "`tOwner name: $ownerName" +Write-Host "`tEmail address: $emailAddr" +Write-Host "`tEnvironment name: $environmentName" +Write-Host "`tEnvironment type: $environmentType" +Write-Host "`tNetwork isolation: $networkIsolation" +Write-Host "`tDeploy hub network: $deployHubNetwork" +Write-Host "`tAzure location: $azureLocationCmd" +Write-Host "`tDeploy second location: $MultiLocation" + +if ($MultiLocation) { + Write-Host "`tSecond Azure location: $secondAzureLocationCmd" +} +Write-Host "`tUse common App Service Plan: $casp" + +if (!$NoPrompt) { + Write-Host "`nDo you want to proceed with the deployment?" + $items = @("Continue to deployment.", "Cancel deployment.") + $q = ShowMenu -keys $truefalse -items $items -defaultValue "false" + if ($q -eq "false") { + exit 0 + } +} + +# Check if any object has the "Name" property equal to "$environmentName" +$jsonEnvironmentOutput = (azd env list -o json) +$envList = $jsonEnvironmentOutput | ConvertFrom-Json +$environmentFound = $false + +foreach ($env in $envList) { + if ($env.Name -eq $environmentName) { + $environmentFound = $true + break + } +} + +if ($environmentFound) { + Write-Host "`nWARNING: Environment $environmentName already exists. " -ForegroundColor Yellow + # Select the existing environment + azd env select $environmentName +} else { + # Create the environment + azd env new $environmentName +} + +$azureSubscriptionId = (Get-AzContext).Subscription.Id + +azd env set AZURE_SUBSCRIPTION_ID $azureSubscriptionId +azd env set AZURE_LOCATION $azureLocationCmd +azd env set AZURE_ENV_TYPE $environmentType +azd env set NETWORK_ISOLATION $(if ($networkIsolation) { "true" } else { "false" }) +azd env set DEPLOY_HUB_NETWORK $(if ($deployHubNetwork) { "true" } else { "false" }) +azd env set COMMON_APP_SERVICE_PLAN $(if ($casp) { "true" } else { "false" }) +azd env set OWNER_EMAIL $emailAddr +azd env set OWNER_NAME "$ownerName" + +if ($MultiLocation) { + azd env set SECONDARY_AZURE_LOCATION $secondAzureLocationCmd +} + +if ($NoPrompt) { + azd provision --no-prompt +} else { + azd provision +} \ No newline at end of file diff --git a/testscripts/validate-deployment.ps1 b/testscripts/validate-deployment.ps1 new file mode 100644 index 00000000..f86ed17e --- /dev/null +++ b/testscripts/validate-deployment.ps1 @@ -0,0 +1,94 @@ +#Requires -Version 7.0 + +<# +.SYNOPSIS + Examines the web app that was deployed to identify any known issues and provide recommendations. + + +.DESCRIPTION + Use this command to examine your deployed settings and automatically find recommendations + that can help you troubleshoot issues that you may encounter. + + This script was created after identifying intermittent Azure deployment issues. Many + of which can be resolved by re-running 'azd provision' command. + + NOTE: This functionality assumes that the web app, app configuration service, and app + service have already been successfully deployed. + +.PARAMETER ResourceGroupName + A required parameter for the name of resource group that contains the environment that was + created by the azd command. +#> + +Param( + [Alias("g")] + [Parameter(Mandatory = $true, HelpMessage = "Name of the resource group that was created by azd")] + [String]$ResourceGroupName +) + +if ($ResourceGroupName.Length -eq 0) { + Write-Error 'FATAL ERROR: Missing required parameter --resource-group' + exit 6 +} + +if ($ResourceGroupName -eq '-rg') { + Write-Error 'FATAL ERROR: Required parameter --resource-group was not initialized' + exit 7 +} + +### check if group exists ### + +$groupExists=$(az group exists -n $ResourceGroupName) + +if ($groupExists -eq 'false') { + Write-Error "Missing required resource group. The resource group '$ResourceGroupName' does not exist" + Write-Error "Recommended Action: run the `azd provision` command again to overlay the missing settings" + exit 32 +} else { + Write-Debug "Validated that the resource group does exist" +} + +### end check group exists ### + + +### validate web app settings ### + +# checking for known issue 87 +# https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/87 + +foreach ($appTag in @("web-callcenter-frontend", "web-callcenter-service")) { + + $appName=$(az resource list -g "$ResourceGroupName" --query "[? tags.\`"azd-service-name\`" == '$appTag' ].name" -o tsv) + + if ($appName.Length -eq 0) { + Write-Error "Cannot find the app with tag: $appTag" + Write-Error "Recommended Action: run the 'azd provision' command again to overlay the missing settings" + exit 32 + } else { + Write-Debug "Found app with tag: $appTag, appName: $appName" + } + + # Determine which appConfig key we are looking for depending on api versus web flavor of our application + if ($appTag -like "*api") { + $appSettingConfig="Api:AppConfig:Uri" + } else { + $appSettingConfig="App:AppConfig:Uri" + } + + $AppSvcUri=$(az webapp config appsettings list -n $appName -g $ResourceGroupName --query "[?name=='$appSettingConfig'].value" -o tsv) + + if ($AppSvcUri.Length -eq 0) { + Write-Error "Missing required Azure App Service configuration setting $appSettingConfig in app: $appName" + Write-Error "Recommended Action: run the 'azd provision' command again to overlay the missing settings" + exit 35 + } else { + Write-Debug "Validated that the App Service was configured with setting $appSettingsConfig equal to '$AppSvcUri'" + } +} + + +# end of check for issue 87 + +Write-Host "All settings validated successfully..." +Write-Host "If this script was unable to diagnose your problem then please create a GitHub issue" +exit 0 \ No newline at end of file diff --git a/infra/devOpsScripts/validateDeployment.sh b/testscripts/validate-deployment.sh old mode 100644 new mode 100755 similarity index 50% rename from infra/devOpsScripts/validateDeployment.sh rename to testscripts/validate-deployment.sh index 18fe28fd..0ad4cc28 --- a/infra/devOpsScripts/validateDeployment.sh +++ b/testscripts/validate-deployment.sh @@ -1,10 +1,8 @@ #!/bin/bash -# This script is used by our QA process to ensure the quality of this sample it measures -# characteristics of the deployment and will be modified as needed to explore intermittent issues - -# This engineering code may be repurposed for your scenario as desired -# but is not covered by the guidance in this content. +# This script is used by the DevOps flow to validate settings of resources +# that were deployed by the azd command. This script is not intended to be +# run manually by a developer. POSITIONAL_ARGS=() @@ -70,45 +68,38 @@ fi # checking for known issue 87 # https://github.com/Azure/reliable-web-app-pattern-dotnet/issues/87 -frontEndWebAppName=$(az resource list -g "$resourceGroupName" --query "[?tags.\"azd-service-name\"=='web'].name" -o tsv) -if [[ ${#frontEndWebAppName} -eq 0 ]]; then - echo "Cannot find the front-end web app" 1>&2 - echo "Recommended Action: run the 'azd provision' command again to overlay the missing settings" 1>&2 - exit 32 -elif [[ $debug ]]; then - echo "Found front-end web app named '$frontEndWebAppName' " -fi +for appTag in web-callcenter-service web-callcenter-frontend; do -frontEndAppSvcUri=$(az webapp config appsettings list -n $frontEndWebAppName -g $resourceGroupName --query "[?name=='App:AppConfig:Uri'].value" -o tsv) + appName=$(az resource list -g "$resourceGroupName" --query "[?tags.\"azd-service-name\"=='${appTag}'].name" -o tsv) -if [[ ${#frontEndAppSvcUri} -eq 0 ]]; then - echo "Missing required Azure App Service configuration setting front-end web app: App:AppConfig:Uri" 1>&2 - echo "Recommended Action: run the 'azd provision' command again to overlay the missing settings" 1>&2 - exit 33 -elif [[ $debug ]]; then - echo "Validated that the App Service was configured with setting 'App:AppConfig:Uri' equal to '$frontEndAppSvcUri'" -fi + if [[ ${#appName} -eq 0 ]]; then + echo "Cannot find the app with tag: $appTag" 1>&2 + echo "Recommended Action: run the 'azd provision' command again to overlay the missing settings" 1>&2 + exit 32 + elif [[ $debug ]]; then + echo "Found app with tag: '$appTag', appName: '$appName' " + fi -apiWebAppName=$(az resource list -g "$resourceGroupName" --query "[?tags.\"azd-service-name\"=='api'].name" -o tsv) + # Determine which appConfig key we are looking for depending on api versus web flavor of our application + if [[ $appTag == *"api"* ]]; + then + appSettingConfig="Api:AppConfig:Uri" + else + appSettingConfig="App:AppConfig:Uri" + fi -if [[ ${#apiWebAppName} -eq 0 ]]; then - echo "Cannot find the API web app" 1>&2 - echo "Recommended Action: run the 'azd provision' command again to overlay the missing settings" 1>&2 - exit 34 -elif [[ $debug ]]; then - echo "Found API web app named '$apiWebAppName'" -fi + appUri=$(az webapp config appsettings list -n $appName -g $resourceGroupName --query "[?name=='$appSettingConfig'].value" -o tsv) -apiAppSvcUri=$(az webapp config appsettings list -n $apiWebAppName -g $resourceGroupName --query "[?name=='Api:AppConfig:Uri'].value" -o tsv) + if [[ ${#appUri} -eq 0 ]]; then + echo "Missing required Azure App Service configuration setting $appSettingConfig in app: $appName" 1>&2 + echo "Recommended Action: run the 'azd provision' command again to overlay the missing settings" 1>&2 + exit 33 + elif [[ $debug ]]; then + echo "Validated that the App Service was configured with setting '$appSettingConfig' equal to '$appUri'" + fi -if [[ ${#apiAppSvcUri} -eq 0 ]]; then - echo "Missing required Azure App Service configuration setting for api web app: Api:AppConfig:Uri" 1>&2 - echo "Recommended Action: run the 'azd provision' command again to overlay the missing settings" - exit 35 -elif [[ $debug ]]; then - echo "Validated that the App Service was configured with setting 'Api:AppConfig:Uri' equal to '$apiAppSvcUri'" -fi +done # end of check for issue 87 diff --git a/troubleshooting.md b/troubleshooting.md new file mode 100644 index 00000000..e9bcf82c --- /dev/null +++ b/troubleshooting.md @@ -0,0 +1,84 @@ +# Troubleshooting +This document helps with troubleshooting RWA deployment challenges. + +## Error: no project exists; to create a new project, run 'azd init' +This error is most often reported when users try to run `azd` commands before running the `cd` command to switch to the directory where the repo was cloned. + +### Workaround + +Verify that you are in the directory where the `azure.yaml` file is located. You may need to `cd` into the directory to proceed. + +## BadRequest: Azure subscription is not registered with CDN Provider. +This error message surfaces from the `azd provision` command when trying to follow the guide to provision an Azure Front Door. + +Most [Azure resource providers](https://learn.microsoft.com/en-us/azure/azure-resource-manager/troubleshooting/error-register-resource-provider) are registered automatically by the Microsoft Azure portal or the command-line interface, but not all. If you haven't used a particular resource provider before, you might need to register that provider. + +**Full error message** +``` +ERROR: deployment failed: error deploying infrastructure: failed deploying: deploying to subscription: + +Deployment Error Details: +BadRequest: Azure subscription is not registered with CDN Provider. +``` + +### Workaround + +1. Register the provider + ```ps1 + az provider register --namespace Microsoft.Cdn + ``` + +1. Wait for the registration process to complete (waited about 3-min) + +1. Run the following to confirm the provider is registered + ```ps1 + az provider list --query "[? namespace=='Microsoft.Cdn'].id" + ``` + + You should see a notice that the operation succeeded: + ``` + [ + "/subscriptions/{subscriptionId}/providers/Microsoft.Cdn" + ] + ``` + +## Warning: Remote host identification has changed +This warning message is displayed when the SSH key fingerprint for the remote host has changed since the last time you connected. This can happen if you have re-provisioned the environment which will recreate the VMs and thus their fingerprints. + +**Full warning message** +```sh +@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ +@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @ +@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ +IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY! +``` + +### Workaround + +1. Remove the previous fingerprint which is stored in a file called `known_hosts` in your user's `.ssh` directory. Run the following command to remove the old fingerprint: + ```sh + ssh-keygen -R [127.0.0.1]:50022 + ``` + +## The deployment already exists in location +This error most often happens when trying a new region with the same for a deployment with the same name used for the AZD environment name (e.g. by default it would be `dotnetwebapp`). + +When the `azd provision` command runs it creates a deployment resource in your subscription. You must delete this deployment before you can change the Azure region. + +### Workaround + +> The following steps assume you are logged in with `az` cli. + +1. Find the name of the Deployment you want to delete + + ```sh + az deployment sub list --query "[].name" -o tsv + ``` + +1. Delete the deployment by name + + ```sh + az deployment sub delete -n + ``` + +1. You should now be able to run the `azd provision` command and resume your deployment. \ No newline at end of file