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..11485ea 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,10 @@ __pycache__ # Azure App Service artifacts .env .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 d25c0c6..d300e43 100644 --- a/README.md +++ b/README.md @@ -191,9 +191,32 @@ 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 supported 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. +For the full deployment walkthrough, see [deploy/DEPLOYMENT.md](deploy/DEPLOYMENT.md). + +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 +cd .\deploy +azd env new +azd up +``` + +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/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/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/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/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/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..8f841fd --- /dev/null +++ b/deploy/Assign-ServicePrincipalApiRole.ps1 @@ -0,0 +1,61 @@ +[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 +} + +$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 +} + +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..a09cb08 --- /dev/null +++ b/deploy/Assign-VmApiRoles.ps1 @@ -0,0 +1,103 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ResourceGroupName, + + [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 +} + +$groupMappings = @( + @{ Tag = 'linux-host'; GroupId = $LinuxHostGroupId } + @{ Tag = 'avd-host'; GroupId = $AvdHostGroupId } +) + +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 + if ([string]::IsNullOrWhiteSpace($principalId)) { + Write-Warning "Skipping VM '$($virtualMachine.name)' because no managed identity principal id was found." + continue + } + + Ensure-GroupMembership -GroupId $mapping.GroupId -PrincipalId $principalId -VmName $virtualMachine.name + } +} diff --git a/deploy/Build-ContainerImages.ps1 b/deploy/Build-ContainerImages.ps1 new file mode 100644 index 0000000..ac47ffa --- /dev/null +++ b/deploy/Build-ContainerImages.ps1 @@ -0,0 +1,152 @@ +[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' + +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 + } + 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)." + } + } + + Invoke-AzCommandWithRetry -Description "restart frontend app '$FrontendAppName'" -Command { + az webapp restart --name $FrontendAppName --resource-group $ResourceGroupName --only-show-errors --output none + } + + Invoke-AzCommandWithRetry -Description "restart API app '$ApiAppName'" -Command { + az webapp restart --name $ApiAppName --resource-group $ResourceGroupName --only-show-errors --output none + } + + Invoke-AzCommandWithRetry -Description "restart task app '$TaskAppName'" -Command { + az functionapp restart --name $TaskAppName --resource-group $ResourceGroupName --only-show-errors --output none + } +} +finally { + Pop-Location +} diff --git a/deploy/DEPLOYMENT.md b/deploy/DEPLOYMENT.md new file mode 100644 index 0000000..dfab19b --- /dev/null +++ b/deploy/DEPLOYMENT.md @@ -0,0 +1,358 @@ +# 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. +- `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`. +- `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. +- 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. + +## 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 new file mode 100644 index 0000000..fb957ab --- /dev/null +++ b/deploy/Initialize-Database.ps1 @@ -0,0 +1,119 @@ +[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' + +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 +} + +$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 +} + +$connection = Get-SqlConnection -Server $SqlServerFqdn -Database $DatabaseName -Username $SqlAdminLogin -Password $SqlAdminPassword + +try { + $connection.Open() + + 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) + } + } +} +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 new file mode 100644 index 0000000..4bc96c4 --- /dev/null +++ b/deploy/Initialize-DeploymentEnvironment.ps1 @@ -0,0 +1,997 @@ +[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 { + $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 + } + } + } +} + +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' +$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 = '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' + 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 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 + ) + + 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) + + # 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) + + $builder = New-Object System.Text.StringBuilder + foreach ($byte in $bytes) { + [void]$builder.Append($alphabet[$byte % $alphabet.Length]) + } + + 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, + [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 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') +} + +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 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) + + $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-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, + [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-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) + + $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 + } + + $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" + } + } + + Invoke-GraphRestJson -Method 'PATCH' -Url "$($CloudContext.GraphUrl)/v1.0/applications/$($app.id)" -Body $logoutBody + + return $app +} + +function Ensure-ApiApplication { + param( + [Parameter(Mandatory = $true)][hashtable]$CloudContext, + [Parameter(Mandatory = $true)][string]$DisplayName, + [Parameter(Mandatory = $false)][string]$FrontendAppId = '' + ) + + $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 + + $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 = @( + @{ + 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' + } + ) + } + } + + Invoke-GraphRestJson -Method 'PATCH' -Url "$($CloudContext.GraphUrl)/v1.0/applications/$($app.id)" -Body $apiManifest + + $appRoles = @( + @{ + allowedMemberTypes = @('User') + description = 'Full access to Linux Broker management APIs.' + displayName = 'FullAccess' + id = $apiRoleIds.FullAccess + isEnabled = $true + value = 'FullAccess' + } + @{ + allowedMemberTypes = @('Application') + description = 'Allows the scheduled task function app to call maintenance endpoints.' + displayName = 'ScheduledTask' + id = $apiRoleIds.ScheduledTask + isEnabled = $true + value = 'ScheduledTask' + } + @{ + allowedMemberTypes = @('Application', 'User') + description = 'Allows AVD host automation to call AVD-specific endpoints.' + displayName = 'AvdHost' + id = $apiRoleIds.AvdHost + isEnabled = $true + value = 'AvdHost' + } + @{ + allowedMemberTypes = @('Application', 'User') + description = 'Allows Linux host automation to call Linux host endpoints.' + displayName = 'LinuxHost' + 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 + } + + return $app +} + +$cloudContext = Get-CloudContext + +$frontendAppDisplayName = "$AppName-$EnvironmentName-frontend-ar" +$apiAppDisplayName = "$AppName-$EnvironmentName-api-ar" +$frontendAppServiceName = "fe-$AppName-$EnvironmentName" +$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 '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 { '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 +Ensure-DefaultEnvValue -Key 'VM_HOST_RESOURCE_GROUP' -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 { '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 +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 { '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 +Ensure-DefaultEnvValue -Key 'vmHostResourceGroup' -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 + +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' + +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 +$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 +$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 +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 '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' +$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 = $bicepParameterEntries +} + +$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 new file mode 100644 index 0000000..ece0b2c --- /dev/null +++ b/deploy/Post-Provision.ps1 @@ -0,0 +1,153 @@ +[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, + + [Parameter(Mandatory = $false)] + [string]$AvdHostGroupId, + + [Parameter(Mandatory = $false)] + [string]$LinuxHostGroupId +) + +$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($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)) { + $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" ` + -EnvironmentName $EnvironmentName + +& "$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 ` + -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 new file mode 100644 index 0000000..58ac23e --- /dev/null +++ b/deploy/azure.yaml @@ -0,0 +1,15 @@ +name: linuxbrokerforavdaccess +metadata: + template: linuxbrokerforavdaccess@0.0.1 +infra: + provider: bicep + path: ./bicep +hooks: + preprovision: + windows: + shell: pwsh + run: ./Initialize-DeploymentEnvironment.ps1 + postprovision: + windows: + shell: pwsh + run: ./Post-Provision.ps1 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..0d469a8 --- /dev/null +++ b/deploy/bicep/main.bicep @@ -0,0 +1,203 @@ +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 + +@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 = '' + +@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 = '' + +@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 = 'P2mv3' + +@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 = 'SSH' + +@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 + linuxHostSshPrivateKey: linuxHostSshPrivateKey + 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..533a141 --- /dev/null +++ b/deploy/bicep/main.json @@ -0,0 +1,3074 @@ +{ + "$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": "16092647517579810232" + } + }, + "parameters": { + "appName": { + "type": "string", + "metadata": { + "description": "Application name used for resource naming." + } + }, + "environmentName": { + "type": "string", + "metadata": { + "description": "Deployment environment name." + } + }, + "location": { + "type": "string", + "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", + "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": "", + "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", + "defaultValue": "", + "metadata": { + "description": "SQL administrator password." + } + }, + "flaskKey": { + "type": "securestring", + "defaultValue": "", + "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", + "defaultValue": "", + "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": "P2mv3", + "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": "SSH", + "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')]" + }, + "linuxHostSshPrivateKey": { + "value": "[parameters('linuxHostSshPrivateKey')]" + }, + "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": "14112124161390832818" + } + }, + "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" + }, + "linuxHostSshPrivateKey": { + "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": "P2mv3" + }, + "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": "SSH", + "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-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')]", + "OTEL_SERVICE_NAME": "[variables('frontendAppName')]", + "SCM_DO_BUILD_DURING_DEPLOYMENT": "false", + "TENANT_ID": "[parameters('tenantId')]", + "WEBSITE_AUTH_AAD_ALLOWED_TENANTS": "[parameters('tenantId')]" + } + }, + "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": "11175018908924583799" + } + }, + "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')]" + }, + "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)]" + } + } + } + } + }, + { + "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')]" + }, + "linuxHostSshPrivateKey": { + "value": "[parameters('linuxHostSshPrivateKey')]" + } + }, + "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": "11314698381309119362" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "keyVaultName": { + "type": "string" + }, + "sqlAdminPassword": { + "type": "securestring" + }, + "linuxHostSshPrivateKey": { + "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-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'), 'linux-host')]", + "properties": { + "value": "[parameters('linuxHostSshPrivateKey')]" + }, + "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]" + } + } + } + } + }, + { + "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": "4807701873718480196" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "appServicePlanName": { + "type": "string" + }, + "skuName": { + "type": "string", + "defaultValue": "P2mv3" + } + }, + "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": "[variables('frontendSettings')]" + }, + "authSettings": { + "value": "[variables('frontendAuthSettings')]" + }, + "healthCheckPath": { + "value": "/health" + }, + "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": "876570250764564740" + } + }, + "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": {} + }, + "authSettings": { + "type": "object", + "defaultValue": {} + }, + "healthCheckPath": { + "type": "string", + "defaultValue": "" + }, + "alwaysOn": { + "type": "bool", + "defaultValue": true + }, + "useManagedIdentityForRegistry": { + "type": "bool", + "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", + "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": "[variables('webSiteConfig')]" + } + }, + { + "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'))]" + ] + }, + { + "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", + "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', '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('linuxHostPrivateKeySecretName')]", + "LINUX_HOST_ADMIN_LOGIN_NAME": "[parameters('linuxHostAdminLoginName')]", + "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]", + "VM_RESOURCE_GROUP": "[variables('effectiveVmResourceGroup')]", + "VM_SUBSCRIPTION_ID": "[parameters('vmSubscriptionId')]" + } + }, + "authSettings": { + "value": "[variables('apiAuthSettings')]" + }, + "healthCheckPath": { + "value": "/health" + }, + "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": "876570250764564740" + } + }, + "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": {} + }, + "authSettings": { + "type": "object", + "defaultValue": {} + }, + "healthCheckPath": { + "type": "string", + "defaultValue": "" + }, + "alwaysOn": { + "type": "bool", + "defaultValue": true + }, + "useManagedIdentityForRegistry": { + "type": "bool", + "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", + "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": "[variables('webSiteConfig')]" + } + }, + { + "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'))]" + ] + }, + { + "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", + "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', 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": "[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))]" + } + }, + "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')]" + }, + "linuxBrokerApiBaseUrl": { + "value": "[variables('frontendApiBaseUrl')]" + }, + "linuxBrokerApiClientId": { + "value": "[parameters('apiClientId')]" + } + }, + "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": "7373843181961101080" + } + }, + "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 + }, + "linuxBrokerApiBaseUrl": { + "type": "string" + }, + "linuxBrokerApiClientId": { + "type": "string" + }, + "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'))]" + } + ], + "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": { + "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": "[format('bash Configure-RHEL7-Host.sh \"{0}\" \"{1}\"', parameters('linuxBrokerApiBaseUrl'), parameters('linuxBrokerApiClientId'))]" + } + }, + "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": "[format('bash Configure-RHEL8-Host.sh \"{0}\" \"{1}\"', parameters('linuxBrokerApiBaseUrl'), parameters('linuxBrokerApiClientId'))]" + } + }, + "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": "[format('bash Configure-RHEL9-Host.sh \"{0}\" \"{1}\"', parameters('linuxBrokerApiBaseUrl'), parameters('linuxBrokerApiClientId'))]" + } + }, + "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": "[format('bash Configure-Ubuntu24_desktop-Host.sh \"{0}\" \"{1}\"', parameters('linuxBrokerApiBaseUrl'), parameters('linuxBrokerApiClientId'))]" + } + } + }, + "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": "[union(createObject('computerName', variables('vmNames')[copyIndex()], 'adminUsername', parameters('adminUsername'), 'linuxConfiguration', variables('linuxConfiguration')), variables('adminCredentials'))]", + "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.example.json b/deploy/bicep/main.parameters.example.json new file mode 100644 index 0000000..95630ff --- /dev/null +++ b/deploy/bicep/main.parameters.example.json @@ -0,0 +1,111 @@ +{ + "$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}" + }, + "appServicePlanSku": { + "value": "P2mv3" + }, + "appName": { + "value": "linuxbroker" + }, + "tenantId": { + "value": "" + }, + "frontendClientId": { + "value": "" + }, + "frontendClientSecret": { + "value": "" + }, + "apiClientId": { + "value": "" + }, + "apiClientSecret": { + "value": "" + }, + "avdHostGroupId": { + "value": "" + }, + "linuxHostGroupId": { + "value": "" + }, + "linuxHostSshPrivateKey": { + "value": "" + }, + "linuxHostSshPublicKey": { + "value": "" + }, + "sqlAdminLogin": { + "value": "brokeradmin" + }, + "sqlAdminPassword": { + "value": "" + }, + "flaskKey": { + "value": "" + }, + "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 new file mode 100644 index 0000000..30b99da --- /dev/null +++ b/deploy/bicep/main.resources.bicep @@ -0,0 +1,450 @@ +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 +@secure() +param linuxHostSshPrivateKey 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 = 'P2mv3' +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 = 'SSH' +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-password' +var linuxHostPrivateKeySecretName = 'linux-host' + +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 + linuxHostSshPrivateKey: linuxHostSshPrivateKey + } +} + +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 = 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: frontendClientSecret + OTEL_SERVICE_NAME: frontendAppName + 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: linuxHostPrivateKeySecretName + LINUX_HOST_ADMIN_LOGIN_NAME: linuxHostAdminLoginName + 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 + 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 + authSettings: frontendAuthSettings + healthCheckPath: '/health' + 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 + authSettings: apiAuthSettings + healthCheckPath: '/health' + 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 + linuxBrokerApiBaseUrl: frontendApiBaseUrl + linuxBrokerApiClientId: apiClientId + } +} + +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 78% rename from bicep/Linux/main.bicep rename to deploy/bicep/modules/Linux/main.bicep index 5663dff..4da70ed 100644 --- a/bicep/Linux/main.bicep +++ b/deploy/bicep/modules/Linux/main.bicep @@ -1,19 +1,18 @@ 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 +param linuxBrokerApiBaseUrl string +param linuxBrokerApiClientId string -// VM Parameters - Authentication @allowed([ 'Password' 'SSH' @@ -24,17 +23,33 @@ 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 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': { @@ -46,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': { @@ -58,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': { @@ -70,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': { @@ -82,15 +97,13 @@ 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}"' } } } -// 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 +114,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 +139,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 @@ -140,26 +151,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: [ { @@ -189,7 +185,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..20fd906 --- /dev/null +++ b/deploy/bicep/modules/apps/container-web-app.bicep @@ -0,0 +1,85 @@ +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 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 + tags: tags + kind: 'app,linux,container' + identity: { + type: 'SystemAssigned' + } + properties: { + serverFarmId: serverFarmId + httpsOnly: true + siteConfig: webSiteConfig + } +} + +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 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' + 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..bea9775 --- /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 = 'P2mv3' + +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..52c7bff --- /dev/null +++ b/deploy/bicep/modules/core/key-vault.bicep @@ -0,0 +1,46 @@ +param location string = resourceGroup().location +param tags object = {} +param keyVaultName string +@secure() +param sqlAdminPassword string +@secure() +param linuxHostSshPrivateKey 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-password' + properties: { + value: sqlAdminPassword + } +} + +resource linuxHostPrivateKeySecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { + parent: keyVault + name: 'linux-host' + properties: { + value: linuxHostSshPrivateKey + } +} + +output name string = keyVault.name +output id string = keyVault.id +output vaultUri string = keyVault.properties.vaultUri 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..4cbf462 --- /dev/null +++ b/deploy/bicep/modules/core/storage-account.bicep @@ -0,0 +1,24 @@ +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 +output connectionString string = 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${storageAccount.listKeys().keys[0].value};EndpointSuffix=${environment().suffixes.storage}' 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/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 %} - -