From c3ec0648c76db65c61d3da81cf5592543d596e89 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Sun, 29 Mar 2026 13:42:28 -0400 Subject: [PATCH 1/3] deployment improvements --- .dockerignore | 17 + .gitignore | 1 + README.md | 17 +- api/Dockerfile | 20 + bicep/AVD/README.md | 41 - bicep/AVD/main.bicepparam | 20 - bicep/Linux/README.md | 37 - bicep/Linux/main.bicepparam | 17 - deploy/Assign-FunctionAppApiRole.ps1 | 27 + deploy/Assign-ServicePrincipalApiRole.ps1 | 53 + deploy/Assign-VmApiRoles.ps1 | 37 + deploy/Build-ContainerImages.ps1 | 111 + deploy/Initialize-Database.ps1 | 57 + deploy/Initialize-DeploymentEnvironment.ps1 | 460 +++ deploy/Post-Provision.ps1 | 129 + deploy/azure.yaml | 32 + .../azurecli}/Assign-AppRoleToFunctionApp.ps1 | 2 +- deploy/bicep/main.bicep | 198 ++ deploy/bicep/main.json | 2963 +++++++++++++++++ deploy/bicep/main.parameters.json | 39 + deploy/bicep/main.resources.bicep | 369 ++ .../bicep/modules}/AVD/main.bicep | 15 +- .../bicep/modules}/AVD/token.bicep | 5 +- .../bicep/modules}/Linux/main.bicep | 17 +- .../modules/apps/container-function-app.bicep | 51 + .../modules/apps/container-web-app.bicep | 70 + .../bicep/modules/core/app-service-plan.bicep | 23 + .../modules/core/container-registry.bicep | 20 + deploy/bicep/modules/core/key-vault.bicep | 68 + deploy/bicep/modules/core/networking.bicep | 81 + deploy/bicep/modules/core/observability.bicep | 35 + deploy/bicep/modules/core/sql-database.bicep | 55 + .../bicep/modules/core/storage-account.bicep | 23 + front_end/Dockerfile | 20 + task/Dockerfile | 13 + 35 files changed, 4995 insertions(+), 148 deletions(-) create mode 100644 .dockerignore create mode 100644 api/Dockerfile delete mode 100644 bicep/AVD/README.md delete mode 100644 bicep/AVD/main.bicepparam delete mode 100644 bicep/Linux/README.md delete mode 100644 bicep/Linux/main.bicepparam create mode 100644 deploy/Assign-FunctionAppApiRole.ps1 create mode 100644 deploy/Assign-ServicePrincipalApiRole.ps1 create mode 100644 deploy/Assign-VmApiRoles.ps1 create mode 100644 deploy/Build-ContainerImages.ps1 create mode 100644 deploy/Initialize-Database.ps1 create mode 100644 deploy/Initialize-DeploymentEnvironment.ps1 create mode 100644 deploy/Post-Provision.ps1 create mode 100644 deploy/azure.yaml rename {deploy_infrastructure => deploy/azurecli}/Assign-AppRoleToFunctionApp.ps1 (99%) create mode 100644 deploy/bicep/main.bicep create mode 100644 deploy/bicep/main.json create mode 100644 deploy/bicep/main.parameters.json create mode 100644 deploy/bicep/main.resources.bicep rename {bicep => deploy/bicep/modules}/AVD/main.bicep (94%) rename {bicep => deploy/bicep/modules}/AVD/token.bicep (90%) rename {bicep => deploy/bicep/modules}/Linux/main.bicep (92%) create mode 100644 deploy/bicep/modules/apps/container-function-app.bicep create mode 100644 deploy/bicep/modules/apps/container-web-app.bicep create mode 100644 deploy/bicep/modules/core/app-service-plan.bicep create mode 100644 deploy/bicep/modules/core/container-registry.bicep create mode 100644 deploy/bicep/modules/core/key-vault.bicep create mode 100644 deploy/bicep/modules/core/networking.bicep create mode 100644 deploy/bicep/modules/core/observability.bicep create mode 100644 deploy/bicep/modules/core/sql-database.bicep create mode 100644 deploy/bicep/modules/core/storage-account.bicep create mode 100644 front_end/Dockerfile create mode 100644 task/Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e35d1cb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.github +.azure +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +*.log +*.db +.env +local.settings.json +bin +obj +artifacts +example_code +priv-folder diff --git a/.gitignore b/.gitignore index 8b5b5f8..f921ea1 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ __pycache__ # Azure App Service artifacts .env .pem +.azure # Configured custom script extensions, powershell scripts, and linux scripts priv-* diff --git a/README.md b/README.md index d25c0c6..22f9615 100644 --- a/README.md +++ b/README.md @@ -191,9 +191,22 @@ Given that AVD acts as a pass-through in this solution, starting with **light to ## Getting Started -*(Instructions on deployment, configuration, and usage will be provided here.)* +The consolidated deployment entrypoint for this repository is in `deploy/`. -While we work on more detailed instructions, you can deploy the web apps from VS Code or running az web deploy. You can deploy function using VS Code. To support managed identity versus using SAS keys, there are a number of permissions that must be applied, please use the RBAC section to facility implementing them. We will release detailed instructions with video guidance over the coming weeks. +- AZD manifest: `deploy/azure.yaml` +- Bicep entrypoint: `deploy/bicep/main.bicep` +- Parameter template: `deploy/bicep/main.parameters.json` +- Deployment scripts: `deploy/*.ps1` + +From the repository root, use the following flow: + +```powershell +Set-Location .\deploy +azd env new +azd up +``` + +Before running `azd up`, populate the required Entra application and secret values in your azd environment or parameter file. The deployment scripts under `deploy/` handle the environment bootstrap, post-provision role assignment, container image builds, and database initialization paths used by this solution. ## Contributing diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..9ea375d --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV WEBSITES_PORT=8000 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential curl freetds-dev gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY api/requirements.txt ./requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +COPY api/ ./ + +EXPOSE 8000 + +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"] diff --git a/bicep/AVD/README.md b/bicep/AVD/README.md deleted file mode 100644 index e85ed91..0000000 --- a/bicep/AVD/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Azure Virtual Desktop (AVD) Bicep Deployment - -This directory contains Bicep templates to help with deploying and managing Azure Virtual Desktop (AVD) infrastructure to be used with the Linux Broker for AVD. - -## Parameter Files - -### `main.bicepparam` - -This parameter file contains the following key parameters: - -- **AVD Config**: - - `hostPoolName`: The name of the AVD host pool (e.g., `hp-test-01`). - - `sessionHostCount`: The number of session host VMs to deploy (e.g., `2`). - - `maxSessionLimit`: The maximum number of sessions per host (e.g., `5`). - -- **VM Config**: - - `vmNamePrefix`: The prefix for the session host VM names (e.g., `hptest`). - - `vmSize`: The size of the session host VMs (e.g., `Standard_DS2_v2`). - - `adminUsername`: The administrator username for the VMs (e.g., `avdadmin`). - - `adminPassword`: The administrator password for the VMs (e.g., `NotaPassword!`). - -- **Network Config**: - - `subnetName`: The name of the subnet (e.g., `sn00`). - - `vnetName`: The name of the virtual network (e.g., `vnet-avd-01`). - - `vnetResourceGroup`: The resource group containing the virtual network (e.g., `rg-avd-bicep-01`). - -- **API Config**: - - `linuxBrokerApiBaseUrl`: The base URL for the AVD Linux Broker API (e.g., `https://your-broker.domain.com/api`). - -## Deployment Instructions - -1. Ensure you have the necessary prerequisites installed, including the Azure CLI and Bicep CLI. - -2. Update the parameter files (`main.bicepparam`) with values specific to your environment. - -3. Deploy the infrastructure using the following command: - - ```bash - az deployment group create --resource-group --template-file main.bicep --parameters @main.bicepparam - ``` - diff --git a/bicep/AVD/main.bicepparam b/bicep/AVD/main.bicepparam deleted file mode 100644 index ec447ca..0000000 --- a/bicep/AVD/main.bicepparam +++ /dev/null @@ -1,20 +0,0 @@ -using 'main.bicep' - -/*AVD Config*/ -param hostPoolName = 'myHostPool' -param sessionHostCount = 2 -param maxSessionLimit = 5 - -/*VM Config*/ -param vmNamePrefix = 'myVMName' -param vmSize = 'Standard_DS2_v2' -param adminUsername = 'myAdminUser' -param adminPassword = 'NotaPassword!' - -/*Network Config*/ -param subnetName = 'mySubnetName' -param vnetName = 'myVnetName' -param vnetResourceGroup = 'myVnetResourceGroup' - -/*API Config*/ -param linuxBrokerApiBaseUrl = 'https://your-broker.domain.com/api' diff --git a/bicep/Linux/README.md b/bicep/Linux/README.md deleted file mode 100644 index 00b1f77..0000000 --- a/bicep/Linux/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# Linux Compute Deployment - -This directory contains Bicep templates to help with deploying and managing the Linux Virtual Machines that are to be used with the Linux Broker for AVD. - -## Parameter Files - -### `main.bicepparam` - -This parameter file contains the following key parameters: - -- **Network Config**: - - `subnetName`: The name of the subnet (e.g., `mySubnet`). - - `vnetName`: The name of the virtual network (e.g., `myVnet`). - - `vnetResourceGroup`: The resource group containing the virtual network (e.g., `myResourceGroup`). - -- **VM Config - General**: - - `vmNamePrefix`: The prefix for the VM names (e.g., `myLinuxVM`). - - `vmSize`: The size of the VMs (e.g., `Standard_D2s_v3`). - - `numberOfVMs`: The number of VMs to deploy (e.g., `2`). - - `OSVersion`: The operating system version for the VMs (e.g., `24_04-lts`). - -- **VM Config - Auth**: - - `authType`: The authentication type for the VMs (e.g., `Password` or `SSH`). - - `adminUsername`: The administrator username for the VMs (e.g., `myAdminUser`). - - `adminPassword`: The administrator password for the VMs (e.g., `JustASecret!`). - -## Deployment Instructions - -1. Ensure you have the necessary prerequisites installed, including the Azure CLI and Bicep CLI. - -2. Update the parameter files (`main.bicepparam`) with values specific to your environment. - -3. Deploy the infrastructure using the following command: - - ```bash - az deployment group create --resource-group --template-file main.bicep --parameters @main.bicepparam - ``` diff --git a/bicep/Linux/main.bicepparam b/bicep/Linux/main.bicepparam deleted file mode 100644 index b8a2228..0000000 --- a/bicep/Linux/main.bicepparam +++ /dev/null @@ -1,17 +0,0 @@ -using './main.bicep' - -/*Network Config*/ -param subnetName = 'mySubnet' -param vnetName = 'myVnet' -param vnetResourceGroup = 'myResourceGroup' - -/*VM Config - General*/ -param vmNamePrefix = 'myLinuxVM' -param vmSize = 'Standard_D2s_v3' -param numberOfVMs = 2 -param OSVersion = '24_04-lts' - -/*VM Config - Auth*/ -param authType = 'Password' -param adminUsername = 'myAdminUser' -param adminPassword = 'JustASecret!' diff --git a/deploy/Assign-FunctionAppApiRole.ps1 b/deploy/Assign-FunctionAppApiRole.ps1 new file mode 100644 index 0000000..546a5f9 --- /dev/null +++ b/deploy/Assign-FunctionAppApiRole.ps1 @@ -0,0 +1,27 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ResourceGroupName, + + [Parameter(Mandatory = $true)] + [string]$TaskAppName, + + [Parameter(Mandatory = $true)] + [string]$ApiClientId, + + [string]$RoleValue = 'ScheduledTask' +) + +$ErrorActionPreference = 'Stop' + +$identity = az functionapp identity show --name $TaskAppName --resource-group $ResourceGroupName --output json | ConvertFrom-Json +if (-not $identity.principalId) { + throw "Unable to resolve managed identity for function app '$TaskAppName'." +} + +& "$PSScriptRoot/Assign-ServicePrincipalApiRole.ps1" ` + -PrincipalId $identity.principalId ` + -ApiClientId $ApiClientId ` + -RoleValue $RoleValue + +Write-Host "Ensured '$RoleValue' application permission for function app '$TaskAppName'." diff --git a/deploy/Assign-ServicePrincipalApiRole.ps1 b/deploy/Assign-ServicePrincipalApiRole.ps1 new file mode 100644 index 0000000..929648b --- /dev/null +++ b/deploy/Assign-ServicePrincipalApiRole.ps1 @@ -0,0 +1,53 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$PrincipalId, + + [Parameter(Mandatory = $true)] + [string]$ApiClientId, + + [Parameter(Mandatory = $true)] + [string]$RoleValue +) + +$ErrorActionPreference = 'Stop' + +$cloud = az cloud show --output json | ConvertFrom-Json +$graphUrl = if ($cloud.name -eq 'AzureUSGovernment') { + 'https://graph.microsoft.us' +} +else { + 'https://graph.microsoft.com' +} + +$apiServicePrincipal = az ad sp list --filter "appId eq '$ApiClientId'" --output json | ConvertFrom-Json | Select-Object -First 1 +if (-not $apiServicePrincipal) { + throw "Unable to resolve service principal for API client id '$ApiClientId'." +} + +$role = $apiServicePrincipal.appRoles | Where-Object { + $_.value -eq $RoleValue -and $_.allowedMemberTypes -contains 'Application' +} | Select-Object -First 1 + +if (-not $role) { + throw "App role '$RoleValue' was not found on API service principal '$ApiClientId'." +} + +$existingAssignments = az rest --method GET --url "$graphUrl/v1.0/servicePrincipals/$PrincipalId/appRoleAssignments" --output json | ConvertFrom-Json +$assignment = $existingAssignments.value | Where-Object { + $_.resourceId -eq $apiServicePrincipal.id -and $_.appRoleId -eq $role.id +} | Select-Object -First 1 + +if ($assignment) { + Write-Host "Principal '$PrincipalId' already has '$RoleValue' app role assignment." + exit 0 +} + +$payload = @{ + principalId = $PrincipalId + resourceId = $apiServicePrincipal.id + appRoleId = $role.id +} | ConvertTo-Json -Compress + +az rest --method POST --url "$graphUrl/v1.0/servicePrincipals/$PrincipalId/appRoleAssignments" --headers 'Content-Type=application/json' --body $payload | Out-Null +Write-Host "Assigned '$RoleValue' application permission to principal '$PrincipalId'." diff --git a/deploy/Assign-VmApiRoles.ps1 b/deploy/Assign-VmApiRoles.ps1 new file mode 100644 index 0000000..f37bb9e --- /dev/null +++ b/deploy/Assign-VmApiRoles.ps1 @@ -0,0 +1,37 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ResourceGroupName, + + [Parameter(Mandatory = $true)] + [string]$ApiClientId +) + +$ErrorActionPreference = 'Stop' + +$virtualMachines = az vm list --resource-group $ResourceGroupName --output json | ConvertFrom-Json +if (-not $virtualMachines) { + Write-Host "No virtual machines found in resource group '$ResourceGroupName'." + exit 0 +} + +$roleMappings = @( + @{ Tag = 'linux-host'; RoleValue = 'LinuxHost' } + @{ Tag = 'avd-host'; RoleValue = 'AvdHost' } +) + +foreach ($mapping in $roleMappings) { + $matchingVms = $virtualMachines | Where-Object { $_.tags.'broker-role' -eq $mapping.Tag } + foreach ($virtualMachine in $matchingVms) { + $principalId = az vm show --resource-group $ResourceGroupName --name $virtualMachine.name --query identity.principalId --output tsv + if ([string]::IsNullOrWhiteSpace($principalId)) { + Write-Warning "Skipping VM '$($virtualMachine.name)' because no managed identity principal id was found." + continue + } + + & "$PSScriptRoot/Assign-ServicePrincipalApiRole.ps1" ` + -PrincipalId $principalId ` + -ApiClientId $ApiClientId ` + -RoleValue $mapping.RoleValue + } +} diff --git a/deploy/Build-ContainerImages.ps1 b/deploy/Build-ContainerImages.ps1 new file mode 100644 index 0000000..a932f38 --- /dev/null +++ b/deploy/Build-ContainerImages.ps1 @@ -0,0 +1,111 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$RegistryName, + + [Parameter(Mandatory = $false)] + [string]$ResourceGroupName, + + [Parameter(Mandatory = $false)] + [string]$FrontendAppName, + + [Parameter(Mandatory = $false)] + [string]$ApiAppName, + + [Parameter(Mandatory = $false)] + [string]$TaskAppName, + + [Parameter(Mandatory = $false)] + [string]$EnvironmentName +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($EnvironmentName)) { + $EnvironmentName = if (-not [string]::IsNullOrWhiteSpace($env:AZURE_ENV_NAME)) { + $env:AZURE_ENV_NAME + } + elseif (-not [string]::IsNullOrWhiteSpace($env:AZURE_ENVIRONMENT_NAME)) { + $env:AZURE_ENVIRONMENT_NAME + } + else { + '' + } +} + +function Get-AzdEnvValue { + param([Parameter(Mandatory = $true)][string]$Key) + + if ([string]::IsNullOrWhiteSpace($EnvironmentName)) { + return '' + } + + $value = azd env get-value $Key --environment $EnvironmentName 2>$null + if ($LASTEXITCODE -ne 0) { + return '' + } + + return ($value | Out-String).Trim() +} + +if ([string]::IsNullOrWhiteSpace($RegistryName)) { + $RegistryName = Get-AzdEnvValue -Key 'containerRegistryName' +} + +if ([string]::IsNullOrWhiteSpace($ResourceGroupName)) { + $ResourceGroupName = Get-AzdEnvValue -Key 'resourceGroupName' +} + +if ([string]::IsNullOrWhiteSpace($FrontendAppName)) { + $FrontendAppName = Get-AzdEnvValue -Key 'frontendAppName' +} + +if ([string]::IsNullOrWhiteSpace($ApiAppName)) { + $ApiAppName = Get-AzdEnvValue -Key 'apiAppName' +} + +if ([string]::IsNullOrWhiteSpace($TaskAppName)) { + $TaskAppName = Get-AzdEnvValue -Key 'taskAppName' +} + +if ([string]::IsNullOrWhiteSpace($RegistryName) -or [string]::IsNullOrWhiteSpace($ResourceGroupName) -or [string]::IsNullOrWhiteSpace($FrontendAppName) -or [string]::IsNullOrWhiteSpace($ApiAppName) -or [string]::IsNullOrWhiteSpace($TaskAppName)) { + throw 'Build inputs could not be fully resolved from parameters or azd environment values.' +} + +$repoRoot = Split-Path -Parent $PSScriptRoot + +$images = @( + @{ Name = 'frontend'; Dockerfile = 'front_end/Dockerfile' }, + @{ Name = 'api'; Dockerfile = 'api/Dockerfile' }, + @{ Name = 'task'; Dockerfile = 'task/Dockerfile' } +) + +Push-Location $repoRoot +try { + foreach ($image in $images) { + Write-Host "Building $($image.Name):latest in ACR '$RegistryName'" + az acr build --registry $RegistryName --image "$($image.Name):latest" --file $image.Dockerfile --no-logs --output none . + if ($LASTEXITCODE -ne 0) { + throw "ACR build failed for $($image.Name)." + } + } + + az webapp restart --name $FrontendAppName --resource-group $ResourceGroupName | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Failed to restart frontend app '$FrontendAppName'." + } + + az webapp restart --name $ApiAppName --resource-group $ResourceGroupName | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Failed to restart API app '$ApiAppName'." + } + + az functionapp restart --name $TaskAppName --resource-group $ResourceGroupName | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Failed to restart task app '$TaskAppName'." + } +} +finally { + Pop-Location +} diff --git a/deploy/Initialize-Database.ps1 b/deploy/Initialize-Database.ps1 new file mode 100644 index 0000000..fc8d08a --- /dev/null +++ b/deploy/Initialize-Database.ps1 @@ -0,0 +1,57 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$SqlServerFqdn, + + [Parameter(Mandatory = $true)] + [string]$DatabaseName, + + [Parameter(Mandatory = $true)] + [string]$SqlAdminLogin, + + [Parameter(Mandatory = $true)] + [string]$SqlAdminPassword, + + [Parameter(Mandatory = $true)] + [string]$ScriptsPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($env:SKIP_SQL_BOOTSTRAP -eq 'true') { + Write-Host 'Skipping SQL bootstrap because SKIP_SQL_BOOTSTRAP=true.' + exit 0 +} + +$resolvedScriptsPath = Resolve-Path $ScriptsPath -ErrorAction Stop +$sqlFiles = Get-ChildItem -Path $resolvedScriptsPath -Filter '*.sql' | Sort-Object Name + +if (-not $sqlFiles) { + Write-Host "No SQL files found in $resolvedScriptsPath." + exit 0 +} + +$invokeSqlCmd = Get-Command Invoke-Sqlcmd -ErrorAction SilentlyContinue +$sqlcmdExe = Get-Command sqlcmd -ErrorAction SilentlyContinue + +if (-not $invokeSqlCmd -and -not $sqlcmdExe) { + Write-Warning 'Skipping SQL bootstrap because neither Invoke-Sqlcmd nor sqlcmd is available on this machine.' + exit 0 +} + +foreach ($sqlFile in $sqlFiles) { + Write-Host "Applying $($sqlFile.Name)" + try { + if ($invokeSqlCmd) { + Invoke-Sqlcmd -ServerInstance $SqlServerFqdn -Database $DatabaseName -Username $SqlAdminLogin -Password $SqlAdminPassword -InputFile $sqlFile.FullName -Encrypt Mandatory -TrustServerCertificate:$false | Out-Null + } + else { + & $sqlcmdExe.Source -S $SqlServerFqdn -d $DatabaseName -U $SqlAdminLogin -P $SqlAdminPassword -N -i $sqlFile.FullName | Out-Null + } + } + catch { + Write-Warning "SQL bootstrap stopped on $($sqlFile.Name): $($_.Exception.Message)" + exit 0 + } +} diff --git a/deploy/Initialize-DeploymentEnvironment.ps1 b/deploy/Initialize-DeploymentEnvironment.ps1 new file mode 100644 index 0000000..2d58310 --- /dev/null +++ b/deploy/Initialize-DeploymentEnvironment.ps1 @@ -0,0 +1,460 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [ValidateLength(3, 12)] + [ValidatePattern('^[a-zA-Z0-9]+$')] + [string]$AppName, + + [Parameter(Mandatory = $false)] + [ValidateLength(2, 16)] + [ValidatePattern('^[a-zA-Z0-9-]+$')] + [string]$EnvironmentName +) + +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($AppName)) { + $AppName = if (-not [string]::IsNullOrWhiteSpace($env:AZURE_APP_NAME)) { + $env:AZURE_APP_NAME + } + elseif (-not [string]::IsNullOrWhiteSpace($env:APP_NAME)) { + $env:APP_NAME + } + else { + 'linuxbroker' + } +} + +if ([string]::IsNullOrWhiteSpace($EnvironmentName)) { + $EnvironmentName = if (-not [string]::IsNullOrWhiteSpace($env:AZURE_ENV_NAME)) { + $env:AZURE_ENV_NAME + } + elseif (-not [string]::IsNullOrWhiteSpace($env:AZURE_ENVIRONMENT_NAME)) { + $env:AZURE_ENVIRONMENT_NAME + } + else { + throw 'EnvironmentName was not provided and AZURE_ENV_NAME is not set.' + } +} + +$graphAppId = '00000003-0000-0000-c000-000000000000' +$frontendGraphDelegatedPermissions = @( + @{ name = 'User.Read'; id = 'e1fe6dd8-ba31-4d61-89e7-88639da4683d' } + @{ name = 'profile'; id = '14dad69e-099b-42c9-810b-d002981feec1' } + @{ name = 'email'; id = '64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0' } + @{ name = 'Group.Read.All'; id = '5f8c59db-677d-491f-a6b8-5f174b11ec1d' } + @{ name = 'offline_access'; id = '7427e0e9-2fba-42fe-b0c0-848c9e6a8182' } + @{ name = 'openid'; id = '37f7f235-527c-4136-accd-4a02d197296e' } +) + +$apiScopeId = '58db6e6d-38d5-4ce2-bf0a-7fd9cfd5f00a' +$apiRoleIds = @{ + FullAccess = '4b2d5f7f-7cc1-4303-8d4b-bd7d2cfe2ca6' + ScheduledTask = 'd11a6ed0-ee5e-4305-a2a2-252a8107d84f' + AvdHost = '2dd7deea-1e20-4f32-a733-c6f6ec1d2519' + LinuxHost = '29a8a5a0-2090-4e94-a49d-3386640f0058' +} + +function Get-CloudContext { + $cloud = az cloud show --output json | ConvertFrom-Json + + switch ($cloud.name) { + 'AzureUSGovernment' { + return @{ + Name = $cloud.name + GraphUrl = 'https://graph.microsoft.us' + AppServiceDomain = 'azurewebsites.us' + } + } + default { + return @{ + Name = $cloud.name + GraphUrl = 'https://graph.microsoft.com' + AppServiceDomain = 'azurewebsites.net' + } + } + } +} + +function Get-AzdEnvValue { + param([Parameter(Mandatory = $true)][string]$Key) + + $value = azd env get-value $Key --environment $EnvironmentName 2>$null + if ($LASTEXITCODE -ne 0) { + return '' + } + + return ($value | Out-String).Trim() +} + +function Set-AzdEnvValue { + param( + [Parameter(Mandatory = $true)][string]$Key, + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value + ) + + azd env set $Key $Value --environment $EnvironmentName | Out-Null +} + +function New-RandomSecret { + param([int]$Length = 40) + + $alphabet = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@$%^*-_=+' + $bytes = New-Object byte[] ($Length) + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) + + $builder = New-Object System.Text.StringBuilder + foreach ($byte in $bytes) { + [void]$builder.Append($alphabet[$byte % $alphabet.Length]) + } + + return $builder.ToString() +} + +function Ensure-DefaultEnvValue { + param( + [Parameter(Mandatory = $true)][string]$Key, + [Parameter(Mandatory = $true)][scriptblock]$ValueFactory + ) + + $existing = Get-AzdEnvValue -Key $Key + if ([string]::IsNullOrWhiteSpace($existing)) { + $value = & $ValueFactory + Set-AzdEnvValue -Key $Key -Value $value + return $value + } + + return $existing +} + +function Get-JsonFilePath { + $path = [System.IO.Path]::GetTempFileName() + return [System.IO.Path]::ChangeExtension($path, '.json') +} + +function Write-JsonFile { + param([Parameter(Mandatory = $true)]$InputObject) + + $path = Get-JsonFilePath + $InputObject | ConvertTo-Json -Depth 20 | Set-Content -Path $path -Encoding utf8 + return $path +} + +function Get-MatchingApp { + param([Parameter(Mandatory = $true)][string]$DisplayName) + + $apps = az ad app list --display-name $DisplayName --output json | ConvertFrom-Json + if ($apps -is [System.Array]) { + return $apps | Where-Object { $_.displayName -eq $DisplayName } | Select-Object -First 1 + } + + if ($apps -and $apps.displayName -eq $DisplayName) { + return $apps + } + + return $null +} + +function Ensure-ServicePrincipal { + param([Parameter(Mandatory = $true)][string]$AppId) + + $servicePrincipals = az ad sp list --filter "appId eq '$AppId'" --output json | ConvertFrom-Json + $existing = $servicePrincipals | Select-Object -First 1 + if ($existing) { + return $existing + } + + return az ad sp create --id $AppId --output json | ConvertFrom-Json +} + +function Ensure-ClientSecret { + param( + [Parameter(Mandatory = $true)]$Application, + [Parameter(Mandatory = $true)][string]$EnvClientIdKey, + [Parameter(Mandatory = $true)][string]$EnvSecretKey + ) + + $existingClientId = Get-AzdEnvValue -Key $EnvClientIdKey + $existingSecret = Get-AzdEnvValue -Key $EnvSecretKey + + Set-AzdEnvValue -Key $EnvClientIdKey -Value $Application.appId + + if (-not [string]::IsNullOrWhiteSpace($existingSecret) -and $existingClientId -eq $Application.appId) { + return $existingSecret + } + + $secret = az ad app credential reset --id $Application.appId --append --output json | ConvertFrom-Json + Set-AzdEnvValue -Key $EnvSecretKey -Value $secret.password + return $secret.password +} + +function Ensure-Group { + param([Parameter(Mandatory = $true)][string]$DisplayName) + + $groups = az ad group list --filter "displayName eq '$DisplayName'" --output json | ConvertFrom-Json + $existing = $groups | Select-Object -First 1 + if ($existing) { + return $existing + } + + return az ad group create --display-name $DisplayName --mail-nickname $DisplayName --output json | ConvertFrom-Json +} + +function Ensure-FrontendApplication { + param( + [Parameter(Mandatory = $true)][hashtable]$CloudContext, + [Parameter(Mandatory = $true)][string]$DisplayName, + [Parameter(Mandatory = $true)][string]$AppServiceName, + [Parameter(Mandatory = $true)][string]$ApiAppId + ) + + $redirectBase = "https://$AppServiceName.$($CloudContext.AppServiceDomain)" + $redirectUris = @( + "$redirectBase/.auth/login/aad/callback" + "$redirectBase/getAToken" + ) + + $app = Get-MatchingApp -DisplayName $DisplayName + if (-not $app) { + $app = az ad app create --display-name $DisplayName --sign-in-audience AzureADMyOrg --web-redirect-uris $redirectUris --output json | ConvertFrom-Json + } + + az ad app update --id $app.id --web-redirect-uris $redirectUris --web-home-page-url $redirectBase --enable-access-token-issuance true --enable-id-token-issuance true | Out-Null + + $requiredResources = @( + @{ + resourceAppId = $graphAppId + resourceAccess = @($frontendGraphDelegatedPermissions | ForEach-Object { + @{ + id = $_.id + type = 'Scope' + } + }) + } + @{ + resourceAppId = $ApiAppId + resourceAccess = @( + @{ + id = $apiScopeId + type = 'Scope' + } + ) + } + ) + + $requiredResourcesFile = Write-JsonFile -InputObject $requiredResources + try { + az ad app update --id $app.id --required-resource-accesses "@$requiredResourcesFile" | Out-Null + } + finally { + Remove-Item -Path $requiredResourcesFile -ErrorAction SilentlyContinue + } + + $logoutBody = @{ + web = @{ + logoutUrl = "$redirectBase/logout" + } + } + + $logoutPayload = $logoutBody | ConvertTo-Json -Depth 10 -Compress + az rest --method PATCH --url "$($CloudContext.GraphUrl)/v1.0/applications/$($app.id)" --headers 'Content-Type=application/json' --body $logoutPayload | Out-Null + + return $app +} + +function Ensure-ApiApplication { + param( + [Parameter(Mandatory = $true)][hashtable]$CloudContext, + [Parameter(Mandatory = $true)][string]$DisplayName + ) + + $app = Get-MatchingApp -DisplayName $DisplayName + if (-not $app) { + $app = az ad app create --display-name $DisplayName --sign-in-audience AzureADMyOrg --output json | ConvertFrom-Json + } + + az ad app update --id $app.id --identifier-uris "api://$($app.appId)" --requested-access-token-version 2 | Out-Null + + $apiManifest = @{ + identifierUris = @("api://$($app.appId)") + api = @{ + requestedAccessTokenVersion = 2 + oauth2PermissionScopes = @( + @{ + adminConsentDescription = 'Allow the front end to access the Linux Broker API on behalf of the signed-in user.' + adminConsentDisplayName = 'Access Linux Broker API' + id = $apiScopeId + isEnabled = $true + type = 'User' + userConsentDescription = 'Allow the application to access the Linux Broker API on your behalf.' + userConsentDisplayName = 'Access Linux Broker API' + value = 'access_as_user' + } + ) + } + } + + $manifestPayload = $apiManifest | ConvertTo-Json -Depth 20 -Compress + az rest --method PATCH --url "$($CloudContext.GraphUrl)/v1.0/applications/$($app.id)" --headers 'Content-Type=application/json' --body $manifestPayload | Out-Null + + $appRoles = @( + @{ + allowedMemberTypes = @('Application', 'User') + description = 'Full access to Linux Broker management APIs.' + displayName = 'Full Access' + id = $apiRoleIds.FullAccess + isEnabled = $true + value = 'FullAccess' + } + @{ + allowedMemberTypes = @('Application') + description = 'Allows the scheduled task function app to call maintenance endpoints.' + displayName = 'Scheduled Task' + id = $apiRoleIds.ScheduledTask + isEnabled = $true + value = 'ScheduledTask' + } + @{ + allowedMemberTypes = @('Application') + description = 'Allows AVD host automation to call AVD-specific endpoints.' + displayName = 'AVD Host' + id = $apiRoleIds.AvdHost + isEnabled = $true + value = 'AvdHost' + } + @{ + allowedMemberTypes = @('Application') + description = 'Allows Linux host automation to call Linux host endpoints.' + displayName = 'Linux Host' + id = $apiRoleIds.LinuxHost + isEnabled = $true + value = 'LinuxHost' + } + ) + + $appRolesFile = Write-JsonFile -InputObject $appRoles + try { + az ad app update --id $app.id --app-roles "@$appRolesFile" | Out-Null + } + finally { + Remove-Item -Path $appRolesFile -ErrorAction SilentlyContinue + } + + $graphSp = az ad sp show --id $graphAppId --output json | ConvertFrom-Json + $groupMemberReadAllRoleId = $graphSp.appRoles | Where-Object { + $_.value -eq 'GroupMember.Read.All' -and $_.allowedMemberTypes -contains 'Application' + } | Select-Object -ExpandProperty id -First 1 + + if ($groupMemberReadAllRoleId) { + $requiredResources = @( + @{ + resourceAppId = $graphAppId + resourceAccess = @( + @{ + id = $groupMemberReadAllRoleId + type = 'Role' + } + ) + } + ) + + $requiredResourcesFile = Write-JsonFile -InputObject $requiredResources + try { + az ad app update --id $app.id --required-resource-accesses "@$requiredResourcesFile" | Out-Null + } + finally { + Remove-Item -Path $requiredResourcesFile -ErrorAction SilentlyContinue + } + } + + return $app +} + +$cloudContext = Get-CloudContext + +$frontendAppDisplayName = "$AppName-$EnvironmentName-frontend-ar" +$apiAppDisplayName = "$AppName-$EnvironmentName-api-ar" +$frontendAppServiceName = "$AppName-$EnvironmentName-fe" +$avdGroupName = "$AppName-$EnvironmentName-avd-hosts-sg" +$linuxGroupName = "$AppName-$EnvironmentName-linux-hosts-sg" + +$subscription = az account show --output json | ConvertFrom-Json +$defaultTenantId = $subscription.tenantId + +Ensure-DefaultEnvValue -Key 'appName' -ValueFactory { $AppName } | Out-Null +Ensure-DefaultEnvValue -Key 'environmentName' -ValueFactory { $EnvironmentName } | Out-Null +Ensure-DefaultEnvValue -Key 'AZURE_LOCATION' -ValueFactory { + if (-not [string]::IsNullOrWhiteSpace($env:AZURE_LOCATION)) { + $env:AZURE_LOCATION + } + else { + 'eastus2' + } +} | Out-Null +Ensure-DefaultEnvValue -Key 'location' -ValueFactory { Get-AzdEnvValue -Key 'AZURE_LOCATION' } | Out-Null +Ensure-DefaultEnvValue -Key 'tenantId' -ValueFactory { $defaultTenantId } | Out-Null + +Ensure-DefaultEnvValue -Key 'SQL_ADMIN_LOGIN' -ValueFactory { 'brokeradmin' } | Out-Null +Ensure-DefaultEnvValue -Key 'SQL_DATABASE_NAME' -ValueFactory { 'LinuxBroker' } | Out-Null +Ensure-DefaultEnvValue -Key 'APP_SERVICE_PLAN_SKU' -ValueFactory { 'P1v3' } | Out-Null +Ensure-DefaultEnvValue -Key 'LINUX_HOST_ADMIN_LOGIN_NAME' -ValueFactory { 'avdadmin' } | Out-Null +Ensure-DefaultEnvValue -Key 'DOMAIN_NAME' -ValueFactory { '' } | Out-Null +Ensure-DefaultEnvValue -Key 'NFS_SHARE' -ValueFactory { '' } | Out-Null +Ensure-DefaultEnvValue -Key 'VM_HOST_RESOURCE_GROUP' -ValueFactory { '' } | Out-Null +Ensure-DefaultEnvValue -Key 'ALLOWED_CLIENT_IP' -ValueFactory { '' } | Out-Null +Ensure-DefaultEnvValue -Key 'FLASK_SESSION_SECRET' -ValueFactory { New-RandomSecret -Length 48 } | Out-Null +Ensure-DefaultEnvValue -Key 'SQL_ADMIN_PASSWORD' -ValueFactory { New-RandomSecret -Length 32 } | Out-Null +Ensure-DefaultEnvValue -Key 'HOST_ADMIN_PASSWORD' -ValueFactory { New-RandomSecret -Length 32 } | Out-Null +Ensure-DefaultEnvValue -Key 'deployLinuxHosts' -ValueFactory { 'true' } | Out-Null +Ensure-DefaultEnvValue -Key 'deployAvdHosts' -ValueFactory { 'true' } | Out-Null +Ensure-DefaultEnvValue -Key 'linuxHostCount' -ValueFactory { '2' } | Out-Null +Ensure-DefaultEnvValue -Key 'avdSessionHostCount' -ValueFactory { '1' } | Out-Null +Ensure-DefaultEnvValue -Key 'avdHostPoolName' -ValueFactory { "$AppName-$EnvironmentName-hp" } | Out-Null +Ensure-DefaultEnvValue -Key 'avdVmNamePrefix' -ValueFactory { 'avdhost' } | Out-Null +Ensure-DefaultEnvValue -Key 'linuxHostVmNamePrefix' -ValueFactory { 'lnxhost' } | Out-Null +Ensure-DefaultEnvValue -Key 'linuxHostAuthType' -ValueFactory { 'Password' } | Out-Null +Ensure-DefaultEnvValue -Key 'linuxHostOsVersion' -ValueFactory { '24_04-lts' } | Out-Null +Ensure-DefaultEnvValue -Key 'linuxHostVmSize' -ValueFactory { 'Standard_D2s_v5' } | Out-Null +Ensure-DefaultEnvValue -Key 'avdVmSize' -ValueFactory { 'Standard_D8s_v5' } | Out-Null +Ensure-DefaultEnvValue -Key 'avdMaxSessionLimit' -ValueFactory { '5' } | Out-Null +Ensure-DefaultEnvValue -Key 'vmSubscriptionId' -ValueFactory { $subscription.id } | Out-Null +Ensure-DefaultEnvValue -Key 'sqlAdminLogin' -ValueFactory { 'brokeradmin' } | Out-Null +Ensure-DefaultEnvValue -Key 'sqlDatabaseName' -ValueFactory { 'LinuxBroker' } | Out-Null +Ensure-DefaultEnvValue -Key 'appServicePlanSku' -ValueFactory { 'P1v3' } | Out-Null +Ensure-DefaultEnvValue -Key 'linuxHostAdminLoginName' -ValueFactory { 'avdadmin' } | Out-Null +Ensure-DefaultEnvValue -Key 'domainName' -ValueFactory { '' } | Out-Null +Ensure-DefaultEnvValue -Key 'nfsShare' -ValueFactory { '' } | Out-Null +Ensure-DefaultEnvValue -Key 'vmHostResourceGroup' -ValueFactory { '' } | Out-Null +Ensure-DefaultEnvValue -Key 'allowedClientIp' -ValueFactory { '' } | Out-Null +Ensure-DefaultEnvValue -Key 'flaskKey' -ValueFactory { Get-AzdEnvValue -Key 'FLASK_SESSION_SECRET' } | Out-Null +Ensure-DefaultEnvValue -Key 'sqlAdminPassword' -ValueFactory { Get-AzdEnvValue -Key 'SQL_ADMIN_PASSWORD' } | Out-Null +Ensure-DefaultEnvValue -Key 'hostAdminPassword' -ValueFactory { Get-AzdEnvValue -Key 'HOST_ADMIN_PASSWORD' } | Out-Null + +$apiApp = Ensure-ApiApplication -CloudContext $cloudContext -DisplayName $apiAppDisplayName +Ensure-ServicePrincipal -AppId $apiApp.appId | Out-Null +Ensure-ClientSecret -Application $apiApp -EnvClientIdKey 'API_CLIENT_ID' -EnvSecretKey 'API_CLIENT_SECRET' | Out-Null + +$frontendApp = Ensure-FrontendApplication -CloudContext $cloudContext -DisplayName $frontendAppDisplayName -AppServiceName $frontendAppServiceName -ApiAppId $apiApp.appId +Ensure-ServicePrincipal -AppId $frontendApp.appId | Out-Null +Ensure-ClientSecret -Application $frontendApp -EnvClientIdKey 'FRONTEND_CLIENT_ID' -EnvSecretKey 'FRONTEND_CLIENT_SECRET' | Out-Null + +$avdGroup = Ensure-Group -DisplayName $avdGroupName +$linuxGroup = Ensure-Group -DisplayName $linuxGroupName + +Set-AzdEnvValue -Key 'API_CLIENT_ID' -Value $apiApp.appId +Set-AzdEnvValue -Key 'FRONTEND_CLIENT_ID' -Value $frontendApp.appId +Set-AzdEnvValue -Key 'AVD_HOST_GROUP_ID' -Value $avdGroup.id +Set-AzdEnvValue -Key 'LINUX_HOST_GROUP_ID' -Value $linuxGroup.id +Set-AzdEnvValue -Key 'apiClientId' -Value $apiApp.appId +Set-AzdEnvValue -Key 'frontendClientId' -Value $frontendApp.appId +Set-AzdEnvValue -Key 'avdHostGroupId' -Value $avdGroup.id +Set-AzdEnvValue -Key 'linuxHostGroupId' -Value $linuxGroup.id +Set-AzdEnvValue -Key 'frontendClientSecret' -Value (Get-AzdEnvValue -Key 'FRONTEND_CLIENT_SECRET') +Set-AzdEnvValue -Key 'apiClientSecret' -Value (Get-AzdEnvValue -Key 'API_CLIENT_SECRET') + +Write-Host "Configured azd environment '$EnvironmentName' with Entra application and host group values." +Write-Host "API application: $($apiApp.displayName) ($($apiApp.appId))" +Write-Host "Frontend application: $($frontendApp.displayName) ($($frontendApp.appId))" +Write-Host "AVD host group: $($avdGroup.displayName) ($($avdGroup.id))" +Write-Host "Linux host group: $($linuxGroup.displayName) ($($linuxGroup.id))" +Write-Host 'Admin consent is still required for the configured Microsoft Graph and API permissions.' diff --git a/deploy/Post-Provision.ps1 b/deploy/Post-Provision.ps1 new file mode 100644 index 0000000..e651bd6 --- /dev/null +++ b/deploy/Post-Provision.ps1 @@ -0,0 +1,129 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$ResourceGroupName, + + [Parameter(Mandatory = $false)] + [string]$TaskAppName, + + [Parameter(Mandatory = $false)] + [string]$ApiClientId, + + [Parameter(Mandatory = $false)] + [string]$SqlServerFqdn, + + [Parameter(Mandatory = $false)] + [string]$DatabaseName, + + [Parameter(Mandatory = $false)] + [string]$SqlAdminLogin, + + [Parameter(Mandatory = $false)] + [string]$SqlAdminPassword, + + [Parameter(Mandatory = $false)] + [string]$ScriptsPath, + + [Parameter(Mandatory = $false)] + [string]$SubscriptionId, + + [Parameter(Mandatory = $false)] + [string]$EnvironmentName +) + +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($EnvironmentName)) { + $EnvironmentName = if (-not [string]::IsNullOrWhiteSpace($env:AZURE_ENV_NAME)) { + $env:AZURE_ENV_NAME + } + elseif (-not [string]::IsNullOrWhiteSpace($env:AZURE_ENVIRONMENT_NAME)) { + $env:AZURE_ENVIRONMENT_NAME + } + else { + throw 'EnvironmentName was not provided and AZURE_ENV_NAME is not set.' + } +} + +function Get-AzdEnvValue { + param([Parameter(Mandatory = $true)][string]$Key) + + $value = azd env get-value $Key --environment $EnvironmentName 2>$null + if ($LASTEXITCODE -ne 0) { + return '' + } + + return ($value | Out-String).Trim() +} + +if ([string]::IsNullOrWhiteSpace($ResourceGroupName)) { + $ResourceGroupName = Get-AzdEnvValue -Key 'resourceGroupName' +} + +if ([string]::IsNullOrWhiteSpace($SubscriptionId)) { + $SubscriptionId = Get-AzdEnvValue -Key 'AZURE_SUBSCRIPTION_ID' +} + +if ([string]::IsNullOrWhiteSpace($SubscriptionId)) { + $SubscriptionId = $env:AZURE_SUBSCRIPTION_ID +} + +if ([string]::IsNullOrWhiteSpace($TaskAppName)) { + $TaskAppName = Get-AzdEnvValue -Key 'taskAppName' +} + +if ([string]::IsNullOrWhiteSpace($ApiClientId)) { + $ApiClientId = Get-AzdEnvValue -Key 'apiClientId' +} + +if ([string]::IsNullOrWhiteSpace($DatabaseName)) { + $DatabaseName = (Get-AzdEnvValue -Key 'sqlDatabaseName') + if ([string]::IsNullOrWhiteSpace($DatabaseName)) { + $DatabaseName = Get-AzdEnvValue -Key 'sqlDatabaseName' + } +} + +if ([string]::IsNullOrWhiteSpace($SqlAdminLogin)) { + $SqlAdminLogin = Get-AzdEnvValue -Key 'sqlAdminLogin' +} + +if ([string]::IsNullOrWhiteSpace($SqlAdminPassword)) { + $SqlAdminPassword = Get-AzdEnvValue -Key 'sqlAdminPassword' +} + +if ([string]::IsNullOrWhiteSpace($ScriptsPath)) { + $ScriptsPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'sql_queries' +} + +if (-not [string]::IsNullOrWhiteSpace($SubscriptionId)) { + az account set --subscription $SubscriptionId | Out-Null +} + +if ([string]::IsNullOrWhiteSpace($SqlServerFqdn)) { + $sqlServerName = Get-AzdEnvValue -Key 'sqlServerName' + if (-not [string]::IsNullOrWhiteSpace($sqlServerName) -and -not [string]::IsNullOrWhiteSpace($ResourceGroupName)) { + $SqlServerFqdn = az sql server show --name $sqlServerName --resource-group $ResourceGroupName --query fullyQualifiedDomainName --output tsv + } +} + +if ([string]::IsNullOrWhiteSpace($ResourceGroupName) -or [string]::IsNullOrWhiteSpace($TaskAppName) -or [string]::IsNullOrWhiteSpace($ApiClientId) -or [string]::IsNullOrWhiteSpace($SqlServerFqdn) -or [string]::IsNullOrWhiteSpace($DatabaseName) -or [string]::IsNullOrWhiteSpace($SqlAdminLogin) -or [string]::IsNullOrWhiteSpace($SqlAdminPassword)) { + throw 'Post-provision inputs could not be fully resolved from parameters or azd environment values.' +} + +& "$PSScriptRoot/Build-ContainerImages.ps1" + +& "$PSScriptRoot/Initialize-Database.ps1" ` + -SqlServerFqdn $SqlServerFqdn ` + -DatabaseName $DatabaseName ` + -SqlAdminLogin $SqlAdminLogin ` + -SqlAdminPassword $SqlAdminPassword ` + -ScriptsPath $ScriptsPath + +& "$PSScriptRoot/Assign-FunctionAppApiRole.ps1" ` + -ResourceGroupName $ResourceGroupName ` + -TaskAppName $TaskAppName ` + -ApiClientId $ApiClientId + +& "$PSScriptRoot/Assign-VmApiRoles.ps1" ` + -ResourceGroupName $ResourceGroupName ` + -ApiClientId $ApiClientId diff --git a/deploy/azure.yaml b/deploy/azure.yaml new file mode 100644 index 0000000..68e33cf --- /dev/null +++ b/deploy/azure.yaml @@ -0,0 +1,32 @@ +name: linuxbrokerforavdaccess +metadata: + template: linuxbrokerforavdaccess@0.0.1 +infra: + provider: bicep + path: ./bicep +workflows: + up: + steps: + - azd: provision +hooks: + preprovision: + windows: + shell: pwsh + run: ./Initialize-DeploymentEnvironment.ps1 + postprovision: + windows: + shell: pwsh + run: ./Post-Provision.ps1 +services: + frontend: + project: ../front_end + host: appservice + language: py + api: + project: ../api + host: appservice + language: py + task: + project: ../task + host: function + language: py diff --git a/deploy_infrastructure/Assign-AppRoleToFunctionApp.ps1 b/deploy/azurecli/Assign-AppRoleToFunctionApp.ps1 similarity index 99% rename from deploy_infrastructure/Assign-AppRoleToFunctionApp.ps1 rename to deploy/azurecli/Assign-AppRoleToFunctionApp.ps1 index 85f4bf6..d13041c 100644 --- a/deploy_infrastructure/Assign-AppRoleToFunctionApp.ps1 +++ b/deploy/azurecli/Assign-AppRoleToFunctionApp.ps1 @@ -29,4 +29,4 @@ Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $functionAppSp.Id | AppRoleAssigned = $appRole.DisplayName ResourceDisplayName = $appRoleAssignment.ResourceDisplayName } -} +} \ No newline at end of file diff --git a/deploy/bicep/main.bicep b/deploy/bicep/main.bicep new file mode 100644 index 0000000..96b67af --- /dev/null +++ b/deploy/bicep/main.bicep @@ -0,0 +1,198 @@ +targetScope = 'subscription' + +@description('Application name used for resource naming.') +param appName string + +@description('Deployment environment name.') +param environmentName string + +@description('Azure region for all resources.') +param location string = deployment().location + +@description('Tags applied to provisioned resources.') +param tags object = {} + +@description('Optional explicit resource group name. When empty, a name is generated.') +param resourceGroupName string = '' + +@description('Tenant ID used by the frontend and API applications.') +param tenantId string + +@description('Frontend Entra app client ID.') +param frontendClientId string + +@secure() +@description('Frontend Entra app client secret.') +param frontendClientSecret string + +@description('API Entra app client ID.') +param apiClientId string + +@secure() +@description('API Entra app client secret.') +param apiClientSecret string + +@description('Azure AD group ID used for AVD host access.') +param avdHostGroupId string = '' + +@description('Azure AD group ID used for Linux host access.') +param linuxHostGroupId string = '' + +@description('SQL administrator login.') +param sqlAdminLogin string = 'brokeradmin' + +@secure() +@description('SQL administrator password.') +param sqlAdminPassword string + +@secure() +@description('Flask session key for the frontend app.') +param flaskKey string + +@description('Optional custom domain used by Linux hosts.') +param domainName string = '' + +@description('Optional NFS share used by Linux hosts.') +param nfsShare string = '' + +@description('Admin login name used for Linux host provisioning.') +param linuxHostAdminLoginName string = 'avdadmin' + +@secure() +@description('Admin password used for Linux and AVD host provisioning.') +param hostAdminPassword string + +@description('Optional resource group that contains managed VMs. Defaults to the deployment resource group.') +param vmHostResourceGroup string = '' + +@description('Subscription ID that contains managed VMs. Defaults to the current subscription.') +param vmSubscriptionId string = subscription().subscriptionId + +@description('Optional IPv4 address allowed through the SQL firewall.') +param allowedClientIp string = '' + +@description('App Service plan SKU name.') +param appServicePlanSku string = 'P1v3' + +@description('Deploy Linux broker host VMs.') +param deployLinuxHosts bool = false + +@description('Deploy Azure Virtual Desktop session hosts.') +param deployAvdHosts bool = false + +@description('Linux host VM name prefix.') +param linuxHostVmNamePrefix string = 'lnxhost' + +@description('Linux host VM size.') +param linuxHostVmSize string = 'Standard_D2s_v5' + +@description('Number of Linux host VMs to deploy.') +param linuxHostCount int = 0 + +@allowed([ + 'Password' + 'SSH' +]) +@description('Authentication mode for Linux host VMs.') +param linuxHostAuthType string = 'Password' + +@description('SSH public key used when Linux host auth type is SSH.') +param linuxHostSshPublicKey string = '' + +@allowed([ + '7-LVM' + '8-LVM' + '9-LVM' + '24_04-lts' +]) +@description('Linux host OS image SKU.') +param linuxHostOsVersion string = '24_04-lts' + +@description('AVD host pool name.') +param avdHostPoolName string = '' + +@description('Number of AVD session hosts to deploy.') +param avdSessionHostCount int = 0 + +@description('Maximum number of sessions per AVD session host.') +param avdMaxSessionLimit int = 5 + +@description('AVD session host VM name prefix.') +param avdVmNamePrefix string = 'avdhost' + +@allowed([ + 'Standard_DS2_v2' + 'Standard_D8s_v5' + 'Standard_D8s_v4' + 'Standard_F8s_v2' + 'Standard_D8as_v4' + 'Standard_D16s_v5' + 'Standard_D16s_v4' + 'Standard_F16s_v2' + 'Standard_D16as_v4' +]) +@description('AVD session host VM size.') +param avdVmSize string = 'Standard_D8s_v5' + +@description('Resource group used for deployment.') +var effectiveResourceGroupName = empty(resourceGroupName) ? 'rg-${appName}-${environmentName}' : resourceGroupName + +resource rg 'Microsoft.Resources/resourceGroups@2024-03-01' = { + name: effectiveResourceGroupName + location: location + tags: tags +} + +module resources 'main.resources.bicep' = { + name: 'resources' + scope: rg + params: { + appName: appName + environmentName: environmentName + location: location + tags: tags + tenantId: tenantId + frontendClientId: frontendClientId + frontendClientSecret: frontendClientSecret + apiClientId: apiClientId + apiClientSecret: apiClientSecret + avdHostGroupId: avdHostGroupId + linuxHostGroupId: linuxHostGroupId + sqlAdminLogin: sqlAdminLogin + sqlAdminPassword: sqlAdminPassword + flaskKey: flaskKey + domainName: domainName + nfsShare: nfsShare + linuxHostAdminLoginName: linuxHostAdminLoginName + hostAdminPassword: hostAdminPassword + vmHostResourceGroup: vmHostResourceGroup + vmSubscriptionId: vmSubscriptionId + allowedClientIp: allowedClientIp + appServicePlanSku: appServicePlanSku + deployLinuxHosts: deployLinuxHosts + deployAvdHosts: deployAvdHosts + linuxHostVmNamePrefix: linuxHostVmNamePrefix + linuxHostVmSize: linuxHostVmSize + linuxHostCount: linuxHostCount + linuxHostAuthType: linuxHostAuthType + linuxHostSshPublicKey: linuxHostSshPublicKey + linuxHostOsVersion: linuxHostOsVersion + avdHostPoolName: avdHostPoolName + avdSessionHostCount: avdSessionHostCount + avdMaxSessionLimit: avdMaxSessionLimit + avdVmNamePrefix: avdVmNamePrefix + avdVmSize: avdVmSize + } +} + +output resourceGroupName string = rg.name +output frontendAppName string = resources.outputs.frontendAppName +output frontendUrl string = resources.outputs.frontendUrl +output apiAppName string = resources.outputs.apiAppName +output apiUrl string = resources.outputs.apiUrl +output taskAppName string = resources.outputs.taskAppName +output keyVaultName string = resources.outputs.keyVaultName +output containerRegistryName string = resources.outputs.containerRegistryName +output sqlServerName string = resources.outputs.sqlServerName +output sqlDatabaseName string = resources.outputs.sqlDatabaseName +output virtualNetworkName string = resources.outputs.virtualNetworkName diff --git a/deploy/bicep/main.json b/deploy/bicep/main.json new file mode 100644 index 0000000..9f94421 --- /dev/null +++ b/deploy/bicep/main.json @@ -0,0 +1,2963 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.25.53.49325", + "templateHash": "2150617746229150476" + } + }, + "parameters": { + "appName": { + "type": "string", + "metadata": { + "description": "Application name used for resource naming." + } + }, + "environmentName": { + "type": "string", + "metadata": { + "description": "Deployment environment name." + } + }, + "location": { + "type": "string", + "defaultValue": "[deployment().location]", + "metadata": { + "description": "Azure region for all resources." + } + }, + "tags": { + "type": "object", + "defaultValue": {}, + "metadata": { + "description": "Tags applied to provisioned resources." + } + }, + "resourceGroupName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional explicit resource group name. When empty, a name is generated." + } + }, + "tenantId": { + "type": "string", + "metadata": { + "description": "Tenant ID used by the frontend and API applications." + } + }, + "frontendClientId": { + "type": "string", + "metadata": { + "description": "Frontend Entra app client ID." + } + }, + "frontendClientSecret": { + "type": "securestring", + "metadata": { + "description": "Frontend Entra app client secret." + } + }, + "apiClientId": { + "type": "string", + "metadata": { + "description": "API Entra app client ID." + } + }, + "apiClientSecret": { + "type": "securestring", + "metadata": { + "description": "API Entra app client secret." + } + }, + "avdHostGroupId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Azure AD group ID used for AVD host access." + } + }, + "linuxHostGroupId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Azure AD group ID used for Linux host access." + } + }, + "sqlAdminLogin": { + "type": "string", + "defaultValue": "brokeradmin", + "metadata": { + "description": "SQL administrator login." + } + }, + "sqlAdminPassword": { + "type": "securestring", + "metadata": { + "description": "SQL administrator password." + } + }, + "flaskKey": { + "type": "securestring", + "metadata": { + "description": "Flask session key for the frontend app." + } + }, + "domainName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional custom domain used by Linux hosts." + } + }, + "nfsShare": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional NFS share used by Linux hosts." + } + }, + "linuxHostAdminLoginName": { + "type": "string", + "defaultValue": "avdadmin", + "metadata": { + "description": "Admin login name used for Linux host provisioning." + } + }, + "hostAdminPassword": { + "type": "securestring", + "metadata": { + "description": "Admin password used for Linux and AVD host provisioning." + } + }, + "vmHostResourceGroup": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional resource group that contains managed VMs. Defaults to the deployment resource group." + } + }, + "vmSubscriptionId": { + "type": "string", + "defaultValue": "[subscription().subscriptionId]", + "metadata": { + "description": "Subscription ID that contains managed VMs. Defaults to the current subscription." + } + }, + "allowedClientIp": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional IPv4 address allowed through the SQL firewall." + } + }, + "appServicePlanSku": { + "type": "string", + "defaultValue": "P1v3", + "metadata": { + "description": "App Service plan SKU name." + } + }, + "deployLinuxHosts": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Deploy Linux broker host VMs." + } + }, + "deployAvdHosts": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Deploy Azure Virtual Desktop session hosts." + } + }, + "linuxHostVmNamePrefix": { + "type": "string", + "defaultValue": "lnxhost", + "metadata": { + "description": "Linux host VM name prefix." + } + }, + "linuxHostVmSize": { + "type": "string", + "defaultValue": "Standard_D2s_v5", + "metadata": { + "description": "Linux host VM size." + } + }, + "linuxHostCount": { + "type": "int", + "defaultValue": 0, + "metadata": { + "description": "Number of Linux host VMs to deploy." + } + }, + "linuxHostAuthType": { + "type": "string", + "defaultValue": "Password", + "allowedValues": [ + "Password", + "SSH" + ], + "metadata": { + "description": "Authentication mode for Linux host VMs." + } + }, + "linuxHostSshPublicKey": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "SSH public key used when Linux host auth type is SSH." + } + }, + "linuxHostOsVersion": { + "type": "string", + "defaultValue": "24_04-lts", + "allowedValues": [ + "7-LVM", + "8-LVM", + "9-LVM", + "24_04-lts" + ], + "metadata": { + "description": "Linux host OS image SKU." + } + }, + "avdHostPoolName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "AVD host pool name." + } + }, + "avdSessionHostCount": { + "type": "int", + "defaultValue": 0, + "metadata": { + "description": "Number of AVD session hosts to deploy." + } + }, + "avdMaxSessionLimit": { + "type": "int", + "defaultValue": 5, + "metadata": { + "description": "Maximum number of sessions per AVD session host." + } + }, + "avdVmNamePrefix": { + "type": "string", + "defaultValue": "avdhost", + "metadata": { + "description": "AVD session host VM name prefix." + } + }, + "avdVmSize": { + "type": "string", + "defaultValue": "Standard_D8s_v5", + "allowedValues": [ + "Standard_DS2_v2", + "Standard_D8s_v5", + "Standard_D8s_v4", + "Standard_F8s_v2", + "Standard_D8as_v4", + "Standard_D16s_v5", + "Standard_D16s_v4", + "Standard_F16s_v2", + "Standard_D16as_v4" + ], + "metadata": { + "description": "AVD session host VM size." + } + } + }, + "variables": { + "effectiveResourceGroupName": "[if(empty(parameters('resourceGroupName')), format('rg-{0}-{1}', parameters('appName'), parameters('environmentName')), parameters('resourceGroupName'))]" + }, + "resources": [ + { + "type": "Microsoft.Resources/resourceGroups", + "apiVersion": "2024-03-01", + "name": "[variables('effectiveResourceGroupName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]" + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "resources", + "resourceGroup": "[variables('effectiveResourceGroupName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "appName": { + "value": "[parameters('appName')]" + }, + "environmentName": { + "value": "[parameters('environmentName')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "tenantId": { + "value": "[parameters('tenantId')]" + }, + "frontendClientId": { + "value": "[parameters('frontendClientId')]" + }, + "frontendClientSecret": { + "value": "[parameters('frontendClientSecret')]" + }, + "apiClientId": { + "value": "[parameters('apiClientId')]" + }, + "apiClientSecret": { + "value": "[parameters('apiClientSecret')]" + }, + "avdHostGroupId": { + "value": "[parameters('avdHostGroupId')]" + }, + "linuxHostGroupId": { + "value": "[parameters('linuxHostGroupId')]" + }, + "sqlAdminLogin": { + "value": "[parameters('sqlAdminLogin')]" + }, + "sqlAdminPassword": { + "value": "[parameters('sqlAdminPassword')]" + }, + "flaskKey": { + "value": "[parameters('flaskKey')]" + }, + "domainName": { + "value": "[parameters('domainName')]" + }, + "nfsShare": { + "value": "[parameters('nfsShare')]" + }, + "linuxHostAdminLoginName": { + "value": "[parameters('linuxHostAdminLoginName')]" + }, + "hostAdminPassword": { + "value": "[parameters('hostAdminPassword')]" + }, + "vmHostResourceGroup": { + "value": "[parameters('vmHostResourceGroup')]" + }, + "vmSubscriptionId": { + "value": "[parameters('vmSubscriptionId')]" + }, + "allowedClientIp": { + "value": "[parameters('allowedClientIp')]" + }, + "appServicePlanSku": { + "value": "[parameters('appServicePlanSku')]" + }, + "deployLinuxHosts": { + "value": "[parameters('deployLinuxHosts')]" + }, + "deployAvdHosts": { + "value": "[parameters('deployAvdHosts')]" + }, + "linuxHostVmNamePrefix": { + "value": "[parameters('linuxHostVmNamePrefix')]" + }, + "linuxHostVmSize": { + "value": "[parameters('linuxHostVmSize')]" + }, + "linuxHostCount": { + "value": "[parameters('linuxHostCount')]" + }, + "linuxHostAuthType": { + "value": "[parameters('linuxHostAuthType')]" + }, + "linuxHostSshPublicKey": { + "value": "[parameters('linuxHostSshPublicKey')]" + }, + "linuxHostOsVersion": { + "value": "[parameters('linuxHostOsVersion')]" + }, + "avdHostPoolName": { + "value": "[parameters('avdHostPoolName')]" + }, + "avdSessionHostCount": { + "value": "[parameters('avdSessionHostCount')]" + }, + "avdMaxSessionLimit": { + "value": "[parameters('avdMaxSessionLimit')]" + }, + "avdVmNamePrefix": { + "value": "[parameters('avdVmNamePrefix')]" + }, + "avdVmSize": { + "value": "[parameters('avdVmSize')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.25.53.49325", + "templateHash": "17774190860254507244" + } + }, + "parameters": { + "appName": { + "type": "string", + "metadata": { + "description": "Application name used for resource naming." + } + }, + "environmentName": { + "type": "string" + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "tenantId": { + "type": "string" + }, + "frontendClientId": { + "type": "string" + }, + "frontendClientSecret": { + "type": "securestring" + }, + "apiClientId": { + "type": "string" + }, + "apiClientSecret": { + "type": "securestring" + }, + "avdHostGroupId": { + "type": "string", + "defaultValue": "" + }, + "linuxHostGroupId": { + "type": "string", + "defaultValue": "" + }, + "sqlAdminLogin": { + "type": "string", + "defaultValue": "brokeradmin" + }, + "sqlAdminPassword": { + "type": "securestring" + }, + "flaskKey": { + "type": "securestring" + }, + "domainName": { + "type": "string", + "defaultValue": "" + }, + "nfsShare": { + "type": "string", + "defaultValue": "" + }, + "linuxHostAdminLoginName": { + "type": "string", + "defaultValue": "avdadmin" + }, + "hostAdminPassword": { + "type": "securestring" + }, + "vmHostResourceGroup": { + "type": "string", + "defaultValue": "" + }, + "vmSubscriptionId": { + "type": "string", + "defaultValue": "[subscription().subscriptionId]" + }, + "allowedClientIp": { + "type": "string", + "defaultValue": "" + }, + "appServicePlanSku": { + "type": "string", + "defaultValue": "P1v3" + }, + "deployLinuxHosts": { + "type": "bool", + "defaultValue": false + }, + "deployAvdHosts": { + "type": "bool", + "defaultValue": false + }, + "linuxHostVmNamePrefix": { + "type": "string", + "defaultValue": "lnxhost" + }, + "linuxHostVmSize": { + "type": "string", + "defaultValue": "Standard_D2s_v5" + }, + "linuxHostCount": { + "type": "int", + "defaultValue": 0 + }, + "linuxHostAuthType": { + "type": "string", + "defaultValue": "Password", + "allowedValues": [ + "Password", + "SSH" + ] + }, + "linuxHostSshPublicKey": { + "type": "string", + "defaultValue": "" + }, + "linuxHostOsVersion": { + "type": "string", + "defaultValue": "24_04-lts", + "allowedValues": [ + "7-LVM", + "8-LVM", + "9-LVM", + "24_04-lts" + ] + }, + "avdHostPoolName": { + "type": "string", + "defaultValue": "" + }, + "avdSessionHostCount": { + "type": "int", + "defaultValue": 0 + }, + "avdMaxSessionLimit": { + "type": "int", + "defaultValue": 5 + }, + "avdVmNamePrefix": { + "type": "string", + "defaultValue": "avdhost" + }, + "avdVmSize": { + "type": "string", + "defaultValue": "Standard_D8s_v5", + "allowedValues": [ + "Standard_DS2_v2", + "Standard_D8s_v5", + "Standard_D8s_v4", + "Standard_F8s_v2", + "Standard_D8as_v4", + "Standard_D16s_v5", + "Standard_D16s_v4", + "Standard_F16s_v2", + "Standard_D16as_v4" + ] + } + }, + "variables": { + "sanitizedApp": "[toLower(replace(parameters('appName'), '-', ''))]", + "sanitizedEnv": "[toLower(replace(parameters('environmentName'), '-', ''))]", + "sqlLocation": "[if(equals(parameters('location'), 'eastus2'), 'eastus', parameters('location'))]", + "suffix": "[toLower(uniqueString(subscription().subscriptionId, resourceGroup().id, parameters('appName'), parameters('environmentName')))]", + "sqlSuffix": "[toLower(uniqueString(subscription().subscriptionId, resourceGroup().id, parameters('appName'), parameters('environmentName'), variables('sqlLocation')))]", + "storageAccountName": "[take(format('{0}{1}{2}', variables('sanitizedApp'), variables('sanitizedEnv'), variables('suffix')), 24)]", + "keyVaultName": "[take(format('kv{0}{1}{2}', variables('sanitizedApp'), variables('sanitizedEnv'), variables('suffix')), 24)]", + "containerRegistryName": "[take(format('{0}{1}{2}', variables('sanitizedApp'), variables('sanitizedEnv'), variables('suffix')), 50)]", + "sqlServerName": "[take(format('sql-{0}-{1}-{2}', variables('sanitizedApp'), variables('sanitizedEnv'), variables('sqlSuffix')), 63)]", + "sqlDatabaseName": "LinuxBroker", + "logAnalyticsName": "[format('log-{0}-{1}', parameters('appName'), parameters('environmentName'))]", + "applicationInsightsName": "[format('appi-{0}-{1}', parameters('appName'), parameters('environmentName'))]", + "appServicePlanName": "[format('asp-{0}-{1}', parameters('appName'), parameters('environmentName'))]", + "frontendAppName": "[format('fe-{0}-{1}', parameters('appName'), parameters('environmentName'))]", + "apiAppName": "[format('api-{0}-{1}', parameters('appName'), parameters('environmentName'))]", + "taskAppName": "[format('task-{0}-{1}', parameters('appName'), parameters('environmentName'))]", + "vnetName": "[format('vnet-{0}-{1}', parameters('appName'), parameters('environmentName'))]", + "appSubnetName": "snet-appsvc", + "linuxSubnetName": "snet-linux-hosts", + "avdSubnetName": "snet-avd-hosts", + "privateEndpointSubnetName": "snet-private-endpoints", + "effectiveVmResourceGroup": "[if(empty(parameters('vmHostResourceGroup')), resourceGroup().name, parameters('vmHostResourceGroup'))]", + "keyVaultSecretsUserRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]", + "acrPullRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]", + "databasePasswordSecretName": "db-admin-password", + "apiSecretName": "api-client-secret", + "frontendApiBaseUrl": "[format('https://{0}.azurewebsites.net/api', variables('apiAppName'))]" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.ContainerRegistry/registries/{0}', variables('containerRegistryName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', variables('containerRegistryName')), variables('frontendAppName'), 'frontend-acr-pull')]", + "properties": { + "principalId": "[reference(resourceId('Microsoft.Resources/deployments', 'frontendApp'), '2022-09-01').outputs.principalId.value]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('acrPullRoleDefinitionId')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'frontendApp')]" + ] + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.ContainerRegistry/registries/{0}', variables('containerRegistryName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', variables('containerRegistryName')), variables('apiAppName'), 'api-acr-pull')]", + "properties": { + "principalId": "[reference(resourceId('Microsoft.Resources/deployments', 'apiApp'), '2022-09-01').outputs.principalId.value]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('acrPullRoleDefinitionId')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'apiApp')]" + ] + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.ContainerRegistry/registries/{0}', variables('containerRegistryName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', variables('containerRegistryName')), variables('taskAppName'), 'task-acr-pull')]", + "properties": { + "principalId": "[reference(resourceId('Microsoft.Resources/deployments', 'taskApp'), '2022-09-01').outputs.principalId.value]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('acrPullRoleDefinitionId')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'taskApp')]" + ] + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.KeyVault/vaults/{0}', variables('keyVaultName'))]", + "name": "[guid(resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName')), variables('apiAppName'), 'api-keyvault-secrets-user')]", + "properties": { + "principalId": "[reference(resourceId('Microsoft.Resources/deployments', 'apiApp'), '2022-09-01').outputs.principalId.value]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('keyVaultSecretsUserRoleDefinitionId')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'apiApp')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "networking", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "vnetName": { + "value": "[variables('vnetName')]" + }, + "appSubnetName": { + "value": "[variables('appSubnetName')]" + }, + "linuxSubnetName": { + "value": "[variables('linuxSubnetName')]" + }, + "avdSubnetName": { + "value": "[variables('avdSubnetName')]" + }, + "privateEndpointSubnetName": { + "value": "[variables('privateEndpointSubnetName')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.25.53.49325", + "templateHash": "8950926823151503542" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "vnetName": { + "type": "string" + }, + "appSubnetName": { + "type": "string", + "defaultValue": "snet-appsvc" + }, + "linuxSubnetName": { + "type": "string", + "defaultValue": "snet-linux-hosts" + }, + "avdSubnetName": { + "type": "string", + "defaultValue": "snet-avd-hosts" + }, + "privateEndpointSubnetName": { + "type": "string", + "defaultValue": "snet-private-endpoints" + } + }, + "resources": [ + { + "type": "Microsoft.Network/virtualNetworks", + "apiVersion": "2024-05-01", + "name": "[parameters('vnetName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "addressSpace": { + "addressPrefixes": [ + "10.40.0.0/16" + ] + } + } + }, + { + "type": "Microsoft.Network/virtualNetworks/subnets", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', parameters('vnetName'), parameters('appSubnetName'))]", + "properties": { + "addressPrefix": "10.40.1.0/24", + "delegations": [ + { + "name": "appservice", + "properties": { + "serviceName": "Microsoft.Web/serverFarms" + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + ] + }, + { + "type": "Microsoft.Network/virtualNetworks/subnets", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', parameters('vnetName'), parameters('linuxSubnetName'))]", + "properties": { + "addressPrefix": "10.40.2.0/24" + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('appSubnetName'))]", + "[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + ] + }, + { + "type": "Microsoft.Network/virtualNetworks/subnets", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', parameters('vnetName'), parameters('avdSubnetName'))]", + "properties": { + "addressPrefix": "10.40.3.0/24" + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('linuxSubnetName'))]", + "[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + ] + }, + { + "type": "Microsoft.Network/virtualNetworks/subnets", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', parameters('vnetName'), parameters('privateEndpointSubnetName'))]", + "properties": { + "addressPrefix": "10.40.4.0/24", + "privateEndpointNetworkPolicies": "Disabled" + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('avdSubnetName'))]", + "[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + ] + } + ], + "outputs": { + "vnetName": { + "type": "string", + "value": "[parameters('vnetName')]" + }, + "vnetId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + }, + "appSubnetName": { + "type": "string", + "value": "[parameters('appSubnetName')]" + }, + "appSubnetId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('appSubnetName'))]" + }, + "linuxSubnetName": { + "type": "string", + "value": "[parameters('linuxSubnetName')]" + }, + "linuxSubnetId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('linuxSubnetName'))]" + }, + "avdSubnetName": { + "type": "string", + "value": "[parameters('avdSubnetName')]" + }, + "avdSubnetId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('avdSubnetName'))]" + }, + "privateEndpointSubnetName": { + "type": "string", + "value": "[parameters('privateEndpointSubnetName')]" + }, + "privateEndpointSubnetId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('privateEndpointSubnetName'))]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "observability", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "logAnalyticsWorkspaceName": { + "value": "[variables('logAnalyticsName')]" + }, + "applicationInsightsName": { + "value": "[variables('applicationInsightsName')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.25.53.49325", + "templateHash": "5711525070604425972" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "logAnalyticsWorkspaceName": { + "type": "string" + }, + "applicationInsightsName": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces", + "apiVersion": "2023-09-01", + "name": "[parameters('logAnalyticsWorkspaceName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "sku": { + "name": "PerGB2018" + }, + "retentionInDays": 30, + "features": { + "enableLogAccessUsingOnlyResourcePermissions": true + } + } + }, + { + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[parameters('applicationInsightsName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "web", + "properties": { + "Application_Type": "web", + "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', parameters('logAnalyticsWorkspaceName'))]", + "IngestionMode": "LogAnalytics" + }, + "dependsOn": [ + "[resourceId('Microsoft.OperationalInsights/workspaces', parameters('logAnalyticsWorkspaceName'))]" + ] + } + ], + "outputs": { + "applicationInsightsConnectionString": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Insights/components', parameters('applicationInsightsName')), '2020-02-02').ConnectionString]" + }, + "applicationInsightsName": { + "type": "string", + "value": "[parameters('applicationInsightsName')]" + }, + "logAnalyticsWorkspaceId": { + "type": "string", + "value": "[resourceId('Microsoft.OperationalInsights/workspaces', parameters('logAnalyticsWorkspaceName'))]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "containerRegistry", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "containerRegistryName": { + "value": "[variables('containerRegistryName')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.25.53.49325", + "templateHash": "7030396897292414962" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "containerRegistryName": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.ContainerRegistry/registries", + "apiVersion": "2023-07-01", + "name": "[parameters('containerRegistryName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "Basic" + }, + "properties": { + "adminUserEnabled": false, + "publicNetworkAccess": "Enabled" + } + } + ], + "outputs": { + "id": { + "type": "string", + "value": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName'))]" + }, + "loginServer": { + "type": "string", + "value": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('containerRegistryName')), '2023-07-01').loginServer]" + }, + "name": { + "type": "string", + "value": "[parameters('containerRegistryName')]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "storageAccount", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "storageAccountName": { + "value": "[variables('storageAccountName')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.25.53.49325", + "templateHash": "15020683059166618150" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "storageAccountName": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2023-05-01", + "name": "[parameters('storageAccountName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "Standard_LRS" + }, + "kind": "StorageV2", + "properties": { + "accessTier": "Hot", + "allowBlobPublicAccess": false, + "minimumTlsVersion": "TLS1_2", + "publicNetworkAccess": "Enabled", + "supportsHttpsTrafficOnly": true + } + } + ], + "outputs": { + "id": { + "type": "string", + "value": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]" + }, + "name": { + "type": "string", + "value": "[parameters('storageAccountName')]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "keyVault", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "keyVaultName": { + "value": "[variables('keyVaultName')]" + }, + "sqlAdminPassword": { + "value": "[parameters('sqlAdminPassword')]" + }, + "frontendClientSecret": { + "value": "[parameters('frontendClientSecret')]" + }, + "apiClientSecret": { + "value": "[parameters('apiClientSecret')]" + }, + "hostAdminPassword": { + "value": "[parameters('hostAdminPassword')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.25.53.49325", + "templateHash": "13548772835488998202" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "keyVaultName": { + "type": "string" + }, + "sqlAdminPassword": { + "type": "securestring" + }, + "frontendClientSecret": { + "type": "securestring" + }, + "apiClientSecret": { + "type": "securestring" + }, + "hostAdminPassword": { + "type": "securestring" + } + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults", + "apiVersion": "2023-07-01", + "name": "[parameters('keyVaultName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "tenantId": "[subscription().tenantId]", + "enableRbacAuthorization": true, + "enabledForDeployment": false, + "enabledForDiskEncryption": false, + "enabledForTemplateDeployment": false, + "publicNetworkAccess": "Enabled", + "sku": { + "family": "A", + "name": "standard" + }, + "softDeleteRetentionInDays": 90 + } + }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2023-07-01", + "name": "[format('{0}/{1}', parameters('keyVaultName'), 'db-admin-password')]", + "properties": { + "value": "[parameters('sqlAdminPassword')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" + ] + }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2023-07-01", + "name": "[format('{0}/{1}', parameters('keyVaultName'), 'frontend-client-secret')]", + "properties": { + "value": "[parameters('frontendClientSecret')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" + ] + }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2023-07-01", + "name": "[format('{0}/{1}', parameters('keyVaultName'), 'api-client-secret')]", + "properties": { + "value": "[parameters('apiClientSecret')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" + ] + }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2023-07-01", + "name": "[format('{0}/{1}', parameters('keyVaultName'), 'host-admin-password')]", + "properties": { + "value": "[parameters('hostAdminPassword')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" + ] + } + ], + "outputs": { + "name": { + "type": "string", + "value": "[parameters('keyVaultName')]" + }, + "id": { + "type": "string", + "value": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" + }, + "vaultUri": { + "type": "string", + "value": "[reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri]" + }, + "frontendAuthKeyUri": { + "type": "string", + "value": "[reference(resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'frontend-client-secret'), '2023-07-01').secretUriWithVersion]" + }, + "apiAuthKeyUri": { + "type": "string", + "value": "[reference(resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'api-client-secret'), '2023-07-01').secretUriWithVersion]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "sql", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[variables('sqlLocation')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "sqlServerName": { + "value": "[variables('sqlServerName')]" + }, + "databaseName": { + "value": "[variables('sqlDatabaseName')]" + }, + "administratorLogin": { + "value": "[parameters('sqlAdminLogin')]" + }, + "administratorPassword": { + "value": "[parameters('sqlAdminPassword')]" + }, + "allowedClientIp": { + "value": "[parameters('allowedClientIp')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.25.53.49325", + "templateHash": "4013855852680854809" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "sqlServerName": { + "type": "string" + }, + "databaseName": { + "type": "string" + }, + "administratorLogin": { + "type": "string" + }, + "administratorPassword": { + "type": "securestring" + }, + "allowedClientIp": { + "type": "string", + "defaultValue": "" + } + }, + "resources": [ + { + "type": "Microsoft.Sql/servers", + "apiVersion": "2023-08-01-preview", + "name": "[parameters('sqlServerName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "administratorLogin": "[parameters('administratorLogin')]", + "administratorLoginPassword": "[parameters('administratorPassword')]", + "minimalTlsVersion": "1.2", + "publicNetworkAccess": "Enabled" + } + }, + { + "type": "Microsoft.Sql/servers/databases", + "apiVersion": "2023-08-01-preview", + "name": "[format('{0}/{1}', parameters('sqlServerName'), parameters('databaseName'))]", + "location": "[parameters('location')]", + "sku": { + "name": "Basic", + "tier": "Basic" + }, + "properties": { + "collation": "SQL_Latin1_General_CP1_CI_AS" + }, + "dependsOn": [ + "[resourceId('Microsoft.Sql/servers', parameters('sqlServerName'))]" + ] + }, + { + "type": "Microsoft.Sql/servers/firewallRules", + "apiVersion": "2023-08-01-preview", + "name": "[format('{0}/{1}', parameters('sqlServerName'), 'AllowAzureServices')]", + "properties": { + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + }, + "dependsOn": [ + "[resourceId('Microsoft.Sql/servers', parameters('sqlServerName'))]" + ] + }, + { + "condition": "[not(empty(parameters('allowedClientIp')))]", + "type": "Microsoft.Sql/servers/firewallRules", + "apiVersion": "2023-08-01-preview", + "name": "[format('{0}/{1}', parameters('sqlServerName'), 'AllowClientIp')]", + "properties": { + "startIpAddress": "[parameters('allowedClientIp')]", + "endIpAddress": "[parameters('allowedClientIp')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Sql/servers', parameters('sqlServerName'))]" + ] + } + ], + "outputs": { + "sqlServerName": { + "type": "string", + "value": "[parameters('sqlServerName')]" + }, + "sqlServerFullyQualifiedDomainName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Sql/servers', parameters('sqlServerName')), '2023-08-01-preview').fullyQualifiedDomainName]" + }, + "databaseName": { + "type": "string", + "value": "[parameters('databaseName')]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "appServicePlan", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "appServicePlanName": { + "value": "[variables('appServicePlanName')]" + }, + "skuName": { + "value": "[parameters('appServicePlanSku')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.25.53.49325", + "templateHash": "3371409296139664791" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "appServicePlanName": { + "type": "string" + }, + "skuName": { + "type": "string", + "defaultValue": "P1v3" + } + }, + "resources": [ + { + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2023-12-01", + "name": "[parameters('appServicePlanName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "[parameters('skuName')]", + "tier": "PremiumV3", + "size": "[parameters('skuName')]", + "capacity": 1 + }, + "kind": "linux", + "properties": { + "reserved": true + } + } + ], + "outputs": { + "id": { + "type": "string", + "value": "[resourceId('Microsoft.Web/serverfarms', parameters('appServicePlanName'))]" + }, + "name": { + "type": "string", + "value": "[parameters('appServicePlanName')]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "frontendApp", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[union(parameters('tags'), createObject('azd-service-name', 'frontend'))]" + }, + "appName": { + "value": "[variables('frontendAppName')]" + }, + "serverFarmId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appServicePlan'), '2022-09-01').outputs.id.value]" + }, + "containerImageName": { + "value": "[format('{0}/frontend:latest', reference(resourceId('Microsoft.Resources/deployments', 'containerRegistry'), '2022-09-01').outputs.loginServer.value)]" + }, + "containerRegistryLoginServer": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'containerRegistry'), '2022-09-01').outputs.loginServer.value]" + }, + "applicationInsightsConnectionString": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'observability'), '2022-09-01').outputs.applicationInsightsConnectionString.value]" + }, + "appSettings": { + "value": { + "API_CLIENT_ID": "[parameters('apiClientId')]", + "API_URL": "[variables('frontendApiBaseUrl')]", + "CLIENT_ID": "[parameters('frontendClientId')]", + "FLASK_KEY": "[parameters('flaskKey')]", + "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET": "[format('@Microsoft.KeyVault(SecretUri={0})', reference(resourceId('Microsoft.Resources/deployments', 'keyVault'), '2022-09-01').outputs.frontendAuthKeyUri.value)]", + "SCM_DO_BUILD_DURING_DEPLOYMENT": "false", + "TENANT_ID": "[parameters('tenantId')]", + "WEBSITE_AUTH_AAD_ALLOWED_TENANTS": "[parameters('tenantId')]" + } + }, + "alwaysOn": { + "value": true + }, + "useManagedIdentityForRegistry": { + "value": true + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.25.53.49325", + "templateHash": "18437243938497326171" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "appName": { + "type": "string" + }, + "serverFarmId": { + "type": "string" + }, + "containerImageName": { + "type": "string" + }, + "containerRegistryLoginServer": { + "type": "string" + }, + "applicationInsightsConnectionString": { + "type": "string" + }, + "appSettings": { + "type": "object", + "defaultValue": {} + }, + "alwaysOn": { + "type": "bool", + "defaultValue": true + }, + "useManagedIdentityForRegistry": { + "type": "bool", + "defaultValue": true + } + }, + "resources": [ + { + "type": "Microsoft.Web/sites", + "apiVersion": "2023-12-01", + "name": "[parameters('appName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "app,linux,container", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "serverFarmId": "[parameters('serverFarmId')]", + "httpsOnly": true, + "siteConfig": { + "alwaysOn": "[parameters('alwaysOn')]", + "acrUseManagedIdentityCreds": "[parameters('useManagedIdentityForRegistry')]", + "linuxFxVersion": "[format('DOCKER|{0}', parameters('containerImageName'))]", + "minTlsVersion": "1.2" + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2023-12-01", + "name": "[format('{0}/{1}', parameters('appName'), 'appsettings')]", + "properties": "[union(createObject('APPLICATIONINSIGHTS_CONNECTION_STRING', parameters('applicationInsightsConnectionString'), 'DOCKER_REGISTRY_SERVER_URL', format('https://{0}', parameters('containerRegistryLoginServer')), 'WEBSITES_ENABLE_APP_SERVICE_STORAGE', 'false'), parameters('appSettings'))]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', parameters('appName'))]" + ] + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2023-12-01", + "name": "[format('{0}/{1}', parameters('appName'), 'logs')]", + "properties": { + "applicationLogs": { + "fileSystem": { + "level": "Information" + } + }, + "detailedErrorMessages": { + "enabled": true + }, + "failedRequestsTracing": { + "enabled": true + }, + "httpLogs": { + "fileSystem": { + "enabled": true, + "retentionInDays": 7, + "retentionInMb": 35 + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', parameters('appName'))]" + ] + } + ], + "outputs": { + "id": { + "type": "string", + "value": "[resourceId('Microsoft.Web/sites', parameters('appName'))]" + }, + "name": { + "type": "string", + "value": "[parameters('appName')]" + }, + "principalId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Web/sites', parameters('appName')), '2023-12-01', 'full').identity.principalId]" + }, + "defaultHostName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Web/sites', parameters('appName')), '2023-12-01').defaultHostName]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'appServicePlan')]", + "[resourceId('Microsoft.Resources/deployments', 'containerRegistry')]", + "[resourceId('Microsoft.Resources/deployments', 'keyVault')]", + "[resourceId('Microsoft.Resources/deployments', 'observability')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "apiApp", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[union(parameters('tags'), createObject('azd-service-name', 'api'))]" + }, + "appName": { + "value": "[variables('apiAppName')]" + }, + "serverFarmId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appServicePlan'), '2022-09-01').outputs.id.value]" + }, + "containerImageName": { + "value": "[format('{0}/api:latest', reference(resourceId('Microsoft.Resources/deployments', 'containerRegistry'), '2022-09-01').outputs.loginServer.value)]" + }, + "containerRegistryLoginServer": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'containerRegistry'), '2022-09-01').outputs.loginServer.value]" + }, + "applicationInsightsConnectionString": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'observability'), '2022-09-01').outputs.applicationInsightsConnectionString.value]" + }, + "appSettings": { + "value": { + "AVD_HOST_GROUP_ID": "[parameters('avdHostGroupId')]", + "CLIENT_ID": "[parameters('apiClientId')]", + "DB_DATABASE": "[reference(resourceId('Microsoft.Resources/deployments', 'sql'), '2022-09-01').outputs.databaseName.value]", + "DB_PASSWORD_NAME": "[variables('databasePasswordSecretName')]", + "DB_SERVER": "[reference(resourceId('Microsoft.Resources/deployments', 'sql'), '2022-09-01').outputs.sqlServerFullyQualifiedDomainName.value]", + "DB_USERNAME": "[parameters('sqlAdminLogin')]", + "DOMAIN_NAME": "[parameters('domainName')]", + "GRAPH_API_ENDPOINT": "https://graph.microsoft.com/.default", + "KEY_NAME": "[variables('apiSecretName')]", + "LINUX_HOST_ADMIN_LOGIN_NAME": "[parameters('linuxHostAdminLoginName')]", + "LINUX_HOST_GROUP_ID": "[parameters('linuxHostGroupId')]", + "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET": "[format('@Microsoft.KeyVault(SecretUri={0})', reference(resourceId('Microsoft.Resources/deployments', 'keyVault'), '2022-09-01').outputs.apiAuthKeyUri.value)]", + "NFS_SHARE": "[parameters('nfsShare')]", + "SCM_DO_BUILD_DURING_DEPLOYMENT": "false", + "TENANT_ID": "[parameters('tenantId')]", + "VAULT_URL": "[reference(resourceId('Microsoft.Resources/deployments', 'keyVault'), '2022-09-01').outputs.vaultUri.value]", + "VM_RESOURCE_GROUP": "[variables('effectiveVmResourceGroup')]", + "VM_SUBSCRIPTION_ID": "[parameters('vmSubscriptionId')]" + } + }, + "alwaysOn": { + "value": true + }, + "useManagedIdentityForRegistry": { + "value": true + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.25.53.49325", + "templateHash": "18437243938497326171" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "appName": { + "type": "string" + }, + "serverFarmId": { + "type": "string" + }, + "containerImageName": { + "type": "string" + }, + "containerRegistryLoginServer": { + "type": "string" + }, + "applicationInsightsConnectionString": { + "type": "string" + }, + "appSettings": { + "type": "object", + "defaultValue": {} + }, + "alwaysOn": { + "type": "bool", + "defaultValue": true + }, + "useManagedIdentityForRegistry": { + "type": "bool", + "defaultValue": true + } + }, + "resources": [ + { + "type": "Microsoft.Web/sites", + "apiVersion": "2023-12-01", + "name": "[parameters('appName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "app,linux,container", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "serverFarmId": "[parameters('serverFarmId')]", + "httpsOnly": true, + "siteConfig": { + "alwaysOn": "[parameters('alwaysOn')]", + "acrUseManagedIdentityCreds": "[parameters('useManagedIdentityForRegistry')]", + "linuxFxVersion": "[format('DOCKER|{0}', parameters('containerImageName'))]", + "minTlsVersion": "1.2" + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2023-12-01", + "name": "[format('{0}/{1}', parameters('appName'), 'appsettings')]", + "properties": "[union(createObject('APPLICATIONINSIGHTS_CONNECTION_STRING', parameters('applicationInsightsConnectionString'), 'DOCKER_REGISTRY_SERVER_URL', format('https://{0}', parameters('containerRegistryLoginServer')), 'WEBSITES_ENABLE_APP_SERVICE_STORAGE', 'false'), parameters('appSettings'))]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', parameters('appName'))]" + ] + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2023-12-01", + "name": "[format('{0}/{1}', parameters('appName'), 'logs')]", + "properties": { + "applicationLogs": { + "fileSystem": { + "level": "Information" + } + }, + "detailedErrorMessages": { + "enabled": true + }, + "failedRequestsTracing": { + "enabled": true + }, + "httpLogs": { + "fileSystem": { + "enabled": true, + "retentionInDays": 7, + "retentionInMb": 35 + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', parameters('appName'))]" + ] + } + ], + "outputs": { + "id": { + "type": "string", + "value": "[resourceId('Microsoft.Web/sites', parameters('appName'))]" + }, + "name": { + "type": "string", + "value": "[parameters('appName')]" + }, + "principalId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Web/sites', parameters('appName')), '2023-12-01', 'full').identity.principalId]" + }, + "defaultHostName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Web/sites', parameters('appName')), '2023-12-01').defaultHostName]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'appServicePlan')]", + "[resourceId('Microsoft.Resources/deployments', 'containerRegistry')]", + "[resourceId('Microsoft.Resources/deployments', 'keyVault')]", + "[resourceId('Microsoft.Resources/deployments', 'observability')]", + "[resourceId('Microsoft.Resources/deployments', 'sql')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "taskApp", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[union(parameters('tags'), createObject('azd-service-name', 'task'))]" + }, + "appName": { + "value": "[variables('taskAppName')]" + }, + "serverFarmId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appServicePlan'), '2022-09-01').outputs.id.value]" + }, + "containerImageName": { + "value": "[format('{0}/task:latest', reference(resourceId('Microsoft.Resources/deployments', 'containerRegistry'), '2022-09-01').outputs.loginServer.value)]" + }, + "containerRegistryLoginServer": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'containerRegistry'), '2022-09-01').outputs.loginServer.value]" + }, + "applicationInsightsConnectionString": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'observability'), '2022-09-01').outputs.applicationInsightsConnectionString.value]" + }, + "storageConnectionString": { + "value": "[createObject('API_CLIENT_ID', parameters('apiClientId'), 'API_URL', variables('frontendApiBaseUrl'), 'AzureWebJobsStorage', format('DefaultEndpointsProtocol=https;AccountName={0};AccountKey={1};EndpointSuffix={2}', reference(resourceId('Microsoft.Resources/deployments', 'storageAccount'), '2022-09-01').outputs.name.value, listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), '2023-05-01').keys[0].value, environment().suffixes.storage), 'FUNCTIONS_EXTENSION_VERSION', '~4', 'FUNCTIONS_WORKER_RUNTIME', 'python', 'SCM_DO_BUILD_DURING_DEPLOYMENT', 'false', 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING', format('DefaultEndpointsProtocol=https;AccountName={0};AccountKey={1};EndpointSuffix={2}', reference(resourceId('Microsoft.Resources/deployments', 'storageAccount'), '2022-09-01').outputs.name.value, listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), '2023-05-01').keys[0].value, environment().suffixes.storage), 'WEBSITE_CONTENTSHARE', toLower(take(format('{0}content', variables('taskAppName')), 63))).AzureWebJobsStorage]" + }, + "appSettings": { + "value": { + "API_CLIENT_ID": "[parameters('apiClientId')]", + "API_URL": "[variables('frontendApiBaseUrl')]", + "AzureWebJobsStorage": "[format('DefaultEndpointsProtocol=https;AccountName={0};AccountKey={1};EndpointSuffix={2}', reference(resourceId('Microsoft.Resources/deployments', 'storageAccount'), '2022-09-01').outputs.name.value, listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), '2023-05-01').keys[0].value, environment().suffixes.storage)]", + "FUNCTIONS_EXTENSION_VERSION": "~4", + "FUNCTIONS_WORKER_RUNTIME": "python", + "SCM_DO_BUILD_DURING_DEPLOYMENT": "false", + "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING": "[format('DefaultEndpointsProtocol=https;AccountName={0};AccountKey={1};EndpointSuffix={2}', reference(resourceId('Microsoft.Resources/deployments', 'storageAccount'), '2022-09-01').outputs.name.value, listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), '2023-05-01').keys[0].value, environment().suffixes.storage)]", + "WEBSITE_CONTENTSHARE": "[toLower(take(format('{0}content', variables('taskAppName')), 63))]" + } + }, + "useManagedIdentityForRegistry": { + "value": true + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.25.53.49325", + "templateHash": "18147226453473071152" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "appName": { + "type": "string" + }, + "serverFarmId": { + "type": "string" + }, + "containerImageName": { + "type": "string" + }, + "containerRegistryLoginServer": { + "type": "string" + }, + "applicationInsightsConnectionString": { + "type": "string" + }, + "storageConnectionString": { + "type": "securestring" + }, + "appSettings": { + "type": "object", + "defaultValue": {} + }, + "useManagedIdentityForRegistry": { + "type": "bool", + "defaultValue": true + } + }, + "resources": [ + { + "type": "Microsoft.Web/sites", + "apiVersion": "2023-12-01", + "name": "[parameters('appName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "functionapp,linux,container", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "serverFarmId": "[parameters('serverFarmId')]", + "httpsOnly": true, + "siteConfig": { + "acrUseManagedIdentityCreds": "[parameters('useManagedIdentityForRegistry')]", + "alwaysOn": true, + "appCommandLine": "", + "ftpsState": "FtpsOnly", + "linuxFxVersion": "[format('DOCKER|{0}', parameters('containerImageName'))]", + "minTlsVersion": "1.2" + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2023-12-01", + "name": "[format('{0}/{1}', parameters('appName'), 'appsettings')]", + "properties": "[union(createObject('APPLICATIONINSIGHTS_CONNECTION_STRING', parameters('applicationInsightsConnectionString'), 'AzureWebJobsStorage', parameters('storageConnectionString'), 'DOCKER_REGISTRY_SERVER_URL', format('https://{0}', parameters('containerRegistryLoginServer')), 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING', parameters('storageConnectionString'), 'WEBSITE_CONTENTSHARE', toLower(take(format('{0}content', parameters('appName')), 63)), 'WEBSITES_ENABLE_APP_SERVICE_STORAGE', 'false'), parameters('appSettings'))]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', parameters('appName'))]" + ] + } + ], + "outputs": { + "id": { + "type": "string", + "value": "[resourceId('Microsoft.Web/sites', parameters('appName'))]" + }, + "name": { + "type": "string", + "value": "[parameters('appName')]" + }, + "principalId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Web/sites', parameters('appName')), '2023-12-01', 'full').identity.principalId]" + }, + "defaultHostName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Web/sites', parameters('appName')), '2023-12-01').defaultHostName]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'appServicePlan')]", + "[resourceId('Microsoft.Resources/deployments', 'containerRegistry')]", + "[resourceId('Microsoft.Resources/deployments', 'observability')]", + "[resourceId('Microsoft.Resources/deployments', 'storageAccount')]" + ] + }, + { + "condition": "[and(parameters('deployLinuxHosts'), greater(parameters('linuxHostCount'), 0))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "linuxHosts", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[union(parameters('tags'), createObject('broker-role', 'linux-host'))]" + }, + "vnetName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'networking'), '2022-09-01').outputs.vnetName.value]" + }, + "subnetName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'networking'), '2022-09-01').outputs.linuxSubnetName.value]" + }, + "vnetResourceGroup": { + "value": "[resourceGroup().name]" + }, + "vmNamePrefix": { + "value": "[parameters('linuxHostVmNamePrefix')]" + }, + "vmSize": { + "value": "[parameters('linuxHostVmSize')]" + }, + "numberOfVMs": { + "value": "[parameters('linuxHostCount')]" + }, + "authType": { + "value": "[parameters('linuxHostAuthType')]" + }, + "adminUsername": { + "value": "[parameters('linuxHostAdminLoginName')]" + }, + "adminPassword": { + "value": "[parameters('hostAdminPassword')]" + }, + "sshPublicKey": { + "value": "[parameters('linuxHostSshPublicKey')]" + }, + "OSVersion": { + "value": "[parameters('linuxHostOsVersion')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.25.53.49325", + "templateHash": "152351611456708088" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "vnetName": { + "type": "string" + }, + "subnetName": { + "type": "string" + }, + "vnetResourceGroup": { + "type": "string" + }, + "vmNamePrefix": { + "type": "string" + }, + "vmSize": { + "type": "string" + }, + "numberOfVMs": { + "type": "int", + "minValue": 1, + "maxValue": 20 + }, + "authType": { + "type": "string", + "allowedValues": [ + "Password", + "SSH" + ] + }, + "adminUsername": { + "type": "string" + }, + "adminPassword": { + "type": "securestring" + }, + "sshPublicKey": { + "type": "string", + "defaultValue": "" + }, + "OSVersion": { + "type": "string", + "allowedValues": [ + "7-LVM", + "8-LVM", + "9-LVM", + "24_04-lts" + ] + } + }, + "variables": { + "copy": [ + { + "name": "vmNames", + "count": "[length(range(1, parameters('numberOfVMs')))]", + "input": "[format('{0}-{1}', parameters('vmNamePrefix'), padLeft(range(1, parameters('numberOfVMs'))[copyIndex('vmNames')], 2, '0'))]" + } + ], + "adminPass": "[if(equals(parameters('authType'), 'Password'), parameters('adminPassword'), parameters('sshPublicKey'))]", + "imageConfigs": { + "7-LVM": { + "image": { + "publisher": "RedHat", + "offer": "RHEL", + "sku": "7-LVM", + "version": "latest" + }, + "script": { + "uri": "https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main/custom_script_extensions/Configure-RHEL7-Host.sh", + "cmd": "bash Configure-RHEL7-Host.sh" + } + }, + "8-LVM": { + "image": { + "publisher": "RedHat", + "offer": "RHEL", + "sku": "8-LVM", + "version": "latest" + }, + "script": { + "uri": "https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main/custom_script_extensions/Configure-RHEL8-Host.sh", + "cmd": "bash Configure-RHEL8-Host.sh" + } + }, + "9-LVM": { + "image": { + "publisher": "RedHat", + "offer": "RHEL", + "sku": "9-LVM", + "version": "latest" + }, + "script": { + "uri": "https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main/custom_script_extensions/Configure-RHEL9-Host.sh", + "cmd": "bash Configure-RHEL9-Host.sh" + } + }, + "24_04-lts": { + "image": { + "publisher": "canonical", + "offer": "ubuntu-24_04-lts", + "sku": "server", + "version": "latest" + }, + "script": { + "uri": "https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh", + "cmd": "bash Configure-Ubuntu24_desktop-Host.sh" + } + } + }, + "selectedConfig": "[variables('imageConfigs')[parameters('OSVersion')]]" + }, + "resources": [ + { + "copy": { + "name": "nic", + "count": "[length(variables('vmNames'))]" + }, + "type": "Microsoft.Network/networkInterfaces", + "apiVersion": "2024-05-01", + "name": "[format('{0}-nic', variables('vmNames')[copyIndex()])]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "ipConfigurations": [ + { + "name": "ipconfig1", + "properties": { + "subnet": { + "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroup')), 'Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('subnetName'))]" + }, + "privateIPAllocationMethod": "Dynamic" + } + } + ] + } + }, + { + "copy": { + "name": "vmLinuxHost", + "count": "[length(variables('vmNames'))]" + }, + "type": "Microsoft.Compute/virtualMachines", + "apiVersion": "2022-03-01", + "name": "[variables('vmNames')[copyIndex()]]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "hardwareProfile": { + "vmSize": "[parameters('vmSize')]" + }, + "osProfile": { + "computerName": "[variables('vmNames')[copyIndex()]]", + "adminUsername": "[parameters('adminUsername')]", + "adminPassword": "[variables('adminPass')]", + "linuxConfiguration": "[if(equals(parameters('authType'), 'SSH'), createObject('disablePasswordAuthentication', true(), 'ssh', createObject('publicKeys', createArray(createObject('path', format('/home/{0}/.ssh/authorized_keys', parameters('adminUsername')), 'keyData', parameters('sshPublicKey'))))), createObject('disablePasswordAuthentication', false()))]" + }, + "networkProfile": { + "networkInterfaces": [ + { + "id": "[resourceId('Microsoft.Network/networkInterfaces', format('{0}-nic', variables('vmNames')[copyIndex()]))]" + } + ] + }, + "storageProfile": { + "imageReference": "[variables('selectedConfig').image]", + "osDisk": { + "createOption": "FromImage" + } + }, + "securityProfile": { + "securityType": "TrustedLaunch", + "uefiSettings": { + "secureBootEnabled": true, + "vTpmEnabled": true + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/networkInterfaces', format('{0}-nic', variables('vmNames')[copyIndex()]))]", + "[resourceId('Microsoft.Network/networkInterfaces', format('{0}-nic', variables('vmNames')[copyIndex()]))]" + ] + }, + { + "copy": { + "name": "linuxCustomScriptExtension", + "count": "[length(variables('vmNames'))]" + }, + "type": "Microsoft.Compute/virtualMachines/extensions", + "apiVersion": "2022-03-01", + "name": "[format('{0}/customScript', variables('vmNames')[copyIndex()])]", + "location": "[parameters('location')]", + "properties": { + "publisher": "Microsoft.Azure.Extensions", + "type": "CustomScript", + "typeHandlerVersion": "2.1", + "autoUpgradeMinorVersion": true, + "settings": { + "fileUris": [ + "[variables('selectedConfig').script.uri]" + ] + }, + "protectedSettings": { + "commandToExecute": "[variables('selectedConfig').script.cmd]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Compute/virtualMachines', variables('vmNames')[copyIndex()])]" + ] + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'networking')]" + ] + }, + { + "condition": "[and(and(parameters('deployAvdHosts'), greater(parameters('avdSessionHostCount'), 0)), not(empty(parameters('avdHostPoolName'))))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "avdHosts", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[union(parameters('tags'), createObject('broker-role', 'avd-host'))]" + }, + "vnetName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'networking'), '2022-09-01').outputs.vnetName.value]" + }, + "subnetName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'networking'), '2022-09-01').outputs.avdSubnetName.value]" + }, + "vnetResourceGroup": { + "value": "[resourceGroup().name]" + }, + "hostPoolName": { + "value": "[parameters('avdHostPoolName')]" + }, + "sessionHostCount": { + "value": "[parameters('avdSessionHostCount')]" + }, + "maxSessionLimit": { + "value": "[parameters('avdMaxSessionLimit')]" + }, + "vmNamePrefix": { + "value": "[parameters('avdVmNamePrefix')]" + }, + "vmSize": { + "value": "[parameters('avdVmSize')]" + }, + "adminUsername": { + "value": "[parameters('linuxHostAdminLoginName')]" + }, + "adminPassword": { + "value": "[parameters('hostAdminPassword')]" + }, + "linuxBrokerApiBaseUrl": { + "value": "[variables('frontendApiBaseUrl')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.25.53.49325", + "templateHash": "12345906028026815088" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "vnetName": { + "type": "string" + }, + "subnetName": { + "type": "string" + }, + "vnetResourceGroup": { + "type": "string" + }, + "hostPoolName": { + "type": "string" + }, + "friendlyName": { + "type": "string", + "defaultValue": "[parameters('hostPoolName')]" + }, + "loadBalancerType": { + "type": "string", + "defaultValue": "BreadthFirst" + }, + "preferredAppGroupType": { + "type": "string", + "defaultValue": "Desktop" + }, + "sessionHostCount": { + "type": "int" + }, + "maxSessionLimit": { + "type": "int" + }, + "tokenValidityLength": { + "type": "string", + "defaultValue": "PT8H", + "metadata": { + "description": "Token validity duration in ISO 8601 format" + } + }, + "baseTime": { + "type": "string", + "defaultValue": "[utcNow('u')]", + "metadata": { + "description": "Generated. Do not provide a value! This date value is used to generate a registration token." + } + }, + "agentUpdate": { + "type": "object", + "defaultValue": { + "type": "Scheduled", + "useSessionHostLocalTime": true, + "maintenanceWindowTimeZone": "UTC", + "maintenanceWindows": [ + { + "dayOfWeek": "Saturday", + "hour": 2, + "duration": "02:00" + } + ] + }, + "metadata": { + "description": "Agent update configuration" + } + }, + "vmNamePrefix": { + "type": "string", + "maxLength": 10 + }, + "vmSize": { + "type": "string", + "allowedValues": [ + "Standard_DS2_v2", + "Standard_D8s_v5", + "Standard_D8s_v4", + "Standard_F8s_v2", + "Standard_D8as_v4", + "Standard_D16s_v5", + "Standard_D16s_v4", + "Standard_F16s_v2", + "Standard_D16as_v4" + ], + "metadata": { + "description": "The size of the session host VMs" + } + }, + "adminUsername": { + "type": "string" + }, + "adminPassword": { + "type": "securestring" + }, + "linuxBrokerApiBaseUrl": { + "type": "string", + "metadata": { + "description": "Base URL for the AVD Linux Broker API" + } + }, + "linuxBrokerConfigScriptUri": { + "type": "string", + "defaultValue": "https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main/custom_script_extensions/Configure-AVD-Host.ps1", + "metadata": { + "description": "URI for the AVD Linux Broker configuration script" + } + } + }, + "variables": { + "copy": [ + { + "name": "vmNames", + "count": "[length(range(1, parameters('sessionHostCount')))]", + "input": "[format('{0}-{1}', parameters('vmNamePrefix'), padLeft(range(1, parameters('sessionHostCount'))[copyIndex('vmNames')], 2, '0'))]" + } + ], + "osImage": "microsoftwindowsdesktop:Windows-11:win11-24h2-avd:latest", + "storageAccountName": "wvdportalstorageblob", + "containerName": "galleryartifacts", + "blobName01": "Configuration_1.0.02990.697.zip", + "AVDartifactsLocation": "[format('https://{0}.blob.{1}/{2}/{3}', variables('storageAccountName'), environment().suffixes.storage, variables('containerName'), variables('blobName01'))]", + "intune": false, + "aadJoin": true, + "aadJoinPreview": false + }, + "resources": [ + { + "type": "Microsoft.DesktopVirtualization/hostPools", + "apiVersion": "2024-04-03", + "name": "[parameters('hostPoolName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "friendlyName": "[parameters('friendlyName')]", + "hostPoolType": "Pooled", + "preferredAppGroupType": "[parameters('preferredAppGroupType')]", + "loadBalancerType": "[parameters('loadBalancerType')]", + "maxSessionLimit": "[parameters('maxSessionLimit')]", + "startVMOnConnect": false, + "validationEnvironment": false, + "agentUpdate": "[parameters('agentUpdate')]", + "registrationInfo": { + "expirationTime": "[dateTimeAdd(parameters('baseTime'), parameters('tokenValidityLength'))]", + "registrationTokenOperation": "Update" + } + } + }, + { + "type": "Microsoft.DesktopVirtualization/applicationGroups", + "apiVersion": "2024-04-03", + "name": "[format('{0}-desktopAppGroup', parameters('hostPoolName'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "applicationGroupType": "Desktop", + "hostPoolArmPath": "[resourceId('Microsoft.DesktopVirtualization/hostpools', parameters('hostPoolName'))]" + }, + "dependsOn": [ + "[resourceId('Microsoft.DesktopVirtualization/hostPools', parameters('hostPoolName'))]" + ] + }, + { + "type": "Microsoft.DesktopVirtualization/workspaces", + "apiVersion": "2024-11-01-preview", + "name": "[format('{0}-workspace', parameters('hostPoolName'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "friendlyName": "[format('{0} Workspace', parameters('friendlyName'))]", + "applicationGroupReferences": [ + "[resourceId('Microsoft.DesktopVirtualization/applicationGroups', format('{0}-desktopAppGroup', parameters('hostPoolName')))]" + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.DesktopVirtualization/applicationGroups', format('{0}-desktopAppGroup', parameters('hostPoolName')))]" + ] + }, + { + "copy": { + "name": "nic", + "count": "[length(variables('vmNames'))]" + }, + "type": "Microsoft.Network/networkInterfaces", + "apiVersion": "2024-05-01", + "name": "[format('{0}-nic', variables('vmNames')[copyIndex()])]", + "location": "[parameters('location')]", + "properties": { + "ipConfigurations": [ + { + "name": "ipconfig1", + "properties": { + "subnet": { + "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroup')), 'Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('subnetName'))]" + }, + "privateIPAllocationMethod": "Dynamic" + } + } + ] + } + }, + { + "copy": { + "name": "vmSessionHost", + "count": "[length(variables('vmNames'))]" + }, + "type": "Microsoft.Compute/virtualMachines", + "apiVersion": "2024-11-01", + "name": "[variables('vmNames')[copyIndex()]]", + "location": "[parameters('location')]", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "hardwareProfile": { + "vmSize": "[parameters('vmSize')]" + }, + "osProfile": { + "computerName": "[variables('vmNames')[copyIndex()]]", + "adminUsername": "[parameters('adminUsername')]", + "adminPassword": "[parameters('adminPassword')]" + }, + "storageProfile": { + "imageReference": { + "publisher": "[split(variables('osImage'), ':')[0]]", + "offer": "[split(variables('osImage'), ':')[1]]", + "sku": "[split(variables('osImage'), ':')[2]]", + "version": "[split(variables('osImage'), ':')[3]]" + }, + "osDisk": { + "createOption": "FromImage", + "managedDisk": { + "storageAccountType": "Premium_LRS" + } + } + }, + "networkProfile": { + "networkInterfaces": [ + { + "id": "[resourceId('Microsoft.Network/networkInterfaces', format('{0}-nic', variables('vmNames')[copyIndex()]))]" + } + ] + }, + "diagnosticsProfile": { + "bootDiagnostics": { + "enabled": true, + "storageUri": "" + } + }, + "securityProfile": { + "securityType": "TrustedLaunch", + "uefiSettings": { + "secureBootEnabled": true, + "vTpmEnabled": true + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'hostPoolRegistrationToken')]", + "[resourceId('Microsoft.Network/networkInterfaces', format('{0}-nic', variables('vmNames')[copyIndex()]))]", + "[resourceId('Microsoft.Network/networkInterfaces', format('{0}-nic', variables('vmNames')[copyIndex()]))]" + ] + }, + { + "copy": { + "name": "entraloginExtension", + "count": "[length(variables('vmNames'))]" + }, + "type": "Microsoft.Compute/virtualMachines/extensions", + "apiVersion": "2024-11-01", + "name": "[format('{0}/AADLoginForWindows', variables('vmNames')[copyIndex()])]", + "location": "[resourceGroup().location]", + "properties": { + "publisher": "Microsoft.Azure.ActiveDirectory", + "type": "AADLoginForWindows", + "typeHandlerVersion": "2.0", + "autoUpgradeMinorVersion": true, + "settings": "[if(variables('intune'), createObject('mdmId', '0000000a-0000-0000-c000-000000000000'), null())]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/networkInterfaces', format('{0}-nic', variables('vmNames')[copyIndex()]))]", + "[resourceId('Microsoft.Compute/virtualMachines', variables('vmNames')[copyIndex()])]" + ] + }, + { + "copy": { + "name": "avdDscExtension", + "count": "[length(variables('vmNames'))]" + }, + "type": "Microsoft.Compute/virtualMachines/extensions", + "apiVersion": "2024-11-01", + "name": "[format('{0}/Microsoft.PowerShell.DSC', variables('vmNames')[copyIndex()])]", + "location": "[resourceGroup().location]", + "properties": { + "publisher": "Microsoft.Powershell", + "type": "DSC", + "typeHandlerVersion": "2.83", + "autoUpgradeMinorVersion": true, + "settings": { + "modulesUrl": "[variables('AVDartifactsLocation')]", + "configurationFunction": "Configuration.ps1\\AddSessionHost", + "properties": { + "hostPoolName": "[parameters('hostPoolName')]", + "registrationInfoTokenCredential": { + "UserName": "PLACEHOLDER_DO_NOT_USE", + "Password": "PrivateSettingsRef:RegistrationInfoToken" + }, + "aadJoin": "[variables('aadJoin')]", + "UseAgentDownloadEndpoint": true, + "aadJoinPreview": "[variables('aadJoinPreview')]", + "mdmId": "[if(variables('intune'), '0000000a-0000-0000-c000-000000000000', '')]", + "sessionHostConfigurationLastUpdateTime": "" + } + }, + "protectedSettings": { + "Items": { + "RegistrationInfoToken": "[reference(resourceId('Microsoft.Resources/deployments', 'hostPoolRegistrationToken'), '2022-09-01').outputs.registrationToken.value]" + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Compute/virtualMachines/extensions', split(format('{0}/AADLoginForWindows', variables('vmNames')[copyIndex()]), '/')[0], split(format('{0}/AADLoginForWindows', variables('vmNames')[copyIndex()]), '/')[1])]", + "[resourceId('Microsoft.DesktopVirtualization/hostPools', parameters('hostPoolName'))]", + "[resourceId('Microsoft.Resources/deployments', 'hostPoolRegistrationToken')]", + "[resourceId('Microsoft.Compute/virtualMachines', variables('vmNames')[copyIndex()])]" + ] + }, + { + "copy": { + "name": "linuxBrokerConfig", + "count": "[length(variables('vmNames'))]" + }, + "type": "Microsoft.Compute/virtualMachines/extensions", + "apiVersion": "2024-11-01", + "name": "[format('{0}/CustomScriptExtension', variables('vmNames')[copyIndex()])]", + "location": "[parameters('location')]", + "properties": { + "publisher": "Microsoft.Compute", + "type": "CustomScriptExtension", + "typeHandlerVersion": "1.10", + "autoUpgradeMinorVersion": true, + "settings": { + "fileUris": "[array(parameters('linuxBrokerConfigScriptUri'))]" + }, + "protectedSettings": { + "commandToExecute": "[format('powershell -ExecutionPolicy Unrestricted -File Configure-AVD-Host.ps1 -LinuxBrokerApiBaseUrl \"{0}\"', parameters('linuxBrokerApiBaseUrl'))]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Compute/virtualMachines/extensions', split(format('{0}/Microsoft.PowerShell.DSC', variables('vmNames')[copyIndex()]), '/')[0], split(format('{0}/Microsoft.PowerShell.DSC', variables('vmNames')[copyIndex()]), '/')[1])]", + "[resourceId('Microsoft.Compute/virtualMachines/extensions', split(format('{0}/AADLoginForWindows', variables('vmNames')[copyIndex()]), '/')[0], split(format('{0}/AADLoginForWindows', variables('vmNames')[copyIndex()]), '/')[1])]", + "[resourceId('Microsoft.Resources/deployments', 'hostPoolRegistrationToken')]", + "[resourceId('Microsoft.Compute/virtualMachines', variables('vmNames')[copyIndex()])]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "hostPoolRegistrationToken", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "hostPoolName": { + "value": "[parameters('hostPoolName')]" + }, + "tags": { + "value": "[reference(resourceId('Microsoft.DesktopVirtualization/hostPools', parameters('hostPoolName')), '2024-04-03', 'full').tags]" + }, + "location": { + "value": "[reference(resourceId('Microsoft.DesktopVirtualization/hostPools', parameters('hostPoolName')), '2024-04-03', 'full').location]" + }, + "hostPoolType": { + "value": "[reference(resourceId('Microsoft.DesktopVirtualization/hostPools', parameters('hostPoolName')), '2024-04-03').hostPoolType]" + }, + "friendlyName": { + "value": "[reference(resourceId('Microsoft.DesktopVirtualization/hostPools', parameters('hostPoolName')), '2024-04-03').friendlyName]" + }, + "loadBalancerType": { + "value": "[reference(resourceId('Microsoft.DesktopVirtualization/hostPools', parameters('hostPoolName')), '2024-04-03').loadBalancerType]" + }, + "preferredAppGroupType": { + "value": "[reference(resourceId('Microsoft.DesktopVirtualization/hostPools', parameters('hostPoolName')), '2024-04-03').preferredAppGroupType]" + }, + "maxSessionLimit": { + "value": "[reference(resourceId('Microsoft.DesktopVirtualization/hostPools', parameters('hostPoolName')), '2024-04-03').maxSessionLimit]" + }, + "startVMOnConnect": { + "value": "[reference(resourceId('Microsoft.DesktopVirtualization/hostPools', parameters('hostPoolName')), '2024-04-03').startVMOnConnect]" + }, + "validationEnvironment": { + "value": "[reference(resourceId('Microsoft.DesktopVirtualization/hostPools', parameters('hostPoolName')), '2024-04-03').validationEnvironment]" + }, + "agentUpdate": { + "value": "[reference(resourceId('Microsoft.DesktopVirtualization/hostPools', parameters('hostPoolName')), '2024-04-03').agentUpdate]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.25.53.49325", + "templateHash": "16967542276948022964" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "hostPoolName": { + "type": "string" + }, + "friendlyName": { + "type": "string" + }, + "hostPoolType": { + "type": "string" + }, + "loadBalancerType": { + "type": "string" + }, + "preferredAppGroupType": { + "type": "string" + }, + "maxSessionLimit": { + "type": "int" + }, + "startVMOnConnect": { + "type": "bool" + }, + "validationEnvironment": { + "type": "bool" + }, + "agentUpdate": { + "type": "object" + }, + "tokenValidityLength": { + "type": "string", + "defaultValue": "PT8H", + "metadata": { + "description": "Token validity duration in ISO 8601 format" + } + }, + "baseTime": { + "type": "string", + "defaultValue": "[utcNow('u')]", + "metadata": { + "description": "Generated. Do not provide a value! This date value is used to generate a registration token." + } + } + }, + "resources": [ + { + "type": "Microsoft.DesktopVirtualization/hostPools", + "apiVersion": "2024-04-03", + "name": "[parameters('hostPoolName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "friendlyName": "[parameters('friendlyName')]", + "hostPoolType": "[parameters('hostPoolType')]", + "loadBalancerType": "[parameters('loadBalancerType')]", + "preferredAppGroupType": "[parameters('preferredAppGroupType')]", + "maxSessionLimit": "[parameters('maxSessionLimit')]", + "startVMOnConnect": "[parameters('startVMOnConnect')]", + "validationEnvironment": "[parameters('validationEnvironment')]", + "agentUpdate": "[parameters('agentUpdate')]", + "registrationInfo": { + "expirationTime": "[dateTimeAdd(parameters('baseTime'), parameters('tokenValidityLength'))]", + "registrationTokenOperation": "Update" + } + } + } + ], + "outputs": { + "registrationToken": { + "type": "string", + "value": "[first(listRegistrationTokens(resourceId('Microsoft.DesktopVirtualization/hostPools', parameters('hostPoolName')), '2024-04-03').value).token]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.DesktopVirtualization/applicationGroups', format('{0}-desktopAppGroup', parameters('hostPoolName')))]", + "[resourceId('Microsoft.DesktopVirtualization/hostPools', parameters('hostPoolName'))]", + "[resourceId('Microsoft.DesktopVirtualization/workspaces', format('{0}-workspace', parameters('hostPoolName')))]" + ] + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'networking')]" + ] + } + ], + "outputs": { + "frontendAppName": { + "type": "string", + "value": "[variables('frontendAppName')]" + }, + "frontendUrl": { + "type": "string", + "value": "[format('https://{0}.azurewebsites.net', variables('frontendAppName'))]" + }, + "apiAppName": { + "type": "string", + "value": "[variables('apiAppName')]" + }, + "apiUrl": { + "type": "string", + "value": "[format('https://{0}.azurewebsites.net/api', variables('apiAppName'))]" + }, + "taskAppName": { + "type": "string", + "value": "[variables('taskAppName')]" + }, + "keyVaultName": { + "type": "string", + "value": "[variables('keyVaultName')]" + }, + "containerRegistryName": { + "type": "string", + "value": "[variables('containerRegistryName')]" + }, + "sqlServerName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'sql'), '2022-09-01').outputs.sqlServerName.value]" + }, + "sqlDatabaseName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'sql'), '2022-09-01').outputs.databaseName.value]" + }, + "virtualNetworkName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'networking'), '2022-09-01').outputs.vnetName.value]" + } + } + } + }, + "dependsOn": [ + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('effectiveResourceGroupName'))]" + ] + } + ], + "outputs": { + "resourceGroupName": { + "type": "string", + "value": "[variables('effectiveResourceGroupName')]" + }, + "frontendAppName": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('effectiveResourceGroupName')), 'Microsoft.Resources/deployments', 'resources'), '2022-09-01').outputs.frontendAppName.value]" + }, + "frontendUrl": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('effectiveResourceGroupName')), 'Microsoft.Resources/deployments', 'resources'), '2022-09-01').outputs.frontendUrl.value]" + }, + "apiAppName": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('effectiveResourceGroupName')), 'Microsoft.Resources/deployments', 'resources'), '2022-09-01').outputs.apiAppName.value]" + }, + "apiUrl": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('effectiveResourceGroupName')), 'Microsoft.Resources/deployments', 'resources'), '2022-09-01').outputs.apiUrl.value]" + }, + "taskAppName": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('effectiveResourceGroupName')), 'Microsoft.Resources/deployments', 'resources'), '2022-09-01').outputs.taskAppName.value]" + }, + "keyVaultName": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('effectiveResourceGroupName')), 'Microsoft.Resources/deployments', 'resources'), '2022-09-01').outputs.keyVaultName.value]" + }, + "containerRegistryName": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('effectiveResourceGroupName')), 'Microsoft.Resources/deployments', 'resources'), '2022-09-01').outputs.containerRegistryName.value]" + }, + "sqlServerName": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('effectiveResourceGroupName')), 'Microsoft.Resources/deployments', 'resources'), '2022-09-01').outputs.sqlServerName.value]" + }, + "sqlDatabaseName": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('effectiveResourceGroupName')), 'Microsoft.Resources/deployments', 'resources'), '2022-09-01').outputs.sqlDatabaseName.value]" + }, + "virtualNetworkName": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('effectiveResourceGroupName')), 'Microsoft.Resources/deployments', 'resources'), '2022-09-01').outputs.virtualNetworkName.value]" + } + } +} \ No newline at end of file diff --git a/deploy/bicep/main.parameters.json b/deploy/bicep/main.parameters.json new file mode 100644 index 0000000..4a1a074 --- /dev/null +++ b/deploy/bicep/main.parameters.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appName": { + "value": "linuxbroker" + }, + "environmentName": { + "value": "dev" + }, + "location": { + "value": "eastus2" + }, + "tenantId": { + "value": "00000000-0000-0000-0000-000000000000" + }, + "frontendClientId": { + "value": "00000000-0000-0000-0000-000000000000" + }, + "frontendClientSecret": { + "value": "replace-me" + }, + "apiClientId": { + "value": "00000000-0000-0000-0000-000000000000" + }, + "apiClientSecret": { + "value": "replace-me" + }, + "sqlAdminPassword": { + "value": "ReplaceMe!123456789" + }, + "flaskKey": { + "value": "replace-me" + }, + "hostAdminPassword": { + "value": "ReplaceMe!123456789" + } + } +} diff --git a/deploy/bicep/main.resources.bicep b/deploy/bicep/main.resources.bicep new file mode 100644 index 0000000..5ba6fc2 --- /dev/null +++ b/deploy/bicep/main.resources.bicep @@ -0,0 +1,369 @@ +targetScope = 'resourceGroup' + +@description('Application name used for resource naming.') +param appName string +param environmentName string +param location string = resourceGroup().location +param tags object = {} +param tenantId string +param frontendClientId string +@secure() +param frontendClientSecret string +param apiClientId string +@secure() +param apiClientSecret string +param avdHostGroupId string = '' +param linuxHostGroupId string = '' +param sqlAdminLogin string = 'brokeradmin' +@secure() +param sqlAdminPassword string +@secure() +param flaskKey string +param domainName string = '' +param nfsShare string = '' +param linuxHostAdminLoginName string = 'avdadmin' +@secure() +param hostAdminPassword string +param vmHostResourceGroup string = '' +param vmSubscriptionId string = subscription().subscriptionId +param allowedClientIp string = '' +param appServicePlanSku string = 'P1v3' +param deployLinuxHosts bool = false +param deployAvdHosts bool = false +param linuxHostVmNamePrefix string = 'lnxhost' +param linuxHostVmSize string = 'Standard_D2s_v5' +param linuxHostCount int = 0 +@allowed([ + 'Password' + 'SSH' +]) +param linuxHostAuthType string = 'Password' +param linuxHostSshPublicKey string = '' +@allowed([ + '7-LVM' + '8-LVM' + '9-LVM' + '24_04-lts' +]) +param linuxHostOsVersion string = '24_04-lts' +param avdHostPoolName string = '' +param avdSessionHostCount int = 0 +param avdMaxSessionLimit int = 5 +param avdVmNamePrefix string = 'avdhost' +@allowed([ + 'Standard_DS2_v2' + 'Standard_D8s_v5' + 'Standard_D8s_v4' + 'Standard_F8s_v2' + 'Standard_D8as_v4' + 'Standard_D16s_v5' + 'Standard_D16s_v4' + 'Standard_F16s_v2' + 'Standard_D16as_v4' +]) +param avdVmSize string = 'Standard_D8s_v5' + +var sanitizedApp = toLower(replace(appName, '-', '')) +var sanitizedEnv = toLower(replace(environmentName, '-', '')) +var sqlLocation = location == 'eastus2' ? 'eastus' : location +var suffix = toLower(uniqueString(subscription().subscriptionId, resourceGroup().id, appName, environmentName)) +var sqlSuffix = toLower(uniqueString(subscription().subscriptionId, resourceGroup().id, appName, environmentName, sqlLocation)) +var storageAccountName = take('${sanitizedApp}${sanitizedEnv}${suffix}', 24) +var keyVaultName = take('kv${sanitizedApp}${sanitizedEnv}${suffix}', 24) +var containerRegistryName = take('${sanitizedApp}${sanitizedEnv}${suffix}', 50) +var sqlServerName = take('sql-${sanitizedApp}-${sanitizedEnv}-${sqlSuffix}', 63) +var sqlDatabaseName = 'LinuxBroker' +var logAnalyticsName = 'log-${appName}-${environmentName}' +var applicationInsightsName = 'appi-${appName}-${environmentName}' +var appServicePlanName = 'asp-${appName}-${environmentName}' +var frontendAppName = 'fe-${appName}-${environmentName}' +var apiAppName = 'api-${appName}-${environmentName}' +var taskAppName = 'task-${appName}-${environmentName}' +var vnetName = 'vnet-${appName}-${environmentName}' +var appSubnetName = 'snet-appsvc' +var linuxSubnetName = 'snet-linux-hosts' +var avdSubnetName = 'snet-avd-hosts' +var privateEndpointSubnetName = 'snet-private-endpoints' +var effectiveVmResourceGroup = empty(vmHostResourceGroup) ? resourceGroup().name : vmHostResourceGroup +var keyVaultSecretsUserRoleDefinitionId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') +var acrPullRoleDefinitionId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') +var databasePasswordSecretName = 'db-admin-password' +var apiSecretName = 'api-client-secret' + +module networking 'modules/core/networking.bicep' = { + name: 'networking' + params: { + location: location + tags: tags + vnetName: vnetName + appSubnetName: appSubnetName + linuxSubnetName: linuxSubnetName + avdSubnetName: avdSubnetName + privateEndpointSubnetName: privateEndpointSubnetName + } +} + +module observability 'modules/core/observability.bicep' = { + name: 'observability' + params: { + location: location + tags: tags + logAnalyticsWorkspaceName: logAnalyticsName + applicationInsightsName: applicationInsightsName + } +} + +module containerRegistry 'modules/core/container-registry.bicep' = { + name: 'containerRegistry' + params: { + location: location + tags: tags + containerRegistryName: containerRegistryName + } +} + +module storageAccount 'modules/core/storage-account.bicep' = { + name: 'storageAccount' + params: { + location: location + tags: tags + storageAccountName: storageAccountName + } +} + +module keyVault 'modules/core/key-vault.bicep' = { + name: 'keyVault' + params: { + location: location + tags: tags + keyVaultName: keyVaultName + sqlAdminPassword: sqlAdminPassword + frontendClientSecret: frontendClientSecret + apiClientSecret: apiClientSecret + hostAdminPassword: hostAdminPassword + } +} + +module sql 'modules/core/sql-database.bicep' = { + name: 'sql' + params: { + location: sqlLocation + tags: tags + sqlServerName: sqlServerName + databaseName: sqlDatabaseName + administratorLogin: sqlAdminLogin + administratorPassword: sqlAdminPassword + allowedClientIp: allowedClientIp + } +} + +module appServicePlan 'modules/core/app-service-plan.bicep' = { + name: 'appServicePlan' + params: { + location: location + tags: tags + appServicePlanName: appServicePlanName + skuName: appServicePlanSku + } +} + +resource acrResource 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { + name: containerRegistryName +} + +resource keyVaultResource 'Microsoft.KeyVault/vaults@2023-07-01' existing = { + name: keyVaultName +} + +var frontendImageName = '${containerRegistry.outputs.loginServer}/frontend:latest' +var apiImageName = '${containerRegistry.outputs.loginServer}/api:latest' +var taskImageName = '${containerRegistry.outputs.loginServer}/task:latest' +var frontendApiBaseUrl = 'https://${apiAppName}.azurewebsites.net/api' +var storageConnectionString = 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.outputs.name};AccountKey=${listKeys(resourceId('Microsoft.Storage/storageAccounts', storageAccountName), '2023-05-01').keys[0].value};EndpointSuffix=${environment().suffixes.storage}' +var frontendSettings = { + API_CLIENT_ID: apiClientId + API_URL: frontendApiBaseUrl + CLIENT_ID: frontendClientId + FLASK_KEY: flaskKey + MICROSOFT_PROVIDER_AUTHENTICATION_SECRET: '@Microsoft.KeyVault(SecretUri=${keyVault.outputs.frontendAuthKeyUri})' + SCM_DO_BUILD_DURING_DEPLOYMENT: 'false' + TENANT_ID: tenantId + WEBSITE_AUTH_AAD_ALLOWED_TENANTS: tenantId +} +var apiSettings = { + AVD_HOST_GROUP_ID: avdHostGroupId + CLIENT_ID: apiClientId + DB_DATABASE: sql.outputs.databaseName + DB_PASSWORD_NAME: databasePasswordSecretName + DB_SERVER: sql.outputs.sqlServerFullyQualifiedDomainName + DB_USERNAME: sqlAdminLogin + DOMAIN_NAME: domainName + GRAPH_API_ENDPOINT: 'https://graph.microsoft.com/.default' + KEY_NAME: apiSecretName + LINUX_HOST_ADMIN_LOGIN_NAME: linuxHostAdminLoginName + LINUX_HOST_GROUP_ID: linuxHostGroupId + MICROSOFT_PROVIDER_AUTHENTICATION_SECRET: '@Microsoft.KeyVault(SecretUri=${keyVault.outputs.apiAuthKeyUri})' + NFS_SHARE: nfsShare + SCM_DO_BUILD_DURING_DEPLOYMENT: 'false' + TENANT_ID: tenantId + VAULT_URL: keyVault.outputs.vaultUri + VM_RESOURCE_GROUP: effectiveVmResourceGroup + VM_SUBSCRIPTION_ID: vmSubscriptionId +} +var functionSettings = { + API_CLIENT_ID: apiClientId + API_URL: frontendApiBaseUrl + AzureWebJobsStorage: storageConnectionString + FUNCTIONS_EXTENSION_VERSION: '~4' + FUNCTIONS_WORKER_RUNTIME: 'python' + SCM_DO_BUILD_DURING_DEPLOYMENT: 'false' + WEBSITE_CONTENTAZUREFILECONNECTIONSTRING: storageConnectionString + WEBSITE_CONTENTSHARE: toLower(take('${taskAppName}content', 63)) +} + +module frontendApp 'modules/apps/container-web-app.bicep' = { + name: 'frontendApp' + params: { + location: location + tags: union(tags, { + 'azd-service-name': 'frontend' + }) + appName: frontendAppName + serverFarmId: appServicePlan.outputs.id + containerImageName: frontendImageName + containerRegistryLoginServer: containerRegistry.outputs.loginServer + applicationInsightsConnectionString: observability.outputs.applicationInsightsConnectionString + appSettings: frontendSettings + alwaysOn: true + useManagedIdentityForRegistry: true + } +} + +module apiApp 'modules/apps/container-web-app.bicep' = { + name: 'apiApp' + params: { + location: location + tags: union(tags, { + 'azd-service-name': 'api' + }) + appName: apiAppName + serverFarmId: appServicePlan.outputs.id + containerImageName: apiImageName + containerRegistryLoginServer: containerRegistry.outputs.loginServer + applicationInsightsConnectionString: observability.outputs.applicationInsightsConnectionString + appSettings: apiSettings + alwaysOn: true + useManagedIdentityForRegistry: true + } +} + +module taskApp 'modules/apps/container-function-app.bicep' = { + name: 'taskApp' + params: { + location: location + tags: union(tags, { + 'azd-service-name': 'task' + }) + appName: taskAppName + serverFarmId: appServicePlan.outputs.id + containerImageName: taskImageName + containerRegistryLoginServer: containerRegistry.outputs.loginServer + applicationInsightsConnectionString: observability.outputs.applicationInsightsConnectionString + storageConnectionString: functionSettings.AzureWebJobsStorage + appSettings: functionSettings + useManagedIdentityForRegistry: true + } +} + +resource frontendAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(acrResource.id, frontendAppName, 'frontend-acr-pull') + scope: acrResource + properties: { + principalId: frontendApp.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: acrPullRoleDefinitionId + } +} + +resource apiAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(acrResource.id, apiAppName, 'api-acr-pull') + scope: acrResource + properties: { + principalId: apiApp.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: acrPullRoleDefinitionId + } +} + +resource taskAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(acrResource.id, taskAppName, 'task-acr-pull') + scope: acrResource + properties: { + principalId: taskApp.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: acrPullRoleDefinitionId + } +} + +resource apiKeyVaultSecretsUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVaultResource.id, apiAppName, 'api-keyvault-secrets-user') + scope: keyVaultResource + properties: { + principalId: apiApp.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: keyVaultSecretsUserRoleDefinitionId + } +} + +module linuxHosts 'modules/Linux/main.bicep' = if (deployLinuxHosts && linuxHostCount > 0) { + name: 'linuxHosts' + params: { + location: location + tags: union(tags, { + 'broker-role': 'linux-host' + }) + vnetName: networking.outputs.vnetName + subnetName: networking.outputs.linuxSubnetName + vnetResourceGroup: resourceGroup().name + vmNamePrefix: linuxHostVmNamePrefix + vmSize: linuxHostVmSize + numberOfVMs: linuxHostCount + authType: linuxHostAuthType + adminUsername: linuxHostAdminLoginName + adminPassword: hostAdminPassword + sshPublicKey: linuxHostSshPublicKey + OSVersion: linuxHostOsVersion + } +} + +module avdHosts 'modules/AVD/main.bicep' = if (deployAvdHosts && avdSessionHostCount > 0 && !empty(avdHostPoolName)) { + name: 'avdHosts' + params: { + location: location + tags: union(tags, { + 'broker-role': 'avd-host' + }) + vnetName: networking.outputs.vnetName + subnetName: networking.outputs.avdSubnetName + vnetResourceGroup: resourceGroup().name + hostPoolName: avdHostPoolName + sessionHostCount: avdSessionHostCount + maxSessionLimit: avdMaxSessionLimit + vmNamePrefix: avdVmNamePrefix + vmSize: avdVmSize + adminUsername: linuxHostAdminLoginName + adminPassword: hostAdminPassword + linuxBrokerApiBaseUrl: frontendApiBaseUrl + } +} + +output frontendAppName string = frontendAppName +output frontendUrl string = 'https://${frontendAppName}.azurewebsites.net' +output apiAppName string = apiAppName +output apiUrl string = 'https://${apiAppName}.azurewebsites.net/api' +output taskAppName string = taskAppName +output keyVaultName string = keyVaultName +output containerRegistryName string = containerRegistryName +output sqlServerName string = sql.outputs.sqlServerName +output sqlDatabaseName string = sql.outputs.databaseName +output virtualNetworkName string = networking.outputs.vnetName diff --git a/bicep/AVD/main.bicep b/deploy/bicep/modules/AVD/main.bicep similarity index 94% rename from bicep/AVD/main.bicep rename to deploy/bicep/modules/AVD/main.bicep index 72dc582..1ca1304 100644 --- a/bicep/AVD/main.bicep +++ b/deploy/bicep/modules/AVD/main.bicep @@ -1,12 +1,10 @@ param location string = resourceGroup().location param tags object = {} -// Network Parameters param vnetName string param subnetName string param vnetResourceGroup string -// AVD Parameters param hostPoolName string param friendlyName string = hostPoolName param loadBalancerType string = 'BreadthFirst' @@ -14,7 +12,7 @@ param preferredAppGroupType string = 'Desktop' param sessionHostCount int param maxSessionLimit int @description('Token validity duration in ISO 8601 format') -param tokenValidityLength string = 'PT8H' // 8 hours by default +param tokenValidityLength string = 'PT8H' @description('Generated. Do not provide a value! This date value is used to generate a registration token.') param baseTime string = utcNow('u') @description('Agent update configuration') @@ -31,7 +29,6 @@ param agentUpdate object = { ] } -// Session Host VM Parameters @maxLength(10) param vmNamePrefix string @description('The size of the session host VMs') @@ -51,17 +48,13 @@ param adminUsername string @secure() param adminPassword string -// Linux Broker API Base URL @description('Base URL for the AVD Linux Broker API') param linuxBrokerApiBaseUrl string -// Linux Broker Configuration Script URI @description('URI for the AVD Linux Broker configuration script') param linuxBrokerConfigScriptUri string = 'https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main/custom_script_extensions/Configure-AVD-Host.ps1' -// Multisession image without Office var osImage = 'microsoftwindowsdesktop:Windows-11:win11-24h2-avd:latest' var vmNames = [for i in range(1, sessionHostCount): '${vmNamePrefix}-${padLeft(i, 2, '0')}'] -// URL to the AVD artifacts location var storageAccountName = 'wvdportalstorageblob' var containerName = 'galleryartifacts' var blobName01 = 'Configuration_1.0.02990.697.zip' @@ -70,7 +63,6 @@ var intune = false var aadJoin = true var aadJoinPreview = false -// Create AVD Host Pool resource hostPool 'Microsoft.DesktopVirtualization/hostPools@2024-04-03' = { name: hostPoolName location: location @@ -91,7 +83,6 @@ resource hostPool 'Microsoft.DesktopVirtualization/hostPools@2024-04-03' = { } } -// Create Desktop Application Group resource desktopAppGroup 'Microsoft.DesktopVirtualization/applicationGroups@2024-04-03' = { name: '${hostPoolName}-desktopAppGroup' location: location @@ -102,7 +93,6 @@ resource desktopAppGroup 'Microsoft.DesktopVirtualization/applicationGroups@2024 } } -// Create Workspace resource workspace 'Microsoft.DesktopVirtualization/workspaces@2024-11-01-preview' = { name: '${hostPoolName}-workspace' location: location @@ -136,7 +126,6 @@ module hostPoolRegistrationToken 'token.bicep' = { ] } -// Retrieve the existing VNet and Subnet resource existingVNet 'Microsoft.Network/virtualNetworks@2021-05-01' existing = { name: vnetName scope: resourceGroup(vnetResourceGroup) @@ -229,7 +218,6 @@ resource vmSessionHost 'Microsoft.Compute/virtualMachines@2024-11-01' = [ } ] -// EntraLoginForWindows Extension resource entraloginExtension 'Microsoft.Compute/virtualMachines/extensions@2024-11-01' = [ for (name, i) in vmNames: { name: '${name}/AADLoginForWindows' @@ -252,7 +240,6 @@ resource entraloginExtension 'Microsoft.Compute/virtualMachines/extensions@2024- } ] -// AVD DSC Configuration resource avdDscExtension 'Microsoft.Compute/virtualMachines/extensions@2024-11-01' = [ for (name, i) in vmNames: { name: '${name}/Microsoft.PowerShell.DSC' diff --git a/bicep/AVD/token.bicep b/deploy/bicep/modules/AVD/token.bicep similarity index 90% rename from bicep/AVD/token.bicep rename to deploy/bicep/modules/AVD/token.bicep index a7775ac..4f5e6de 100644 --- a/bicep/AVD/token.bicep +++ b/deploy/bicep/modules/AVD/token.bicep @@ -11,7 +11,7 @@ param validationEnvironment bool param agentUpdate object @description('Token validity duration in ISO 8601 format') -param tokenValidityLength string = 'PT8H' // 8 hours by default +param tokenValidityLength string = 'PT8H' @description('Generated. Do not provide a value! This date value is used to generate a registration token.') param baseTime string = utcNow('u') @@ -28,7 +28,6 @@ resource hostPoolTokenUpdate 'Microsoft.DesktopVirtualization/hostPools@2024-04- startVMOnConnect: startVMOnConnect validationEnvironment: validationEnvironment agentUpdate: agentUpdate - // Update the registration info with a new token registrationInfo: { expirationTime: dateTimeAdd(baseTime, tokenValidityLength) registrationTokenOperation: 'Update' @@ -36,5 +35,5 @@ resource hostPoolTokenUpdate 'Microsoft.DesktopVirtualization/hostPools@2024-04- } } -@secure() +#disable-next-line outputs-should-not-contain-secrets output registrationToken string = first(hostPoolTokenUpdate.listRegistrationTokens().value)!.token diff --git a/bicep/Linux/main.bicep b/deploy/bicep/modules/Linux/main.bicep similarity index 92% rename from bicep/Linux/main.bicep rename to deploy/bicep/modules/Linux/main.bicep index 5663dff..6c055de 100644 --- a/bicep/Linux/main.bicep +++ b/deploy/bicep/modules/Linux/main.bicep @@ -1,19 +1,16 @@ param location string = resourceGroup().location param tags object = {} -// Network Parameters param vnetName string param subnetName string param vnetResourceGroup string -// VM Parameters - General param vmNamePrefix string param vmSize string @minValue(1) @maxValue(20) param numberOfVMs int -// VM Parameters - Authentication @allowed([ 'Password' 'SSH' @@ -24,12 +21,11 @@ param adminUsername string param adminPassword string param sshPublicKey string = '' -// VM Parameters - OS Image @allowed([ - '7-LVM' // RHEL 7 - '8-LVM' // RHEL 8 - '9-LVM' // RHEL 9 - '24_04-lts' // Ubuntu 24.04 + '7-LVM' + '8-LVM' + '9-LVM' + '24_04-lts' ]) param OSVersion string @@ -87,10 +83,8 @@ var imageConfigs = { } } -// Selected configuration based on OSVersion parameter var selectedConfig = imageConfigs[OSVersion] -// Retrieve existing VNet and Subnet resource existingVNet 'Microsoft.Network/virtualNetworks@2021-05-01' existing = { name: vnetName scope: resourceGroup(vnetResourceGroup) @@ -101,7 +95,6 @@ resource existingSubnet 'Microsoft.Network/virtualNetworks/subnets@2021-05-01' e name: subnetName } -// Create Network Interfaces resource nic 'Microsoft.Network/networkInterfaces@2024-05-01' = [ for (name, i) in vmNames: { name: '${name}-nic' @@ -127,7 +120,6 @@ resource nic 'Microsoft.Network/networkInterfaces@2024-05-01' = [ } ] -// Create Linux VMs resource vmLinuxHost 'Microsoft.Compute/virtualMachines@2022-03-01' = [ for (name, i) in vmNames: { name: name @@ -189,7 +181,6 @@ resource vmLinuxHost 'Microsoft.Compute/virtualMachines@2022-03-01' = [ } ] -// Apply Script Based on Image OS resource linuxCustomScriptExtension 'Microsoft.Compute/virtualMachines/extensions@2022-03-01' = [ for (name, i) in vmNames: { name: '${name}/customScript' diff --git a/deploy/bicep/modules/apps/container-function-app.bicep b/deploy/bicep/modules/apps/container-function-app.bicep new file mode 100644 index 0000000..6b77df8 --- /dev/null +++ b/deploy/bicep/modules/apps/container-function-app.bicep @@ -0,0 +1,51 @@ +param location string = resourceGroup().location +param tags object = {} +param appName string +param serverFarmId string +param containerImageName string +param containerRegistryLoginServer string +param applicationInsightsConnectionString string +@secure() +param storageConnectionString string +param appSettings object = {} +param useManagedIdentityForRegistry bool = true + +resource functionApp 'Microsoft.Web/sites@2023-12-01' = { + name: appName + location: location + tags: tags + kind: 'functionapp,linux,container' + identity: { + type: 'SystemAssigned' + } + properties: { + serverFarmId: serverFarmId + httpsOnly: true + siteConfig: { + acrUseManagedIdentityCreds: useManagedIdentityForRegistry + alwaysOn: true + appCommandLine: '' + ftpsState: 'FtpsOnly' + linuxFxVersion: 'DOCKER|${containerImageName}' + minTlsVersion: '1.2' + } + } +} + +resource functionAppSettings 'Microsoft.Web/sites/config@2023-12-01' = { + parent: functionApp + name: 'appsettings' + properties: union({ + APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsightsConnectionString + AzureWebJobsStorage: storageConnectionString + DOCKER_REGISTRY_SERVER_URL: 'https://${containerRegistryLoginServer}' + WEBSITE_CONTENTAZUREFILECONNECTIONSTRING: storageConnectionString + WEBSITE_CONTENTSHARE: toLower(take('${appName}content', 63)) + WEBSITES_ENABLE_APP_SERVICE_STORAGE: 'false' + }, appSettings) +} + +output id string = functionApp.id +output name string = functionApp.name +output principalId string = functionApp.identity.principalId +output defaultHostName string = functionApp.properties.defaultHostName diff --git a/deploy/bicep/modules/apps/container-web-app.bicep b/deploy/bicep/modules/apps/container-web-app.bicep new file mode 100644 index 0000000..cd33fb0 --- /dev/null +++ b/deploy/bicep/modules/apps/container-web-app.bicep @@ -0,0 +1,70 @@ +param location string = resourceGroup().location +param tags object = {} +param appName string +param serverFarmId string +param containerImageName string +param containerRegistryLoginServer string +param applicationInsightsConnectionString string +param appSettings object = {} +param alwaysOn bool = true +param useManagedIdentityForRegistry bool = true + +resource webApp 'Microsoft.Web/sites@2023-12-01' = { + name: appName + location: location + tags: tags + kind: 'app,linux,container' + identity: { + type: 'SystemAssigned' + } + properties: { + serverFarmId: serverFarmId + httpsOnly: true + siteConfig: { + alwaysOn: alwaysOn + acrUseManagedIdentityCreds: useManagedIdentityForRegistry + linuxFxVersion: 'DOCKER|${containerImageName}' + minTlsVersion: '1.2' + } + } +} + +resource webAppSettings 'Microsoft.Web/sites/config@2023-12-01' = { + parent: webApp + name: 'appsettings' + properties: union({ + APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsightsConnectionString + DOCKER_REGISTRY_SERVER_URL: 'https://${containerRegistryLoginServer}' + WEBSITES_ENABLE_APP_SERVICE_STORAGE: 'false' + }, appSettings) +} + +resource webAppLogs 'Microsoft.Web/sites/config@2023-12-01' = { + parent: webApp + name: 'logs' + properties: { + applicationLogs: { + fileSystem: { + level: 'Information' + } + } + detailedErrorMessages: { + enabled: true + } + failedRequestsTracing: { + enabled: true + } + httpLogs: { + fileSystem: { + enabled: true + retentionInDays: 7 + retentionInMb: 35 + } + } + } +} + +output id string = webApp.id +output name string = webApp.name +output principalId string = webApp.identity.principalId +output defaultHostName string = webApp.properties.defaultHostName diff --git a/deploy/bicep/modules/core/app-service-plan.bicep b/deploy/bicep/modules/core/app-service-plan.bicep new file mode 100644 index 0000000..8885c07 --- /dev/null +++ b/deploy/bicep/modules/core/app-service-plan.bicep @@ -0,0 +1,23 @@ +param location string = resourceGroup().location +param tags object = {} +param appServicePlanName string +param skuName string = 'P1v3' + +resource appServicePlan 'Microsoft.Web/serverfarms@2023-12-01' = { + name: appServicePlanName + location: location + tags: tags + sku: { + name: skuName + tier: 'PremiumV3' + size: skuName + capacity: 1 + } + kind: 'linux' + properties: { + reserved: true + } +} + +output id string = appServicePlan.id +output name string = appServicePlan.name diff --git a/deploy/bicep/modules/core/container-registry.bicep b/deploy/bicep/modules/core/container-registry.bicep new file mode 100644 index 0000000..61e1484 --- /dev/null +++ b/deploy/bicep/modules/core/container-registry.bicep @@ -0,0 +1,20 @@ +param location string = resourceGroup().location +param tags object = {} +param containerRegistryName string + +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { + name: containerRegistryName + location: location + tags: tags + sku: { + name: 'Basic' + } + properties: { + adminUserEnabled: false + publicNetworkAccess: 'Enabled' + } +} + +output id string = containerRegistry.id +output loginServer string = containerRegistry.properties.loginServer +output name string = containerRegistry.name diff --git a/deploy/bicep/modules/core/key-vault.bicep b/deploy/bicep/modules/core/key-vault.bicep new file mode 100644 index 0000000..a21c6e2 --- /dev/null +++ b/deploy/bicep/modules/core/key-vault.bicep @@ -0,0 +1,68 @@ +param location string = resourceGroup().location +param tags object = {} +param keyVaultName string +@secure() +param sqlAdminPassword string +@secure() +param frontendClientSecret string +@secure() +param apiClientSecret string +@secure() +param hostAdminPassword string + +resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = { + name: keyVaultName + location: location + tags: tags + properties: { + tenantId: subscription().tenantId + enableRbacAuthorization: true + enabledForDeployment: false + enabledForDiskEncryption: false + enabledForTemplateDeployment: false + publicNetworkAccess: 'Enabled' + sku: { + family: 'A' + name: 'standard' + } + softDeleteRetentionInDays: 90 + } +} + +resource sqlAdminPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { + parent: keyVault + name: 'db-admin-password' + properties: { + value: sqlAdminPassword + } +} + +resource frontendClientSecretSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { + parent: keyVault + name: 'frontend-client-secret' + properties: { + value: frontendClientSecret + } +} + +resource apiClientSecretSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { + parent: keyVault + name: 'api-client-secret' + properties: { + value: apiClientSecret + } +} + +resource hostAdminPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { + parent: keyVault + name: 'host-admin-password' + properties: { + value: hostAdminPassword + } +} + +output name string = keyVault.name +output id string = keyVault.id +output vaultUri string = keyVault.properties.vaultUri +output frontendAuthKeyUri string = frontendClientSecretSecret.properties.secretUriWithVersion +output apiAuthKeyUri string = apiClientSecretSecret.properties.secretUriWithVersion diff --git a/deploy/bicep/modules/core/networking.bicep b/deploy/bicep/modules/core/networking.bicep new file mode 100644 index 0000000..630c160 --- /dev/null +++ b/deploy/bicep/modules/core/networking.bicep @@ -0,0 +1,81 @@ +param location string = resourceGroup().location +param tags object = {} +param vnetName string +param appSubnetName string = 'snet-appsvc' +param linuxSubnetName string = 'snet-linux-hosts' +param avdSubnetName string = 'snet-avd-hosts' +param privateEndpointSubnetName string = 'snet-private-endpoints' + +resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' = { + name: vnetName + location: location + tags: tags + properties: { + addressSpace: { + addressPrefixes: [ + '10.40.0.0/16' + ] + } + } +} + +resource appSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = { + parent: virtualNetwork + name: appSubnetName + properties: { + addressPrefix: '10.40.1.0/24' + delegations: [ + { + name: 'appservice' + properties: { + serviceName: 'Microsoft.Web/serverFarms' + } + } + ] + } +} + +resource linuxSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = { + parent: virtualNetwork + name: linuxSubnetName + dependsOn: [ + appSubnet + ] + properties: { + addressPrefix: '10.40.2.0/24' + } +} + +resource avdSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = { + parent: virtualNetwork + name: avdSubnetName + dependsOn: [ + linuxSubnet + ] + properties: { + addressPrefix: '10.40.3.0/24' + } +} + +resource privateEndpointSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = { + parent: virtualNetwork + name: privateEndpointSubnetName + dependsOn: [ + avdSubnet + ] + properties: { + addressPrefix: '10.40.4.0/24' + privateEndpointNetworkPolicies: 'Disabled' + } +} + +output vnetName string = virtualNetwork.name +output vnetId string = virtualNetwork.id +output appSubnetName string = appSubnet.name +output appSubnetId string = appSubnet.id +output linuxSubnetName string = linuxSubnet.name +output linuxSubnetId string = linuxSubnet.id +output avdSubnetName string = avdSubnet.name +output avdSubnetId string = avdSubnet.id +output privateEndpointSubnetName string = privateEndpointSubnet.name +output privateEndpointSubnetId string = privateEndpointSubnet.id diff --git a/deploy/bicep/modules/core/observability.bicep b/deploy/bicep/modules/core/observability.bicep new file mode 100644 index 0000000..abecf4f --- /dev/null +++ b/deploy/bicep/modules/core/observability.bicep @@ -0,0 +1,35 @@ +param location string = resourceGroup().location +param tags object = {} +param logAnalyticsWorkspaceName string +param applicationInsightsName string + +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: logAnalyticsWorkspaceName + location: location + tags: tags + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: 30 + features: { + enableLogAccessUsingOnlyResourcePermissions: true + } + } +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { + name: applicationInsightsName + location: location + tags: tags + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: logAnalyticsWorkspace.id + IngestionMode: 'LogAnalytics' + } +} + +output applicationInsightsConnectionString string = applicationInsights.properties.ConnectionString +output applicationInsightsName string = applicationInsights.name +output logAnalyticsWorkspaceId string = logAnalyticsWorkspace.id diff --git a/deploy/bicep/modules/core/sql-database.bicep b/deploy/bicep/modules/core/sql-database.bicep new file mode 100644 index 0000000..80d7582 --- /dev/null +++ b/deploy/bicep/modules/core/sql-database.bicep @@ -0,0 +1,55 @@ +param location string = resourceGroup().location +param tags object = {} +param sqlServerName string +param databaseName string +param administratorLogin string +@secure() +param administratorPassword string +param allowedClientIp string = '' + +resource sqlServer 'Microsoft.Sql/servers@2023-08-01-preview' = { + name: sqlServerName + location: location + tags: tags + properties: { + administratorLogin: administratorLogin + administratorLoginPassword: administratorPassword + minimalTlsVersion: '1.2' + publicNetworkAccess: 'Enabled' + } +} + +resource database 'Microsoft.Sql/servers/databases@2023-08-01-preview' = { + parent: sqlServer + name: databaseName + location: location + sku: { + name: 'Basic' + tier: 'Basic' + } + properties: { + collation: 'SQL_Latin1_General_CP1_CI_AS' + } +} + +resource allowAzureServices 'Microsoft.Sql/servers/firewallRules@2023-08-01-preview' = { + parent: sqlServer + name: 'AllowAzureServices' + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '0.0.0.0' + } +} + +resource allowClientIp 'Microsoft.Sql/servers/firewallRules@2023-08-01-preview' = if (!empty(allowedClientIp)) { + parent: sqlServer + name: 'AllowClientIp' + properties: { + startIpAddress: allowedClientIp + endIpAddress: allowedClientIp + } +} + +output sqlServerName string = sqlServer.name +output sqlServerFullyQualifiedDomainName string = sqlServer.properties.fullyQualifiedDomainName +output databaseName string = database.name diff --git a/deploy/bicep/modules/core/storage-account.bicep b/deploy/bicep/modules/core/storage-account.bicep new file mode 100644 index 0000000..79c6f98 --- /dev/null +++ b/deploy/bicep/modules/core/storage-account.bicep @@ -0,0 +1,23 @@ +param location string = resourceGroup().location +param tags object = {} +param storageAccountName string + +resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: storageAccountName + location: location + tags: tags + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + properties: { + accessTier: 'Hot' + allowBlobPublicAccess: false + minimumTlsVersion: 'TLS1_2' + publicNetworkAccess: 'Enabled' + supportsHttpsTrafficOnly: true + } +} + +output id string = storageAccount.id +output name string = storageAccount.name diff --git a/front_end/Dockerfile b/front_end/Dockerfile new file mode 100644 index 0000000..d413bd0 --- /dev/null +++ b/front_end/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV WEBSITES_PORT=8000 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY front_end/requirements.txt ./requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +COPY front_end/ ./ + +EXPOSE 8000 + +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"] diff --git a/task/Dockerfile b/task/Dockerfile new file mode 100644 index 0000000..c092fd2 --- /dev/null +++ b/task/Dockerfile @@ -0,0 +1,13 @@ +FROM mcr.microsoft.com/azure-functions/python:4-python3.11 + +ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ + AzureFunctionsJobHost__Logging__Console__IsEnabled=true + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY task/requirements.txt /requirements.txt +RUN pip install --no-cache-dir -r /requirements.txt + +COPY task/ /home/site/wwwroot From e1aaa6746940117f2d70541f1f2ffc56de1b0249 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Tue, 31 Mar 2026 09:55:25 -0400 Subject: [PATCH 2/3] fully deployed to public endpoints --- .gitignore | 3 + README.md | 24 +- deploy/Assign-ServicePrincipalApiRole.ps1 | 12 +- deploy/Assign-VmApiRoles.ps1 | 86 ++- deploy/DEPLOYMENT.md | 355 +++++++++++ deploy/Initialize-Database.ps1 | 96 ++- deploy/Initialize-DeploymentEnvironment.ps1 | 549 ++++++++++++++++-- deploy/Post-Provision.ps1 | 30 +- deploy/Register-LinuxHostSqlRecords.ps1 | 99 ++++ deploy/azure.yaml | 17 - deploy/bicep/main.bicep | 25 +- deploy/bicep/main.json | 255 +++++--- deploy/bicep/main.parameters.example.json | 45 ++ deploy/bicep/main.parameters.json | 39 -- deploy/bicep/main.resources.bicep | 95 ++- deploy/bicep/modules/Linux/main.bicep | 40 +- .../modules/apps/container-web-app.bicep | 10 + deploy/bicep/modules/core/key-vault.bicep | 32 +- .../bicep/modules/core/storage-account.bicep | 1 + .../001_create_table-vm_scaling_rules.sql | 44 +- ...2_create_table-vm_scaling_activity_log.sql | 27 +- .../003_create_table-virtual_machines.sql | 37 +- sql_queries/024_create_table-vmusers.sql | 15 +- ...5_create_procedure-RegisterLinuxHostVm.sql | 55 ++ sql_queries/README.md | 378 ++++++------ 25 files changed, 1814 insertions(+), 555 deletions(-) create mode 100644 deploy/DEPLOYMENT.md create mode 100644 deploy/Register-LinuxHostSqlRecords.ps1 create mode 100644 deploy/bicep/main.parameters.example.json delete mode 100644 deploy/bicep/main.parameters.json create mode 100644 sql_queries/025_create_procedure-RegisterLinuxHostVm.sql diff --git a/.gitignore b/.gitignore index f921ea1..11485ea 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ __pycache__ .pem .azure +# Generated Bicep parameters (contains secrets from preprovision) +deploy/bicep/main.parameters.json + # Configured custom script extensions, powershell scripts, and linux scripts priv-* diff --git a/README.md b/README.md index 22f9615..da0a9f4 100644 --- a/README.md +++ b/README.md @@ -191,22 +191,30 @@ Given that AVD acts as a pass-through in this solution, starting with **light to ## Getting Started -The consolidated deployment entrypoint for this repository is in `deploy/`. +The supported deployment entrypoint for this repository is in `deploy/`. -- AZD manifest: `deploy/azure.yaml` -- Bicep entrypoint: `deploy/bicep/main.bicep` -- Parameter template: `deploy/bicep/main.parameters.json` -- Deployment scripts: `deploy/*.ps1` +For the full deployment walkthrough, see [deploy/DEPLOYMENT.md](deploy/DEPLOYMENT.md). -From the repository root, use the following flow: +That guide covers: + +- prerequisites and required permissions +- azd environment values and defaults +- how `azd up` prompts for subscription and deployment region when not pre-set +- Entra app and group bootstrap behavior +- SSH key reuse, prompt, and auto-generation behavior +- what `preprovision`, Bicep provisioning, and `postprovision` each do +- SQL bootstrap, Linux host SQL registration, and validation steps +- troubleshooting and rerun paths + +Quick start from the repository root: ```powershell -Set-Location .\deploy +cd .\deploy azd env new azd up ``` -Before running `azd up`, populate the required Entra application and secret values in your azd environment or parameter file. The deployment scripts under `deploy/` handle the environment bootstrap, post-provision role assignment, container image builds, and database initialization paths used by this solution. +Before running `azd up`, review the detailed guide and set any environment-specific values you need, especially networking, host counts, VM sizes, and SQL firewall access. The deployment scripts under `deploy/` now handle the Entra bootstrap, SSH key flow, post-provision role assignment, container image builds, SQL initialization, and Linux host SQL registration used by this solution. ## Contributing diff --git a/deploy/Assign-ServicePrincipalApiRole.ps1 b/deploy/Assign-ServicePrincipalApiRole.ps1 index 929648b..8f841fd 100644 --- a/deploy/Assign-ServicePrincipalApiRole.ps1 +++ b/deploy/Assign-ServicePrincipalApiRole.ps1 @@ -47,7 +47,15 @@ $payload = @{ principalId = $PrincipalId resourceId = $apiServicePrincipal.id appRoleId = $role.id -} | ConvertTo-Json -Compress +} + +$bodyFile = [System.IO.Path]::ChangeExtension([System.IO.Path]::GetTempFileName(), '.json') +try { + $payload | ConvertTo-Json -Compress | Set-Content -Path $bodyFile -Encoding utf8 + az rest --method POST --url "$graphUrl/v1.0/servicePrincipals/$PrincipalId/appRoleAssignments" --headers 'Content-Type=application/json' --body "@$bodyFile" | Out-Null +} +finally { + Remove-Item -Path $bodyFile -ErrorAction SilentlyContinue +} -az rest --method POST --url "$graphUrl/v1.0/servicePrincipals/$PrincipalId/appRoleAssignments" --headers 'Content-Type=application/json' --body $payload | Out-Null Write-Host "Assigned '$RoleValue' application permission to principal '$PrincipalId'." diff --git a/deploy/Assign-VmApiRoles.ps1 b/deploy/Assign-VmApiRoles.ps1 index f37bb9e..a09cb08 100644 --- a/deploy/Assign-VmApiRoles.ps1 +++ b/deploy/Assign-VmApiRoles.ps1 @@ -3,24 +3,93 @@ param( [Parameter(Mandatory = $true)] [string]$ResourceGroupName, - [Parameter(Mandatory = $true)] - [string]$ApiClientId + [Parameter(Mandatory = $false)] + [string]$AvdHostGroupId, + + [Parameter(Mandatory = $false)] + [string]$LinuxHostGroupId, + + [Parameter(Mandatory = $false)] + [string]$EnvironmentName ) $ErrorActionPreference = 'Stop' +if ([string]::IsNullOrWhiteSpace($EnvironmentName)) { + $EnvironmentName = if (-not [string]::IsNullOrWhiteSpace($env:AZURE_ENV_NAME)) { + $env:AZURE_ENV_NAME + } + elseif (-not [string]::IsNullOrWhiteSpace($env:AZURE_ENVIRONMENT_NAME)) { + $env:AZURE_ENVIRONMENT_NAME + } + else { + '' + } +} + +function Get-AzdEnvValue { + param([Parameter(Mandatory = $true)][string]$Key) + + if ([string]::IsNullOrWhiteSpace($EnvironmentName)) { + return '' + } + + $value = azd env get-value $Key --environment $EnvironmentName 2>$null + if ($LASTEXITCODE -ne 0) { + return '' + } + + return ($value | Out-String).Trim() +} + +function Ensure-GroupMembership { + param( + [Parameter(Mandatory = $true)][string]$GroupId, + [Parameter(Mandatory = $true)][string]$PrincipalId, + [Parameter(Mandatory = $true)][string]$VmName + ) + + $membership = az ad group member check --group $GroupId --member-id $PrincipalId --output json | ConvertFrom-Json + if ($membership.value) { + Write-Host "VM '$VmName' managed identity is already a member of group '$GroupId'." + return + } + + az ad group member add --group $GroupId --member-id $PrincipalId | Out-Null + Write-Host "Added VM '$VmName' managed identity to group '$GroupId'." +} + +if ([string]::IsNullOrWhiteSpace($AvdHostGroupId)) { + $AvdHostGroupId = Get-AzdEnvValue -Key 'avdHostGroupId' + if ([string]::IsNullOrWhiteSpace($AvdHostGroupId)) { + $AvdHostGroupId = Get-AzdEnvValue -Key 'AVD_HOST_GROUP_ID' + } +} + +if ([string]::IsNullOrWhiteSpace($LinuxHostGroupId)) { + $LinuxHostGroupId = Get-AzdEnvValue -Key 'linuxHostGroupId' + if ([string]::IsNullOrWhiteSpace($LinuxHostGroupId)) { + $LinuxHostGroupId = Get-AzdEnvValue -Key 'LINUX_HOST_GROUP_ID' + } +} + $virtualMachines = az vm list --resource-group $ResourceGroupName --output json | ConvertFrom-Json if (-not $virtualMachines) { Write-Host "No virtual machines found in resource group '$ResourceGroupName'." exit 0 } -$roleMappings = @( - @{ Tag = 'linux-host'; RoleValue = 'LinuxHost' } - @{ Tag = 'avd-host'; RoleValue = 'AvdHost' } +$groupMappings = @( + @{ Tag = 'linux-host'; GroupId = $LinuxHostGroupId } + @{ Tag = 'avd-host'; GroupId = $AvdHostGroupId } ) -foreach ($mapping in $roleMappings) { +foreach ($mapping in $groupMappings) { + if ([string]::IsNullOrWhiteSpace($mapping.GroupId)) { + Write-Warning "Skipping '$($mapping.Tag)' VMs because no group id was provided." + continue + } + $matchingVms = $virtualMachines | Where-Object { $_.tags.'broker-role' -eq $mapping.Tag } foreach ($virtualMachine in $matchingVms) { $principalId = az vm show --resource-group $ResourceGroupName --name $virtualMachine.name --query identity.principalId --output tsv @@ -29,9 +98,6 @@ foreach ($mapping in $roleMappings) { continue } - & "$PSScriptRoot/Assign-ServicePrincipalApiRole.ps1" ` - -PrincipalId $principalId ` - -ApiClientId $ApiClientId ` - -RoleValue $mapping.RoleValue + Ensure-GroupMembership -GroupId $mapping.GroupId -PrincipalId $principalId -VmName $virtualMachine.name } } diff --git a/deploy/DEPLOYMENT.md b/deploy/DEPLOYMENT.md new file mode 100644 index 0000000..9544b8c --- /dev/null +++ b/deploy/DEPLOYMENT.md @@ -0,0 +1,355 @@ +# Detailed Deployment Guide + +This guide documents the supported deployment path for this repository. + +The short version stays in [../README.md](../README.md). Use this guide when you need the full `azd` workflow, required permissions, key environment values, SSH key behavior, validation steps, or troubleshooting guidance. + +## Deployment Model + +The supported deployment entrypoint is [azure.yaml](azure.yaml) in the `deploy/` folder. + +- `azd up` is wired to run `provision` and the deployment hooks from [azure.yaml](azure.yaml). +- The `preprovision` hook runs [Initialize-DeploymentEnvironment.ps1](Initialize-DeploymentEnvironment.ps1). +- Infrastructure is provisioned from [bicep/main.bicep](bicep/main.bicep). +- The `postprovision` hook runs [Post-Provision.ps1](Post-Provision.ps1). + +That means the actual deployment flow is: + +1. Bootstrap the azd environment, Entra applications, host groups, and SSH key material. +2. Provision Azure infrastructure with Bicep. +3. Build the container images in Azure Container Registry, restart the apps, initialize SQL, assign the function app role, sync VM group membership, and register Linux hosts in SQL. + +Two details matter here: + +- The supported path is `azd up` from the `deploy/` directory, not a separate manual mix of Bicep plus ad hoc scripts. +- Container images are built remotely with `az acr build`, so local Docker is not required. + +## Prerequisites + +### Local tooling + +- Windows with PowerShell 7 available as `pwsh`. The hooks in [azure.yaml](azure.yaml) are currently configured only under `windows`. +- Azure CLI logged into the target tenant and subscription. +- Azure Developer CLI (`azd`) logged in. +- OpenSSH Client if you want the hook to generate SSH keys automatically or from the local prompt path. + +### Azure and Entra permissions + +You need enough access to do all of the following: + +- Create resource groups and deploy Azure resources. +- Create Azure role assignments. +- Create or update Microsoft Entra app registrations. +- Create or update service principals. +- Create or update Entra security groups. +- Create Entra app-role assignments from groups to the API service principal. + +You also need a tenant admin available to grant admin consent after the app registrations are created. + +## What The Deployment Creates + +At a high level, the deployment provisions and configures the following: + +- Azure Container Registry for the `frontend`, `api`, and `task` images. +- App Service apps for the frontend and API, plus a Function App for scheduled work. +- Azure SQL Database and firewall rules. +- Azure Key Vault. +- App Service plan, storage account, Application Insights, Log Analytics, and networking. +- Optional Linux hosts and optional AVD hosts, depending on azd environment settings. +- Two Entra app registrations: frontend and API. +- Two Entra security groups for VM authorization: AVD hosts and Linux hosts. + +The deployment model now follows these runtime rules: + +- The function app gets the `ScheduledTask` API app role directly. +- VM managed identities do not get direct API role assignments. +- Instead, VM managed identities are added to Entra security groups, and those groups hold the `AvdHost` and `LinuxHost` API app roles. +- Key Vault stores only two deployment secrets: `db-password` and `linux-host`. +- Frontend and API auth secrets are stored in app settings, not in Key Vault. +- Linux hosts are registered into SQL during `postprovision`. AVD hosts are not. + +## Required And Common azd Environment Values + +The preprovision hook seeds many defaults automatically, but you should still treat the following values as the main inputs you may want to review or override. + +The checked-in [bicep/main.parameters.example.json](bicep/main.parameters.example.json) file is a safe template and reference. If you need a manual parameters file, copy it locally to `bicep/main.parameters.json` and fill in the values. The real [bicep/main.parameters.json](bicep/main.parameters.json) file is also generated locally by preprovision, contains secrets, and is gitignored. Prefer the azd environment for operational values. + +### Common values to set explicitly + +- `appName`: base name for generated resources. +- `AZURE_LOCATION`: deployment region. +- `allowedClientIp`: your public client IP for SQL bootstrap from the local machine. +- `deployLinuxHosts`: `true` or `false`. +- `deployAvdHosts`: `true` or `false`. +- `linuxHostCount`: number of Linux hosts to provision. +- `avdSessionHostCount`: number of AVD hosts to provision. +- `linuxHostVmSize`: Linux host VM size. +- `avdVmSize`: AVD host VM size. +- `linuxHostOsVersion`: Linux image SKU. +- `domainName`: domain suffix used by the broker when connecting to Linux hosts. +- `nfsShare`: NFS share path if required by your Linux host configuration. +- `vmHostResourceGroup`: override if managed VMs live in a different resource group. + +### Values that are usually auto-generated + +- `SQL_ADMIN_PASSWORD` +- `HOST_ADMIN_PASSWORD` +- `FLASK_SESSION_SECRET` +- frontend and API client secrets +- frontend and API client IDs if the app registrations do not already exist +- AVD and Linux host group IDs + +When `AZURE_LOCATION` is not already set, `azd up` will prompt you to select an Azure region before provisioning starts. The selected value is saved into the azd environment automatically. You can also pre-set it with `azd env set AZURE_LOCATION ` to skip the prompt. + +### SSH key values for Linux hosts + +The broker requires SSH key-based access to Linux hosts. The relevant azd environment values are: + +- `linuxHostSshPublicKey` +- `linuxHostSshPrivateKey` + +Behavior is now: + +- If both values are already set, the deployment reuses them. +- If neither value is set and the run is local and interactive, the hook prompts you to either generate a new keypair or stop and provide your own. +- If neither value is set and the run is non-interactive, the hook generates a new ed25519 keypair automatically. +- If only one of the two values is set, the hook treats that as a partial keypair state. Local interactive runs prompt you to generate a fresh pair or stop and provide your own. Non-interactive runs automatically regenerate a fresh complete pair. + +The private key is normalized into escaped newline form before it is written back into the azd environment and then stored in Key Vault. + +## Supplying Your Own SSH Keys + +If you want to use an existing SSH keypair instead of the generated one, set both values before running `azd up`. + +PowerShell example: + +```powershell +$publicKey = (Get-Content "$HOME/.ssh/id_ed25519.pub" -Raw).Trim() +$privateKey = (Get-Content "$HOME/.ssh/id_ed25519" -Raw) -replace "`r?`n", '\n' + +azd env set linuxHostSshPublicKey $publicKey +azd env set linuxHostSshPrivateKey $privateKey +``` + +If you prefer to be prompted locally, leave both values unset and run `azd up` from an interactive terminal. + +## Quick Start + +From the repository root: + +```powershell +Set-Location .\deploy +azd auth login +az login +azd env new +``` + +If `AZURE_LOCATION` is not already set, `azd up` will prompt you to select an Azure region before provisioning starts. + +Set the environment values you care about before the first run. Example: + +```powershell +azd env set appName linuxbroker +azd env set allowedClientIp +azd env set deployLinuxHosts true +azd env set deployAvdHosts true +azd env set linuxHostCount 2 +azd env set avdSessionHostCount 1 +``` + +Then run: + +```powershell +azd up +``` + +## What Happens During `preprovision` + +[Initialize-DeploymentEnvironment.ps1](Initialize-DeploymentEnvironment.ps1) performs the environment bootstrap before any Azure resources are provisioned. + +It currently does all of the following: + +- Seeds default azd environment values for common parameters. +- Creates or reuses the frontend Entra app registration. +- Creates or reuses the API Entra app registration. +- Creates service principals for those applications if needed. +- Creates or reuses the AVD host and Linux host Entra security groups. +- Assigns the `AvdHost` and `LinuxHost` app roles from the API application to those groups. +- Creates or reuses frontend and API client secrets. +- Generates or reuses Linux host SSH keys. +- Writes resolved values back into the azd environment in both uppercase and camelCase forms expected by the deployment. + +The API app registration is also configured with the Graph application permissions the API uses to validate host and group membership. + +## What Happens During Provisioning + +[bicep/main.bicep](bicep/main.bicep) and the modules under `bicep/modules/` provision the infrastructure. + +Important deployment characteristics: + +- SQL public network access is enabled. +- Azure services are allowed through the SQL firewall. +- A client-IP firewall rule is added only if `allowedClientIp` is set. +- Linux host auth defaults to `SSH`. +- Key Vault stores `db-password` and `linux-host`. +- The API app receives Key Vault Secrets User access so it can read those secrets at runtime. + +## What Happens During `postprovision` + +[Post-Provision.ps1](Post-Provision.ps1) performs the runtime completion steps after the Azure resources exist. + +It currently runs, in order: + +1. [Build-ContainerImages.ps1](Build-ContainerImages.ps1) +2. [Initialize-Database.ps1](Initialize-Database.ps1) +3. [Assign-FunctionAppApiRole.ps1](Assign-FunctionAppApiRole.ps1) +4. [Assign-VmApiRoles.ps1](Assign-VmApiRoles.ps1) +5. [Register-LinuxHostSqlRecords.ps1](Register-LinuxHostSqlRecords.ps1) + +That means `postprovision` does all of the following: + +- Builds `frontend:latest`, `api:latest`, and `task:latest` in ACR. +- Restarts the frontend app, API app, and function app after the new images are pushed. +- Applies all SQL scripts from [../sql_queries](../sql_queries) through ADO.NET. +- Makes the SQL bootstrap rerunnable by handling `GO` batches and converting procedure creation to `CREATE OR ALTER`. +- Assigns the `ScheduledTask` app role to the function app managed identity. +- Adds AVD and Linux VM managed identities to the corresponding Entra groups. +- Registers Linux hosts into `dbo.VirtualMachines` through `dbo.RegisterLinuxHostVm`. + +## Manual Steps After `azd up` + +The deployment does not grant tenant-wide admin consent automatically. + +After the preprovision hook has created the app registrations, a tenant admin still needs to grant consent for: + +- Frontend delegated permissions such as `User.Read`, `profile`, `email`, `offline_access`, `openid`, and the API delegated scope. +- API application permissions to Microsoft Graph used for group and directory reads. + +Without admin consent, deployment can still complete, but sign-in and Graph-backed authorization checks will not work correctly. + +## Validation Checklist + +After a successful deployment, verify these items: + +### azd environment values + +```powershell +azd env get-values +``` + +Confirm that values such as the following are present: + +- `frontendClientId` +- `apiClientId` +- `avdHostGroupId` +- `linuxHostGroupId` +- `linuxHostSshPublicKey` +- `linuxHostSshPrivateKey` + +### Azure resources + +Verify that the expected resources exist in the target resource group: + +- frontend web app +- API web app +- task function app +- ACR +- Key Vault +- SQL server and database +- optional Linux and AVD VMs + +### Key Vault + +Confirm the vault contains: + +- `db-password` +- `linux-host` + +### SQL + +Confirm the database contains the expected tables and procedures. + +This includes the newer objects used by the current deployment flow: + +- `dbo.VmUsers` +- `dbo.VirtualMachines` +- `dbo.VmScalingRules` +- `dbo.VmScalingActivityLog` +- `dbo.RegisterLinuxHostVm` + +For more database detail, see [../sql_queries/README.md](../sql_queries/README.md). + +### Entra authorization model + +Confirm that: + +- the function app managed identity has the `ScheduledTask` API app role +- the AVD host group has the `AvdHost` API app role +- the Linux host group has the `LinuxHost` API app role +- VM managed identities are members of the correct Entra groups + +### Application health + +Confirm that: + +- the frontend and API apps restarted after the ACR builds +- the function app restarted after the image build +- frontend sign-in works after admin consent is granted +- AVD checkout and Linux host release operations work end to end + +## Rerunning Parts Of The Deployment + +You do not always need to reprovision the whole environment. + +### Rebuild container images and restart apps + +```powershell +Set-Location .\deploy +.\Build-ContainerImages.ps1 -EnvironmentName +``` + +### Rerun post-provision tasks + +```powershell +Set-Location .\deploy +.\Post-Provision.ps1 -EnvironmentName +``` + +That reruns the image build, SQL bootstrap, function-role assignment, VM group sync, and Linux host SQL registration. + +## Troubleshooting + +### The SSH key prompt did not appear + +The prompt only appears for local interactive runs. If the console is redirected or the run is happening in CI, the hook will use the non-interactive path and auto-generate keys instead. + +### `ssh-keygen` was not found + +Install the OpenSSH Client on the local machine, or prepopulate both `linuxHostSshPublicKey` and `linuxHostSshPrivateKey` in the azd environment. + +### SQL bootstrap failed with a connectivity or firewall error + +`postprovision` connects to SQL from the machine running `azd up`, not from inside Azure. Set `allowedClientIp` before provisioning so the Bicep deployment adds the client-IP firewall rule. + +### App registration or group operations failed during `preprovision` + +The signed-in operator likely lacks enough Microsoft Entra permissions to create applications, groups, service principals, or app-role assignments. + +### Sign-in or Graph-backed authorization fails after deployment + +Admin consent was likely not granted yet for the frontend delegated permissions or the API Graph application permissions. + +### Linux hosts were provisioned but do not appear in SQL + +Rerun [Post-Provision.ps1](Post-Provision.ps1) after confirming SQL connectivity. Linux host SQL registration is intentionally limited to Linux hosts only. + +## Related Files + +- [azure.yaml](azure.yaml) +- [Initialize-DeploymentEnvironment.ps1](Initialize-DeploymentEnvironment.ps1) +- [Post-Provision.ps1](Post-Provision.ps1) +- [Build-ContainerImages.ps1](Build-ContainerImages.ps1) +- [Initialize-Database.ps1](Initialize-Database.ps1) +- [Register-LinuxHostSqlRecords.ps1](Register-LinuxHostSqlRecords.ps1) +- [bicep/main.bicep](bicep/main.bicep) +- [../sql_queries/README.md](../sql_queries/README.md) \ No newline at end of file diff --git a/deploy/Initialize-Database.ps1 b/deploy/Initialize-Database.ps1 index fc8d08a..fb957ab 100644 --- a/deploy/Initialize-Database.ps1 +++ b/deploy/Initialize-Database.ps1 @@ -19,6 +19,67 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +function Get-SqlConnection { + param( + [Parameter(Mandatory = $true)][string]$Server, + [Parameter(Mandatory = $true)][string]$Database, + [Parameter(Mandatory = $true)][string]$Username, + [Parameter(Mandatory = $true)][string]$Password + ) + + $connectionString = "Server=tcp:$Server,1433;Initial Catalog=$Database;Persist Security Info=False;User ID=$Username;Password=$Password;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" + return [System.Data.SqlClient.SqlConnection]::new($connectionString) +} + +function Convert-SqlScriptContent { + param( + [Parameter(Mandatory = $true)][string]$Content, + [Parameter(Mandatory = $true)][string]$FileName + ) + + $normalizedContent = $Content -replace "`r`n", "`n" + + if ($FileName -match 'create_procedure|alter_procedure') { + $normalizedContent = [System.Text.RegularExpressions.Regex]::Replace( + $normalizedContent, + '(?im)^\s*(CREATE|ALTER)\s+PROCEDURE\b', + 'CREATE OR ALTER PROCEDURE' + ) + } + + return $normalizedContent +} + +function Split-SqlBatches { + param([Parameter(Mandatory = $true)][string]$Content) + + return [System.Text.RegularExpressions.Regex]::Split($Content, '(?im)^\s*GO\s*$') | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } +} + +function Invoke-SqlBatch { + param( + [Parameter(Mandatory = $true)][System.Data.SqlClient.SqlConnection]$Connection, + [Parameter(Mandatory = $true)][string]$Batch, + [Parameter(Mandatory = $true)][string]$FileName, + [Parameter(Mandatory = $true)][int]$BatchNumber + ) + + $command = $Connection.CreateCommand() + $command.CommandText = $Batch + $command.CommandTimeout = 120 + + try { + [void]$command.ExecuteNonQuery() + } + catch { + throw "Failed executing batch $BatchNumber from '$FileName': $($_.Exception.Message)" + } + finally { + $command.Dispose() + } +} + if ($env:SKIP_SQL_BOOTSTRAP -eq 'true') { Write-Host 'Skipping SQL bootstrap because SKIP_SQL_BOOTSTRAP=true.' exit 0 @@ -32,26 +93,27 @@ if (-not $sqlFiles) { exit 0 } -$invokeSqlCmd = Get-Command Invoke-Sqlcmd -ErrorAction SilentlyContinue -$sqlcmdExe = Get-Command sqlcmd -ErrorAction SilentlyContinue +$connection = Get-SqlConnection -Server $SqlServerFqdn -Database $DatabaseName -Username $SqlAdminLogin -Password $SqlAdminPassword -if (-not $invokeSqlCmd -and -not $sqlcmdExe) { - Write-Warning 'Skipping SQL bootstrap because neither Invoke-Sqlcmd nor sqlcmd is available on this machine.' - exit 0 -} +try { + $connection.Open() -foreach ($sqlFile in $sqlFiles) { - Write-Host "Applying $($sqlFile.Name)" - try { - if ($invokeSqlCmd) { - Invoke-Sqlcmd -ServerInstance $SqlServerFqdn -Database $DatabaseName -Username $SqlAdminLogin -Password $SqlAdminPassword -InputFile $sqlFile.FullName -Encrypt Mandatory -TrustServerCertificate:$false | Out-Null - } - else { - & $sqlcmdExe.Source -S $SqlServerFqdn -d $DatabaseName -U $SqlAdminLogin -P $SqlAdminPassword -N -i $sqlFile.FullName | Out-Null + foreach ($sqlFile in $sqlFiles) { + Write-Host "Applying $($sqlFile.Name)" + + $scriptContent = Get-Content -Path $sqlFile.FullName -Raw -Encoding UTF8 + $convertedContent = Convert-SqlScriptContent -Content $scriptContent -FileName $sqlFile.Name + $batches = @(Split-SqlBatches -Content $convertedContent) + + for ($index = 0; $index -lt $batches.Count; $index++) { + Invoke-SqlBatch -Connection $connection -Batch $batches[$index] -FileName $sqlFile.Name -BatchNumber ($index + 1) } } - catch { - Write-Warning "SQL bootstrap stopped on $($sqlFile.Name): $($_.Exception.Message)" - exit 0 +} +finally { + if ($connection.State -ne [System.Data.ConnectionState]::Closed) { + $connection.Close() } + + $connection.Dispose() } diff --git a/deploy/Initialize-DeploymentEnvironment.ps1 b/deploy/Initialize-DeploymentEnvironment.ps1 index 2d58310..75b3cf2 100644 --- a/deploy/Initialize-DeploymentEnvironment.ps1 +++ b/deploy/Initialize-DeploymentEnvironment.ps1 @@ -21,7 +21,27 @@ if ([string]::IsNullOrWhiteSpace($AppName)) { $env:APP_NAME } else { - 'linuxbroker' + $existingAppName = '' + if (-not [string]::IsNullOrWhiteSpace($EnvironmentName)) { + $existingAppName = (azd env get-value appName --environment $EnvironmentName 2>$null | Out-String).Trim() + } + + if (-not [string]::IsNullOrWhiteSpace($existingAppName)) { + $existingAppName + } + else { + $defaultAppName = 'linuxbroker' + $canPromptForAppName = [Environment]::UserInteractive -and + (-not $env:CI) -and (-not $env:TF_BUILD) -and (-not $env:GITHUB_ACTIONS) -and (-not $env:BUILD_BUILDID) + try { $canPromptForAppName = $canPromptForAppName -and (-not [Console]::IsInputRedirected) } catch { $canPromptForAppName = $false } + if ($canPromptForAppName) { + $inputAppName = Read-Host "Application name for resource naming [$defaultAppName]" + if ([string]::IsNullOrWhiteSpace($inputAppName)) { $defaultAppName } else { $inputAppName.Trim() } + } + else { + $defaultAppName + } + } } } @@ -38,16 +58,17 @@ if ([string]::IsNullOrWhiteSpace($EnvironmentName)) { } $graphAppId = '00000003-0000-0000-c000-000000000000' +$defaultAccessAppRoleId = '00000000-0000-0000-0000-000000000000' $frontendGraphDelegatedPermissions = @( @{ name = 'User.Read'; id = 'e1fe6dd8-ba31-4d61-89e7-88639da4683d' } @{ name = 'profile'; id = '14dad69e-099b-42c9-810b-d002981feec1' } @{ name = 'email'; id = '64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0' } - @{ name = 'Group.Read.All'; id = '5f8c59db-677d-491f-a6b8-5f174b11ec1d' } @{ name = 'offline_access'; id = '7427e0e9-2fba-42fe-b0c0-848c9e6a8182' } @{ name = 'openid'; id = '37f7f235-527c-4136-accd-4a02d197296e' } ) $apiScopeId = '58db6e6d-38d5-4ce2-bf0a-7fd9cfd5f00a' +$frontendScopeId = '9afc8711-1fe8-4b8d-9178-44235aa93b4a' $apiRoleIds = @{ FullAccess = '4b2d5f7f-7cc1-4303-8d4b-bd7d2cfe2ca6' ScheduledTask = 'd11a6ed0-ee5e-4305-a2a2-252a8107d84f' @@ -87,19 +108,70 @@ function Get-AzdEnvValue { return ($value | Out-String).Trim() } +function Get-AzdEnvFilePath { + $envDirectory = Join-Path (Join-Path $PSScriptRoot '.azure') $EnvironmentName + if (-not (Test-Path -Path $envDirectory)) { + New-Item -ItemType Directory -Path $envDirectory -Force | Out-Null + } + + return Join-Path $envDirectory '.env' +} + +function Set-AzdEnvFileValue { + param( + [Parameter(Mandatory = $true)][string]$Key, + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value + ) + + $envFilePath = Get-AzdEnvFilePath + $escapedValue = $Value.Replace('"', '\"') + $serializedLine = $Key + '="' + $escapedValue + '"' + $updatedLines = New-Object System.Collections.Generic.List[string] + $matched = $false + + if (Test-Path -Path $envFilePath) { + foreach ($line in Get-Content -Path $envFilePath -Encoding utf8) { + if ($line.StartsWith($Key + '=')) { + $updatedLines.Add($serializedLine) + $matched = $true + } + else { + $updatedLines.Add($line) + } + } + } + + if (-not $matched) { + $updatedLines.Add($serializedLine) + } + + $updatedContent = ($updatedLines -join "`r`n") + "`r`n" + Set-Content -Path $envFilePath -Value $updatedContent -Encoding utf8 +} + function Set-AzdEnvValue { param( [Parameter(Mandatory = $true)][string]$Key, [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value ) - azd env set $Key $Value --environment $EnvironmentName | Out-Null + if ($Value.StartsWith('-')) { + Set-AzdEnvFileValue -Key $Key -Value $Value + return + } + + azd env set $Key $Value --environment $EnvironmentName 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Failed to set azd environment value '$Key'." + } } function New-RandomSecret { param([int]$Length = 40) - $alphabet = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@$%^*-_=+' + # Use only characters that survive azd env round-trips and are accepted + # by Azure SQL password validation without escaping issues. + $alphabet = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789-_.' $bytes = New-Object byte[] ($Length) [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) @@ -111,6 +183,167 @@ function New-RandomSecret { return $builder.ToString() } +function Get-FirstNonEmptyValue { + param( + [AllowEmptyCollection()] + [AllowEmptyString()] + [string[]]$Values = @() + ) + + foreach ($value in $Values) { + if (-not [string]::IsNullOrWhiteSpace($value)) { + return $value.Trim() + } + } + + return '' +} + +function ConvertTo-EscapedMultilineValue { + param([Parameter(Mandatory = $true)][string]$Value) + + $normalized = $Value.Replace("`r`n", "`n") + return $normalized.Replace("`n", '\n') +} + +function Test-IsInteractiveLocalRun { + $nonInteractiveSignals = @( + 'CI' + 'TF_BUILD' + 'GITHUB_ACTIONS' + 'BUILD_BUILDID' + ) + + foreach ($signal in $nonInteractiveSignals) { + if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($signal))) { + return $false + } + } + + if (-not [Environment]::UserInteractive) { + return $false + } + + try { + # Only check stdin redirection. azd hooks redirect stdout/stderr to + # capture output, but stdin stays connected to the terminal so + # Read-Host and PromptForChoice still work. + return -not [Console]::IsInputRedirected + } + catch { + return $false + } +} + +function Show-LinuxHostSshKeyChoicePrompt { + param([Parameter(Mandatory = $true)][string]$Message) + + $choices = [System.Management.Automation.Host.ChoiceDescription[]]@( + (New-Object System.Management.Automation.Host.ChoiceDescription '&Generate', 'Generate a new Linux host SSH key pair and store it in the azd environment.') + (New-Object System.Management.Automation.Host.ChoiceDescription '&UseMine', 'Stop now so you can set your own Linux host SSH public and private keys and rerun azd.') + ) + + $caption = 'Linux host SSH key pair' + + try { + return $Host.UI.PromptForChoice($caption, $Message, $choices, 0) + } + catch { + return 0 + } +} + +function New-LinuxHostSshKeyPair { + $sshKeyGen = Get-Command ssh-keygen -ErrorAction SilentlyContinue + if (-not $sshKeyGen) { + throw 'OpenSSH ssh-keygen was not found. Install OpenSSH Client or set linuxHostSshPublicKey and linuxHostSshPrivateKey manually.' + } + + $tempDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ("linuxbroker-ssh-" + [guid]::NewGuid().ToString('N')) + $privateKeyPath = Join-Path $tempDirectory 'id_ed25519' + $keyComment = "$AppName-$EnvironmentName-linux-host" + + New-Item -ItemType Directory -Path $tempDirectory -Force | Out-Null + + try { + & $sshKeyGen.Source -q -t ed25519 -N '' -C $keyComment -f $privateKeyPath | Out-Null + if ($LASTEXITCODE -ne 0) { + throw 'ssh-keygen failed while creating the Linux host SSH key pair.' + } + + $privateKey = Get-Content -Path $privateKeyPath -Raw -Encoding utf8 + $publicKey = (Get-Content -Path "$privateKeyPath.pub" -Raw -Encoding utf8).Trim() + + return @{ + PrivateKey = ConvertTo-EscapedMultilineValue -Value $privateKey + PublicKey = $publicKey + } + } + finally { + Remove-Item -Path $tempDirectory -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Ensure-LinuxHostSshKeys { + $resolvedPublicKey = Get-FirstNonEmptyValue -Values @( + (Get-AzdEnvValue -Key 'linuxHostSshPublicKey'), + (Get-AzdEnvValue -Key 'LINUX_HOST_SSH_PUBLIC_KEY') + ) + $resolvedPrivateKey = Get-FirstNonEmptyValue -Values @( + (Get-AzdEnvValue -Key 'linuxHostSshPrivateKey'), + (Get-AzdEnvValue -Key 'LINUX_HOST_SSH_PRIVATE_KEY') + ) + + $hasPublicKey = -not [string]::IsNullOrWhiteSpace($resolvedPublicKey) + $hasPrivateKey = -not [string]::IsNullOrWhiteSpace($resolvedPrivateKey) + + if ($hasPublicKey -xor $hasPrivateKey) { + if (Test-IsInteractiveLocalRun) { + $choice = Show-LinuxHostSshKeyChoicePrompt -Message "A partial Linux host SSH key pair is configured for azd environment '$EnvironmentName'. Generate a fresh complete pair now, or stop and provide your own key pair." + if ($choice -eq 1) { + throw 'Set both linuxHostSshPublicKey and linuxHostSshPrivateKey (or LINUX_HOST_SSH_PUBLIC_KEY and LINUX_HOST_SSH_PRIVATE_KEY) and rerun azd.' + } + } + + Write-Warning 'Detected a partial Linux host SSH key pair in the azd environment. Generating a fresh complete pair.' + $resolvedPublicKey = '' + $resolvedPrivateKey = '' + $hasPublicKey = $false + $hasPrivateKey = $false + } + + if (-not $hasPublicKey -and -not $hasPrivateKey) { + if (Test-IsInteractiveLocalRun) { + $choice = Show-LinuxHostSshKeyChoicePrompt -Message "No Linux host SSH key pair is configured for azd environment '$EnvironmentName'." + if ($choice -eq 1) { + throw 'Set linuxHostSshPublicKey and linuxHostSshPrivateKey (or LINUX_HOST_SSH_PUBLIC_KEY and LINUX_HOST_SSH_PRIVATE_KEY) and rerun azd.' + } + } + + $generatedKeys = New-LinuxHostSshKeyPair + $resolvedPublicKey = $generatedKeys.PublicKey + $resolvedPrivateKey = $generatedKeys.PrivateKey + Write-Host 'Generated a new Linux host SSH key pair for this azd environment.' + } + else { + if ($resolvedPrivateKey.Contains("`n") -or $resolvedPrivateKey.Contains("`r")) { + $resolvedPrivateKey = ConvertTo-EscapedMultilineValue -Value $resolvedPrivateKey + } + + Write-Host 'Using the Linux host SSH key pair already configured in the azd environment.' + } + + Set-AzdEnvValue -Key 'LINUX_HOST_SSH_PUBLIC_KEY' -Value $resolvedPublicKey + Set-AzdEnvValue -Key 'LINUX_HOST_SSH_PRIVATE_KEY' -Value $resolvedPrivateKey + Set-AzdEnvValue -Key 'linuxHostSshPublicKey' -Value $resolvedPublicKey + Set-AzdEnvValue -Key 'linuxHostSshPrivateKey' -Value $resolvedPrivateKey + + return @{ + PublicKey = $resolvedPublicKey + PrivateKey = $resolvedPrivateKey + } +} + function Ensure-DefaultEnvValue { param( [Parameter(Mandatory = $true)][string]$Key, @@ -140,6 +373,22 @@ function Write-JsonFile { return $path } +function Invoke-GraphRestJson { + param( + [Parameter(Mandatory = $true)][string]$Method, + [Parameter(Mandatory = $true)][string]$Url, + [Parameter(Mandatory = $true)]$Body + ) + + $bodyFile = Write-JsonFile -InputObject $Body + try { + az rest --method $Method --url $Url --headers 'Content-Type=application/json' --body "@$bodyFile" | Out-Null + } + finally { + Remove-Item -Path $bodyFile -ErrorAction SilentlyContinue + } +} + function Get-MatchingApp { param([Parameter(Mandatory = $true)][string]$DisplayName) @@ -167,6 +416,92 @@ function Ensure-ServicePrincipal { return az ad sp create --id $AppId --output json | ConvertFrom-Json } +function Ensure-GroupAppRoleAssignment { + param( + [Parameter(Mandatory = $true)][hashtable]$CloudContext, + [Parameter(Mandatory = $true)][string]$GroupId, + [Parameter(Mandatory = $true)][string]$ResourceServicePrincipalId, + [Parameter(Mandatory = $true)][string]$AppRoleId + ) + + $assignmentsResponse = az rest --method GET --url "$($CloudContext.GraphUrl)/v1.0/groups/$GroupId/appRoleAssignments" --output json | ConvertFrom-Json + $existingAssignment = $assignmentsResponse.value | Where-Object { + $_.resourceId -eq $ResourceServicePrincipalId -and $_.appRoleId -eq $AppRoleId + } | Select-Object -First 1 + + if ($existingAssignment) { + return + } + + $payload = @{ + principalId = $GroupId + resourceId = $ResourceServicePrincipalId + appRoleId = $AppRoleId + } + + Invoke-GraphRestJson -Method 'POST' -Url "$($CloudContext.GraphUrl)/v1.0/groups/$GroupId/appRoleAssignments" -Body $payload +} + +function Get-SignedInUser { + try { + $userType = az account show --query user.type --output tsv 2>$null + if ($LASTEXITCODE -ne 0 -or $userType -ne 'user') { + return $null + } + + return az ad signed-in-user show --output json | ConvertFrom-Json + } + catch { + return $null + } +} + +function Ensure-UserAppRoleAssignment { + param( + [Parameter(Mandatory = $true)][hashtable]$CloudContext, + [Parameter(Mandatory = $true)][string]$UserId, + [Parameter(Mandatory = $true)][string]$ResourceServicePrincipalId, + [Parameter(Mandatory = $true)][string]$AppRoleId + ) + + $assignmentsResponse = az rest --method GET --url "$($CloudContext.GraphUrl)/v1.0/users/$UserId/appRoleAssignments" --output json | ConvertFrom-Json + $existingAssignment = $assignmentsResponse.value | Where-Object { + $_.resourceId -eq $ResourceServicePrincipalId -and $_.appRoleId -eq $AppRoleId + } | Select-Object -First 1 + + if ($existingAssignment) { + return + } + + $payload = @{ + principalId = $UserId + resourceId = $ResourceServicePrincipalId + appRoleId = $AppRoleId + } + + Invoke-GraphRestJson -Method 'POST' -Url "$($CloudContext.GraphUrl)/v1.0/users/$UserId/appRoleAssignments" -Body $payload +} + +function Remove-UserAppRoleAssignment { + param( + [Parameter(Mandatory = $true)][hashtable]$CloudContext, + [Parameter(Mandatory = $true)][string]$UserId, + [Parameter(Mandatory = $true)][string]$ResourceServicePrincipalId, + [Parameter(Mandatory = $true)][string]$AppRoleId + ) + + $assignmentsResponse = az rest --method GET --url "$($CloudContext.GraphUrl)/v1.0/users/$UserId/appRoleAssignments" --output json | ConvertFrom-Json + $existingAssignment = $assignmentsResponse.value | Where-Object { + $_.resourceId -eq $ResourceServicePrincipalId -and $_.appRoleId -eq $AppRoleId + } | Select-Object -First 1 + + if (-not $existingAssignment) { + return + } + + az rest --method DELETE --url "$($CloudContext.GraphUrl)/v1.0/users/$UserId/appRoleAssignments/$($existingAssignment.id)" | Out-Null +} + function Ensure-ClientSecret { param( [Parameter(Mandatory = $true)]$Application, @@ -188,6 +523,18 @@ function Ensure-ClientSecret { return $secret.password } +function Ensure-AppAdminConsent { + param( + [Parameter(Mandatory = $true)][string]$AppId, + [Parameter(Mandatory = $true)][string]$DisplayName + ) + + az ad app permission admin-consent --id $AppId 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Warning "Unable to grant admin consent automatically for application '$DisplayName'. Grant tenant-wide consent manually if required." + } +} + function Ensure-Group { param([Parameter(Mandatory = $true)][string]$DisplayName) @@ -250,14 +597,34 @@ function Ensure-FrontendApplication { Remove-Item -Path $requiredResourcesFile -ErrorAction SilentlyContinue } + $frontendManifest = @{ + identifierUris = @("api://$($app.appId)") + api = @{ + requestedAccessTokenVersion = 2 + oauth2PermissionScopes = @( + @{ + adminConsentDescription = "Allow the application to access $DisplayName on behalf of the signed-in user." + adminConsentDisplayName = "Access $DisplayName" + id = $frontendScopeId + isEnabled = $true + type = 'User' + userConsentDescription = "Allow the application to access $DisplayName on your behalf." + userConsentDisplayName = "Access $DisplayName" + value = 'user_impersonation' + } + ) + } + } + + Invoke-GraphRestJson -Method 'PATCH' -Url "$($CloudContext.GraphUrl)/v1.0/applications/$($app.id)" -Body $frontendManifest + $logoutBody = @{ web = @{ logoutUrl = "$redirectBase/logout" } } - $logoutPayload = $logoutBody | ConvertTo-Json -Depth 10 -Compress - az rest --method PATCH --url "$($CloudContext.GraphUrl)/v1.0/applications/$($app.id)" --headers 'Content-Type=application/json' --body $logoutPayload | Out-Null + Invoke-GraphRestJson -Method 'PATCH' -Url "$($CloudContext.GraphUrl)/v1.0/applications/$($app.id)" -Body $logoutBody return $app } @@ -265,7 +632,8 @@ function Ensure-FrontendApplication { function Ensure-ApiApplication { param( [Parameter(Mandatory = $true)][hashtable]$CloudContext, - [Parameter(Mandatory = $true)][string]$DisplayName + [Parameter(Mandatory = $true)][string]$DisplayName, + [Parameter(Mandatory = $false)][string]$FrontendAppId = '' ) $app = Get-MatchingApp -DisplayName $DisplayName @@ -275,9 +643,53 @@ function Ensure-ApiApplication { az ad app update --id $app.id --identifier-uris "api://$($app.appId)" --requested-access-token-version 2 | Out-Null + $graphSp = az ad sp show --id $graphAppId --output json | ConvertFrom-Json + $graphApplicationPermissions = @( + 'Directory.Read.All' + 'Group.Read.All' + 'GroupMember.Read.All' + ) + $graphResourceAccess = @() + + foreach ($permissionValue in $graphApplicationPermissions) { + $roleId = $graphSp.appRoles | Where-Object { + $_.value -eq $permissionValue -and $_.allowedMemberTypes -contains 'Application' + } | Select-Object -ExpandProperty id -First 1 + + if ($roleId) { + $graphResourceAccess += @{ + id = $roleId + type = 'Role' + } + } + } + + $requiredResourceAccess = @() + if ($graphResourceAccess.Count -gt 0) { + $requiredResourceAccess += @{ + resourceAppId = $graphAppId + resourceAccess = $graphResourceAccess + } + } + + $knownClientApplications = @() + $preAuthorizedApplications = @() + if (-not [string]::IsNullOrWhiteSpace($FrontendAppId)) { + $knownClientApplications = @($FrontendAppId) + $preAuthorizedApplications = @( + @{ + appId = $FrontendAppId + delegatedPermissionIds = @($apiScopeId) + } + ) + } + $apiManifest = @{ identifierUris = @("api://$($app.appId)") + requiredResourceAccess = $requiredResourceAccess api = @{ + knownClientApplications = $knownClientApplications + preAuthorizedApplications = $preAuthorizedApplications requestedAccessTokenVersion = 2 oauth2PermissionScopes = @( @{ @@ -294,14 +706,13 @@ function Ensure-ApiApplication { } } - $manifestPayload = $apiManifest | ConvertTo-Json -Depth 20 -Compress - az rest --method PATCH --url "$($CloudContext.GraphUrl)/v1.0/applications/$($app.id)" --headers 'Content-Type=application/json' --body $manifestPayload | Out-Null + Invoke-GraphRestJson -Method 'PATCH' -Url "$($CloudContext.GraphUrl)/v1.0/applications/$($app.id)" -Body $apiManifest $appRoles = @( @{ - allowedMemberTypes = @('Application', 'User') + allowedMemberTypes = @('User') description = 'Full access to Linux Broker management APIs.' - displayName = 'Full Access' + displayName = 'FullAccess' id = $apiRoleIds.FullAccess isEnabled = $true value = 'FullAccess' @@ -309,23 +720,23 @@ function Ensure-ApiApplication { @{ allowedMemberTypes = @('Application') description = 'Allows the scheduled task function app to call maintenance endpoints.' - displayName = 'Scheduled Task' + displayName = 'ScheduledTask' id = $apiRoleIds.ScheduledTask isEnabled = $true value = 'ScheduledTask' } @{ - allowedMemberTypes = @('Application') + allowedMemberTypes = @('Application', 'User') description = 'Allows AVD host automation to call AVD-specific endpoints.' - displayName = 'AVD Host' + displayName = 'AvdHost' id = $apiRoleIds.AvdHost isEnabled = $true value = 'AvdHost' } @{ - allowedMemberTypes = @('Application') + allowedMemberTypes = @('Application', 'User') description = 'Allows Linux host automation to call Linux host endpoints.' - displayName = 'Linux Host' + displayName = 'LinuxHost' id = $apiRoleIds.LinuxHost isEnabled = $true value = 'LinuxHost' @@ -340,33 +751,6 @@ function Ensure-ApiApplication { Remove-Item -Path $appRolesFile -ErrorAction SilentlyContinue } - $graphSp = az ad sp show --id $graphAppId --output json | ConvertFrom-Json - $groupMemberReadAllRoleId = $graphSp.appRoles | Where-Object { - $_.value -eq 'GroupMember.Read.All' -and $_.allowedMemberTypes -contains 'Application' - } | Select-Object -ExpandProperty id -First 1 - - if ($groupMemberReadAllRoleId) { - $requiredResources = @( - @{ - resourceAppId = $graphAppId - resourceAccess = @( - @{ - id = $groupMemberReadAllRoleId - type = 'Role' - } - ) - } - ) - - $requiredResourcesFile = Write-JsonFile -InputObject $requiredResources - try { - az ad app update --id $app.id --required-resource-accesses "@$requiredResourcesFile" | Out-Null - } - finally { - Remove-Item -Path $requiredResourcesFile -ErrorAction SilentlyContinue - } - } - return $app } @@ -383,15 +767,6 @@ $defaultTenantId = $subscription.tenantId Ensure-DefaultEnvValue -Key 'appName' -ValueFactory { $AppName } | Out-Null Ensure-DefaultEnvValue -Key 'environmentName' -ValueFactory { $EnvironmentName } | Out-Null -Ensure-DefaultEnvValue -Key 'AZURE_LOCATION' -ValueFactory { - if (-not [string]::IsNullOrWhiteSpace($env:AZURE_LOCATION)) { - $env:AZURE_LOCATION - } - else { - 'eastus2' - } -} | Out-Null -Ensure-DefaultEnvValue -Key 'location' -ValueFactory { Get-AzdEnvValue -Key 'AZURE_LOCATION' } | Out-Null Ensure-DefaultEnvValue -Key 'tenantId' -ValueFactory { $defaultTenantId } | Out-Null Ensure-DefaultEnvValue -Key 'SQL_ADMIN_LOGIN' -ValueFactory { 'brokeradmin' } | Out-Null @@ -401,7 +776,6 @@ Ensure-DefaultEnvValue -Key 'LINUX_HOST_ADMIN_LOGIN_NAME' -ValueFactory { 'avdad Ensure-DefaultEnvValue -Key 'DOMAIN_NAME' -ValueFactory { '' } | Out-Null Ensure-DefaultEnvValue -Key 'NFS_SHARE' -ValueFactory { '' } | Out-Null Ensure-DefaultEnvValue -Key 'VM_HOST_RESOURCE_GROUP' -ValueFactory { '' } | Out-Null -Ensure-DefaultEnvValue -Key 'ALLOWED_CLIENT_IP' -ValueFactory { '' } | Out-Null Ensure-DefaultEnvValue -Key 'FLASK_SESSION_SECRET' -ValueFactory { New-RandomSecret -Length 48 } | Out-Null Ensure-DefaultEnvValue -Key 'SQL_ADMIN_PASSWORD' -ValueFactory { New-RandomSecret -Length 32 } | Out-Null Ensure-DefaultEnvValue -Key 'HOST_ADMIN_PASSWORD' -ValueFactory { New-RandomSecret -Length 32 } | Out-Null @@ -412,7 +786,11 @@ Ensure-DefaultEnvValue -Key 'avdSessionHostCount' -ValueFactory { '1' } | Out-Nu Ensure-DefaultEnvValue -Key 'avdHostPoolName' -ValueFactory { "$AppName-$EnvironmentName-hp" } | Out-Null Ensure-DefaultEnvValue -Key 'avdVmNamePrefix' -ValueFactory { 'avdhost' } | Out-Null Ensure-DefaultEnvValue -Key 'linuxHostVmNamePrefix' -ValueFactory { 'lnxhost' } | Out-Null -Ensure-DefaultEnvValue -Key 'linuxHostAuthType' -ValueFactory { 'Password' } | Out-Null +Ensure-DefaultEnvValue -Key 'linuxHostAuthType' -ValueFactory { 'SSH' } | Out-Null +Ensure-DefaultEnvValue -Key 'LINUX_HOST_SSH_PUBLIC_KEY' -ValueFactory { '' } | Out-Null +Ensure-DefaultEnvValue -Key 'LINUX_HOST_SSH_PRIVATE_KEY' -ValueFactory { '' } | Out-Null +Ensure-DefaultEnvValue -Key 'linuxHostSshPublicKey' -ValueFactory { Get-AzdEnvValue -Key 'LINUX_HOST_SSH_PUBLIC_KEY' } | Out-Null +Ensure-DefaultEnvValue -Key 'linuxHostSshPrivateKey' -ValueFactory { Get-AzdEnvValue -Key 'LINUX_HOST_SSH_PRIVATE_KEY' } | Out-Null Ensure-DefaultEnvValue -Key 'linuxHostOsVersion' -ValueFactory { '24_04-lts' } | Out-Null Ensure-DefaultEnvValue -Key 'linuxHostVmSize' -ValueFactory { 'Standard_D2s_v5' } | Out-Null Ensure-DefaultEnvValue -Key 'avdVmSize' -ValueFactory { 'Standard_D8s_v5' } | Out-Null @@ -425,22 +803,49 @@ Ensure-DefaultEnvValue -Key 'linuxHostAdminLoginName' -ValueFactory { 'avdadmin' Ensure-DefaultEnvValue -Key 'domainName' -ValueFactory { '' } | Out-Null Ensure-DefaultEnvValue -Key 'nfsShare' -ValueFactory { '' } | Out-Null Ensure-DefaultEnvValue -Key 'vmHostResourceGroup' -ValueFactory { '' } | Out-Null -Ensure-DefaultEnvValue -Key 'allowedClientIp' -ValueFactory { '' } | Out-Null +# Always refresh the client IP since it can change between runs +$detectedIp = try { (Invoke-RestMethod -Uri 'https://ifconfig.me/ip' -TimeoutSec 10).Trim() } catch { '' } +Set-AzdEnvValue -Key 'allowedClientIp' -Value $detectedIp +if ($detectedIp) { Write-Host "Detected client IP for SQL firewall: $detectedIp" } Ensure-DefaultEnvValue -Key 'flaskKey' -ValueFactory { Get-AzdEnvValue -Key 'FLASK_SESSION_SECRET' } | Out-Null Ensure-DefaultEnvValue -Key 'sqlAdminPassword' -ValueFactory { Get-AzdEnvValue -Key 'SQL_ADMIN_PASSWORD' } | Out-Null Ensure-DefaultEnvValue -Key 'hostAdminPassword' -ValueFactory { Get-AzdEnvValue -Key 'HOST_ADMIN_PASSWORD' } | Out-Null +$deployLinuxHostsValue = Get-AzdEnvValue -Key 'deployLinuxHosts' +$linuxHostAuthTypeValue = Get-AzdEnvValue -Key 'linuxHostAuthType' + +if ($deployLinuxHostsValue -eq 'true' -and $linuxHostAuthTypeValue -ne 'SSH') { + throw 'linuxHostAuthType must be SSH for broker-managed Linux hosts because the broker connects to them with an SSH key.' +} + +if ($deployLinuxHostsValue -eq 'true') { + [void](Ensure-LinuxHostSshKeys) +} + $apiApp = Ensure-ApiApplication -CloudContext $cloudContext -DisplayName $apiAppDisplayName -Ensure-ServicePrincipal -AppId $apiApp.appId | Out-Null +$apiServicePrincipal = Ensure-ServicePrincipal -AppId $apiApp.appId Ensure-ClientSecret -Application $apiApp -EnvClientIdKey 'API_CLIENT_ID' -EnvSecretKey 'API_CLIENT_SECRET' | Out-Null +Ensure-AppAdminConsent -AppId $apiApp.appId -DisplayName $apiApp.displayName $frontendApp = Ensure-FrontendApplication -CloudContext $cloudContext -DisplayName $frontendAppDisplayName -AppServiceName $frontendAppServiceName -ApiAppId $apiApp.appId -Ensure-ServicePrincipal -AppId $frontendApp.appId | Out-Null +$frontendServicePrincipal = Ensure-ServicePrincipal -AppId $frontendApp.appId Ensure-ClientSecret -Application $frontendApp -EnvClientIdKey 'FRONTEND_CLIENT_ID' -EnvSecretKey 'FRONTEND_CLIENT_SECRET' | Out-Null +$apiApp = Ensure-ApiApplication -CloudContext $cloudContext -DisplayName $apiAppDisplayName -FrontendAppId $frontendApp.appId +Ensure-AppAdminConsent -AppId $apiApp.appId -DisplayName $apiApp.displayName $avdGroup = Ensure-Group -DisplayName $avdGroupName $linuxGroup = Ensure-Group -DisplayName $linuxGroupName +$deploymentUser = Get-SignedInUser +if ($deploymentUser) { + Ensure-UserAppRoleAssignment -CloudContext $cloudContext -UserId $deploymentUser.id -ResourceServicePrincipalId $frontendServicePrincipal.id -AppRoleId $defaultAccessAppRoleId + Remove-UserAppRoleAssignment -CloudContext $cloudContext -UserId $deploymentUser.id -ResourceServicePrincipalId $apiServicePrincipal.id -AppRoleId $defaultAccessAppRoleId + Ensure-UserAppRoleAssignment -CloudContext $cloudContext -UserId $deploymentUser.id -ResourceServicePrincipalId $apiServicePrincipal.id -AppRoleId $apiRoleIds.FullAccess +} + +Ensure-GroupAppRoleAssignment -CloudContext $cloudContext -GroupId $avdGroup.id -ResourceServicePrincipalId $apiServicePrincipal.id -AppRoleId $apiRoleIds.AvdHost +Ensure-GroupAppRoleAssignment -CloudContext $cloudContext -GroupId $linuxGroup.id -ResourceServicePrincipalId $apiServicePrincipal.id -AppRoleId $apiRoleIds.LinuxHost + Set-AzdEnvValue -Key 'API_CLIENT_ID' -Value $apiApp.appId Set-AzdEnvValue -Key 'FRONTEND_CLIENT_ID' -Value $frontendApp.appId Set-AzdEnvValue -Key 'AVD_HOST_GROUP_ID' -Value $avdGroup.id @@ -457,4 +862,32 @@ Write-Host "API application: $($apiApp.displayName) ($($apiApp.appId))" Write-Host "Frontend application: $($frontendApp.displayName) ($($frontendApp.appId))" Write-Host "AVD host group: $($avdGroup.displayName) ($($avdGroup.id))" Write-Host "Linux host group: $($linuxGroup.displayName) ($($linuxGroup.id))" -Write-Host 'Admin consent is still required for the configured Microsoft Graph and API permissions.' +Write-Host 'Automatic admin consent was attempted for the configured application permissions. If consent was not granted, complete it manually in Microsoft Entra ID.' + +# Generate the Bicep parameters file with the real values so azd passes them +# to the ARM deployment. azd collects parameters BEFORE running preprovision, +# so env values set above would not be picked up through ${...} references or +# auto-mapping. Writing the file here guarantees the deployment gets real values. +$bicepParametersPath = Join-Path $PSScriptRoot 'bicep' 'main.parameters.json' +$bicepParameters = [ordered]@{ + '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#' + contentVersion = '1.0.0.0' + parameters = [ordered]@{ + environmentName = @{ value = '${AZURE_ENV_NAME}' } + location = @{ value = '${AZURE_LOCATION}' } + appName = @{ value = (Get-AzdEnvValue -Key 'appName') } + tenantId = @{ value = (Get-AzdEnvValue -Key 'tenantId') } + frontendClientId = @{ value = (Get-AzdEnvValue -Key 'frontendClientId') } + frontendClientSecret = @{ value = (Get-AzdEnvValue -Key 'frontendClientSecret') } + apiClientId = @{ value = (Get-AzdEnvValue -Key 'apiClientId') } + apiClientSecret = @{ value = (Get-AzdEnvValue -Key 'apiClientSecret') } + linuxHostSshPrivateKey = @{ value = (Get-AzdEnvValue -Key 'linuxHostSshPrivateKey') } + sqlAdminPassword = @{ value = (Get-AzdEnvValue -Key 'sqlAdminPassword') } + flaskKey = @{ value = (Get-AzdEnvValue -Key 'flaskKey') } + hostAdminPassword = @{ value = (Get-AzdEnvValue -Key 'hostAdminPassword') } + allowedClientIp = @{ value = (Get-AzdEnvValue -Key 'allowedClientIp') } + } +} + +$bicepParameters | ConvertTo-Json -Depth 10 | Set-Content -Path $bicepParametersPath -Encoding utf8 +Write-Host "Generated Bicep parameters file at '$bicepParametersPath'." diff --git a/deploy/Post-Provision.ps1 b/deploy/Post-Provision.ps1 index e651bd6..ece0b2c 100644 --- a/deploy/Post-Provision.ps1 +++ b/deploy/Post-Provision.ps1 @@ -28,7 +28,13 @@ param( [string]$SubscriptionId, [Parameter(Mandatory = $false)] - [string]$EnvironmentName + [string]$EnvironmentName, + + [Parameter(Mandatory = $false)] + [string]$AvdHostGroupId, + + [Parameter(Mandatory = $false)] + [string]$LinuxHostGroupId ) $ErrorActionPreference = 'Stop' @@ -76,6 +82,14 @@ if ([string]::IsNullOrWhiteSpace($ApiClientId)) { $ApiClientId = Get-AzdEnvValue -Key 'apiClientId' } +if ([string]::IsNullOrWhiteSpace($AvdHostGroupId)) { + $AvdHostGroupId = Get-AzdEnvValue -Key 'avdHostGroupId' +} + +if ([string]::IsNullOrWhiteSpace($LinuxHostGroupId)) { + $LinuxHostGroupId = Get-AzdEnvValue -Key 'linuxHostGroupId' +} + if ([string]::IsNullOrWhiteSpace($DatabaseName)) { $DatabaseName = (Get-AzdEnvValue -Key 'sqlDatabaseName') if ([string]::IsNullOrWhiteSpace($DatabaseName)) { @@ -110,7 +124,8 @@ if ([string]::IsNullOrWhiteSpace($ResourceGroupName) -or [string]::IsNullOrWhite throw 'Post-provision inputs could not be fully resolved from parameters or azd environment values.' } -& "$PSScriptRoot/Build-ContainerImages.ps1" +& "$PSScriptRoot/Build-ContainerImages.ps1" ` + -EnvironmentName $EnvironmentName & "$PSScriptRoot/Initialize-Database.ps1" ` -SqlServerFqdn $SqlServerFqdn ` @@ -126,4 +141,13 @@ if ([string]::IsNullOrWhiteSpace($ResourceGroupName) -or [string]::IsNullOrWhite & "$PSScriptRoot/Assign-VmApiRoles.ps1" ` -ResourceGroupName $ResourceGroupName ` - -ApiClientId $ApiClientId + -AvdHostGroupId $AvdHostGroupId ` + -LinuxHostGroupId $LinuxHostGroupId ` + -EnvironmentName $EnvironmentName + +& "$PSScriptRoot/Register-LinuxHostSqlRecords.ps1" ` + -ResourceGroupName $ResourceGroupName ` + -SqlServerFqdn $SqlServerFqdn ` + -DatabaseName $DatabaseName ` + -SqlAdminLogin $SqlAdminLogin ` + -SqlAdminPassword $SqlAdminPassword diff --git a/deploy/Register-LinuxHostSqlRecords.ps1 b/deploy/Register-LinuxHostSqlRecords.ps1 new file mode 100644 index 0000000..244b6aa --- /dev/null +++ b/deploy/Register-LinuxHostSqlRecords.ps1 @@ -0,0 +1,99 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ResourceGroupName, + + [Parameter(Mandatory = $true)] + [string]$SqlServerFqdn, + + [Parameter(Mandatory = $true)] + [string]$DatabaseName, + + [Parameter(Mandatory = $true)] + [string]$SqlAdminLogin, + + [Parameter(Mandatory = $true)] + [string]$SqlAdminPassword +) + +$ErrorActionPreference = 'Stop' + +function Get-SqlConnection { + param( + [Parameter(Mandatory = $true)][string]$Server, + [Parameter(Mandatory = $true)][string]$Database, + [Parameter(Mandatory = $true)][string]$Username, + [Parameter(Mandatory = $true)][string]$Password + ) + + $connectionString = "Server=tcp:$Server,1433;Initial Catalog=$Database;Persist Security Info=False;User ID=$Username;Password=$Password;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" + return [System.Data.SqlClient.SqlConnection]::new($connectionString) +} + +function Invoke-LinuxHostRegistration { + param( + [Parameter(Mandatory = $true)][System.Data.SqlClient.SqlConnection]$Connection, + [Parameter(Mandatory = $true)]$VirtualMachine + ) + + $privateIpAddress = (($VirtualMachine.privateIps | Out-String).Trim().Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ }) | Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($privateIpAddress)) { + Write-Warning "Skipping VM '$($VirtualMachine.name)' because no private IP address was resolved." + return + } + + $command = $Connection.CreateCommand() + $command.CommandText = 'EXEC dbo.RegisterLinuxHostVm @Hostname, @IPAddress, @Description;' + $command.CommandTimeout = 60 + + [void]$command.Parameters.Add('@Hostname', [System.Data.SqlDbType]::NVarChar, 255) + [void]$command.Parameters.Add('@IPAddress', [System.Data.SqlDbType]::NVarChar, 50) + [void]$command.Parameters.Add('@Description', [System.Data.SqlDbType]::NVarChar, -1) + + $command.Parameters['@Hostname'].Value = $VirtualMachine.name + $command.Parameters['@IPAddress'].Value = $privateIpAddress + $command.Parameters['@Description'].Value = 'Registered by azd post-provision' + + try { + $reader = $command.ExecuteReader() + try { + if ($reader.Read()) { + Write-Host "Registered Linux host '$($VirtualMachine.name)' in SQL using action '$($reader['RegistrationAction'])'." + } + else { + Write-Host "Registered Linux host '$($VirtualMachine.name)' in SQL." + } + } + finally { + $reader.Close() + } + } + finally { + $command.Dispose() + } +} + +$linuxHosts = az vm list --resource-group $ResourceGroupName --show-details --output json | ConvertFrom-Json | + Where-Object { $_.tags.'broker-role' -eq 'linux-host' } + +if (-not $linuxHosts) { + Write-Host "No Linux host VMs found in resource group '$ResourceGroupName'." + exit 0 +} + +$connection = Get-SqlConnection -Server $SqlServerFqdn -Database $DatabaseName -Username $SqlAdminLogin -Password $SqlAdminPassword + +try { + $connection.Open() + + foreach ($linuxHost in $linuxHosts) { + Invoke-LinuxHostRegistration -Connection $connection -VirtualMachine $linuxHost + } +} +finally { + if ($connection.State -ne [System.Data.ConnectionState]::Closed) { + $connection.Close() + } + + $connection.Dispose() +} \ No newline at end of file diff --git a/deploy/azure.yaml b/deploy/azure.yaml index 68e33cf..58ac23e 100644 --- a/deploy/azure.yaml +++ b/deploy/azure.yaml @@ -4,10 +4,6 @@ metadata: infra: provider: bicep path: ./bicep -workflows: - up: - steps: - - azd: provision hooks: preprovision: windows: @@ -17,16 +13,3 @@ hooks: windows: shell: pwsh run: ./Post-Provision.ps1 -services: - frontend: - project: ../front_end - host: appservice - language: py - api: - project: ../api - host: appservice - language: py - task: - project: ../task - host: function - language: py diff --git a/deploy/bicep/main.bicep b/deploy/bicep/main.bicep index 96b67af..7da1f93 100644 --- a/deploy/bicep/main.bicep +++ b/deploy/bicep/main.bicep @@ -7,7 +7,7 @@ param appName string param environmentName string @description('Azure region for all resources.') -param location string = deployment().location +param location string @description('Tags applied to provisioned resources.') param tags object = {} @@ -16,21 +16,25 @@ param tags object = {} param resourceGroupName string = '' @description('Tenant ID used by the frontend and API applications.') -param tenantId string +param tenantId string = '' @description('Frontend Entra app client ID.') -param frontendClientId string +param frontendClientId string = '' @secure() @description('Frontend Entra app client secret.') -param frontendClientSecret string +param frontendClientSecret string = '' @description('API Entra app client ID.') -param apiClientId string +param apiClientId string = '' @secure() @description('API Entra app client secret.') -param apiClientSecret string +param apiClientSecret string = '' + +@secure() +@description('Linux host SSH private key stored in Key Vault for broker-managed SSH access.') +param linuxHostSshPrivateKey string = '' @description('Azure AD group ID used for AVD host access.') param avdHostGroupId string = '' @@ -43,11 +47,11 @@ param sqlAdminLogin string = 'brokeradmin' @secure() @description('SQL administrator password.') -param sqlAdminPassword string +param sqlAdminPassword string = '' @secure() @description('Flask session key for the frontend app.') -param flaskKey string +param flaskKey string = '' @description('Optional custom domain used by Linux hosts.') param domainName string = '' @@ -60,7 +64,7 @@ param linuxHostAdminLoginName string = 'avdadmin' @secure() @description('Admin password used for Linux and AVD host provisioning.') -param hostAdminPassword string +param hostAdminPassword string = '' @description('Optional resource group that contains managed VMs. Defaults to the deployment resource group.') param vmHostResourceGroup string = '' @@ -94,7 +98,7 @@ param linuxHostCount int = 0 'SSH' ]) @description('Authentication mode for Linux host VMs.') -param linuxHostAuthType string = 'Password' +param linuxHostAuthType string = 'SSH' @description('SSH public key used when Linux host auth type is SSH.') param linuxHostSshPublicKey string = '' @@ -156,6 +160,7 @@ module resources 'main.resources.bicep' = { frontendClientSecret: frontendClientSecret apiClientId: apiClientId apiClientSecret: apiClientSecret + linuxHostSshPrivateKey: linuxHostSshPrivateKey avdHostGroupId: avdHostGroupId linuxHostGroupId: linuxHostGroupId sqlAdminLogin: sqlAdminLogin diff --git a/deploy/bicep/main.json b/deploy/bicep/main.json index 9f94421..0f2cd57 100644 --- a/deploy/bicep/main.json +++ b/deploy/bicep/main.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.25.53.49325", - "templateHash": "2150617746229150476" + "templateHash": "6576658425850583710" } }, "parameters": { @@ -23,7 +23,6 @@ }, "location": { "type": "string", - "defaultValue": "[deployment().location]", "metadata": { "description": "Azure region for all resources." } @@ -44,34 +43,46 @@ }, "tenantId": { "type": "string", + "defaultValue": "", "metadata": { "description": "Tenant ID used by the frontend and API applications." } }, "frontendClientId": { "type": "string", + "defaultValue": "", "metadata": { "description": "Frontend Entra app client ID." } }, "frontendClientSecret": { "type": "securestring", + "defaultValue": "", "metadata": { "description": "Frontend Entra app client secret." } }, "apiClientId": { "type": "string", + "defaultValue": "", "metadata": { "description": "API Entra app client ID." } }, "apiClientSecret": { "type": "securestring", + "defaultValue": "", "metadata": { "description": "API Entra app client secret." } }, + "linuxHostSshPrivateKey": { + "type": "securestring", + "defaultValue": "", + "metadata": { + "description": "Linux host SSH private key stored in Key Vault for broker-managed SSH access." + } + }, "avdHostGroupId": { "type": "string", "defaultValue": "", @@ -95,12 +106,14 @@ }, "sqlAdminPassword": { "type": "securestring", + "defaultValue": "", "metadata": { "description": "SQL administrator password." } }, "flaskKey": { "type": "securestring", + "defaultValue": "", "metadata": { "description": "Flask session key for the frontend app." } @@ -128,6 +141,7 @@ }, "hostAdminPassword": { "type": "securestring", + "defaultValue": "", "metadata": { "description": "Admin password used for Linux and AVD host provisioning." } @@ -197,7 +211,7 @@ }, "linuxHostAuthType": { "type": "string", - "defaultValue": "Password", + "defaultValue": "SSH", "allowedValues": [ "Password", "SSH" @@ -322,6 +336,9 @@ "apiClientSecret": { "value": "[parameters('apiClientSecret')]" }, + "linuxHostSshPrivateKey": { + "value": "[parameters('linuxHostSshPrivateKey')]" + }, "avdHostGroupId": { "value": "[parameters('avdHostGroupId')]" }, @@ -408,7 +425,7 @@ "_generator": { "name": "bicep", "version": "0.25.53.49325", - "templateHash": "17774190860254507244" + "templateHash": "8643162257687112754" } }, "parameters": { @@ -444,6 +461,9 @@ "apiClientSecret": { "type": "securestring" }, + "linuxHostSshPrivateKey": { + "type": "securestring" + }, "avdHostGroupId": { "type": "string", "defaultValue": "" @@ -515,7 +535,7 @@ }, "linuxHostAuthType": { "type": "string", - "defaultValue": "Password", + "defaultValue": "SSH", "allowedValues": [ "Password", "SSH" @@ -592,9 +612,92 @@ "effectiveVmResourceGroup": "[if(empty(parameters('vmHostResourceGroup')), resourceGroup().name, parameters('vmHostResourceGroup'))]", "keyVaultSecretsUserRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]", "acrPullRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]", - "databasePasswordSecretName": "db-admin-password", - "apiSecretName": "api-client-secret", - "frontendApiBaseUrl": "[format('https://{0}.azurewebsites.net/api', variables('apiAppName'))]" + "databasePasswordSecretName": "db-password", + "linuxHostPrivateKeySecretName": "linux-host", + "frontendApiBaseUrl": "[format('https://{0}.azurewebsites.net/api', variables('apiAppName'))]", + "tenantIssuerUrl": "[format('{0}{1}/v2.0', environment().authentication.loginEndpoint, parameters('tenantId'))]", + "frontendAllowedAudiences": [ + "[parameters('frontendClientId')]", + "[format('api://{0}', parameters('frontendClientId'))]" + ], + "apiAllowedAudiences": [ + "[parameters('apiClientId')]", + "[format('api://{0}', parameters('apiClientId'))]" + ], + "frontendAuthSettings": { + "platform": { + "enabled": true, + "runtimeVersion": "~1" + }, + "globalValidation": { + "requireAuthentication": false, + "unauthenticatedClientAction": "AllowAnonymous" + }, + "httpSettings": { + "requireHttps": true, + "routes": { + "apiPrefix": "/.auth" + } + }, + "identityProviders": { + "azureActiveDirectory": { + "enabled": true, + "registration": { + "clientId": "[parameters('frontendClientId')]", + "clientSecretSettingName": "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET", + "openIdIssuer": "[variables('tenantIssuerUrl')]" + }, + "validation": { + "allowedAudiences": "[variables('frontendAllowedAudiences')]" + } + } + }, + "login": { + "preserveUrlFragmentsForLogins": true, + "tokenStore": { + "enabled": true + } + } + }, + "apiAuthSettings": { + "platform": { + "enabled": true, + "runtimeVersion": "~1" + }, + "globalValidation": { + "requireAuthentication": false, + "unauthenticatedClientAction": "AllowAnonymous" + }, + "httpSettings": { + "requireHttps": true, + "routes": { + "apiPrefix": "/.auth" + } + }, + "identityProviders": { + "azureActiveDirectory": { + "enabled": true, + "registration": { + "clientId": "[parameters('apiClientId')]", + "clientSecretSettingName": "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET", + "openIdIssuer": "[variables('tenantIssuerUrl')]" + }, + "validation": { + "allowedAudiences": "[variables('apiAllowedAudiences')]" + } + } + } + }, + "frontendSettings": { + "API_CLIENT_ID": "[parameters('apiClientId')]", + "API_URL": "[variables('frontendApiBaseUrl')]", + "CLIENT_ID": "[parameters('frontendClientId')]", + "FLASK_KEY": "[parameters('flaskKey')]", + "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET": "[parameters('frontendClientSecret')]", + "SCM_DO_BUILD_DURING_DEPLOYMENT": "false", + "TENANT_ID": "[parameters('tenantId')]", + "WEBSITE_AUTH_AAD_ALLOWED_TENANTS": "[parameters('tenantId')]" + } }, "resources": [ { @@ -1044,7 +1147,7 @@ "_generator": { "name": "bicep", "version": "0.25.53.49325", - "templateHash": "15020683059166618150" + "templateHash": "11175018908924583799" } }, "parameters": { @@ -1088,6 +1191,10 @@ "name": { "type": "string", "value": "[parameters('storageAccountName')]" + }, + "connectionString": { + "type": "string", + "value": "[format('DefaultEndpointsProtocol=https;AccountName={0};AccountKey={1};EndpointSuffix={2}', parameters('storageAccountName'), listKeys(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), '2023-05-01').keys[0].value, environment().suffixes.storage)]" } } } @@ -1115,14 +1222,8 @@ "sqlAdminPassword": { "value": "[parameters('sqlAdminPassword')]" }, - "frontendClientSecret": { - "value": "[parameters('frontendClientSecret')]" - }, - "apiClientSecret": { - "value": "[parameters('apiClientSecret')]" - }, - "hostAdminPassword": { - "value": "[parameters('hostAdminPassword')]" + "linuxHostSshPrivateKey": { + "value": "[parameters('linuxHostSshPrivateKey')]" } }, "template": { @@ -1132,7 +1233,7 @@ "_generator": { "name": "bicep", "version": "0.25.53.49325", - "templateHash": "13548772835488998202" + "templateHash": "11314698381309119362" } }, "parameters": { @@ -1150,13 +1251,7 @@ "sqlAdminPassword": { "type": "securestring" }, - "frontendClientSecret": { - "type": "securestring" - }, - "apiClientSecret": { - "type": "securestring" - }, - "hostAdminPassword": { + "linuxHostSshPrivateKey": { "type": "securestring" } }, @@ -1184,7 +1279,7 @@ { "type": "Microsoft.KeyVault/vaults/secrets", "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'db-admin-password')]", + "name": "[format('{0}/{1}', parameters('keyVaultName'), 'db-password')]", "properties": { "value": "[parameters('sqlAdminPassword')]" }, @@ -1195,31 +1290,9 @@ { "type": "Microsoft.KeyVault/vaults/secrets", "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'frontend-client-secret')]", + "name": "[format('{0}/{1}', parameters('keyVaultName'), 'linux-host')]", "properties": { - "value": "[parameters('frontendClientSecret')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'api-client-secret')]", - "properties": { - "value": "[parameters('apiClientSecret')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" - ] - }, - { - "type": "Microsoft.KeyVault/vaults/secrets", - "apiVersion": "2023-07-01", - "name": "[format('{0}/{1}', parameters('keyVaultName'), 'host-admin-password')]", - "properties": { - "value": "[parameters('hostAdminPassword')]" + "value": "[parameters('linuxHostSshPrivateKey')]" }, "dependsOn": [ "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" @@ -1238,14 +1311,6 @@ "vaultUri": { "type": "string", "value": "[reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri]" - }, - "frontendAuthKeyUri": { - "type": "string", - "value": "[reference(resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'frontend-client-secret'), '2023-07-01').secretUriWithVersion]" - }, - "apiAuthKeyUri": { - "type": "string", - "value": "[reference(resourceId('Microsoft.KeyVault/vaults/secrets', parameters('keyVaultName'), 'api-client-secret'), '2023-07-01').secretUriWithVersion]" } } } @@ -1506,16 +1571,10 @@ "value": "[reference(resourceId('Microsoft.Resources/deployments', 'observability'), '2022-09-01').outputs.applicationInsightsConnectionString.value]" }, "appSettings": { - "value": { - "API_CLIENT_ID": "[parameters('apiClientId')]", - "API_URL": "[variables('frontendApiBaseUrl')]", - "CLIENT_ID": "[parameters('frontendClientId')]", - "FLASK_KEY": "[parameters('flaskKey')]", - "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET": "[format('@Microsoft.KeyVault(SecretUri={0})', reference(resourceId('Microsoft.Resources/deployments', 'keyVault'), '2022-09-01').outputs.frontendAuthKeyUri.value)]", - "SCM_DO_BUILD_DURING_DEPLOYMENT": "false", - "TENANT_ID": "[parameters('tenantId')]", - "WEBSITE_AUTH_AAD_ALLOWED_TENANTS": "[parameters('tenantId')]" - } + "value": "[variables('frontendSettings')]" + }, + "authSettings": { + "value": "[variables('frontendAuthSettings')]" }, "alwaysOn": { "value": true @@ -1531,7 +1590,7 @@ "_generator": { "name": "bicep", "version": "0.25.53.49325", - "templateHash": "18437243938497326171" + "templateHash": "15351606689998843173" } }, "parameters": { @@ -1562,6 +1621,10 @@ "type": "object", "defaultValue": {} }, + "authSettings": { + "type": "object", + "defaultValue": {} + }, "alwaysOn": { "type": "bool", "defaultValue": true @@ -1602,6 +1665,17 @@ "[resourceId('Microsoft.Web/sites', parameters('appName'))]" ] }, + { + "condition": "[not(empty(parameters('authSettings')))]", + "type": "Microsoft.Web/sites/config", + "apiVersion": "2023-12-01", + "name": "[format('{0}/{1}', parameters('appName'), 'authsettingsV2')]", + "properties": "[parameters('authSettings')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', parameters('appName'))]", + "[resourceId('Microsoft.Web/sites/config', parameters('appName'), 'appsettings')]" + ] + }, { "type": "Microsoft.Web/sites/config", "apiVersion": "2023-12-01", @@ -1654,7 +1728,6 @@ "dependsOn": [ "[resourceId('Microsoft.Resources/deployments', 'appServicePlan')]", "[resourceId('Microsoft.Resources/deployments', 'containerRegistry')]", - "[resourceId('Microsoft.Resources/deployments', 'keyVault')]", "[resourceId('Microsoft.Resources/deployments', 'observability')]" ] }, @@ -1699,10 +1772,10 @@ "DB_USERNAME": "[parameters('sqlAdminLogin')]", "DOMAIN_NAME": "[parameters('domainName')]", "GRAPH_API_ENDPOINT": "https://graph.microsoft.com/.default", - "KEY_NAME": "[variables('apiSecretName')]", + "KEY_NAME": "[variables('linuxHostPrivateKeySecretName')]", "LINUX_HOST_ADMIN_LOGIN_NAME": "[parameters('linuxHostAdminLoginName')]", "LINUX_HOST_GROUP_ID": "[parameters('linuxHostGroupId')]", - "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET": "[format('@Microsoft.KeyVault(SecretUri={0})', reference(resourceId('Microsoft.Resources/deployments', 'keyVault'), '2022-09-01').outputs.apiAuthKeyUri.value)]", + "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET": "[parameters('apiClientSecret')]", "NFS_SHARE": "[parameters('nfsShare')]", "SCM_DO_BUILD_DURING_DEPLOYMENT": "false", "TENANT_ID": "[parameters('tenantId')]", @@ -1711,6 +1784,9 @@ "VM_SUBSCRIPTION_ID": "[parameters('vmSubscriptionId')]" } }, + "authSettings": { + "value": "[variables('apiAuthSettings')]" + }, "alwaysOn": { "value": true }, @@ -1725,7 +1801,7 @@ "_generator": { "name": "bicep", "version": "0.25.53.49325", - "templateHash": "18437243938497326171" + "templateHash": "15351606689998843173" } }, "parameters": { @@ -1756,6 +1832,10 @@ "type": "object", "defaultValue": {} }, + "authSettings": { + "type": "object", + "defaultValue": {} + }, "alwaysOn": { "type": "bool", "defaultValue": true @@ -1796,6 +1876,17 @@ "[resourceId('Microsoft.Web/sites', parameters('appName'))]" ] }, + { + "condition": "[not(empty(parameters('authSettings')))]", + "type": "Microsoft.Web/sites/config", + "apiVersion": "2023-12-01", + "name": "[format('{0}/{1}', parameters('appName'), 'authsettingsV2')]", + "properties": "[parameters('authSettings')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', parameters('appName'))]", + "[resourceId('Microsoft.Web/sites/config', parameters('appName'), 'appsettings')]" + ] + }, { "type": "Microsoft.Web/sites/config", "apiVersion": "2023-12-01", @@ -1885,17 +1976,17 @@ "value": "[reference(resourceId('Microsoft.Resources/deployments', 'observability'), '2022-09-01').outputs.applicationInsightsConnectionString.value]" }, "storageConnectionString": { - "value": "[createObject('API_CLIENT_ID', parameters('apiClientId'), 'API_URL', variables('frontendApiBaseUrl'), 'AzureWebJobsStorage', format('DefaultEndpointsProtocol=https;AccountName={0};AccountKey={1};EndpointSuffix={2}', reference(resourceId('Microsoft.Resources/deployments', 'storageAccount'), '2022-09-01').outputs.name.value, listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), '2023-05-01').keys[0].value, environment().suffixes.storage), 'FUNCTIONS_EXTENSION_VERSION', '~4', 'FUNCTIONS_WORKER_RUNTIME', 'python', 'SCM_DO_BUILD_DURING_DEPLOYMENT', 'false', 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING', format('DefaultEndpointsProtocol=https;AccountName={0};AccountKey={1};EndpointSuffix={2}', reference(resourceId('Microsoft.Resources/deployments', 'storageAccount'), '2022-09-01').outputs.name.value, listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), '2023-05-01').keys[0].value, environment().suffixes.storage), 'WEBSITE_CONTENTSHARE', toLower(take(format('{0}content', variables('taskAppName')), 63))).AzureWebJobsStorage]" + "value": "[createObject('API_CLIENT_ID', parameters('apiClientId'), 'API_URL', variables('frontendApiBaseUrl'), 'AzureWebJobsStorage', reference(resourceId('Microsoft.Resources/deployments', 'storageAccount'), '2022-09-01').outputs.connectionString.value, 'FUNCTIONS_EXTENSION_VERSION', '~4', 'FUNCTIONS_WORKER_RUNTIME', 'python', 'SCM_DO_BUILD_DURING_DEPLOYMENT', 'false', 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING', reference(resourceId('Microsoft.Resources/deployments', 'storageAccount'), '2022-09-01').outputs.connectionString.value, 'WEBSITE_CONTENTSHARE', toLower(take(format('{0}content', variables('taskAppName')), 63))).AzureWebJobsStorage]" }, "appSettings": { "value": { "API_CLIENT_ID": "[parameters('apiClientId')]", "API_URL": "[variables('frontendApiBaseUrl')]", - "AzureWebJobsStorage": "[format('DefaultEndpointsProtocol=https;AccountName={0};AccountKey={1};EndpointSuffix={2}', reference(resourceId('Microsoft.Resources/deployments', 'storageAccount'), '2022-09-01').outputs.name.value, listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), '2023-05-01').keys[0].value, environment().suffixes.storage)]", + "AzureWebJobsStorage": "[reference(resourceId('Microsoft.Resources/deployments', 'storageAccount'), '2022-09-01').outputs.connectionString.value]", "FUNCTIONS_EXTENSION_VERSION": "~4", "FUNCTIONS_WORKER_RUNTIME": "python", "SCM_DO_BUILD_DURING_DEPLOYMENT": "false", - "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING": "[format('DefaultEndpointsProtocol=https;AccountName={0};AccountKey={1};EndpointSuffix={2}', reference(resourceId('Microsoft.Resources/deployments', 'storageAccount'), '2022-09-01').outputs.name.value, listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), '2023-05-01').keys[0].value, environment().suffixes.storage)]", + "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING": "[reference(resourceId('Microsoft.Resources/deployments', 'storageAccount'), '2022-09-01').outputs.connectionString.value]", "WEBSITE_CONTENTSHARE": "[toLower(take(format('{0}content', variables('taskAppName')), 63))]" } }, @@ -2068,7 +2159,7 @@ "_generator": { "name": "bicep", "version": "0.25.53.49325", - "templateHash": "152351611456708088" + "templateHash": "10371371812490226963" } }, "parameters": { @@ -2135,7 +2226,8 @@ "input": "[format('{0}-{1}', parameters('vmNamePrefix'), padLeft(range(1, parameters('numberOfVMs'))[copyIndex('vmNames')], 2, '0'))]" } ], - "adminPass": "[if(equals(parameters('authType'), 'Password'), parameters('adminPassword'), parameters('sshPublicKey'))]", + "adminCredentials": "[if(equals(parameters('authType'), 'Password'), createObject('adminPassword', parameters('adminPassword')), createObject())]", + "linuxConfiguration": "[if(equals(parameters('authType'), 'SSH'), createObject('disablePasswordAuthentication', true(), 'ssh', createObject('publicKeys', createArray(createObject('path', format('/home/{0}/.ssh/authorized_keys', parameters('adminUsername')), 'keyData', parameters('sshPublicKey'))))), createObject('disablePasswordAuthentication', false()))]", "imageConfigs": { "7-LVM": { "image": { @@ -2230,12 +2322,7 @@ "hardwareProfile": { "vmSize": "[parameters('vmSize')]" }, - "osProfile": { - "computerName": "[variables('vmNames')[copyIndex()]]", - "adminUsername": "[parameters('adminUsername')]", - "adminPassword": "[variables('adminPass')]", - "linuxConfiguration": "[if(equals(parameters('authType'), 'SSH'), createObject('disablePasswordAuthentication', true(), 'ssh', createObject('publicKeys', createArray(createObject('path', format('/home/{0}/.ssh/authorized_keys', parameters('adminUsername')), 'keyData', parameters('sshPublicKey'))))), createObject('disablePasswordAuthentication', false()))]" - }, + "osProfile": "[union(createObject('computerName', variables('vmNames')[copyIndex()], 'adminUsername', parameters('adminUsername'), 'linuxConfiguration', variables('linuxConfiguration')), variables('adminCredentials'))]", "networkProfile": { "networkInterfaces": [ { diff --git a/deploy/bicep/main.parameters.example.json b/deploy/bicep/main.parameters.example.json new file mode 100644 index 0000000..d6f9ece --- /dev/null +++ b/deploy/bicep/main.parameters.example.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "appName": { + "value": "linuxbroker" + }, + "tenantId": { + "value": "" + }, + "frontendClientId": { + "value": "" + }, + "frontendClientSecret": { + "value": "" + }, + "apiClientId": { + "value": "" + }, + "apiClientSecret": { + "value": "" + }, + "linuxHostSshPrivateKey": { + "value": "" + }, + "sqlAdminPassword": { + "value": "" + }, + "flaskKey": { + "value": "" + }, + "hostAdminPassword": { + "value": "" + }, + "allowedClientIp": { + "value": "" + } + } +} \ No newline at end of file diff --git a/deploy/bicep/main.parameters.json b/deploy/bicep/main.parameters.json deleted file mode 100644 index 4a1a074..0000000 --- a/deploy/bicep/main.parameters.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "appName": { - "value": "linuxbroker" - }, - "environmentName": { - "value": "dev" - }, - "location": { - "value": "eastus2" - }, - "tenantId": { - "value": "00000000-0000-0000-0000-000000000000" - }, - "frontendClientId": { - "value": "00000000-0000-0000-0000-000000000000" - }, - "frontendClientSecret": { - "value": "replace-me" - }, - "apiClientId": { - "value": "00000000-0000-0000-0000-000000000000" - }, - "apiClientSecret": { - "value": "replace-me" - }, - "sqlAdminPassword": { - "value": "ReplaceMe!123456789" - }, - "flaskKey": { - "value": "replace-me" - }, - "hostAdminPassword": { - "value": "ReplaceMe!123456789" - } - } -} diff --git a/deploy/bicep/main.resources.bicep b/deploy/bicep/main.resources.bicep index 5ba6fc2..b973cba 100644 --- a/deploy/bicep/main.resources.bicep +++ b/deploy/bicep/main.resources.bicep @@ -12,6 +12,8 @@ param frontendClientSecret string param apiClientId string @secure() param apiClientSecret string +@secure() +param linuxHostSshPrivateKey string param avdHostGroupId string = '' param linuxHostGroupId string = '' param sqlAdminLogin string = 'brokeradmin' @@ -37,7 +39,7 @@ param linuxHostCount int = 0 'Password' 'SSH' ]) -param linuxHostAuthType string = 'Password' +param linuxHostAuthType string = 'SSH' param linuxHostSshPublicKey string = '' @allowed([ '7-LVM' @@ -87,8 +89,8 @@ var privateEndpointSubnetName = 'snet-private-endpoints' var effectiveVmResourceGroup = empty(vmHostResourceGroup) ? resourceGroup().name : vmHostResourceGroup var keyVaultSecretsUserRoleDefinitionId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') var acrPullRoleDefinitionId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') -var databasePasswordSecretName = 'db-admin-password' -var apiSecretName = 'api-client-secret' +var databasePasswordSecretName = 'db-password' +var linuxHostPrivateKeySecretName = 'linux-host' module networking 'modules/core/networking.bicep' = { name: 'networking' @@ -138,9 +140,7 @@ module keyVault 'modules/core/key-vault.bicep' = { tags: tags keyVaultName: keyVaultName sqlAdminPassword: sqlAdminPassword - frontendClientSecret: frontendClientSecret - apiClientSecret: apiClientSecret - hostAdminPassword: hostAdminPassword + linuxHostSshPrivateKey: linuxHostSshPrivateKey } } @@ -179,13 +179,86 @@ var frontendImageName = '${containerRegistry.outputs.loginServer}/frontend:lates var apiImageName = '${containerRegistry.outputs.loginServer}/api:latest' var taskImageName = '${containerRegistry.outputs.loginServer}/task:latest' var frontendApiBaseUrl = 'https://${apiAppName}.azurewebsites.net/api' -var storageConnectionString = 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.outputs.name};AccountKey=${listKeys(resourceId('Microsoft.Storage/storageAccounts', storageAccountName), '2023-05-01').keys[0].value};EndpointSuffix=${environment().suffixes.storage}' +var storageConnectionString = storageAccount.outputs.connectionString +var tenantIssuerUrl = '${environment().authentication.loginEndpoint}${tenantId}/v2.0' +var frontendAllowedAudiences = [ + frontendClientId + 'api://${frontendClientId}' +] +var apiAllowedAudiences = [ + apiClientId + 'api://${apiClientId}' +] +var frontendAuthSettings = { + platform: { + enabled: true + runtimeVersion: '~1' + } + globalValidation: { + requireAuthentication: false + unauthenticatedClientAction: 'AllowAnonymous' + } + httpSettings: { + requireHttps: true + routes: { + apiPrefix: '/.auth' + } + } + identityProviders: { + azureActiveDirectory: { + enabled: true + registration: { + clientId: frontendClientId + clientSecretSettingName: 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET' + openIdIssuer: tenantIssuerUrl + } + validation: { + allowedAudiences: frontendAllowedAudiences + } + } + } + login: { + preserveUrlFragmentsForLogins: true + tokenStore: { + enabled: true + } + } +} +var apiAuthSettings = { + platform: { + enabled: true + runtimeVersion: '~1' + } + globalValidation: { + requireAuthentication: false + unauthenticatedClientAction: 'AllowAnonymous' + } + httpSettings: { + requireHttps: true + routes: { + apiPrefix: '/.auth' + } + } + identityProviders: { + azureActiveDirectory: { + enabled: true + registration: { + clientId: apiClientId + clientSecretSettingName: 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET' + openIdIssuer: tenantIssuerUrl + } + validation: { + allowedAudiences: apiAllowedAudiences + } + } + } +} var frontendSettings = { API_CLIENT_ID: apiClientId API_URL: frontendApiBaseUrl CLIENT_ID: frontendClientId FLASK_KEY: flaskKey - MICROSOFT_PROVIDER_AUTHENTICATION_SECRET: '@Microsoft.KeyVault(SecretUri=${keyVault.outputs.frontendAuthKeyUri})' + MICROSOFT_PROVIDER_AUTHENTICATION_SECRET: frontendClientSecret SCM_DO_BUILD_DURING_DEPLOYMENT: 'false' TENANT_ID: tenantId WEBSITE_AUTH_AAD_ALLOWED_TENANTS: tenantId @@ -199,10 +272,10 @@ var apiSettings = { DB_USERNAME: sqlAdminLogin DOMAIN_NAME: domainName GRAPH_API_ENDPOINT: 'https://graph.microsoft.com/.default' - KEY_NAME: apiSecretName + KEY_NAME: linuxHostPrivateKeySecretName LINUX_HOST_ADMIN_LOGIN_NAME: linuxHostAdminLoginName LINUX_HOST_GROUP_ID: linuxHostGroupId - MICROSOFT_PROVIDER_AUTHENTICATION_SECRET: '@Microsoft.KeyVault(SecretUri=${keyVault.outputs.apiAuthKeyUri})' + MICROSOFT_PROVIDER_AUTHENTICATION_SECRET: apiClientSecret NFS_SHARE: nfsShare SCM_DO_BUILD_DURING_DEPLOYMENT: 'false' TENANT_ID: tenantId @@ -234,6 +307,7 @@ module frontendApp 'modules/apps/container-web-app.bicep' = { containerRegistryLoginServer: containerRegistry.outputs.loginServer applicationInsightsConnectionString: observability.outputs.applicationInsightsConnectionString appSettings: frontendSettings + authSettings: frontendAuthSettings alwaysOn: true useManagedIdentityForRegistry: true } @@ -252,6 +326,7 @@ module apiApp 'modules/apps/container-web-app.bicep' = { containerRegistryLoginServer: containerRegistry.outputs.loginServer applicationInsightsConnectionString: observability.outputs.applicationInsightsConnectionString appSettings: apiSettings + authSettings: apiAuthSettings alwaysOn: true useManagedIdentityForRegistry: true } diff --git a/deploy/bicep/modules/Linux/main.bicep b/deploy/bicep/modules/Linux/main.bicep index 6c055de..b07fe02 100644 --- a/deploy/bicep/modules/Linux/main.bicep +++ b/deploy/bicep/modules/Linux/main.bicep @@ -30,7 +30,24 @@ param sshPublicKey string = '' param OSVersion string var vmNames = [for i in range(1, numberOfVMs): '${vmNamePrefix}-${padLeft(i, 2, '0')}'] -var adminPass = authType == 'Password' ? adminPassword : sshPublicKey +var adminCredentials = authType == 'Password' ? { + adminPassword: adminPassword +} : {} +var linuxConfiguration = authType == 'SSH' + ? { + disablePasswordAuthentication: true + ssh: { + publicKeys: [ + { + path: '/home/${adminUsername}/.ssh/authorized_keys' + keyData: sshPublicKey + } + ] + } + } + : { + disablePasswordAuthentication: false + } var imageConfigs = { '7-LVM': { @@ -132,26 +149,11 @@ resource vmLinuxHost 'Microsoft.Compute/virtualMachines@2022-03-01' = [ hardwareProfile: { vmSize: vmSize } - osProfile: { + osProfile: union({ computerName: vmNames[i] adminUsername: adminUsername - adminPassword: adminPass - linuxConfiguration: authType == 'SSH' - ? { - disablePasswordAuthentication: true - ssh: { - publicKeys: [ - { - path: '/home/${adminUsername}/.ssh/authorized_keys' - keyData: sshPublicKey - } - ] - } - } - : { - disablePasswordAuthentication: false - } - } + linuxConfiguration: linuxConfiguration + }, adminCredentials) networkProfile: { networkInterfaces: [ { diff --git a/deploy/bicep/modules/apps/container-web-app.bicep b/deploy/bicep/modules/apps/container-web-app.bicep index cd33fb0..da69360 100644 --- a/deploy/bicep/modules/apps/container-web-app.bicep +++ b/deploy/bicep/modules/apps/container-web-app.bicep @@ -6,6 +6,7 @@ param containerImageName string param containerRegistryLoginServer string param applicationInsightsConnectionString string param appSettings object = {} +param authSettings object = {} param alwaysOn bool = true param useManagedIdentityForRegistry bool = true @@ -39,6 +40,15 @@ resource webAppSettings 'Microsoft.Web/sites/config@2023-12-01' = { }, appSettings) } +resource webAppAuth 'Microsoft.Web/sites/config@2023-12-01' = if (!empty(authSettings)) { + parent: webApp + name: 'authsettingsV2' + dependsOn: [ + webAppSettings + ] + properties: authSettings +} + resource webAppLogs 'Microsoft.Web/sites/config@2023-12-01' = { parent: webApp name: 'logs' diff --git a/deploy/bicep/modules/core/key-vault.bicep b/deploy/bicep/modules/core/key-vault.bicep index a21c6e2..52c7bff 100644 --- a/deploy/bicep/modules/core/key-vault.bicep +++ b/deploy/bicep/modules/core/key-vault.bicep @@ -4,11 +4,7 @@ param keyVaultName string @secure() param sqlAdminPassword string @secure() -param frontendClientSecret string -@secure() -param apiClientSecret string -@secure() -param hostAdminPassword string +param linuxHostSshPrivateKey string resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = { name: keyVaultName @@ -31,38 +27,20 @@ resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = { resource sqlAdminPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { parent: keyVault - name: 'db-admin-password' + name: 'db-password' properties: { value: sqlAdminPassword } } -resource frontendClientSecretSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { - parent: keyVault - name: 'frontend-client-secret' - properties: { - value: frontendClientSecret - } -} - -resource apiClientSecretSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { - parent: keyVault - name: 'api-client-secret' - properties: { - value: apiClientSecret - } -} - -resource hostAdminPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { +resource linuxHostPrivateKeySecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { parent: keyVault - name: 'host-admin-password' + name: 'linux-host' properties: { - value: hostAdminPassword + value: linuxHostSshPrivateKey } } output name string = keyVault.name output id string = keyVault.id output vaultUri string = keyVault.properties.vaultUri -output frontendAuthKeyUri string = frontendClientSecretSecret.properties.secretUriWithVersion -output apiAuthKeyUri string = apiClientSecretSecret.properties.secretUriWithVersion diff --git a/deploy/bicep/modules/core/storage-account.bicep b/deploy/bicep/modules/core/storage-account.bicep index 79c6f98..4cbf462 100644 --- a/deploy/bicep/modules/core/storage-account.bicep +++ b/deploy/bicep/modules/core/storage-account.bicep @@ -21,3 +21,4 @@ resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = { output id string = storageAccount.id output name string = storageAccount.name +output connectionString string = 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${storageAccount.listKeys().keys[0].value};EndpointSuffix=${environment().suffixes.storage}' diff --git a/sql_queries/001_create_table-vm_scaling_rules.sql b/sql_queries/001_create_table-vm_scaling_rules.sql index e70d614..f3b0e91 100644 --- a/sql_queries/001_create_table-vm_scaling_rules.sql +++ b/sql_queries/001_create_table-vm_scaling_rules.sql @@ -1,23 +1,27 @@ USE linuxbroker; -CREATE TABLE VmScalingRules ( - RuleID INT IDENTITY(1,1) PRIMARY KEY, - MinVMs INT NOT NULL, - MaxVMs INT NOT NULL, - ScaleUpRatio DECIMAL(5,2) NOT NULL, -- Percentage (e.g., 70.00 for 70%) - ScaleUpIncrement INT NOT NULL, - ScaleDownRatio DECIMAL(5,2) NOT NULL, -- Percentage (e.g., 30.00 for 30%) - ScaleDownIncrement INT NOT NULL, - LastChecked DATETIME DEFAULT NULL, - SysStartTime DATETIME2 GENERATED ALWAYS AS ROW START HIDDEN, - SysEndTime DATETIME2 GENERATED ALWAYS AS ROW END HIDDEN, - PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime), - CHECK (MinVMs < MaxVMs), -- Ensures MinVMs is less than MaxVMs - CHECK (ScaleUpRatio > ScaleDownRatio) -- Ensures ScaleUpRatio is greater than ScaleDownRatio -) +IF OBJECT_ID('dbo.VmScalingRules', 'U') IS NULL +BEGIN + CREATE TABLE VmScalingRules ( + RuleID INT IDENTITY(1,1) PRIMARY KEY, + MinVMs INT NOT NULL, + MaxVMs INT NOT NULL, + ScaleUpRatio DECIMAL(5,2) NOT NULL, + ScaleUpIncrement INT NOT NULL, + ScaleDownRatio DECIMAL(5,2) NOT NULL, + ScaleDownIncrement INT NOT NULL, + LastChecked DATETIME DEFAULT NULL, + SysStartTime DATETIME2 GENERATED ALWAYS AS ROW START HIDDEN, + SysEndTime DATETIME2 GENERATED ALWAYS AS ROW END HIDDEN, + PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime), + CHECK (MinVMs < MaxVMs), + CHECK (ScaleUpRatio > ScaleDownRatio) + ) + WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.VmScalingRulesHistory)); +END; -WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.VmScalingRulesHistory)); - - -INSERT INTO VMScalingRules (MinVMs, MaxVMs, ScaleUpRatio, ScaleUpIncrement, ScaleDownRatio, ScaleDownIncrement) -VALUES (2, 10, 70.00, 2, 30.00, 1); +IF NOT EXISTS (SELECT 1 FROM dbo.VmScalingRules) +BEGIN + INSERT INTO dbo.VmScalingRules (MinVMs, MaxVMs, ScaleUpRatio, ScaleUpIncrement, ScaleDownRatio, ScaleDownIncrement) + VALUES (2, 10, 70.00, 2, 30.00, 1); +END; diff --git a/sql_queries/002_create_table-vm_scaling_activity_log.sql b/sql_queries/002_create_table-vm_scaling_activity_log.sql index 5740ffd..08fe384 100644 --- a/sql_queries/002_create_table-vm_scaling_activity_log.sql +++ b/sql_queries/002_create_table-vm_scaling_activity_log.sql @@ -1,14 +1,17 @@ use linuxbroker; -CREATE TABLE VmScalingActivityLog ( - ActivityID INT IDENTITY(1,1) PRIMARY KEY, - CheckTimestamp DATETIME NOT NULL DEFAULT(GETDATE()), - CurrentRunningVMs INT NOT NULL, - CurrentInUseVMs INT NOT NULL, - ActionTaken NVARCHAR(50) NOT NULL, -- "Scale Up", "Scale Down", "No Action" - VMsPoweredOn INT NULL, - VMsPoweredOff INT NULL, - NewTotalVMs INT NOT NULL, - Outcome NVARCHAR(255) NULL, - Notes TEXT NULL -); +IF OBJECT_ID('dbo.VmScalingActivityLog', 'U') IS NULL +BEGIN + CREATE TABLE VmScalingActivityLog ( + ActivityID INT IDENTITY(1,1) PRIMARY KEY, + CheckTimestamp DATETIME NOT NULL DEFAULT(GETDATE()), + CurrentRunningVMs INT NOT NULL, + CurrentInUseVMs INT NOT NULL, + ActionTaken NVARCHAR(50) NOT NULL, + VMsPoweredOn INT NULL, + VMsPoweredOff INT NULL, + NewTotalVMs INT NOT NULL, + Outcome NVARCHAR(255) NULL, + Notes TEXT NULL + ); +END; diff --git a/sql_queries/003_create_table-virtual_machines.sql b/sql_queries/003_create_table-virtual_machines.sql index 72ed766..7f93c2e 100644 --- a/sql_queries/003_create_table-virtual_machines.sql +++ b/sql_queries/003_create_table-virtual_machines.sql @@ -1,19 +1,22 @@ USE linuxbroker; -CREATE TABLE VirtualMachines ( - VMID INT IDENTITY(1,1) PRIMARY KEY, - Hostname VARCHAR(255) NOT NULL, - IPAddress VARCHAR(50), - PowerState VARCHAR(10) CHECK(PowerState IN ('On', 'Off')), - NetworkStatus VARCHAR(16) CHECK(NetworkStatus IN ('Reachable', 'Unreachable')), - VmStatus VARCHAR(16) CHECK(VmStatus IN ('Available', 'CheckedOut', 'Maintenance', 'Released')), - Username VARCHAR(255), - AvdHost VARCHAR(255), - CreateDate DATETIME DEFAULT(GETDATE()), - LastUpdateDate DATETIME DEFAULT(GETDATE()), - Description NVARCHAR(MAX), -- Changed from TEXT to NVARCHAR(MAX) - SysStartTime DATETIME2 GENERATED ALWAYS AS ROW START HIDDEN, - SysEndTime DATETIME2 GENERATED ALWAYS AS ROW END HIDDEN, - PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime) -) -WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.VirtualMachinesHistory)); +IF OBJECT_ID('dbo.VirtualMachines', 'U') IS NULL +BEGIN + CREATE TABLE VirtualMachines ( + VMID INT IDENTITY(1,1) PRIMARY KEY, + Hostname VARCHAR(255) NOT NULL, + IPAddress VARCHAR(50), + PowerState VARCHAR(10) CHECK(PowerState IN ('On', 'Off')), + NetworkStatus VARCHAR(16) CHECK(NetworkStatus IN ('Reachable', 'Unreachable')), + VmStatus VARCHAR(16) CHECK(VmStatus IN ('Available', 'CheckedOut', 'Maintenance', 'Released')), + Username VARCHAR(255), + AvdHost VARCHAR(255), + CreateDate DATETIME DEFAULT(GETDATE()), + LastUpdateDate DATETIME DEFAULT(GETDATE()), + Description NVARCHAR(MAX), + SysStartTime DATETIME2 GENERATED ALWAYS AS ROW START HIDDEN, + SysEndTime DATETIME2 GENERATED ALWAYS AS ROW END HIDDEN, + PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime) + ) + WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.VirtualMachinesHistory)); +END; diff --git a/sql_queries/024_create_table-vmusers.sql b/sql_queries/024_create_table-vmusers.sql index c93b2bf..2383d80 100644 --- a/sql_queries/024_create_table-vmusers.sql +++ b/sql_queries/024_create_table-vmusers.sql @@ -1,5 +1,10 @@ -CREATE TABLE VmUsers ( -    uid INTEGER PRIMARY KEY, -    username VARCHAR(255) UNIQUE NOT NULL, -    CreateDate DATETIME DEFAULT(GETDATE()) -); \ No newline at end of file +USE linuxbroker; + +IF OBJECT_ID('dbo.VmUsers', 'U') IS NULL +BEGIN + CREATE TABLE dbo.VmUsers ( + uid INT PRIMARY KEY, + username VARCHAR(255) UNIQUE NOT NULL, + CreateDate DATETIME DEFAULT(GETDATE()) + ); +END; diff --git a/sql_queries/025_create_procedure-RegisterLinuxHostVm.sql b/sql_queries/025_create_procedure-RegisterLinuxHostVm.sql new file mode 100644 index 0000000..61cbe74 --- /dev/null +++ b/sql_queries/025_create_procedure-RegisterLinuxHostVm.sql @@ -0,0 +1,55 @@ +CREATE PROCEDURE [dbo].[RegisterLinuxHostVm] + @Hostname NVARCHAR(255), + @IPAddress NVARCHAR(50), + @Description NVARCHAR(MAX) = NULL +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @ExistingVmId INT; + + SELECT TOP 1 @ExistingVmId = VMID + FROM dbo.VirtualMachines + WHERE Hostname = @Hostname + ORDER BY VMID; + + IF @ExistingVmId IS NULL + BEGIN + INSERT INTO dbo.VirtualMachines ( + Hostname, + IPAddress, + PowerState, + NetworkStatus, + VmStatus, + Username, + AvdHost, + CreateDate, + LastUpdateDate, + Description + ) + VALUES ( + @Hostname, + @IPAddress, + 'On', + 'Reachable', + 'Available', + NULL, + NULL, + GETDATE(), + GETDATE(), + @Description + ); + + SELECT CAST(SCOPE_IDENTITY() AS INT) AS VMID, 'Inserted' AS RegistrationAction; + RETURN; + END; + + UPDATE dbo.VirtualMachines + SET IPAddress = @IPAddress, + Description = COALESCE(@Description, Description), + LastUpdateDate = GETDATE() + WHERE VMID = @ExistingVmId; + + SELECT @ExistingVmId AS VMID, 'Updated' AS RegistrationAction; +END +GO diff --git a/sql_queries/README.md b/sql_queries/README.md index 4ed0978..8c0a023 100644 --- a/sql_queries/README.md +++ b/sql_queries/README.md @@ -1,280 +1,264 @@ ## Database Setup and SQL Procedures -The `sql_queries` directory contains all the necessary SQL scripts to set up the Azure SQL Database for the Linux Broker for AVD Access solution. These scripts create the required tables and stored procedures that the Broker API and other components utilize to manage VMs, scaling rules, and activity logs. - -### Contents of `sql_queries` - -#### Table Creation Scripts - -1. **Create Tables**: - - `001_create_table-vm_scaling_rules.sql`: Creates the `vm_scaling_rules` table to store scaling rules. - - `002_create_table-vm_scaling_activity_log.sql`: Creates the `vm_scaling_activity_log` table to log scaling activities. - - `003_create_table-virtual_machines.sql`: Creates the `virtual_machines` table to store information about Linux VMs. - -#### Stored Procedure Scripts - -2. **Create Stored Procedures**: - - `005_create_procedure-CheckoutVm.sql`: Checks out a VM for a user. - - `006_create_procedure-DeleteVm.sql`: Deletes a VM record. - - `007_create_procedure-AddVm.sql`: Adds a new VM to the system. - - `008_create_procedure-GetVmDetails.sql`: Retrieves details of a specific VM. - - `009_create_procedure-ReturnVm.sql`: Returns a VM to the pool. - - `010_create_procedure-GetScalingRules.sql`: Retrieves current scaling rules. - - `011_create_procedure-UpdateScalingRule.sql`: Updates a scaling rule. - - `012_create_procedure-TriggerScalingLogic.sql`: Triggers scaling logic based on current metrics. - - `013_create_procedure-GetScalingActivityLog.sql`: Retrieves the scaling activity log. - - `014_create_procedure-GetVms.sql`: Retrieves a list of VMs. - - `015_create_procedure-CreateScalingRule.sql`: Creates a new scaling rule. - - `016_create_procedure-ReleaseVm.sql`: Releases a VM from a user. - - `017_create_procedure-UpdateVmAttributes.sql`: Updates attributes of a VM. - - `018_create_procedure-ReturnReleasedVms.sql`: Returns VMs that were released. - - `019_create_procedure-DeleteScalingRule.sql`: Deletes a scaling rule. - - `020_create_procedure-GetVmHistory.sql`: Retrieves the history of VM status changes. - - `021_create_procedure-GetVmScalingRulesHistory.sql`: Retrieves the history of scaling rule changes. - - `022_create_procedure-GetScalingRuleDetails.sql`: Retrieves details of a specific scaling rule. - - `023_create_procedure-GetDeletedVirtualMachines.sql`: Retrieves records of deleted VMs. +This folder contains the SQL schema and stored procedure scripts used by the Linux Broker for AVD Access solution. -### Prerequisites +The primary deployment path is now automated through the deployment hooks under `deploy/`, not manual `sqlcmd` execution. This document describes both paths: -- **Azure SQL Database Instance**: Ensure you have an Azure SQL Database instance set up. -- **SQL Client Tool**: Use tools like Azure Data Studio, SQL Server Management Studio (SSMS), or SQLCMD. -- **Permissions**: You need sufficient permissions to create tables, stored procedures, and manage users in the database. +- the supported automated path used by `azd up` +- the manual fallback path when you need to apply or verify scripts yourself -### Deployment Steps +For the full deployment workflow around these SQL scripts, see [../deploy/DEPLOYMENT.md](../deploy/DEPLOYMENT.md). -Follow these steps to set up the database: +## Current Deployment Model -#### 1. Connect to Azure SQL Database +### Primary path: automated SQL bootstrap -- Open your SQL client tool. -- Connect to your Azure SQL Database instance using administrator credentials. +The supported deployment flow runs the SQL scripts automatically during `postprovision`. -#### 2. Create Tables +The sequence is: -Run the table creation scripts in the following order: +1. [../deploy/Post-Provision.ps1](../deploy/Post-Provision.ps1) runs after infrastructure provisioning. +2. That script calls [../deploy/Initialize-Database.ps1](../deploy/Initialize-Database.ps1). +3. `Initialize-Database.ps1` loads every `*.sql` file in this folder, sorts them by filename, and applies them in order. +4. After the schema and procedures are in place, [../deploy/Register-LinuxHostSqlRecords.ps1](../deploy/Register-LinuxHostSqlRecords.ps1) registers Linux hosts into `dbo.VirtualMachines`. -**a. Create `vm_scaling_rules` Table** +The automated bootstrap has a few important behaviors: -```sql --- Run 001_create_table-vm_scaling_rules.sql -``` +- It connects to Azure SQL with ADO.NET from the machine running `azd up`. +- It splits scripts on `GO` batch separators. +- It rewrites `CREATE PROCEDURE` and `ALTER PROCEDURE` to `CREATE OR ALTER PROCEDURE` before execution so reruns work cleanly. +- It now fails on SQL errors instead of silently continuing. +- It can be skipped only by setting `SKIP_SQL_BOOTSTRAP=true`. -**b. Create `vm_scaling_activity_log` Table** +### Secondary path: manual execution -```sql --- Run 002_create_table-vm_scaling_activity_log.sql -``` +Manual execution is still available when you want to inspect or repair the database outside the azd workflow. -**c. Create `virtual_machines` Table** +Use that path when you need to: -```sql --- Run 003_create_table-virtual_machines.sql -``` +- validate objects in an existing environment +- replay the scripts after a partial failure +- troubleshoot SQL connectivity or permissions +- apply the schema without running the full deployment flow -#### 3. Deploy Stored Procedures +## Objects In This Folder -Run each stored procedure script sequentially: +### Tables -**a. Checkout VM Procedure** +- `001_create_table-vm_scaling_rules.sql`: creates `dbo.VmScalingRules` +- `002_create_table-vm_scaling_activity_log.sql`: creates `dbo.VmScalingActivityLog` +- `003_create_table-virtual_machines.sql`: creates `dbo.VirtualMachines` +- `024_create_table-vmusers.sql`: creates `dbo.VmUsers` -```sql --- Run 005_create_procedure-CheckoutVm.sql -``` +The table scripts above are written to be rerunnable. -**b. Delete VM Procedure** +### Stored procedures -```sql --- Run 006_create_procedure-DeleteVm.sql -``` +- `005_create_procedure-CheckoutVm.sql`: checks out a VM for a user +- `006_create_procedure-DeleteVm.sql`: deletes a VM record +- `007_create_procedure-AddVm.sql`: adds a VM record manually +- `008_create_procedure-GetVmDetails.sql`: gets details for a specific VM +- `009_create_procedure-ReturnVm.sql`: returns a VM to the pool +- `010_create_procedure-GetScalingRules.sql`: gets scaling rules +- `011_create_procedure-UpdateScalingRule.sql`: updates a scaling rule +- `012_create_procedure-TriggerScalingLogic.sql`: runs scaling logic +- `013_create_procedure-GetScalingActivityLog.sql`: gets scaling activity history +- `014_create_procedure-GetVms.sql`: gets the VM list +- `015_create_procedure-CreateScalingRule.sql`: creates a scaling rule +- `016_create_procedure-ReleaseVm.sql`: releases a checked-out VM +- `017_create_procedure-UpdateVmAttributes.sql`: updates VM attributes +- `018_create_procedure-ReturnReleasedVms.sql`: returns released VMs to the pool +- `019_create_procedure-DeleteScalingRule.sql`: deletes a scaling rule +- `020_create_procedure-GetVmHistory.sql`: gets VM history +- `021_create_procedure-GetVmScalingRulesHistory.sql`: gets scaling rule history +- `022_create_procedure-GetScalingRuleDetails.sql`: gets a specific scaling rule +- `023_create_procedure-GetDeletedVirtualMachines.sql`: gets deleted VM history +- `025_create_procedure-RegisterLinuxHostVm.sql`: upserts Linux host records into `dbo.VirtualMachines` -**c. Add VM Procedure** +## Current Runtime Expectations -```sql --- Run 007_create_procedure-AddVm.sql -``` +The current code and deployment flow depend on the following SQL objects being present: -**d. Get VM Details Procedure** +- `dbo.VmScalingRules` +- `dbo.VmScalingActivityLog` +- `dbo.VirtualMachines` +- `dbo.VmUsers` +- all of the stored procedures above +- especially `dbo.CheckoutVm`, `dbo.ReleaseVm`, `dbo.UpdateVmAttributes`, and `dbo.RegisterLinuxHostVm` -```sql --- Run 008_create_procedure-GetVmDetails.sql -``` +Two current behaviors are worth calling out: -**e. Return VM Procedure** +- `dbo.VmUsers` is required by the API path that creates and tracks Linux-side user IDs. +- `dbo.RegisterLinuxHostVm` is the procedure used by post-provision automation to register Linux hosts automatically. -```sql --- Run 009_create_procedure-ReturnVm.sql -``` +## Automatic Linux Host Registration -**f. Get Scaling Rules Procedure** +After the SQL scripts are applied, [../deploy/Register-LinuxHostSqlRecords.ps1](../deploy/Register-LinuxHostSqlRecords.ps1) connects to Azure and SQL and runs `dbo.RegisterLinuxHostVm` for every VM tagged with `broker-role=linux-host`. -```sql --- Run 010_create_procedure-GetScalingRules.sql -``` +That automation: -**g. Update Scaling Rule Procedure** - -```sql --- Run 011_create_procedure-UpdateScalingRule.sql -``` +- only registers Linux hosts +- does not register AVD hosts +- uses the VM name and resolved private IP address +- inserts a new record if the host is missing +- updates the existing record if the host already exists -**h. Trigger Scaling Logic Procedure** +This means future azd deployments no longer depend on a manual UI step just to seed Linux hosts into the database. -```sql --- Run 012_create_procedure-TriggerScalingLogic.sql -``` +## Manual Deployment Steps -**i. Get Scaling Activity Log Procedure** +If you need to run the SQL setup manually, use the following flow. -```sql --- Run 013_create_procedure-GetScalingActivityLog.sql -``` +### Prerequisites -**j. Get VMs Procedure** +- an Azure SQL Database instance already exists +- you can connect with an admin or equivalent SQL principal +- the client machine is allowed through the SQL firewall -```sql --- Run 014_create_procedure-GetVms.sql -``` +When using the azd deployment flow, remember that SQL bootstrap runs from the local machine. If the SQL firewall does not allow that client IP, the automated bootstrap will fail. -**k. Create Scaling Rule Procedure** +### Recommended manual order -```sql --- Run 015_create_procedure-CreateScalingRule.sql -``` +Run all scripts in filename order. -**l. Release VM Procedure** +That means: -```sql --- Run 016_create_procedure-ReleaseVm.sql -``` +1. Run the table scripts. +2. Run the stored procedure scripts. +3. Verify the objects. +4. Optionally register Linux hosts by executing `dbo.RegisterLinuxHostVm` yourself or rerunning the post-provision script. -**m. Update VM Attributes Procedure** +### Example using `sqlcmd` -```sql --- Run 017_create_procedure-UpdateVmAttributes.sql -``` - -**n. Return Released VMs Procedure** +```powershell +$server = "your_server.database.windows.net" +$database = "LinuxBroker" +$username = "your_username" +$password = "your_password" -```sql --- Run 018_create_procedure-ReturnReleasedVms.sql +Get-ChildItem -Path .\sql_queries -Filter *.sql | + Sort-Object Name | + ForEach-Object { + Write-Host "Applying $($_.Name)" + sqlcmd -S $server -d $database -U $username -P $password -i $_.FullName + } ``` -**o. Delete Scaling Rule Procedure** +Manual execution is useful, but it does not automatically perform the newer post-provision Linux host registration unless you run that step separately. -```sql --- Run 019_create_procedure-DeleteScalingRule.sql -``` +## Verification -**p. Get VM History Procedure** - -```sql --- Run 020_create_procedure-GetVmHistory.sql -``` +After bootstrap, verify both tables and procedures. -**q. Get VM Scaling Rules History Procedure** +### Check tables ```sql --- Run 021_create_procedure-GetVmScalingRulesHistory.sql +SELECT name +FROM sys.tables +WHERE name IN ('VmScalingRules', 'VmScalingActivityLog', 'VirtualMachines', 'VmUsers') +ORDER BY name; ``` -**r. Get Scaling Rule Details Procedure** +### Check procedures ```sql --- Run 022_create_procedure-GetScalingRuleDetails.sql +SELECT name +FROM sys.procedures +WHERE name IN ( + 'CheckoutVm', + 'DeleteVm', + 'AddVm', + 'GetVmDetails', + 'ReturnVm', + 'GetScalingRules', + 'UpdateScalingRule', + 'TriggerScalingLogic', + 'GetScalingActivityLog', + 'GetVms', + 'CreateScalingRule', + 'ReleaseVm', + 'UpdateVmAttributes', + 'ReturnReleasedVms', + 'DeleteScalingRule', + 'GetVmHistory', + 'GetVmScalingRulesHistory', + 'GetScalingRuleDetails', + 'GetDeletedVirtualMachines', + 'RegisterLinuxHostVm' +) +ORDER BY name; ``` -**s. Get Deleted Virtual Machines Procedure** +### Check Linux host rows ```sql --- Run 023_create_procedure-GetDeletedVirtualMachines.sql +SELECT Hostname, IPAddress, PowerState, NetworkStatus, VmStatus, LastUpdateDate +FROM dbo.VirtualMachines +ORDER BY Hostname; ``` -#### 4. Verify Deployment - -After running all scripts: - -- **Check Tables**: - - ```sql - SELECT name FROM sys.tables; - ``` +## Rerun Paths - Ensure `vm_scaling_rules`, `vm_scaling_activity_log`, and `virtual_machines` tables are listed. +If the database bootstrap needs to be rerun, the preferred path is to rerun the deployment script rather than manually replaying only a subset of files. -- **Check Stored Procedures**: +From the `deploy/` directory: - ```sql - SELECT name FROM sys.procedures; - ``` - - Verify that all stored procedures are present. - -#### 5. Grant Permissions (If Necessary) - -Ensure that the managed identities and users have the appropriate permissions to execute the stored procedures: - -- **Grant Execute Permission**: - - ```sql - GRANT EXECUTE ON [schema].[procedure_name] TO [user_or_role]; - ``` - -### Notes and Best Practices +```powershell +.\Initialize-Database.ps1 ` + -SqlServerFqdn .database.windows.net ` + -DatabaseName LinuxBroker ` + -SqlAdminLogin ` + -SqlAdminPassword ` + -ScriptsPath ..\sql_queries +``` -- **Run Scripts Sequentially**: The order of execution is crucial due to dependencies between tables and stored procedures. -- **Backup Database**: If you're deploying to an existing database, consider taking a backup before making changes. -- **Use Transaction Blocks**: For critical deployments, wrap your scripts in transactions to ensure atomicity. -- **Error Handling**: Check for errors after running each script and resolve any issues before proceeding. -- **Security**: Ensure that connection strings and credentials are secured. Use Azure Key Vault where applicable. +If you also want to refresh Linux host records after the schema run: -### Example Deployment Using SQLCMD +```powershell +.\Register-LinuxHostSqlRecords.ps1 ` + -ResourceGroupName ` + -SqlServerFqdn .database.windows.net ` + -DatabaseName LinuxBroker ` + -SqlAdminLogin ` + -SqlAdminPassword +``` -If you prefer to run scripts from the command line using `sqlcmd`, here's how you can do it: +## Troubleshooting -```bash -sqlcmd -S your_server.database.windows.net -U your_username -P your_password -d your_database -i "sql_queries/001_create_table-vm_scaling_rules.sql" +### SQL bootstrap failed during `azd up` -sqlcmd -S your_server.database.windows.net -U your_username -P your_password -d your_database -i "sql_queries/002_create_table-vm_scaling_activity_log.sql" +Common causes: --- Continue running all scripts in order -``` +- the local client IP is not allowed through the SQL firewall +- the SQL admin credentials are wrong +- an earlier script failed and blocked a later dependency -Replace `your_server`, `your_username`, `your_password`, and `your_database` with your actual database connection details. +The automated bootstrap now stops at the first SQL error, so the failing file name and batch number are the first place to look. -### Automating Deployment with a Script +### `VmUsers` is missing -You can create a deployment script to automate running all SQL files in the correct order. Here's an example using a PowerShell script: +This table is now part of the supported schema and is required by the API user-creation path. Rerun the bootstrap or apply `024_create_table-vmusers.sql` manually. -```powershell -$server = "your_server.database.windows.net" -$username = "your_username" -$password = "your_password" -$database = "your_database" -$sqlFiles = Get-ChildItem -Path "sql_queries" -Filter "*.sql" | Sort-Object Name +### Linux hosts were deployed but are not in `dbo.VirtualMachines` -foreach ($file in $sqlFiles) { - Write-Host "Executing $($file.Name)..." - sqlcmd -S $server -U $username -P $password -d $database -i $file.FullName -} -``` +Rerun [../deploy/Register-LinuxHostSqlRecords.ps1](../deploy/Register-LinuxHostSqlRecords.ps1), or rerun [../deploy/Post-Provision.ps1](../deploy/Post-Provision.ps1) if you want the full post-provision sequence. -### Troubleshooting +### You only changed a stored procedure -- **Common Errors**: +Keep the change in the numbered SQL file in source control, then rerun the bootstrap. The deployment script converts procedure creation statements into `CREATE OR ALTER PROCEDURE`, so reruns are supported. - - *Permission Denied*: Ensure your user has the necessary permissions. - - *Syntax Errors*: Check the SQL script for typos or syntax issues. - - *Missing Objects*: Verify that dependent tables or procedures exist before running a script. +## Summary -- **Logging**: +Treat this folder as the source of truth for the broker database schema and procedure layer. - - Enable logging in your SQL client to capture detailed error messages. - - Review Azure SQL Database logs for any server-side issues. +For new environments: -### Updating the Database +- let `azd up` drive the SQL bootstrap automatically +- use the deployment scripts under `deploy/` to rerun or troubleshoot +- expect Linux hosts to be auto-registered into SQL after bootstrap -If you need to update existing stored procedures or tables: +For manual intervention: -- **Modify the Script**: Update the SQL script with the necessary changes. -- **Run ALTER Commands**: Use `ALTER PROCEDURE` or `ALTER TABLE` instead of `CREATE`. -- **Version Control**: Keep your SQL scripts under version control to track changes. \ No newline at end of file +- execute the scripts in filename order +- verify `VmUsers` and `RegisterLinuxHostVm` in addition to the older objects +- rerun the deployment scripts when you want behavior that matches the supported automated path \ No newline at end of file From 1949de7dd10bf85ed08985510ded660749afb2dd Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Tue, 31 Mar 2026 13:39:08 -0400 Subject: [PATCH 3/3] finalized deployment code --- README.md | 4 +- api/app.py | 28 +++- api/requirements.txt | 1 + .../Configure-RHEL7-Host.sh | 25 +++- .../Configure-RHEL8-Host.sh | 25 +++- .../Configure-RHEL9-Host.sh | 22 ++- .../Configure-Ubuntu24_desktop-Host.sh | 22 ++- deploy/Build-ContainerImages.ps1 | 59 ++++++-- deploy/DEPLOYMENT.md | 3 + deploy/Initialize-DeploymentEnvironment.ps1 | 140 +++++++++++++++--- deploy/bicep/main.bicep | 2 +- deploy/bicep/main.json | 74 +++++---- deploy/bicep/main.parameters.example.json | 66 +++++++++ deploy/bicep/main.resources.bicep | 8 +- deploy/bicep/modules/Linux/main.bicep | 10 +- .../modules/apps/container-web-app.bicep | 17 ++- .../bicep/modules/core/app-service-plan.bicep | 2 +- front_end/app.py | 14 +- front_end/requirements.txt | 1 + front_end/route_vm_management.py | 2 + front_end/templates/base.html | 2 - .../RHEL/release-session.sh | 2 +- .../Ubuntu/release-session.sh | 2 +- 23 files changed, 445 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index da0a9f4..d300e43 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,9 @@ azd env new azd up ``` -Before running `azd up`, review the detailed guide and set any environment-specific values you need, especially networking, host counts, VM sizes, and SQL firewall access. The deployment scripts under `deploy/` now handle the Entra bootstrap, SSH key flow, post-provision role assignment, container image builds, SQL initialization, and Linux host SQL registration used by this solution. +The deployment defaults the App Service plan to Premium v3 `P2mv3`, which provides the minimum supported baseline of 4 vCPUs and 32 GB memory for the frontend, API, and task apps. + +Before running `azd up`, review the detailed guide and set any environment-specific values you need, especially networking, host counts, VM sizes, App Service plan sizing, and SQL firewall access. The deployment scripts under `deploy/` now handle the Entra bootstrap, SSH key flow, App Service health checks on `/health`, Application Insights wiring for the frontend and API, post-provision role assignment, container image builds, SQL initialization, and Linux host SQL registration used by this solution. ## Contributing diff --git a/api/app.py b/api/app.py index 35574f0..70fe22f 100644 --- a/api/app.py +++ b/api/app.py @@ -11,6 +11,12 @@ import logging import re +from azure.monitor.opentelemetry import configure_azure_monitor + +connection_string = os.environ.get('APPLICATIONINSIGHTS_CONNECTION_STRING') +if connection_string: + configure_azure_monitor(connection_string=connection_string, logger_name='linuxbroker.api') + from flask import Flask, jsonify, request from azure.identity import DefaultAzureCredential from azure.mgmt.compute import ComputeManagementClient @@ -31,7 +37,25 @@ # Logging Configuration logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) +logger = logging.getLogger('linuxbroker.api') + + +@app.route('/health', methods=['GET']) +def health(): + conn = get_db_connection() + if not conn: + return jsonify({'status': 'unhealthy'}), 503 + + try: + cursor = conn.cursor() + cursor.execute('SELECT 1') + cursor.fetchone() + return jsonify({'status': 'healthy', 'version': app.config['VERSION']}), 200 + except Exception as e: + logger.error("Health check failed: %s", e) + return jsonify({'status': 'unhealthy'}), 503 + finally: + conn.close() # =============================== # Functions @@ -433,7 +457,7 @@ def get_all_vms(): conn.close() if not rows: - return "No VMs found.", 404 + return jsonify([]), 200 return jsonify(rows), 200 diff --git a/api/requirements.txt b/api/requirements.txt index b32b901..89a535a 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -2,6 +2,7 @@ Flask==2.2.2 gunicorn Werkzeug==2.2.2 pyodbc==4.0.35 +azure-monitor-opentelemetry==1.8.7 azure-identity==1.17.1 azure-mgmt-compute==33.0.0 pyjwt==2.9.0 diff --git a/custom_script_extensions/Configure-RHEL7-Host.sh b/custom_script_extensions/Configure-RHEL7-Host.sh index 8a42eb4..82b2aa7 100644 --- a/custom_script_extensions/Configure-RHEL7-Host.sh +++ b/custom_script_extensions/Configure-RHEL7-Host.sh @@ -2,6 +2,24 @@ # Installs and configures the necessary packages for Linux Broker for AVD Access on RHEL 7 +LINUXBROKER_API_BASE_URL="${1:-}" +LINUXBROKER_API_CLIENT_ID="${2:-}" + +if [ -z "$LINUXBROKER_API_BASE_URL" ] || [ -z "$LINUXBROKER_API_CLIENT_ID" ]; then + echo "Linux Broker API base URL and client ID are required." + exit 1 +fi + +case "$LINUXBROKER_API_BASE_URL" in + https://*) ;; + *) + echo "Linux Broker API base URL must start with https://" + exit 1 + ;; +esac + +LINUXBROKER_API_BASE_URL="${LINUXBROKER_API_BASE_URL%/}" + # =============================== # Variables @@ -28,8 +46,8 @@ CURRENT_USERS_DETAILS="$output_directory/xrdp-loggedin-users.txt" CRON_SCHEDULE="0 * * * *" -YOUR_LINUXBROKER_API_CLIENT_ID="my_actual_client_id" -YOUR_LINUXBROKER_API_URL="my.actual.linuxbroker.api.url" +YOUR_LINUXBROKER_API_CLIENT_ID="$LINUXBROKER_API_CLIENT_ID" +YOUR_LINUXBROKER_API_BASE_URL="$LINUXBROKER_API_BASE_URL" # =============================== # Execution @@ -122,7 +140,8 @@ echo "Downloading release-session.sh..." sudo wget -O "$SCRIPT_PATH" "$release_session_url" sudo sed -i "s|YOUR_LINUX_BROKER_API_CLIENT_ID|$YOUR_LINUXBROKER_API_CLIENT_ID|g" "$SCRIPT_PATH" -sudo sed -i "s|YOUR_LINUX_BROKER_API_URL|$YOUR_LINUXBROKER_API_URL|g" "$SCRIPT_PATH" +sudo sed -i "s|YOUR_LINUX_BROKER_API_BASE_URL|$YOUR_LINUXBROKER_API_BASE_URL|g" "$SCRIPT_PATH" +sudo sed -i "s|YOUR_LINUX_BROKER_API_URL|$YOUR_LINUXBROKER_API_BASE_URL|g" "$SCRIPT_PATH" echo "Downloading xrdp-who-xnc.sh..." sudo wget -O "$output_directory/xrdp-who-xnc.sh" "$xrdp_who_xnc_url" diff --git a/custom_script_extensions/Configure-RHEL8-Host.sh b/custom_script_extensions/Configure-RHEL8-Host.sh index 34d4c5d..7eec67c 100644 --- a/custom_script_extensions/Configure-RHEL8-Host.sh +++ b/custom_script_extensions/Configure-RHEL8-Host.sh @@ -2,6 +2,24 @@ # Installs and configures the necessary packages for Linux Broker for AVD Access on RHEL 8 +LINUXBROKER_API_BASE_URL="${1:-}" +LINUXBROKER_API_CLIENT_ID="${2:-}" + +if [ -z "$LINUXBROKER_API_BASE_URL" ] || [ -z "$LINUXBROKER_API_CLIENT_ID" ]; then + echo "Linux Broker API base URL and client ID are required." + exit 1 +fi + +case "$LINUXBROKER_API_BASE_URL" in + https://*) ;; + *) + echo "Linux Broker API base URL must start with https://" + exit 1 + ;; +esac + +LINUXBROKER_API_BASE_URL="${LINUXBROKER_API_BASE_URL%/}" + # =============================== # Variables @@ -50,8 +68,8 @@ CURRENT_USERS_DETAILS="$output_directory/xrdp-loggedin-users.txt" CRON_SCHEDULE="0 * * * *" -YOUR_LINUXBROKER_API_CLIENT_ID="my_actual_client_id" -YOUR_LINUXBROKER_API_URL="my.actual.linuxbroker.api.url" +YOUR_LINUXBROKER_API_CLIENT_ID="$LINUXBROKER_API_CLIENT_ID" +YOUR_LINUXBROKER_API_BASE_URL="$LINUXBROKER_API_BASE_URL" # =============================== # Execution @@ -145,7 +163,8 @@ echo "Downloading release-session.sh..." sudo wget -O "$SCRIPT_PATH" "$release_session_url" sudo sed -i "s|YOUR_LINUX_BROKER_API_CLIENT_ID|$YOUR_LINUXBROKER_API_CLIENT_ID|g" "$SCRIPT_PATH" -sudo sed -i "s|YOUR_LINUX_BROKER_API_URL|$YOUR_LINUXBROKER_API_URL|g" "$SCRIPT_PATH" +sudo sed -i "s|YOUR_LINUX_BROKER_API_BASE_URL|$YOUR_LINUXBROKER_API_BASE_URL|g" "$SCRIPT_PATH" +sudo sed -i "s|YOUR_LINUX_BROKER_API_URL|$YOUR_LINUXBROKER_API_BASE_URL|g" "$SCRIPT_PATH" echo "Downloading xrdp-who-xorg.sh..." sudo wget -O "$output_directory/xrdp-who-xorg.sh" "$xrdp_who_xorg_url" diff --git a/custom_script_extensions/Configure-RHEL9-Host.sh b/custom_script_extensions/Configure-RHEL9-Host.sh index 5e3e722..7c27358 100644 --- a/custom_script_extensions/Configure-RHEL9-Host.sh +++ b/custom_script_extensions/Configure-RHEL9-Host.sh @@ -2,6 +2,21 @@ # Installs and configures the necessary packages for Linux Broker for AVD Access on RHEL 9 +LINUXBROKER_API_BASE_URL="${1:-}" +LINUXBROKER_API_CLIENT_ID="${2:-}" + +if [[ -z "$LINUXBROKER_API_BASE_URL" || -z "$LINUXBROKER_API_CLIENT_ID" ]]; then + echo "Linux Broker API base URL and client ID are required." + exit 1 +fi + +if [[ "$LINUXBROKER_API_BASE_URL" != https://* ]]; then + echo "Linux Broker API base URL must start with https://" + exit 1 +fi + +LINUXBROKER_API_BASE_URL="${LINUXBROKER_API_BASE_URL%/}" + # =============================== # Variables @@ -28,8 +43,8 @@ CURRENT_USERS_DETAILS="$output_directory/xrdp-loggedin-users.txt" CRON_SCHEDULE="0 * * * *" -YOUR_LINUXBROKER_API_CLIENT_ID="my_actual_client_id" -YOUR_LINUXBROKER_API_URL="my.actual.linuxbroker.api.url" +YOUR_LINUXBROKER_API_CLIENT_ID="$LINUXBROKER_API_CLIENT_ID" +YOUR_LINUXBROKER_API_BASE_URL="$LINUXBROKER_API_BASE_URL" # =============================== # Execution @@ -134,7 +149,8 @@ echo "Downloading release-session.sh..." sudo wget -O "$SCRIPT_PATH" "$release_session_url" sudo sed -i "s|YOUR_LINUX_BROKER_API_CLIENT_ID|$YOUR_LINUXBROKER_API_CLIENT_ID|g" "$SCRIPT_PATH" -sudo sed -i "s|YOUR_LINUX_BROKER_API_URL|$YOUR_LINUXBROKER_API_URL|g" "$SCRIPT_PATH" +sudo sed -i "s|YOUR_LINUX_BROKER_API_BASE_URL|$YOUR_LINUXBROKER_API_BASE_URL|g" "$SCRIPT_PATH" +sudo sed -i "s|YOUR_LINUX_BROKER_API_URL|$YOUR_LINUXBROKER_API_BASE_URL|g" "$SCRIPT_PATH" echo "Downloading xrdp-who-xnc.sh..." sudo wget -O "$output_directory/xrdp-who-xnc.sh" "$xrdp_who_xnc_url" diff --git a/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh b/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh index abe1f66..76d6f8d 100644 --- a/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh +++ b/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh @@ -2,6 +2,21 @@ # Installs and configures the necessary packages for Linux Broker for AVD Access on Ubuntu 24 desktop +LINUXBROKER_API_BASE_URL="${1:-}" +LINUXBROKER_API_CLIENT_ID="${2:-}" + +if [[ -z "$LINUXBROKER_API_BASE_URL" || -z "$LINUXBROKER_API_CLIENT_ID" ]]; then + echo "Linux Broker API base URL and client ID are required." + exit 1 +fi + +if [[ "$LINUXBROKER_API_BASE_URL" != https://* ]]; then + echo "Linux Broker API base URL must start with https://" + exit 1 +fi + +LINUXBROKER_API_BASE_URL="${LINUXBROKER_API_BASE_URL%/}" + # =============================== # Variables @@ -20,8 +35,8 @@ CURRENT_USERS_DETAILS="$output_directory/xrdp-loggedin-users.txt" CRON_SCHEDULE="0 * * * *" -YOUR_LINUXBROKER_API_CLIENT_ID="my_actual_client_id" -YOUR_LINUXBROKER_API_URL="my.actual.linuxbroker.api.url" +YOUR_LINUXBROKER_API_CLIENT_ID="$LINUXBROKER_API_CLIENT_ID" +YOUR_LINUXBROKER_API_BASE_URL="$LINUXBROKER_API_BASE_URL" # =============================== # Execution @@ -108,7 +123,8 @@ echo "Downloading release-session.sh..." sudo wget -O "$SCRIPT_PATH" "$release_session_url" sudo sed -i "s|YOUR_LINUX_BROKER_API_CLIENT_ID|$YOUR_LINUXBROKER_API_CLIENT_ID|g" "$SCRIPT_PATH" -sudo sed -i "s|YOUR_LINUX_BROKER_API_URL|$YOUR_LINUXBROKER_API_URL|g" "$SCRIPT_PATH" +sudo sed -i "s|YOUR_LINUX_BROKER_API_BASE_URL|$YOUR_LINUXBROKER_API_BASE_URL|g" "$SCRIPT_PATH" +sudo sed -i "s|YOUR_LINUX_BROKER_API_URL|$YOUR_LINUXBROKER_API_BASE_URL|g" "$SCRIPT_PATH" echo "Downloading xrdp-who-xnc.sh..." sudo wget -O "$output_directory/xrdp-who-xnc.sh" "$xrdp_who_xnc_url" diff --git a/deploy/Build-ContainerImages.ps1 b/deploy/Build-ContainerImages.ps1 index a932f38..ac47ffa 100644 --- a/deploy/Build-ContainerImages.ps1 +++ b/deploy/Build-ContainerImages.ps1 @@ -22,6 +22,50 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +function Invoke-AzCommandWithRetry { + param( + [Parameter(Mandatory = $true)] + [scriptblock]$Command, + + [Parameter(Mandatory = $true)] + [string]$Description, + + [Parameter(Mandatory = $false)] + [int]$MaxAttempts = 4, + + [Parameter(Mandatory = $false)] + [int]$InitialDelaySeconds = 5 + ) + + $lastError = '' + + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + $commandOutput = & $Command 2>&1 | Out-String + if ($LASTEXITCODE -eq 0) { + return + } + + $lastError = $commandOutput.Trim() + if ($attempt -ge $MaxAttempts) { + break + } + + $delaySeconds = [Math]::Min($InitialDelaySeconds * [Math]::Pow(2, $attempt - 1), 30) + Write-Warning "$Description failed on attempt $attempt of $MaxAttempts. Retrying in $([int]$delaySeconds) seconds." + if (-not [string]::IsNullOrWhiteSpace($lastError)) { + Write-Warning $lastError + } + + Start-Sleep -Seconds ([int]$delaySeconds) + } + + if ([string]::IsNullOrWhiteSpace($lastError)) { + throw "Failed to $Description after $MaxAttempts attempts." + } + + throw "Failed to $Description after $MaxAttempts attempts. Last error: $lastError" +} + if ([string]::IsNullOrWhiteSpace($EnvironmentName)) { $EnvironmentName = if (-not [string]::IsNullOrWhiteSpace($env:AZURE_ENV_NAME)) { $env:AZURE_ENV_NAME @@ -91,19 +135,16 @@ try { } } - az webapp restart --name $FrontendAppName --resource-group $ResourceGroupName | Out-Null - if ($LASTEXITCODE -ne 0) { - throw "Failed to restart frontend app '$FrontendAppName'." + Invoke-AzCommandWithRetry -Description "restart frontend app '$FrontendAppName'" -Command { + az webapp restart --name $FrontendAppName --resource-group $ResourceGroupName --only-show-errors --output none } - az webapp restart --name $ApiAppName --resource-group $ResourceGroupName | Out-Null - if ($LASTEXITCODE -ne 0) { - throw "Failed to restart API app '$ApiAppName'." + Invoke-AzCommandWithRetry -Description "restart API app '$ApiAppName'" -Command { + az webapp restart --name $ApiAppName --resource-group $ResourceGroupName --only-show-errors --output none } - az functionapp restart --name $TaskAppName --resource-group $ResourceGroupName | Out-Null - if ($LASTEXITCODE -ne 0) { - throw "Failed to restart task app '$TaskAppName'." + Invoke-AzCommandWithRetry -Description "restart task app '$TaskAppName'" -Command { + az functionapp restart --name $TaskAppName --resource-group $ResourceGroupName --only-show-errors --output none } } finally { diff --git a/deploy/DEPLOYMENT.md b/deploy/DEPLOYMENT.md index 9544b8c..dfab19b 100644 --- a/deploy/DEPLOYMENT.md +++ b/deploy/DEPLOYMENT.md @@ -78,6 +78,7 @@ The checked-in [bicep/main.parameters.example.json](bicep/main.parameters.exampl - `appName`: base name for generated resources. - `AZURE_LOCATION`: deployment region. +- `appServicePlanSku`: App Service plan SKU. The deployment baseline defaults to Premium v3 `P2mv3` for 4 vCPUs and 32 GB memory. - `allowedClientIp`: your public client IP for SQL bootstrap from the local machine. - `deployLinuxHosts`: `true` or `false`. - `deployAvdHosts`: `true` or `false`. @@ -190,6 +191,8 @@ Important deployment characteristics: - SQL public network access is enabled. - Azure services are allowed through the SQL firewall. - A client-IP firewall rule is added only if `allowedClientIp` is set. +- The frontend and API App Services enable App Service health checks on `/health`. +- The frontend and API apps are instrumented with Azure Monitor OpenTelemetry and receive `APPLICATIONINSIGHTS_CONNECTION_STRING` and `OTEL_SERVICE_NAME` through app settings. - Linux host auth defaults to `SSH`. - Key Vault stores `db-password` and `linux-host`. - The API app receives Key Vault Secrets User access so it can read those secrets at runtime. diff --git a/deploy/Initialize-DeploymentEnvironment.ps1 b/deploy/Initialize-DeploymentEnvironment.ps1 index 75b3cf2..4bc96c4 100644 --- a/deploy/Initialize-DeploymentEnvironment.ps1 +++ b/deploy/Initialize-DeploymentEnvironment.ps1 @@ -360,6 +360,78 @@ function Ensure-DefaultEnvValue { return $existing } +function Ensure-EnvValue { + param( + [Parameter(Mandatory = $true)][string]$Key, + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value + ) + + $existing = Get-AzdEnvValue -Key $Key + if ($existing -ne $Value) { + Set-AzdEnvValue -Key $Key -Value $Value + } + + return $Value +} + +function Get-RequiredAzdEnvValue { + param([Parameter(Mandatory = $true)][string]$Key) + + $value = Get-AzdEnvValue -Key $Key + if ([string]::IsNullOrWhiteSpace($value)) { + throw "Required azd environment value '$Key' is missing." + } + + return $value +} + +function ConvertTo-BoolParameterValue { + param( + [Parameter(Mandatory = $true)][string]$Key, + [Parameter(Mandatory = $false)][bool]$DefaultValue = $false + ) + + $value = Get-AzdEnvValue -Key $Key + if ([string]::IsNullOrWhiteSpace($value)) { + return $DefaultValue + } + + switch ($value.Trim().ToLowerInvariant()) { + 'true' { return $true } + 'false' { return $false } + default { throw "azd environment value '$Key' must be 'true' or 'false', but was '$value'." } + } +} + +function ConvertTo-IntParameterValue { + param( + [Parameter(Mandatory = $true)][string]$Key, + [Parameter(Mandatory = $false)][int]$DefaultValue = 0 + ) + + $value = Get-AzdEnvValue -Key $Key + if ([string]::IsNullOrWhiteSpace($value)) { + return $DefaultValue + } + + $parsedValue = 0 + if (-not [int]::TryParse($value, [ref]$parsedValue)) { + throw "azd environment value '$Key' must be an integer, but was '$value'." + } + + return $parsedValue +} + +function Add-BicepParameterValue { + param( + [Parameter(Mandatory = $true)][System.Collections.IDictionary]$ParameterCollection, + [Parameter(Mandatory = $true)][string]$ParameterName, + [Parameter(Mandatory = $true)]$Value + ) + + $ParameterCollection[$ParameterName] = @{ value = $Value } +} + function Get-JsonFilePath { $path = [System.IO.Path]::GetTempFileName() return [System.IO.Path]::ChangeExtension($path, '.json') @@ -758,7 +830,7 @@ $cloudContext = Get-CloudContext $frontendAppDisplayName = "$AppName-$EnvironmentName-frontend-ar" $apiAppDisplayName = "$AppName-$EnvironmentName-api-ar" -$frontendAppServiceName = "$AppName-$EnvironmentName-fe" +$frontendAppServiceName = "fe-$AppName-$EnvironmentName" $avdGroupName = "$AppName-$EnvironmentName-avd-hosts-sg" $linuxGroupName = "$AppName-$EnvironmentName-linux-hosts-sg" @@ -771,7 +843,7 @@ Ensure-DefaultEnvValue -Key 'tenantId' -ValueFactory { $defaultTenantId } | Out- Ensure-DefaultEnvValue -Key 'SQL_ADMIN_LOGIN' -ValueFactory { 'brokeradmin' } | Out-Null Ensure-DefaultEnvValue -Key 'SQL_DATABASE_NAME' -ValueFactory { 'LinuxBroker' } | Out-Null -Ensure-DefaultEnvValue -Key 'APP_SERVICE_PLAN_SKU' -ValueFactory { 'P1v3' } | Out-Null +Ensure-DefaultEnvValue -Key 'APP_SERVICE_PLAN_SKU' -ValueFactory { 'P2mv3' } | Out-Null Ensure-DefaultEnvValue -Key 'LINUX_HOST_ADMIN_LOGIN_NAME' -ValueFactory { 'avdadmin' } | Out-Null Ensure-DefaultEnvValue -Key 'DOMAIN_NAME' -ValueFactory { '' } | Out-Null Ensure-DefaultEnvValue -Key 'NFS_SHARE' -ValueFactory { '' } | Out-Null @@ -798,7 +870,7 @@ Ensure-DefaultEnvValue -Key 'avdMaxSessionLimit' -ValueFactory { '5' } | Out-Nul Ensure-DefaultEnvValue -Key 'vmSubscriptionId' -ValueFactory { $subscription.id } | Out-Null Ensure-DefaultEnvValue -Key 'sqlAdminLogin' -ValueFactory { 'brokeradmin' } | Out-Null Ensure-DefaultEnvValue -Key 'sqlDatabaseName' -ValueFactory { 'LinuxBroker' } | Out-Null -Ensure-DefaultEnvValue -Key 'appServicePlanSku' -ValueFactory { 'P1v3' } | Out-Null +Ensure-DefaultEnvValue -Key 'appServicePlanSku' -ValueFactory { 'P2mv3' } | Out-Null Ensure-DefaultEnvValue -Key 'linuxHostAdminLoginName' -ValueFactory { 'avdadmin' } | Out-Null Ensure-DefaultEnvValue -Key 'domainName' -ValueFactory { '' } | Out-Null Ensure-DefaultEnvValue -Key 'nfsShare' -ValueFactory { '' } | Out-Null @@ -811,6 +883,14 @@ Ensure-DefaultEnvValue -Key 'flaskKey' -ValueFactory { Get-AzdEnvValue -Key 'FLA Ensure-DefaultEnvValue -Key 'sqlAdminPassword' -ValueFactory { Get-AzdEnvValue -Key 'SQL_ADMIN_PASSWORD' } | Out-Null Ensure-DefaultEnvValue -Key 'hostAdminPassword' -ValueFactory { Get-AzdEnvValue -Key 'HOST_ADMIN_PASSWORD' } | Out-Null +if ((Get-AzdEnvValue -Key 'APP_SERVICE_PLAN_SKU') -eq 'P1v3') { + Ensure-EnvValue -Key 'APP_SERVICE_PLAN_SKU' -Value 'P2mv3' | Out-Null +} + +if ((Get-AzdEnvValue -Key 'appServicePlanSku') -eq 'P1v3') { + Ensure-EnvValue -Key 'appServicePlanSku' -Value 'P2mv3' | Out-Null +} + $deployLinuxHostsValue = Get-AzdEnvValue -Key 'deployLinuxHosts' $linuxHostAuthTypeValue = Get-AzdEnvValue -Key 'linuxHostAuthType' @@ -869,24 +949,48 @@ Write-Host 'Automatic admin consent was attempted for the configured application # so env values set above would not be picked up through ${...} references or # auto-mapping. Writing the file here guarantees the deployment gets real values. $bicepParametersPath = Join-Path $PSScriptRoot 'bicep' 'main.parameters.json' +$bicepParameterEntries = [ordered]@{} + +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'environmentName' -Value '${AZURE_ENV_NAME}' +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'location' -Value '${AZURE_LOCATION}' +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'appName' -Value (Get-RequiredAzdEnvValue -Key 'appName') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'tenantId' -Value (Get-RequiredAzdEnvValue -Key 'tenantId') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'frontendClientId' -Value (Get-RequiredAzdEnvValue -Key 'frontendClientId') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'frontendClientSecret' -Value (Get-RequiredAzdEnvValue -Key 'frontendClientSecret') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'apiClientId' -Value (Get-RequiredAzdEnvValue -Key 'apiClientId') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'apiClientSecret' -Value (Get-RequiredAzdEnvValue -Key 'apiClientSecret') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'linuxHostSshPrivateKey' -Value (Get-RequiredAzdEnvValue -Key 'linuxHostSshPrivateKey') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'linuxHostSshPublicKey' -Value (Get-RequiredAzdEnvValue -Key 'linuxHostSshPublicKey') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'avdHostGroupId' -Value (Get-RequiredAzdEnvValue -Key 'avdHostGroupId') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'linuxHostGroupId' -Value (Get-RequiredAzdEnvValue -Key 'linuxHostGroupId') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'sqlAdminLogin' -Value (Get-RequiredAzdEnvValue -Key 'sqlAdminLogin') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'sqlAdminPassword' -Value (Get-RequiredAzdEnvValue -Key 'sqlAdminPassword') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'flaskKey' -Value (Get-RequiredAzdEnvValue -Key 'flaskKey') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'domainName' -Value (Get-AzdEnvValue -Key 'domainName') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'nfsShare' -Value (Get-AzdEnvValue -Key 'nfsShare') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'linuxHostAdminLoginName' -Value (Get-RequiredAzdEnvValue -Key 'linuxHostAdminLoginName') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'hostAdminPassword' -Value (Get-RequiredAzdEnvValue -Key 'hostAdminPassword') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'vmHostResourceGroup' -Value (Get-AzdEnvValue -Key 'vmHostResourceGroup') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'vmSubscriptionId' -Value (Get-RequiredAzdEnvValue -Key 'vmSubscriptionId') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'allowedClientIp' -Value (Get-AzdEnvValue -Key 'allowedClientIp') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'appServicePlanSku' -Value (Get-RequiredAzdEnvValue -Key 'appServicePlanSku') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'deployLinuxHosts' -Value (ConvertTo-BoolParameterValue -Key 'deployLinuxHosts') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'deployAvdHosts' -Value (ConvertTo-BoolParameterValue -Key 'deployAvdHosts') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'linuxHostVmNamePrefix' -Value (Get-RequiredAzdEnvValue -Key 'linuxHostVmNamePrefix') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'linuxHostVmSize' -Value (Get-RequiredAzdEnvValue -Key 'linuxHostVmSize') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'linuxHostCount' -Value (ConvertTo-IntParameterValue -Key 'linuxHostCount') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'linuxHostAuthType' -Value (Get-RequiredAzdEnvValue -Key 'linuxHostAuthType') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'linuxHostOsVersion' -Value (Get-RequiredAzdEnvValue -Key 'linuxHostOsVersion') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'avdHostPoolName' -Value (Get-RequiredAzdEnvValue -Key 'avdHostPoolName') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'avdSessionHostCount' -Value (ConvertTo-IntParameterValue -Key 'avdSessionHostCount') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'avdMaxSessionLimit' -Value (ConvertTo-IntParameterValue -Key 'avdMaxSessionLimit' -DefaultValue 5) +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'avdVmNamePrefix' -Value (Get-RequiredAzdEnvValue -Key 'avdVmNamePrefix') +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'avdVmSize' -Value (Get-RequiredAzdEnvValue -Key 'avdVmSize') + $bicepParameters = [ordered]@{ '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#' contentVersion = '1.0.0.0' - parameters = [ordered]@{ - environmentName = @{ value = '${AZURE_ENV_NAME}' } - location = @{ value = '${AZURE_LOCATION}' } - appName = @{ value = (Get-AzdEnvValue -Key 'appName') } - tenantId = @{ value = (Get-AzdEnvValue -Key 'tenantId') } - frontendClientId = @{ value = (Get-AzdEnvValue -Key 'frontendClientId') } - frontendClientSecret = @{ value = (Get-AzdEnvValue -Key 'frontendClientSecret') } - apiClientId = @{ value = (Get-AzdEnvValue -Key 'apiClientId') } - apiClientSecret = @{ value = (Get-AzdEnvValue -Key 'apiClientSecret') } - linuxHostSshPrivateKey = @{ value = (Get-AzdEnvValue -Key 'linuxHostSshPrivateKey') } - sqlAdminPassword = @{ value = (Get-AzdEnvValue -Key 'sqlAdminPassword') } - flaskKey = @{ value = (Get-AzdEnvValue -Key 'flaskKey') } - hostAdminPassword = @{ value = (Get-AzdEnvValue -Key 'hostAdminPassword') } - allowedClientIp = @{ value = (Get-AzdEnvValue -Key 'allowedClientIp') } - } + parameters = $bicepParameterEntries } $bicepParameters | ConvertTo-Json -Depth 10 | Set-Content -Path $bicepParametersPath -Encoding utf8 diff --git a/deploy/bicep/main.bicep b/deploy/bicep/main.bicep index 7da1f93..0d469a8 100644 --- a/deploy/bicep/main.bicep +++ b/deploy/bicep/main.bicep @@ -76,7 +76,7 @@ param vmSubscriptionId string = subscription().subscriptionId param allowedClientIp string = '' @description('App Service plan SKU name.') -param appServicePlanSku string = 'P1v3' +param appServicePlanSku string = 'P2mv3' @description('Deploy Linux broker host VMs.') param deployLinuxHosts bool = false diff --git a/deploy/bicep/main.json b/deploy/bicep/main.json index 0f2cd57..533a141 100644 --- a/deploy/bicep/main.json +++ b/deploy/bicep/main.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.25.53.49325", - "templateHash": "6576658425850583710" + "templateHash": "16092647517579810232" } }, "parameters": { @@ -169,7 +169,7 @@ }, "appServicePlanSku": { "type": "string", - "defaultValue": "P1v3", + "defaultValue": "P2mv3", "metadata": { "description": "App Service plan SKU name." } @@ -425,7 +425,7 @@ "_generator": { "name": "bicep", "version": "0.25.53.49325", - "templateHash": "8643162257687112754" + "templateHash": "14112124161390832818" } }, "parameters": { @@ -511,7 +511,7 @@ }, "appServicePlanSku": { "type": "string", - "defaultValue": "P1v3" + "defaultValue": "P2mv3" }, "deployLinuxHosts": { "type": "bool", @@ -694,6 +694,7 @@ "CLIENT_ID": "[parameters('frontendClientId')]", "FLASK_KEY": "[parameters('flaskKey')]", "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET": "[parameters('frontendClientSecret')]", + "OTEL_SERVICE_NAME": "[variables('frontendAppName')]", "SCM_DO_BUILD_DURING_DEPLOYMENT": "false", "TENANT_ID": "[parameters('tenantId')]", "WEBSITE_AUTH_AAD_ALLOWED_TENANTS": "[parameters('tenantId')]" @@ -1487,7 +1488,7 @@ "_generator": { "name": "bicep", "version": "0.25.53.49325", - "templateHash": "3371409296139664791" + "templateHash": "4807701873718480196" } }, "parameters": { @@ -1504,7 +1505,7 @@ }, "skuName": { "type": "string", - "defaultValue": "P1v3" + "defaultValue": "P2mv3" } }, "resources": [ @@ -1576,6 +1577,9 @@ "authSettings": { "value": "[variables('frontendAuthSettings')]" }, + "healthCheckPath": { + "value": "/health" + }, "alwaysOn": { "value": true }, @@ -1590,7 +1594,7 @@ "_generator": { "name": "bicep", "version": "0.25.53.49325", - "templateHash": "15351606689998843173" + "templateHash": "876570250764564740" } }, "parameters": { @@ -1625,6 +1629,10 @@ "type": "object", "defaultValue": {} }, + "healthCheckPath": { + "type": "string", + "defaultValue": "" + }, "alwaysOn": { "type": "bool", "defaultValue": true @@ -1634,6 +1642,9 @@ "defaultValue": true } }, + "variables": { + "webSiteConfig": "[union(createObject('alwaysOn', parameters('alwaysOn'), 'acrUseManagedIdentityCreds', parameters('useManagedIdentityForRegistry'), 'linuxFxVersion', format('DOCKER|{0}', parameters('containerImageName')), 'minTlsVersion', '1.2'), if(empty(parameters('healthCheckPath')), createObject(), createObject('healthCheckPath', parameters('healthCheckPath'))))]" + }, "resources": [ { "type": "Microsoft.Web/sites", @@ -1648,12 +1659,7 @@ "properties": { "serverFarmId": "[parameters('serverFarmId')]", "httpsOnly": true, - "siteConfig": { - "alwaysOn": "[parameters('alwaysOn')]", - "acrUseManagedIdentityCreds": "[parameters('useManagedIdentityForRegistry')]", - "linuxFxVersion": "[format('DOCKER|{0}', parameters('containerImageName'))]", - "minTlsVersion": "1.2" - } + "siteConfig": "[variables('webSiteConfig')]" } }, { @@ -1777,6 +1783,7 @@ "LINUX_HOST_GROUP_ID": "[parameters('linuxHostGroupId')]", "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET": "[parameters('apiClientSecret')]", "NFS_SHARE": "[parameters('nfsShare')]", + "OTEL_SERVICE_NAME": "[variables('apiAppName')]", "SCM_DO_BUILD_DURING_DEPLOYMENT": "false", "TENANT_ID": "[parameters('tenantId')]", "VAULT_URL": "[reference(resourceId('Microsoft.Resources/deployments', 'keyVault'), '2022-09-01').outputs.vaultUri.value]", @@ -1787,6 +1794,9 @@ "authSettings": { "value": "[variables('apiAuthSettings')]" }, + "healthCheckPath": { + "value": "/health" + }, "alwaysOn": { "value": true }, @@ -1801,7 +1811,7 @@ "_generator": { "name": "bicep", "version": "0.25.53.49325", - "templateHash": "15351606689998843173" + "templateHash": "876570250764564740" } }, "parameters": { @@ -1836,6 +1846,10 @@ "type": "object", "defaultValue": {} }, + "healthCheckPath": { + "type": "string", + "defaultValue": "" + }, "alwaysOn": { "type": "bool", "defaultValue": true @@ -1845,6 +1859,9 @@ "defaultValue": true } }, + "variables": { + "webSiteConfig": "[union(createObject('alwaysOn', parameters('alwaysOn'), 'acrUseManagedIdentityCreds', parameters('useManagedIdentityForRegistry'), 'linuxFxVersion', format('DOCKER|{0}', parameters('containerImageName')), 'minTlsVersion', '1.2'), if(empty(parameters('healthCheckPath')), createObject(), createObject('healthCheckPath', parameters('healthCheckPath'))))]" + }, "resources": [ { "type": "Microsoft.Web/sites", @@ -1859,12 +1876,7 @@ "properties": { "serverFarmId": "[parameters('serverFarmId')]", "httpsOnly": true, - "siteConfig": { - "alwaysOn": "[parameters('alwaysOn')]", - "acrUseManagedIdentityCreds": "[parameters('useManagedIdentityForRegistry')]", - "linuxFxVersion": "[format('DOCKER|{0}', parameters('containerImageName'))]", - "minTlsVersion": "1.2" - } + "siteConfig": "[variables('webSiteConfig')]" } }, { @@ -2150,6 +2162,12 @@ }, "OSVersion": { "value": "[parameters('linuxHostOsVersion')]" + }, + "linuxBrokerApiBaseUrl": { + "value": "[variables('frontendApiBaseUrl')]" + }, + "linuxBrokerApiClientId": { + "value": "[parameters('apiClientId')]" } }, "template": { @@ -2159,7 +2177,7 @@ "_generator": { "name": "bicep", "version": "0.25.53.49325", - "templateHash": "10371371812490226963" + "templateHash": "7373843181961101080" } }, "parameters": { @@ -2191,6 +2209,12 @@ "minValue": 1, "maxValue": 20 }, + "linuxBrokerApiBaseUrl": { + "type": "string" + }, + "linuxBrokerApiClientId": { + "type": "string" + }, "authType": { "type": "string", "allowedValues": [ @@ -2238,7 +2262,7 @@ }, "script": { "uri": "https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main/custom_script_extensions/Configure-RHEL7-Host.sh", - "cmd": "bash Configure-RHEL7-Host.sh" + "cmd": "[format('bash Configure-RHEL7-Host.sh \"{0}\" \"{1}\"', parameters('linuxBrokerApiBaseUrl'), parameters('linuxBrokerApiClientId'))]" } }, "8-LVM": { @@ -2250,7 +2274,7 @@ }, "script": { "uri": "https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main/custom_script_extensions/Configure-RHEL8-Host.sh", - "cmd": "bash Configure-RHEL8-Host.sh" + "cmd": "[format('bash Configure-RHEL8-Host.sh \"{0}\" \"{1}\"', parameters('linuxBrokerApiBaseUrl'), parameters('linuxBrokerApiClientId'))]" } }, "9-LVM": { @@ -2262,7 +2286,7 @@ }, "script": { "uri": "https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main/custom_script_extensions/Configure-RHEL9-Host.sh", - "cmd": "bash Configure-RHEL9-Host.sh" + "cmd": "[format('bash Configure-RHEL9-Host.sh \"{0}\" \"{1}\"', parameters('linuxBrokerApiBaseUrl'), parameters('linuxBrokerApiClientId'))]" } }, "24_04-lts": { @@ -2274,7 +2298,7 @@ }, "script": { "uri": "https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh", - "cmd": "bash Configure-Ubuntu24_desktop-Host.sh" + "cmd": "[format('bash Configure-Ubuntu24_desktop-Host.sh \"{0}\" \"{1}\"', parameters('linuxBrokerApiBaseUrl'), parameters('linuxBrokerApiClientId'))]" } } }, diff --git a/deploy/bicep/main.parameters.example.json b/deploy/bicep/main.parameters.example.json index d6f9ece..95630ff 100644 --- a/deploy/bicep/main.parameters.example.json +++ b/deploy/bicep/main.parameters.example.json @@ -8,6 +8,9 @@ "location": { "value": "${AZURE_LOCATION}" }, + "appServicePlanSku": { + "value": "P2mv3" + }, "appName": { "value": "linuxbroker" }, @@ -26,9 +29,21 @@ "apiClientSecret": { "value": "" }, + "avdHostGroupId": { + "value": "" + }, + "linuxHostGroupId": { + "value": "" + }, "linuxHostSshPrivateKey": { "value": "" }, + "linuxHostSshPublicKey": { + "value": "" + }, + "sqlAdminLogin": { + "value": "brokeradmin" + }, "sqlAdminPassword": { "value": "" }, @@ -38,8 +53,59 @@ "hostAdminPassword": { "value": "" }, + "linuxHostAdminLoginName": { + "value": "avdadmin" + }, + "domainName": { + "value": "" + }, + "nfsShare": { + "value": "" + }, + "vmHostResourceGroup": { + "value": "" + }, + "vmSubscriptionId": { + "value": "" + }, "allowedClientIp": { "value": "" + }, + "deployLinuxHosts": { + "value": true + }, + "deployAvdHosts": { + "value": true + }, + "linuxHostVmNamePrefix": { + "value": "lnxhost" + }, + "linuxHostVmSize": { + "value": "Standard_D2s_v5" + }, + "linuxHostCount": { + "value": 2 + }, + "linuxHostAuthType": { + "value": "SSH" + }, + "linuxHostOsVersion": { + "value": "24_04-lts" + }, + "avdHostPoolName": { + "value": "linuxbroker--hp" + }, + "avdSessionHostCount": { + "value": 1 + }, + "avdMaxSessionLimit": { + "value": 5 + }, + "avdVmNamePrefix": { + "value": "avdhost" + }, + "avdVmSize": { + "value": "Standard_D8s_v5" } } } \ No newline at end of file diff --git a/deploy/bicep/main.resources.bicep b/deploy/bicep/main.resources.bicep index b973cba..30b99da 100644 --- a/deploy/bicep/main.resources.bicep +++ b/deploy/bicep/main.resources.bicep @@ -29,7 +29,7 @@ param hostAdminPassword string param vmHostResourceGroup string = '' param vmSubscriptionId string = subscription().subscriptionId param allowedClientIp string = '' -param appServicePlanSku string = 'P1v3' +param appServicePlanSku string = 'P2mv3' param deployLinuxHosts bool = false param deployAvdHosts bool = false param linuxHostVmNamePrefix string = 'lnxhost' @@ -259,6 +259,7 @@ var frontendSettings = { CLIENT_ID: frontendClientId FLASK_KEY: flaskKey MICROSOFT_PROVIDER_AUTHENTICATION_SECRET: frontendClientSecret + OTEL_SERVICE_NAME: frontendAppName SCM_DO_BUILD_DURING_DEPLOYMENT: 'false' TENANT_ID: tenantId WEBSITE_AUTH_AAD_ALLOWED_TENANTS: tenantId @@ -277,6 +278,7 @@ var apiSettings = { LINUX_HOST_GROUP_ID: linuxHostGroupId MICROSOFT_PROVIDER_AUTHENTICATION_SECRET: apiClientSecret NFS_SHARE: nfsShare + OTEL_SERVICE_NAME: apiAppName SCM_DO_BUILD_DURING_DEPLOYMENT: 'false' TENANT_ID: tenantId VAULT_URL: keyVault.outputs.vaultUri @@ -308,6 +310,7 @@ module frontendApp 'modules/apps/container-web-app.bicep' = { applicationInsightsConnectionString: observability.outputs.applicationInsightsConnectionString appSettings: frontendSettings authSettings: frontendAuthSettings + healthCheckPath: '/health' alwaysOn: true useManagedIdentityForRegistry: true } @@ -327,6 +330,7 @@ module apiApp 'modules/apps/container-web-app.bicep' = { applicationInsightsConnectionString: observability.outputs.applicationInsightsConnectionString appSettings: apiSettings authSettings: apiAuthSettings + healthCheckPath: '/health' alwaysOn: true useManagedIdentityForRegistry: true } @@ -408,6 +412,8 @@ module linuxHosts 'modules/Linux/main.bicep' = if (deployLinuxHosts && linuxHost adminPassword: hostAdminPassword sshPublicKey: linuxHostSshPublicKey OSVersion: linuxHostOsVersion + linuxBrokerApiBaseUrl: frontendApiBaseUrl + linuxBrokerApiClientId: apiClientId } } diff --git a/deploy/bicep/modules/Linux/main.bicep b/deploy/bicep/modules/Linux/main.bicep index b07fe02..4da70ed 100644 --- a/deploy/bicep/modules/Linux/main.bicep +++ b/deploy/bicep/modules/Linux/main.bicep @@ -10,6 +10,8 @@ param vmSize string @minValue(1) @maxValue(20) param numberOfVMs int +param linuxBrokerApiBaseUrl string +param linuxBrokerApiClientId string @allowed([ 'Password' @@ -59,7 +61,7 @@ var imageConfigs = { } script: { uri: 'https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main/custom_script_extensions/Configure-RHEL7-Host.sh' - cmd: 'bash Configure-RHEL7-Host.sh' + cmd: 'bash Configure-RHEL7-Host.sh "${linuxBrokerApiBaseUrl}" "${linuxBrokerApiClientId}"' } } '8-LVM': { @@ -71,7 +73,7 @@ var imageConfigs = { } script: { uri: 'https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main/custom_script_extensions/Configure-RHEL8-Host.sh' - cmd: 'bash Configure-RHEL8-Host.sh' + cmd: 'bash Configure-RHEL8-Host.sh "${linuxBrokerApiBaseUrl}" "${linuxBrokerApiClientId}"' } } '9-LVM': { @@ -83,7 +85,7 @@ var imageConfigs = { } script: { uri: 'https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main/custom_script_extensions/Configure-RHEL9-Host.sh' - cmd: 'bash Configure-RHEL9-Host.sh' + cmd: 'bash Configure-RHEL9-Host.sh "${linuxBrokerApiBaseUrl}" "${linuxBrokerApiClientId}"' } } '24_04-lts': { @@ -95,7 +97,7 @@ var imageConfigs = { } script: { uri: 'https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh' - cmd: 'bash Configure-Ubuntu24_desktop-Host.sh' + cmd: 'bash Configure-Ubuntu24_desktop-Host.sh "${linuxBrokerApiBaseUrl}" "${linuxBrokerApiClientId}"' } } } diff --git a/deploy/bicep/modules/apps/container-web-app.bicep b/deploy/bicep/modules/apps/container-web-app.bicep index da69360..20fd906 100644 --- a/deploy/bicep/modules/apps/container-web-app.bicep +++ b/deploy/bicep/modules/apps/container-web-app.bicep @@ -7,9 +7,19 @@ param containerRegistryLoginServer string param applicationInsightsConnectionString string param appSettings object = {} param authSettings object = {} +param healthCheckPath string = '' param alwaysOn bool = true param useManagedIdentityForRegistry bool = true +var webSiteConfig = union({ + alwaysOn: alwaysOn + acrUseManagedIdentityCreds: useManagedIdentityForRegistry + linuxFxVersion: 'DOCKER|${containerImageName}' + minTlsVersion: '1.2' +}, empty(healthCheckPath) ? {} : { + healthCheckPath: healthCheckPath +}) + resource webApp 'Microsoft.Web/sites@2023-12-01' = { name: appName location: location @@ -21,12 +31,7 @@ resource webApp 'Microsoft.Web/sites@2023-12-01' = { properties: { serverFarmId: serverFarmId httpsOnly: true - siteConfig: { - alwaysOn: alwaysOn - acrUseManagedIdentityCreds: useManagedIdentityForRegistry - linuxFxVersion: 'DOCKER|${containerImageName}' - minTlsVersion: '1.2' - } + siteConfig: webSiteConfig } } diff --git a/deploy/bicep/modules/core/app-service-plan.bicep b/deploy/bicep/modules/core/app-service-plan.bicep index 8885c07..bea9775 100644 --- a/deploy/bicep/modules/core/app-service-plan.bicep +++ b/deploy/bicep/modules/core/app-service-plan.bicep @@ -1,7 +1,7 @@ param location string = resourceGroup().location param tags object = {} param appServicePlanName string -param skuName string = 'P1v3' +param skuName string = 'P2mv3' resource appServicePlan 'Microsoft.Web/serverfarms@2023-12-01' = { name: appServicePlanName diff --git a/front_end/app.py b/front_end/app.py index dc6f16b..be60489 100644 --- a/front_end/app.py +++ b/front_end/app.py @@ -1,7 +1,13 @@ import os import logging -from flask import Flask, render_template, send_from_directory +from azure.monitor.opentelemetry import configure_azure_monitor + +connection_string = os.environ.get('APPLICATIONINSIGHTS_CONNECTION_STRING') +if connection_string: + configure_azure_monitor(connection_string=connection_string, logger_name='linuxbroker.frontend') + +from flask import Flask, jsonify, render_template, send_from_directory from flask_session import Session from route_authentication import register_route_authentication from route_user import register_route_user @@ -21,7 +27,7 @@ # Logging Configuration logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) +logger = logging.getLogger('linuxbroker.frontend') # =============================== # General Routes @@ -30,6 +36,10 @@ def index(): return render_template('index.html') +@app.route('/health') +def health(): + return jsonify({"status": "healthy", "version": app.config['VERSION']}), 200 + @app.route('/favicon.ico') def favicon(): return send_from_directory(os.path.join(app.root_path, 'static'), 'favicon.ico', mimetype='image/vnd.microsoft.icon') diff --git a/front_end/requirements.txt b/front_end/requirements.txt index 7558659..60654df 100644 --- a/front_end/requirements.txt +++ b/front_end/requirements.txt @@ -1,6 +1,7 @@ Flask==2.2.2 gunicorn Werkzeug==2.2.2 +azure-monitor-opentelemetry==1.8.7 requests==2.26.0 msal==1.31.0 Flask-Session==0.8.0 diff --git a/front_end/route_vm_management.py b/front_end/route_vm_management.py index e6860e5..35e7b24 100644 --- a/front_end/route_vm_management.py +++ b/front_end/route_vm_management.py @@ -18,6 +18,8 @@ def view_all_vms(): return redirect(url_for('login')) headers = {'Authorization': f'Bearer {access_token}'} response = requests.get(f"{API_URL}/vms", headers=headers) + if response.status_code == 404: + return render_template('vm/view_all_vms.html', vms=[]) response.raise_for_status() vms = response.json() return render_template('vm/view_all_vms.html', vms=vms) diff --git a/front_end/templates/base.html b/front_end/templates/base.html index 3dcbd7c..a2a2a54 100644 --- a/front_end/templates/base.html +++ b/front_end/templates/base.html @@ -6,8 +6,6 @@ {% block title %}Linux Broker Management Portal{% endblock %} - -