Home | Quick Start | Host Pool Deployment | Image Build | Artifacts | Features | Parameters
🔧 Technical References:
- Image Management Template Documentation - Artifacts storage infrastructure
- Image Build Template Documentation - Using artifacts in image builds
- Overview
- Architecture
- Artifacts Directory Structure
- Creating Custom Artifact Packages
- Master Scripts
- Integration with Deployments
- Best Practices
- Troubleshooting
The artifacts system in this AVD solution provides a flexible, Zero Trust-compliant method for deploying software and configurations to both custom images and session hosts. Artifacts are packages containing PowerShell scripts, installers, and supporting files that are:
- Stored in Azure Blob Storage
- Downloaded and executed during image builds or session host deployments
- Managed through the
Invoke-Customization.ps1orchestration script
| Concept | Description |
|---|---|
| Artifact | A folder containing a PowerShell script and supporting files (installers, configuration files, etc.) |
| Artifact Package | The zipped version of an artifact folder, uploaded to Azure Blob Storage |
| Invoke-Customization.ps1 | The orchestration script (.common/scripts/Invoke-Customization.ps1) that downloads and executes a single artifact |
| Customization | A reference to an artifact package with optional arguments, defined in deployment parameters |
| Run Command | Azure VM Run Command that executes Invoke-Customization.ps1 for each artifact |
Both image builds and session host deployments use the same mechanism:
- Bicep/ARM deployment loops through the customizations array
- For each customization, a separate VM Run Command is created
- Each Run Command executes
Invoke-Customization.ps1with parameters:Name- The customization nameUri- The blob storage URL to the artifactArguments- Optional arguments string- Authentication parameters for blob storage access
Invoke-Customization.ps1downloads the artifact and executes it- The
@batchSize(1)decorator ensures customizations execute sequentially
graph TD
A[Artifacts in Blob Storage] --> B[Image Build Process]
A --> C[Session Host Deployment]
B --> D[Bicep loops through customizations array]
C --> E[Bicep loops through sessionHostCustomizations array]
D --> F[Creates Run Command per customization]
E --> G[Creates Run Command per customization]
F --> H[Each runs Invoke-Customization.ps1]
G --> I[Each runs Invoke-Customization.ps1]
H --> J[Custom Image Created]
I --> K[Session Hosts Configured]
Image Build Process:
- Uses
customizationsparameter array - Bicep creates a Run Command for each item
- Each Run Command executes
Invoke-Customization.ps1once - Sequential execution via
@batchSize(1)
Session Host Deployment:
- Uses
sessionHostCustomizationsparameter array - Bicep creates a Run Command for each item
- Each Run Command executes
Invoke-Customization.ps1once - Sequential execution via
@batchSize(1)
┌────────────────────────────────────────────────────────────────┐
│ 1. Preparation Phase (Deploy-ImageManagement.ps1) │
├────────────────────────────────────────────────────────────────┤
│ • Download software from internet (optional) │
│ • Compress each artifact folder → ZIP files │
│ • Upload to Azure Blob Storage │
└──────────────────┬─────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ 2. Bicep/ARM Deployment (Looping Logic) │
├────────────────────────────────────────────────────────────────┤
│ │
│ Image Build: Session Host Deployment: │
│ ┌──────────────────────────┐ ┌────────────────────────────┐ │
│ │ imageBuild.bicep │ │ invokeCustomizations.bicep │ │
│ ├──────────────────────────┤ ├────────────────────────────┤ │
│ │ for each customization: │ │ for each customization: │ │
│ │ Create Run Command │ │ Create Run Command │ │
│ │ @batchSize(1) │ │ @batchSize(1) │ │
│ └──────────┬───────────────┘ └──────────┬─────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────┐ ┌────────────────────────────┐ │
│ │ VM Run Command #1 │ │ VM Run Command #1 │ │
│ │ Invoke-Customization.ps1 │ │ Invoke-Customization.ps1 │ │
│ │ -Name "FSLogix" │ │ -Name "Configure-Office" │ │
│ │ -Uri "https://..." │ │ -Uri "https://..." │ │
│ │ -Arguments "" │ │ -Arguments "-param value" │ │
│ └──────────┬───────────────┘ └──────────┬─────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────┐ ┌────────────────────────────┐ │
│ │ VM Run Command #2 │ │ VM Run Command #2 │ │
│ │ Invoke-Customization.ps1 │ │ Invoke-Customization.ps1 │ │
│ └──────────┬───────────────┘ └──────────┬─────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ (continues...) (continues...) │
│ │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ 3. Execution Phase (Invoke-Customization.ps1) │
├────────────────────────────────────────────────────────────────┤
│ • Download artifact from blob storage (with managed identity) │
│ • Determine file type (.zip, .ps1, .exe, .msi, .bat) │
│ • Extract if ZIP, find PS1 script inside │
│ • Execute with optional Arguments parameter │
│ • Log all activity to C:\Windows\Logs\[Name].log │
│ • Return exit code to Run Command │
└────────────────────────────────────────────────────────────────┘
Looping is handled by Bicep/ARM:
- Image builds:
deployments/imageBuild/modules/applyCustomizationsBatch.bicep - Session hosts:
deployments/hostpools/modules/sessionHosts/modules/invokeCustomizations.bicep
Each artifact execution is independent:
- Separate Run Command resource for each customization
@batchSize(1)ensures sequential execution- Each Run Command imports and executes
Invoke-Customization.ps1 - Script is embedded inline via
loadTextContent()function
Parameters support:
- Arguments are passed as a single string to
Invoke-Customization.ps1 - For PowerShell scripts, arguments are parsed into named parameters
- Format:
-ParameterName Value -SwitchParameter - Example:
-InstallMode Full -SkipShortcuts
All artifacts are stored in the repository at:
.common/artifacts/
.common/artifacts/
│
├── uploadedFileVersionInfo.txt # Auto-generated version info
│
├── FSLogix.zip # Pre-packaged artifact (optional)
├── WDOT.zip # Pre-packaged artifact (optional)
│
├── Install-FSLogix/ # Artifact Package Example
│ ├── Install-FSLogix.ps1 # Main script (required)
│ └── [supporting files] # Installers, configs (optional)
│
├── VSCode/ # Artifact Package Example
│ ├── Install_VSCode.ps1 # Main script (required)
│ ├── VSCodeSetup.exe # Installer (optional)
│ └── readme.md # Documentation (optional)
│
├── Configure-Office365Policy/ # Configuration-only example
│ ├── Configure-Office365.ps1 # Main script (required)
│ └── office365.admx # Policy templates (optional)
│
├── LGPO/ # Tool-based artifact
│ ├── install-lgpo.ps1 # Main script (required)
│ └── LGPO.exe # Tool executable (optional)
│
└── [Your-Custom-Artifact]/ # Your custom package
├── Install-[AppName].ps1 # Main script (required)
├── [installer-file] # Application installer (optional)
└── [config-files] # Supporting files (optional)
Note: The orchestration script Invoke-Customization.ps1 is located at .common/scripts/Invoke-Customization.ps1 (not in the artifacts directory). It is embedded into ARM/Bicep deployments using the loadTextContent() function.
When Deploy-ImageManagement.ps1 processes the .common/artifacts/ directory for upload to blob storage:
| Source | Processing | Result |
|---|---|---|
| Subfolders | Compressed to ZIP | Each subfolder becomes a separate .zip file (e.g., Chrome/ → Chrome.zip) |
| Root files | Copied as-is | Files in the root are uploaded individually without modification |
Example:
.common/artifacts/
├── Chrome/ → Uploaded as Chrome.zip
│ ├── Install-Chrome.ps1
│ └── installer.msi
├── FSLogix/ → Uploaded as FSLogix.zip
│ └── Install-FSLogix.ps1
└── teamsbootstrapper.exe → Uploaded as teamsbootstrapper.exe
When Invoke-Customization.ps1 downloads and executes an artifact during deployment:
| File Extension | Execution Behavior | Arguments Handling |
|---|---|---|
| .ps1 | Executed directly with PowerShell | Arguments string parsed into named parameters and splatted |
| .exe | Executed with Start-Process |
Arguments string passed directly to executable |
| .msi | Executed with msiexec.exe /i |
Arguments string passed directly to msiexec |
| .bat | Executed with cmd.exe |
Arguments string passed directly to batch file |
| .zip | Extracted, then finds first .ps1 in root and executes it | Arguments string parsed into named parameters and splatted to the PS1 |
Auto-generated by Deploy-ImageManagement.ps1, this file tracks downloaded software versions:
SoftwareName = Visual Studio Code
DownloadUrl = https://code.visualstudio.com/sha/download?build=stable&os=win32-x64
Download File = VSCodeUserSetup-x64-1.85.1.exe
ProductVersion = 1.85.1
FileVersion = 1.85.1.23348
Downloaded on = 12/15/2024 10:30:45 AM
--------------------------------------------------
SoftwareName = FSLogix
DownloadUrl = https://aka.ms/fslogix_download
Download File = FSLogix_Apps_2.9.8884.27471.zip
ProductVersion = 2.9.8884.27471
FileVersion = 2.9.8884.27471
Downloaded on = 12/15/2024 10:31:12 AM
--------------------------------------------------
Create a new folder in .common/artifacts/ with a descriptive name:
# Example: Creating an artifact for Google Chrome
New-Item -Path ".\.common\artifacts\Chrome" -ItemType DirectoryNaming Conventions:
- Use PascalCase (e.g.,
MyApp,Configure-Setting) - Be descriptive but concise
- Avoid spaces and special characters
Every artifact package must contain exactly one PowerShell script that performs the installation or configuration.
Important: If you need parameters, use standard PowerShell named parameters. The Invoke-Customization.ps1 orchestrator will parse the Arguments string and pass them to your script as named parameters.
Script Template:
#region Parameters (Optional - only if you need them)
Param(
[Parameter(Mandatory = $false)]
[string]$InstallMode = 'Full',
[Parameter(Mandatory = $false)]
[switch]$SkipShortcuts
)
#endregion
#region Initialization
$SoftwareName = 'MyApplication'
$LogPath = 'C:\Windows\Logs'
$Script:Name = 'Install-MyApp'
#endregion
#region Functions
Function Write-Log {
Param (
[Parameter(Mandatory = $false)]
[ValidateSet("Info", "Warning", "Error")]
$Category = 'Info',
[Parameter(Mandatory = $true)]
$Message
)
$Date = Get-Date
$Content = "[$Date]`t$Category`t`t$Message`n"
Add-Content $Script:Log $Content -ErrorAction Stop
Write-Host $Content
}
Function New-Log {
Param (
[Parameter(Mandatory = $true)]
[string]$Path
)
$Date = Get-Date -UFormat "%Y-%m-%d_%H-%M-%S"
$Script:LogFile = "$Script:Name-$Date.log"
if (-not (Test-Path $Path)) {
New-Item -Path $Path -ItemType Directory -Force | Out-Null
}
$Script:Log = Join-Path $Path $Script:LogFile
Add-Content $Script:Log "Date`t`t`tCategory`t`tDetails"
}
#endregion
#region Main Script
try {
# Initialize logging
New-Log -Path $LogPath
Write-Log -Category Info -Message "Starting $SoftwareName installation"
# Log received parameters
Write-Log -Category Info -Message "InstallMode: $InstallMode"
Write-Log -Category Info -Message "SkipShortcuts: $SkipShortcuts"
# Locate installer in script directory
$ScriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path
$Installer = Get-ChildItem -Path $ScriptPath -Filter "*.msi" | Select-Object -First 1
if (-not $Installer) {
throw "Installer not found in $ScriptPath"
}
Write-Log -Category Info -Message "Found installer: $($Installer.Name)"
# Build arguments based on parameters
$Arguments = "/i `"$($Installer.FullName)`" /qn /norestart"
if ($InstallMode -eq 'Minimal') {
$Arguments += " ADDLOCAL=Core"
}
# Execute installation
Write-Log -Category Info -Message "Executing: msiexec.exe $Arguments"
$Process = Start-Process -FilePath "msiexec.exe" -ArgumentList $Arguments -Wait -PassThru
$ExitCode = $Process.ExitCode
if ($ExitCode -eq 0 -or $ExitCode -eq 3010) {
Write-Log -Category Info -Message "Installation completed successfully. Exit code: $ExitCode"
exit 0
}
else {
Write-Log -Category Error -Message "Installation failed with exit code: $ExitCode"
exit $ExitCode
}
}
catch {
Write-Log -Category Error -Message "Error during installation: $_"
exit 1
}
#endregionHow Parameters Work:
When you define this customization in your deployment:
{
"name": "MyApp",
"blobNameOrUri": "MyApp.zip",
"arguments": "-InstallMode Minimal -SkipShortcuts"
}The Invoke-Customization.ps1 script will:
- Download and extract MyApp.zip
- Find Install-MyApp.ps1 inside
- Parse the arguments string:
-InstallMode Minimal -SkipShortcuts - Call the script:
& Install-MyApp.ps1 -InstallMode Minimal -SkipShortcuts
Your script receives: $InstallMode = "Minimal" and $SkipShortcuts = $true
Place any required files in the same directory:
Chrome/
├── Install-Chrome.ps1
└── GoogleChromeEnterpriseBundle64.msi
Best Practices for Supporting Files:
- Keep installers in the same directory as the script
- Use relative paths to locate files
- Include any configuration files needed
- Document file requirements in a readme.md
Before uploading, test your script locally:
# Test with no parameters
.\.common\artifacts\Chrome\Install-Chrome.ps1
# Test with named parameters
.\.common\artifacts\Chrome\Install-Chrome.ps1 -InstallMode Full -SkipShortcutsOnce tested, deploy the artifact:
# Update artifacts in blob storage
.\deployments\Deploy-ImageManagement.ps1 `
-StorageAccountResourceId "/subscriptions/.../storageAccounts/stabc123" `
-ManagedIdentityResourceID "/subscriptions/.../userAssignedIdentities/uami-123" `
-DeleteExistingBlobsSome installers may be too large to store in the repo. Download them during execution:
Param(
[Parameter(Mandatory = $false)]
[string]$InstallerUrl = "https://example.com/installer.exe"
)
# Download the installer
$InstallerPath = Join-Path $env:TEMP "installer.exe"
Write-Log -Category Info -Message "Downloading installer from $InstallerUrl"
Invoke-WebRequest -Uri $InstallerUrl -OutFile $InstallerPath -UseBasicParsing
# Verify download
if (-not (Test-Path $InstallerPath)) {
throw "Failed to download installer"
}
# Install
Start-Process -FilePath $InstallerPath -ArgumentList "/S" -WaitFor configuration-only artifacts (no installer):
Function Set-RegistryValue {
Param (
[Parameter(Mandatory = $true)]
[string]$Key,
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $true)]
$Value,
[Parameter(Mandatory = $false)]
[Microsoft.Win32.RegistryValueKind]$Type = 'String'
)
if (-not (Test-Path -LiteralPath $Key)) {
New-Item -Path $Key -Force | Out-Null
Write-Log -Category Info -Message "Created registry key: $Key"
}
New-ItemProperty -LiteralPath $Key -Name $Name -Value $Value -PropertyType $Type -Force | Out-Null
Write-Log -Category Info -Message "Set registry value: $Key\$Name = $Value"
}
# Example usage
Set-RegistryValue -Key "HKLM:\SOFTWARE\MyApp" -Name "Setting1" -Value "Enabled" -Type StringParam(
[Parameter(Mandatory = $false)]
[ValidateSet('Full', 'Minimal', 'Custom')]
[string]$InstallMode = 'Full',
[Parameter(Mandatory = $false)]
[switch]$SkipShortcuts
)
Write-Log -Category Info -Message "InstallMode: $InstallMode"
Write-Log -Category Info -Message "SkipShortcuts: $SkipShortcuts"
# Conditional logic
switch ($InstallMode) {
'Full' {
Write-Log -Category Info -Message "Performing full installation"
# Full installation code
}
'Minimal' {
Write-Log -Category Info -Message "Performing minimal installation"
# Minimal installation code
}
'Custom' {
Write-Log -Category Info -Message "Performing custom installation"
# Custom installation code
}
}
if (-not $SkipShortcuts) {
# Create shortcuts
Write-Log -Category Info -Message "Creating shortcuts"
}For artifacts with multiple installers:
# Locate all installers
$ScriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path
$Installers = Get-ChildItem -Path $ScriptPath -Filter "*.exe"
foreach ($Installer in $Installers) {
Write-Log -Category Info -Message "Installing: $($Installer.Name)"
$Process = Start-Process -FilePath $Installer.FullName -ArgumentList "/S" -Wait -PassThru
if ($Process.ExitCode -ne 0 -and $Process.ExitCode -ne 3010) {
Write-Log -Category Warning -Message "$($Installer.Name) failed with exit code: $($Process.ExitCode)"
}
else {
Write-Log -Category Info -Message "$($Installer.Name) completed successfully"
}
}- Script uses standard PowerShell named parameters (NOT DynParameters)
- Script includes logging functionality
- Script handles errors gracefully
- Script returns appropriate exit codes (0 = success)
- Script uses relative paths for file access (
$PSScriptRootorSplit-Path) - Script includes header comments explaining purpose
- Script name follows convention:
Install-[Name].ps1orConfigure-[Name].ps1
The Invoke-Customization.ps1 script (.common/scripts/Invoke-Customization.ps1) is the core orchestration script used by both image builds and session host deployments. It handles one customization at a time.
Key Points:
- Location:
.common/scripts/Invoke-Customization.ps1 - Usage: Embedded into Bicep/ARM via
loadTextContent()function - Execution: One instance per customization via VM Run Commands
- Looping: Handled by Bicep/ARM deployment, NOT by the script
- Parameters: Standard PowerShell parameters, NOT DynParameters
| Parameter | Type | Required | Description |
|---|---|---|---|
APIVersion |
string | No | IMDS API version for managed identity tokens (default based on cloud) |
Arguments |
string | No | Arguments string passed to the artifact (default: empty) |
BlobStorageSuffix |
string | Yes | Blob storage endpoint suffix (e.g., blob.core.usgovcloudapi.net) |
BuildDir |
string | No | Build directory for temp files (used in image builds) |
Name |
string | Yes | Name of the customization (used for logging) |
Uri |
string | Yes | Full URI to the artifact in blob storage |
UserAssignedIdentityClientId |
string | No | Client ID for managed identity authentication |
Execution Flow:
1. START: Invoke-Customization.ps1 receives parameters from Run Command
↓
2. Start transcript logging to C:\Windows\Logs\[Name].log
↓
3. Create temp directory ($env:TEMP\[Name] or BuildDir\[Name])
↓
4. Authentication check:
- If Uri is in blob storage AND UserAssignedIdentityClientId provided:
→ Get OAuth token from Azure Instance Metadata Service (IMDS)
→ Add Bearer token to download headers
- Else:
→ Download without authentication (public or SAS token in URL)
↓
5. Download artifact from Uri to temp directory
↓
6. Determine file extension and handle accordingly:
├─ .EXE:
│ └─ Execute: Start-Process with Arguments
├─ .MSI:
│ └─ Execute: msiexec.exe /i [file] with Arguments
├─ .BAT:
│ └─ Execute: cmd.exe with Arguments
├─ .PS1:
│ ├─ If Arguments provided:
│ │ ├─ Parse Arguments string into parameter hashtable
│ │ └─ Call script with splatted parameters: & $Script @params
│ └─ Else: & $Script
└─ .ZIP:
├─ Extract to subfolder in temp directory
├─ Find first .ps1 file in root of extracted content
├─ If Arguments provided:
│ ├─ Parse Arguments into parameter hashtable
│ └─ Call script with splatted parameters: & $Script @params
└─ Else: & $Script
↓
7. Cleanup temp directory (if in $env:TEMP)
↓
8. Stop transcript
↓
9. END: Exit code returned to Run Command
For PowerShell Scripts (.ps1 or .ps1 inside .zip):
The Arguments string is parsed into named parameters using the ConvertTo-ParametersSplat function:
Input Format:
-ParameterName Value -SwitchParameter -AnotherParam "Value with spaces"
Parsing Rules:
- Parameters start with
-followed by parameter name - Switch parameters have no value (set to
$true) - Boolean values:
true→$true,false→$false - Quoted strings are preserved
- Result is hashtable splatted to the PowerShell script
Example:
Deployment configuration:
{
"name": "MyApp",
"blobNameOrUri": "MyApp.zip",
"arguments": "-InstallMode Full -SkipShortcuts -LogPath C:\\Logs"
}Parsed to hashtable:
@{
InstallMode = "Full"
SkipShortcuts = $true
LogPath = "C:\Logs"
}Called as:
& Install-MyApp.ps1 @params
# Equivalent to: & Install-MyApp.ps1 -InstallMode "Full" -SkipShortcuts -LogPath "C:\Logs"For EXE/MSI/BAT files:
Arguments are passed directly to Start-Process without parsing. Use appropriate syntax for the executable:
{
"name": "TeamsBootstrapper",
"blobNameOrUri": "teamsbootstrapper.exe",
"arguments": "-p -o C:\\Logs"
}| Extension | Handler | Arguments Usage |
|---|---|---|
| .exe | Start-Process -FilePath $file -ArgumentList $args |
Direct pass-through |
| .msi | msiexec.exe /i $file $args |
Direct pass-through |
| .bat | cmd.exe $file $args |
Direct pass-through |
| .ps1 | & $script @parsedParams |
Parsed into parameters |
| .zip | Extract → Find .ps1 → & $script @parsedParams |
Parsed into parameters |
All activity is logged via PowerShell transcript:
Log Location: C:\Windows\Logs\[Name].log
Log Content:
- All parameter values
- Download progress
- File type detection
- Execution commands
- Exit codes
- Any errors
Example Log:
[12/22/2024 10:30:15] Starting 'MyApp' script with the following parameters.
[12/22/2024 10:30:15] APIVersion: 2018-02-01
[12/22/2024 10:30:15] BlobStorageSuffix: blob.core.usgovcloudapi.net
[12/22/2024 10:30:15] Name: MyApp
[12/22/2024 10:30:15] Uri: https://staccount.blob.core.usgovcloudapi.net/artifacts/MyApp.zip
[12/22/2024 10:30:15] Arguments: -InstallMode Full
[12/22/2024 10:30:16] Downloading 'https://...' to 'C:\Temp\MyApp'
[12/22/2024 10:30:20] Finished downloading
[12/22/2024 10:30:21] Extracting 'MyApp.zip' to 'C:\Temp\MyApp\MyApp'
[12/22/2024 10:30:22] Finding PowerShell script in root of 'C:\Temp\MyApp\MyApp'
[12/22/2024 10:30:22] Calling PowerShell Script with arguments '-InstallMode Full'
[12/22/2024 10:32:45] Script completed
How Invoke-Customization.ps1 is Called:
Image Builds:
File: deployments/imageBuild/modules/applyCustomization.bicep
resource runCommand 'Microsoft.Compute/virtualMachines/runCommands@2023-09-01' = {
parent: virtualMachine
name: customizer.name
location: location
properties: {
parameters: [
{ name: 'APIVersion', value: apiVersion }
{ name: 'BlobStorageSuffix', value: 'blob.${environment().suffixes.storage}' }
{ name: 'UserAssignedIdentityClientId', value: userAssignedIdentityClientId }
{ name: 'Name', value: customizer.name }
{ name: 'Uri', value: customizer.uri }
{ name: 'Arguments', value: customizer.?arguments ?? '' }
{ name: 'BuildDir', value: buildDir }
]
source: {
script: loadTextContent('../../../../.common/scripts/Invoke-Customization.ps1')
}
treatFailureAsDeploymentFailure: true
}
}Session Hosts:
File: deployments/hostpools/modules/sessionHosts/modules/invokeCustomizations.bicep
@batchSize(1)
resource runCommands 'Microsoft.Compute/virtualMachines/runCommands@2023-03-01' = [for customizer in customizers: {
name: customizer.name
location: location
parent: virtualMachine
properties: {
parameters: [
{ name: 'APIVersion', value: apiVersion }
{ name: 'BlobStorageSuffix', value: 'blob.${environment().suffixes.storage}' }
{ name: 'UserAssignedIdentityClientId', value: userAssignedIdentityClientId }
{ name: 'Name', value: customizer.name }
{ name: 'Uri', value: customizer.uri }
{ name: 'Arguments', value: customizer.arguments }
]
source: {
script: loadTextContent('../../../../../.common/scripts/Invoke-Customization.ps1')
}
treatFailureAsDeploymentFailure: true
}
}]Key Points:
loadTextContent()embeds the entire script inline@batchSize(1)ensures sequential execution- Bicep loops through customizations array
- Each customization gets its own Run Command resource
- Run Commands execute independently
Step 1: Prepare Artifacts
# Deploy image management resources and upload artifacts
.\deployments\Deploy-ImageManagement.ps1 `
-DeployImageManagementResources `
-Location "East US 2" `
-ParameterFilePrefix "production"Step 2: Reference Artifacts in Image Build
Edit your image build parameters file:
{
"artifactsContainerUri": {
"value": "https://staccount.blob.core.usgovcloudapi.net/artifacts/"
},
"artifactsUserAssignedIdentityResourceId": {
"value": "/subscriptions/.../userAssignedIdentities/uami-artifacts"
},
"customizations": {
"value": [
{
"name": "LGPO",
"blobNameOrUri": "LGPO.zip",
"arguments": ""
},
{
"name": "FSLogix",
"blobNameOrUri": "FSLogix.zip",
"arguments": ""
},
{
"name": "Teams",
"blobNameOrUri": "teamsbootstrapper.exe",
"arguments": "-p"
},
{
"name": "Office365",
"blobNameOrUri": "Configure-Office365.zip",
"arguments": ""
},
{
"name": "Chrome",
"blobNameOrUri": "Chrome.zip",
"arguments": "-InstallMode Enterprise -DisableUpdates"
}
]
}
}What Happens:
- Bicep loops through the
customizationsarray - For each item, creates a VM Run Command
- Each Run Command executes
Invoke-Customization.ps1with:Name= customization nameUri= constructed full URL (artifactsContainerUri + blobNameOrUri)Arguments= arguments string
- Run Commands execute sequentially (
@batchSize(1))
Step 3: Deploy Image Build
New-AzDeployment `
-Location "East US 2" `
-TemplateFile ".\deployments\imageBuild\imageBuild.bicep" `
-TemplateParameterFile ".\deployments\imageBuild\parameters\production.imageBuild.parameters.json"Step 1: Prepare Artifacts
Same as image build - artifacts must be uploaded to blob storage.
Step 2: Reference Artifacts in Host Pool Deployment
Edit your host pool parameters file:
{
"artifactsContainerUri": {
"value": "https://staccount.blob.core.usgovcloudapi.net/artifacts/"
},
"artifactsUserAssignedIdentityResourceId": {
"value": "/subscriptions/.../userAssignedIdentities/uami-artifacts"
},
"sessionHostCustomizations": {
"value": [
{
"name": "Configure-OneDrive",
"blobNameOrUri": "Configure-OneDrive.zip",
"arguments": "-TenantId 12345678-1234-1234-1234-123456789012 -EnableKFM"
},
{
"name": "Install-LineOfBusinessApp",
"blobNameOrUri": "LOBApp.zip",
"arguments": "-InstallMode Full -SkipShortcuts"
},
{
"name": "Configure-EdgePolicy",
"blobNameOrUri": "Configure-EdgePolicy.zip",
"arguments": ""
}
]
}
}What Happens:
- Bicep loops through the
sessionHostCustomizationsarray - For each session host VM, creates Run Commands for all customizations
- Each Run Command executes
Invoke-Customization.ps1with appropriate parameters - Run Commands execute sequentially per VM (
@batchSize(1))
Step 3: Deploy Host Pool
New-AzDeployment `
-Location "East US 2" `
-TemplateFile ".\deployments\hostpools\hostpool.bicep" `
-TemplateParameterFile ".\deployments\hostpools\parameters\production.hostpool.parameters.json"-
Always Use Logging
- Include comprehensive logging in every script
- Log to
C:\Windows\Logsfor consistency - Include timestamps and categories (Info/Warning/Error)
-
Handle Errors Gracefully
- Use try/catch blocks
- Return meaningful exit codes
- Log errors before exiting
-
Make Scripts Idempotent
- Check if software is already installed
- Skip if already configured correctly
- Allow re-running without issues
-
Use Relative Paths
- Never hardcode paths
- Use
$PSScriptRootorSplit-Path -Parent $MyInvocation.MyCommand.Path - Assume files are in the same directory as the script
-
Document Your Scripts
- Include header comments explaining purpose
- Document parameters and expected behavior
- Create a readme.md for complex artifacts
-
One Script Per Package
- Each artifact folder should have exactly one main .ps1 script
- Use clear, descriptive names
- Follow naming convention:
Install-[Name].ps1orConfigure-[Name].ps1
-
Keep Packages Small
- Don't include unnecessary files
- Download large installers during execution if possible
- Use separate packages for unrelated functionality
-
Version Control
- Let Deploy-ImageManagement.ps1 download latest versions
- Check uploadedFileVersionInfo.txt for current versions
- Test with new versions before production deployment
-
Test in Development First
- Test locally before uploading
- Deploy to dev environment before production
- Use different parameter file prefixes for environments
-
Use Image Layers
- Install common software in base image
- Apply environment-specific configs on session hosts
- Minimize session host customizations for faster provisioning
-
Parameter Management
- Use arguments parameter for environment-specific values
- Don't hardcode tenant IDs or configuration values
- Store sensitive data in Key Vault, not in scripts
- Pass sensitive values as parameters from Key Vault references
-
Avoid Hardcoded Credentials
- Never store credentials in scripts
- Use managed identities for authentication
- Retrieve secrets from Key Vault if needed
-
Validate Downloads
- Verify file hashes for critical downloads
- Use HTTPS for all downloads
- Check file signatures where applicable
-
Minimize Permissions
- Scripts run with system privileges - be careful
- Don't install software that runs with unnecessary permissions
- Follow principle of least privilege
Symptoms:
- Run Command completes but artifact script doesn't execute
- No errors in Invoke-Customization.ps1 log
Solutions:
-
Check script exists in ZIP:
# Extract and check contents Expand-Archive -Path ".\MyApp.zip" -DestinationPath ".\temp" -Force Get-ChildItem .\temp\ -Recurse # Must see .ps1 file in ROOT of extracted content
-
Verify ZIP structure:
- ✅ Correct:
MyApp.zip\Install-MyApp.ps1 - ❌ Wrong:
MyApp.zip\MyApp\Install-MyApp.ps1(extra folder level)
- ✅ Correct:
-
Check Invoke-Customization log:
# On the VM, check the log Get-Content "C:\Windows\Logs\MyApp.log" # Look for "Finding PowerShell script in root"
Symptoms:
- Script runs but can't find installer or supporting files
- Error: "Cannot find path..."
Solutions:
-
Use correct path resolution:
# Wrong - don't use hardcoded paths $Installer = "C:\Installers\app.msi" # Correct - use script location $ScriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path $Installer = Get-ChildItem -Path $ScriptPath -Filter "*.msi" | Select-Object -First 1 # Alternative $ScriptPath = $PSScriptRoot
-
Verify files are in artifact:
# Check artifact folder contents before zipping Get-ChildItem .\.common\artifacts\MyApp\ -Recurse
Symptoms:
- Script receives wrong parameter values or no parameters
- Parameters show as empty or default values
Solutions:
-
Verify parameter declaration:
# Script must use standard named parameters Param( [Parameter(Mandatory = $false)] [string]$InstallMode = 'Full', [Parameter(Mandatory = $false)] [switch]$SkipShortcuts )
-
Check arguments format:
// Correct format in deployment { "name": "MyScript", "blobNameOrUri": "MyScript.zip", "arguments": "-InstallMode Minimal -SkipShortcuts" } // Not: "arguments": "InstallMode=Minimal;SkipShortcuts=True"
-
Test locally with same syntax:
# Test exactly as Invoke-Customization will call it & .\Install-MyApp.ps1 -InstallMode Minimal -SkipShortcuts
-
Add debug logging:
# At start of script Write-Host "Received parameters:" Write-Host " InstallMode: $InstallMode" Write-Host " SkipShortcuts: $SkipShortcuts"
Symptoms:
- Installer executes but returns error code
- Common codes: 1603, 1619, 1641, 3010
Solutions:
-
Accept common success codes:
$Process = Start-Process -FilePath $Installer -ArgumentList $Arguments -Wait -PassThru $ExitCode = $Process.ExitCode # 3010 = reboot required (still success) # 1641 = reboot initiated (still success) if ($ExitCode -eq 0 -or $ExitCode -eq 3010 -or $ExitCode -eq 1641) { Write-Log "Installation successful (exit code: $ExitCode)" exit 0 }
-
Enable detailed logging:
# For MSI $LogFile = Join-Path $env:TEMP "installer.log" $Arguments = "/i `"$Installer`" /qn /norestart /l*v `"$LogFile`"" # For EXE (if supported) $Arguments = "/silent /log:`"$LogFile`""
-
Verify prerequisites:
- Check if installer requires specific OS version
- Verify architecture matches (x86 vs x64)
- Ensure .NET Framework or VC++ redistributables are installed
# Check what's currently deployed
$StorageAccount = "staccount"
$Container = "artifacts"
$Context = (Get-AzStorageAccount -ResourceGroupName "rg-name" -Name $StorageAccount).Context
Get-AzStorageBlob -Container $Container -Context $Context |
Select-Object Name, LastModified, Length |
Format-Table -AutoSize# Download artifact for inspection
$BlobName = "MyApp.zip"
$LocalPath = ".\temp\$BlobName"
Get-AzStorageBlobContent -Container $Container -Blob $BlobName -Destination $LocalPath -Context $Context
# Extract and inspect
Expand-Archive -Path $LocalPath -DestinationPath ".\temp\extracted" -Force
Get-ChildItem .\temp\extracted\ -RecurseImage Build:
If you enabled collectCustomizationLogs during deployment, all logs are automatically saved to blob storage in the image-customization-logs container. See the Image Build Guide - Getting Detailed Logs for details on accessing these logs.
Alternatively, you can check logs directly on the build VM during or after the build:
# On build VM during image build
# Each customization creates its own log file
Get-Content "C:\Windows\Logs\Install-MyApp.log"
# List all customization logs
Get-ChildItem "C:\Windows\Logs\" -Filter "*.log" |
Where-Object { $_.Name -match '^(Install-|Configure-|Enable-)' } |
Sort-Object LastWriteTimeSession Host:
# On session host, check individual customization logs
Get-Content "C:\Windows\Logs\Install-MyApp.log"
# View all customization logs sorted by execution time
Get-ChildItem "C:\Windows\Logs\" -Filter "*.log" |
Where-Object { $_.Name -match '^(Install-|Configure-|Enable-)' } |
Sort-Object LastWriteTime |
ForEach-Object {
Write-Host "`n=== $($_.Name) (Last Modified: $($_.LastWriteTime)) ===" -ForegroundColor Cyan
Get-Content $_.FullName -Tail 20
}
# Check VM Run Command execution status
Get-Content "C:\WindowsAzure\Logs\Plugins\Microsoft.CPlat.Core.RunCommandWindows\*\*\RunCommand.log" -Tail 100If you encounter issues not covered here:
-
Check existing documentation:
-
Review example artifacts:
.common/artifacts/VSCode/- Simple installer example.common/artifacts/Configure-Office365Policy/- Configuration example.common/artifacts/Install-FSLogix/- Download and install example
-
Search GitHub issues:
- FederalAVD Issues
- Search for error messages or similar problems
-
Create a new issue:
- Include artifact script code
- Include relevant log excerpts
- Describe expected vs. actual behavior
- Mention Azure environment (Commercial, Government, etc.)
- Deploy-ImageManagement Script Guide - Detailed script documentation
- Quick Start Guide - Complete deployment walkthrough
- Parameters Reference - All deployment parameters
- Air-Gapped Cloud Guide - Special considerations for air-gapped environments
Last Updated: December 2024